Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61193629b6 | |||
| e3a27422a1 | |||
| 32d7fd7cc9 | |||
| de666b24c3 | |||
| a4fb97aef8 | |||
| da4634d67e | |||
| 869be660fd | |||
| a8916c3e08 | |||
| 79b2345834 | |||
| 4df5b849ac | |||
| a58151e99e | |||
| 1fd093d95d | |||
| f210f09caf | |||
| 042f3b6a65 | |||
| bc40388914 | |||
| b719194046 | |||
| 7570df76d3 | |||
| 244949caa3 | |||
| a5a0d06dbe | |||
| 6882761f4c | |||
| 15f3797f1e | |||
| 534d670b21 | |||
| b351a81c8f | |||
| f655efc570 | |||
| c4116e54c9 | |||
| c3fec1426c | |||
| a2761e4b98 | |||
| 4a469fbe06 | |||
| e2fa6754bb | |||
| b76561a780 | |||
| c49fccbe0c | |||
| 5622e51006 | |||
| 9e479ce675 | |||
| af691f3291 | |||
| 453340e71e | |||
| b64d670303 | |||
| c83e9397e6 | |||
| 74b9218a92 | |||
| 532e9933f3 | |||
| ee8add4416 | |||
| bc4fce5fbe | |||
| 7a0b8525a9 | |||
| 560b327ee1 | |||
| d1b6cff085 | |||
| ef17d2e595 | |||
| e439100937 | |||
| 7c9621040e | |||
| 1b0baf7025 | |||
| f31af0093f | |||
| 6e365ef1a9 | |||
| 1dbd3b2a6d | |||
| 48c3c56073 | |||
| 5475ab2aa3 | |||
| 1a143beeb9 | |||
| 641b2ecbcf | |||
| 09d1bbac00 | |||
| b869af2b3d | |||
| 56be42913c | |||
| dc8a2dd52c | |||
| d605d0b20d | |||
| 85676db3a5 | |||
| bec2988309 | |||
| 7cd5cde315 | |||
| 7c92297d0e | |||
| 81f09a7054 | |||
| c962b86bde | |||
| fcd0b9b355 | |||
| 0d3ec46c14 | |||
| 662f3f9f5c | |||
| dcd2509548 | |||
| 64e4726fff | |||
| 494da22cd1 | |||
| 063005fefa | |||
| ffcc8d1065 | |||
| 4b374fd177 | |||
| 54f0dbddb9 | |||
| c19d124e89 | |||
| f3f328c25c | |||
| 4584612a1a | |||
| 4203b84d51 | |||
| 29370fde3c | |||
| 3f23a1acd3 | |||
| 4d5c6ac892 | |||
| c4086c243c | |||
| a971db3ee5 | |||
| 5f8fa7004c | |||
| 059a6218f7 | |||
| 8149739161 | |||
| 2c16062457 | |||
| dc21cbad53 | |||
| dfbf6793de | |||
| a243cfd126 | |||
| 5cad9b260e | |||
| a3073d16bf | |||
| efcc2311e6 | |||
| 7014c9376c | |||
| 27b3a014da | |||
| 55e8bf70d9 | |||
| c0ce5d02bd | |||
| a28f4cdd25 | |||
| a008530af6 | |||
| 1ff3875a19 | |||
| 85af126406 | |||
| f2f6eeb74e | |||
| 8c0a32025d | |||
| 5ffbc42d8c | |||
| 5f0e0482ed | |||
| d892ab9e12 | |||
| 9f62f2c242 | |||
| a88721ce31 | |||
| 4902295211 | |||
| b474d63335 | |||
| 5058a56645 | |||
| dc12c3732e | |||
| c1c68c9134 | |||
| af06648558 |
@@ -21,6 +21,8 @@ desktop.ini
|
||||
# NuGet
|
||||
packages/
|
||||
*.nupkg
|
||||
# … but DO track repo-local feed for mxaccessgw client (not yet on public nuget.org).
|
||||
!nuget-packages/*.nupkg
|
||||
|
||||
# Certificates
|
||||
*.pfx
|
||||
|
||||
@@ -150,3 +150,5 @@ dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tc
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode"
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500
|
||||
```
|
||||
|
||||
Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Akka" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Cluster" Version="1.5.62" />
|
||||
@@ -35,7 +33,6 @@
|
||||
<PackageVersion Include="libplctag" Version="1.5.2" />
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
<PackageVersion Include="MessagePack" Version="2.5.187" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||
@@ -73,6 +70,7 @@
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
|
||||
@@ -98,6 +96,7 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
<PackageVersion Include="xunit.v3" Version="1.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
<add key="local-mxgw" value="./nuget-packages" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -21,16 +21,27 @@
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/">
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/Driver CLIs/">
|
||||
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
|
||||
@@ -61,6 +72,7 @@
|
||||
<Project Path="tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Server/">
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj" />
|
||||
<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.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj" />
|
||||
@@ -70,6 +82,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/">
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
|
||||
@@ -87,6 +100,8 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/Driver CLIs/">
|
||||
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj" />
|
||||
|
||||
@@ -98,7 +98,7 @@ services:
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
|
||||
admin-b:
|
||||
<<: *otopcua-host
|
||||
@@ -115,7 +115,7 @@ services:
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
|
||||
driver-a:
|
||||
<<: *otopcua-host
|
||||
@@ -129,7 +129,7 @@ services:
|
||||
Cluster__Roles__0: "driver"
|
||||
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4840:4840"
|
||||
|
||||
@@ -143,7 +143,7 @@ services:
|
||||
Cluster__PublicHostname: "driver-b"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4841:4840"
|
||||
|
||||
@@ -168,7 +168,7 @@ services:
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4842:4840"
|
||||
|
||||
@@ -191,7 +191,7 @@ services:
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4843:4840"
|
||||
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4844:4840"
|
||||
|
||||
@@ -236,7 +236,7 @@ services:
|
||||
Security__Jwt__Issuer: "otopcua-dev"
|
||||
Security__Jwt__Audience: "otopcua-dev"
|
||||
Authentication__Ldap__DevStubMode: "true"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
ports:
|
||||
- "4845:4840"
|
||||
|
||||
@@ -248,7 +248,7 @@ services:
|
||||
- --providers.file.watch=true
|
||||
- --api.insecure=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "9200:80" # host port 9200 → traefik :80 entrypoint (80 conflicts with scadabridge-traefik)
|
||||
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
|
||||
volumes:
|
||||
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# AdminUI — Driver-Specific Pages
|
||||
|
||||
**Status:** Design approved, ready for implementation planning
|
||||
**Date:** 2026-05-28
|
||||
**Branch:** `master` (work to land on a feature branch)
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
Today the AdminUI has a single generic `DriverEdit.razor` page (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`, 323 lines) that edits every driver type via a raw JSON `DriverConfig` textarea. The page itself flags this as temporary:
|
||||
|
||||
> Per Q1 of the AdminUI rebuild plan, typed driver editors (Modbus, FOCAS) are deferred… lands in a Phase C.2 follow-up.
|
||||
|
||||
This design is that follow-up. Goals:
|
||||
|
||||
1. Replace the JSON blob with a **typed form per driver type** that exposes every supported configuration option.
|
||||
2. Add three driver-aware operator capabilities to each page: **Test Connect**, **live runtime status**, and a **driver-specific tag/address picker**.
|
||||
3. Add **Reconnect / Restart** controls on the status panel for authorized users.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
All 9 driver types ship typed pages in this work:
|
||||
|
||||
```
|
||||
ModbusTcp, AbCip, AbLegacy, S7, TwinCat, FOCAS,
|
||||
OpcUaClient, Galaxy, Historian.Wonderware
|
||||
```
|
||||
|
||||
Each typed page exposes the full surface of its driver's options class — the JSON editor is retired; the typed form is the only way to edit driver config from the AdminUI.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Project layout
|
||||
|
||||
```
|
||||
src/Drivers/
|
||||
ZB.MOM.WW.OtOpcUa.Driver.<Type>/ (runtime, unchanged behavior)
|
||||
ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts/ NEW — POCO options + DataAnnotations only
|
||||
<Type>DriverOptions.cs (moved from runtime project)
|
||||
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/
|
||||
Components/Pages/Clusters/Drivers/ NEW folder
|
||||
DriverTypePicker.razor route: /clusters/{id}/drivers/new
|
||||
DriverEditRouter.razor route: /clusters/{id}/drivers/{instanceId}
|
||||
ModbusDriverPage.razor route: /clusters/{id}/drivers/new/modbus
|
||||
GalaxyDriverPage.razor route: /clusters/{id}/drivers/new/galaxy
|
||||
S7DriverPage.razor
|
||||
OpcUaClientDriverPage.razor
|
||||
AbCipDriverPage.razor
|
||||
AbLegacyDriverPage.razor
|
||||
TwinCatDriverPage.razor
|
||||
FocasDriverPage.razor
|
||||
HistorianWonderwareDriverPage.razor
|
||||
Components/Shared/Drivers/ NEW folder
|
||||
DriverFormShell.razor panel layout + Save/Cancel/Delete
|
||||
DriverIdentitySection.razor InstanceId, Name, Namespace, Enabled
|
||||
DriverResilienceSection.razor Polly overrides
|
||||
DriverStatusPanel.razor live status + Reconnect/Restart
|
||||
DriverTestConnectButton.razor per-driver-timeout probe
|
||||
DriverTagPicker.razor modal shell, hosts per-driver picker body
|
||||
Hubs/
|
||||
DriverStatusHub.cs NEW SignalR hub at /hubs/driverstatus
|
||||
DriverStatusSignalRBridge.cs NEW (mirrors FleetStatusSignalRBridge)
|
||||
```
|
||||
|
||||
### 3.2 Routing
|
||||
|
||||
- `/clusters/{ClusterId}/drivers` — existing `ClusterDrivers.razor` list, unchanged.
|
||||
- `/clusters/{ClusterId}/drivers/new` — new `DriverTypePicker.razor` (operator picks driver type).
|
||||
- `/clusters/{ClusterId}/drivers/new/{driverType}` — typed new-form for that type.
|
||||
- `/clusters/{ClusterId}/drivers/{DriverInstanceId}` — `DriverEditRouter.razor` reads the row's `DriverType`, dispatches to the right `*DriverPage` via `<DynamicComponent>` (no redirect flicker).
|
||||
|
||||
### 3.3 Schema source
|
||||
|
||||
Each driver's `Options` class moves to a new `Driver.<Type>.Contracts` csproj — POCO + `System.ComponentModel.DataAnnotations` attributes only, no NuGet references, no project references. The runtime driver project adds a `ProjectReference` to its contracts sibling and re-uses the same type (single source of truth, no `TypeForwardedTo` needed if the namespace is preserved). The AdminUI gains 9 `ProjectReference`s — all pure POCO, so no native deps (Galaxy COM, FOCAS native libs, OPC UA stack) leak into the AdminUI publish output.
|
||||
|
||||
Attributes used:
|
||||
|
||||
- `[Required]`, `[Range(...)]`, `[RegularExpression(...)]` — render as inputs + `<ValidationMessage>` via `DataAnnotationsValidator`.
|
||||
- `[Display(Name, Description, GroupName)]` — label, help-text under field, panel section.
|
||||
- `[DataType(DataType.Password)]` — render as `<InputText type="password">` (e.g. mxaccessgw API key).
|
||||
|
||||
The `*DriverPage.razor` files **explicitly** bind each field (no runtime reflection). Attributes drive labels/help/validation but not field discovery — this avoids the "metadata silently drifts from rendering" trap.
|
||||
|
||||
### 3.4 Persistence
|
||||
|
||||
`DriverInstance.DriverConfig` stays a JSON string column (no schema change). On save: typed form-model serialized via `System.Text.Json` against the driver's Options class. On load: row's JSON deserialized into the matching Options class with `JsonSerializerOptions { UnmappedMemberHandling = Skip }` so old/unknown fields are silently dropped on next save. Version skew is bounded by the fact that drivers ship as one host binary.
|
||||
|
||||
`RowVersion` optimistic concurrency unchanged from today's `DriverEdit.razor`.
|
||||
|
||||
## 4. Test Connect
|
||||
|
||||
### 4.1 Flow
|
||||
|
||||
```
|
||||
[Browser] [AdminUI server] [Cluster]
|
||||
|
||||
DriverGalaxyPage AdminProbeService AdminOperationsActor
|
||||
| | |
|
||||
|-- click TestConnect --->| |
|
||||
| |-- Ask<TestDriverConnect> --->|
|
||||
| | (driverType, configJson, |
|
||||
| | timeoutSecs) |
|
||||
| | |--> spawn transient probe actor
|
||||
| | | (resolves IDriverProbe by
|
||||
| | | driverType via DI)
|
||||
| | |<-- ProbeResult (ok, latencyMs)
|
||||
| |<-- TestDriverConnectResult --|
|
||||
|<-- green / red chip ----|
|
||||
```
|
||||
|
||||
### 4.2 Components
|
||||
|
||||
- **`IDriverProbe`** — interface in `Core.Abstractions` (or equivalent). One implementation per driver type, lives in the driver's **runtime** project. Reuses the existing `IHostConnectivityProbe` plumbing where present (FOCAS, TwinCAT confirmed). For drivers without one, the probe is a cheap subset of the real connect path: TCP `SocketAsyncOperations` for Modbus/AbCip/S7, session open+close for OpcUaClient, `MxCommand.Ping` for Galaxy. Probes **never write**.
|
||||
- **`TestDriverConnect` message** in `Commons/Messages/Admin` — `(string driverType, string configJson, TimeSpan timeout)`. Handler in `AdminOperationsActor`: resolves the right probe via keyed DI (`IServiceProvider.GetRequiredKeyedService<IDriverProbe>(driverType)`), deserializes JSON into the matching Options class, calls `probe.RunAsync(options, ct)`. Returns `TestDriverConnectResult(bool ok, string? message, TimeSpan? latency)`.
|
||||
- **`AdminProbeService`** (AdminUI side) — thin wrapper around the existing AdminOperationsActor bridge. Caller passes timeout; service enforces a 60s hard backstop.
|
||||
- **`<DriverTestConnectButton>`** — accepts driver type + `Func<string>` to build form JSON on-click. Renders button + inline result chip (auto-clears after 30s). Disabled while in-flight.
|
||||
|
||||
### 4.3 Timeout
|
||||
|
||||
Each driver's Options class exposes a `ProbeTimeout` (`TimeSpan` or `int Seconds`) with a driver-appropriate default — e.g. Modbus 5s, OpcUaClient 15s, Galaxy 30s. The button reads from the live form (not the persisted row), so an operator can override the timeout per probe attempt. Server-side max = 60s.
|
||||
|
||||
### 4.4 Safety
|
||||
|
||||
- Probe spawns a transient actor with the *form's* config — the live driver actor (using the *persisted* config) is untouched.
|
||||
- Probe never mutates the live driver or the database.
|
||||
- Probe inherits the user context via the existing AdminOperationsActor audit-log entry.
|
||||
|
||||
## 5. Live Status Panel
|
||||
|
||||
### 5.1 Flow
|
||||
|
||||
```
|
||||
DriverActor DriverStatusSignalRBridge DriverStatusPanel (browser)
|
||||
| ^ |
|
||||
|-- publishes |-- subscribed in |
|
||||
| DriverHealthChanged | OnInitializedAsync |
|
||||
| to event stream | with InstanceId filter |
|
||||
| | |
|
||||
| |-- pushes update -------------->|
|
||||
| |
|
||||
| |-- renders state chip,
|
||||
| | last-success, error count,
|
||||
| | Reconnect/Restart buttons
|
||||
```
|
||||
|
||||
### 5.2 Reused infrastructure
|
||||
|
||||
Driver actors already maintain `DriverHealth(state, lastSuccessUtc, lastError)` — confirmed in FOCAS (`FocasDriver.cs`) and TwinCAT. The bridge mirrors the existing `FleetStatusSignalRBridge` + `AlertSignalRBridge` pattern. SignalR hub uses the same cookie-auth as existing hubs.
|
||||
|
||||
### 5.3 New components
|
||||
|
||||
- **`DriverStatusHub`** — single method `JoinDriver(string driverInstanceId)`, adds connection to a per-instance group and immediately replies with the current snapshot.
|
||||
- **`DriverStatusSignalRBridge`** — subscribes to per-cluster driver-health event stream, fans out into SignalR groups keyed by `driverInstanceId`. Only running drivers publish; `Enabled=false` instances render "Disabled — not deployed" without subscribing.
|
||||
- **`<DriverStatusPanel>`** — props `DriverInstanceId`, `Enabled`. Opens hub on init, calls `JoinDriver`, registers `On<StatusSnapshot>("status", ...)`. Renders state chip (`Healthy` / `Connecting` / `Faulted` / `Unknown`) + last-success timestamp ("2s ago") + error count over last 5min + last error message (collapsed, expandable). Disposes hub on dispose.
|
||||
|
||||
### 5.4 Reconnect / Restart controls
|
||||
|
||||
Two buttons on the status panel:
|
||||
|
||||
- **Reconnect** — driver actor closes + reopens its transport, keeps actor alive. Fast, idempotent. No confirm dialog.
|
||||
- **Restart** — full actor stop + respawn, loses in-memory state. Slower, can interrupt active subscriptions. Confirm dialog required.
|
||||
|
||||
Both:
|
||||
|
||||
- Gated by authorization policy `DriverOperator` (mapped to an LDAP group via existing `Authentication.Ldap` config). **Hidden** (not just disabled) for unauthorized users — same approach as other AdminUI gated actions.
|
||||
- Dispatch `RestartDriver` / `ReconnectDriver` messages through `AdminOperationsActor`, which audit-logs each operation.
|
||||
- Show spinner + inline "Reconnecting…" chip; panel reflects new state via the SignalR push once health changes.
|
||||
- Disabled when `Enabled=false` (nothing to restart) and during any in-flight Test Connect on the same page.
|
||||
|
||||
### 5.5 Out of scope this PR
|
||||
|
||||
History graphs (latency/error rate over time), deep diagnostics (per-tag last values, queue depths), and per-driver bespoke controls beyond Reconnect/Restart — all follow-ups.
|
||||
|
||||
### 5.6 Edge cases
|
||||
|
||||
- **Driver not yet deployed** (row exists, `Enabled=true`, cluster hasn't picked it up) — panel shows "Awaiting deployment", `DriverHealth.Unknown`.
|
||||
- **Edit page open while driver is running** — status reflects deployed config, not the form. Banner: "Showing live status for the deployed config — your unsaved changes take effect after Save → next deploy cycle."
|
||||
- **Test Connect + live status** — probe runs in a transient actor (Section 4), live status reflects the persistent actor. Don't interfere.
|
||||
|
||||
## 6. Tag / Address Picker
|
||||
|
||||
A picker slot on each page, launched as a modal so the config form stays visible behind it.
|
||||
|
||||
### 6.1 Shared shell
|
||||
|
||||
`<DriverTagPicker>` — modal chrome + search box + "use this address" action that emits a string back to the parent (e.g. `4x0001` for Modbus, `ns=2;s=Channel.Device.Tag` for OPC UA). Where the picked address lands depends on context: from "create equipment / create tag" flow, pushed into that form; standalone, copy-to-clipboard.
|
||||
|
||||
### 6.2 Per-driver bodies — first pass (all static address builders)
|
||||
|
||||
| Driver | Picker body |
|
||||
|---|---|
|
||||
| Modbus | Register-type dropdown + offset spinner + length → `4x00001-4` |
|
||||
| AbCip | Tag name + element index, PLC-family hint from form |
|
||||
| AbLegacy | File type (N/B/F/I/O/S/T/C/R) + file number + element, PLC-family-aware |
|
||||
| S7 | Area (DB/M/I/Q) + db-number + offset + S7 type → `DB10.DBD20:REAL` |
|
||||
| TwinCat | ADS variable name (free-text + format hint) |
|
||||
| FOCAS | Parameter group dropdown + parameter ID; drives FOCAS function-code lookup |
|
||||
| OpcUaClient | Static helper (NodeId free-text) — **live browse deferred** |
|
||||
| Galaxy | Static helper (tag_name.AttributeName free-text) — **live browse deferred** |
|
||||
| Historian.Wonderware | Tag name + retrieval mode + interval |
|
||||
|
||||
### 6.3 Deferred to follow-up
|
||||
|
||||
- **OpcUaClient live browse** — open session against configured endpoint, walk address space, return NodeId. Reuses the existing `Client.CLI` browse path or calls the OPC UA stack inline. Requires endpoint config to be valid (Test Connect first).
|
||||
- **Galaxy live browse** — calls mxaccessgw's `GalaxyRepository.ListObjects` / `ListAttributes` via gRPC. Returns `tag_name.AttributeName`. Reuses `IGalaxyHierarchySource`.
|
||||
- **Historian.Wonderware tag list** — pull from historian's tag store.
|
||||
|
||||
The picker slot is wired so swapping a static builder for a live browser later is a 1-component swap, not a page rewrite.
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
| Failure | Surface |
|
||||
|---|---|
|
||||
| Invalid form input | `DataAnnotationsValidator` + per-field `<ValidationMessage>`; Save disabled. |
|
||||
| `DbUpdateConcurrencyException` | Red banner — "Another user changed this driver instance, reload before re-applying." (matches existing pattern) |
|
||||
| FK violation (Namespace deleted while edit open) | Catch `DbUpdateException` — "Namespace `<id>` no longer exists in this cluster — pick another or recreate it." |
|
||||
| Probe — driver-side exception | Probe actor catches, returns `(false, ex.Message, null)`. Red chip with message. Full stack to Serilog with audit context. |
|
||||
| Probe — timeout | `(false, "Probe timed out after {n}s", null)`. Server-side 60s backstop. |
|
||||
| Probe — DI lookup fails (unknown driver type) | Defensive — `(false, "No probe registered for driver type '{type}'", null)`. Error-level log. |
|
||||
| SignalR disconnect | "Reconnecting…" chip + SignalR auto-reconnect. Stale snapshot dimmed after 30s. |
|
||||
| Reconnect/Restart on stopped driver | "Driver is not running on any node". Button re-enables. |
|
||||
| Authorization denied | Reconnect/Restart buttons hidden for unauthorized users. |
|
||||
| Corrupted `DriverConfig` JSON on row load | Yellow banner — "Saved config could not be parsed against the current schema; falling back to defaults. Save will overwrite." Original JSON preserved in banner for copy-paste. |
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Unit tests (`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend existing project, or create if absent)
|
||||
|
||||
- `DriverPageFormSerializationTests` — 9 drivers × round-trip Options ↔ JSON ↔ form ↔ DB row. Asserts no loss for known fields, unknown fields dropped silently.
|
||||
- `DriverTestConnectButtonTests` — render tests: enabled/disabled states, timeout behavior, result chip.
|
||||
- `DriverStatusPanelTests` — render snapshots for each `DriverState`, disabled mode, stale-data dim.
|
||||
- `DriverRestartReconnectAuthorizationTests` — buttons hidden without `DriverOperator` policy.
|
||||
- Address-builder unit tests per driver — 9 small suites covering canonical address formats.
|
||||
|
||||
### 8.2 Integration tests (`tests/Server/.../IntegrationTests/`)
|
||||
|
||||
- `DriverTestConnectE2eTests` — Modbus + AbCip + S7 against Docker fixtures (`lmxopcua-fix up modbus` etc.). Green probe vs sim, red probe vs wrong port, timeout vs black-holed IP.
|
||||
- `DriverReconnectE2eTests` — start a driver, click Reconnect, assert `Connecting → Healthy` transition within N seconds.
|
||||
- `DriverStatusHubE2eTests` — open hub, force state change, assert push arrives within 1s.
|
||||
|
||||
### 8.3 Manual smoke (run before PR ship)
|
||||
|
||||
Operator on the dev VM with Docker fixtures available:
|
||||
|
||||
1. Pre-flight:
|
||||
- `lmxopcua-fix up modbus standard` — Modbus sim running on `10.100.0.35:5020`.
|
||||
- AdminUI deployed and reachable.
|
||||
- LDAP user has the `DriverOperator` (or `FleetAdmin`) role.
|
||||
|
||||
2. Type picker:
|
||||
- Navigate to `/clusters/<id>/drivers/new`. Verify 9 driver-type cards render.
|
||||
- Click "ModbusTcp". Verify the typed form opens on `/clusters/<id>/drivers/new/modbustcp`.
|
||||
|
||||
3. Test Connect (form-driven, no save):
|
||||
- Fill in Host=`10.100.0.35`, Port=`5020`, leave defaults otherwise.
|
||||
- Click "Test Connect". Verify green chip + latency < 100ms.
|
||||
- Change port to `9999`. Click again. Verify red chip with "ConnectionRefused" or similar.
|
||||
- Change host to `1.2.3.4`. Click again. Within (default 5s) the chip shows "Probe timed out after 5s".
|
||||
|
||||
4. Save + edit:
|
||||
- Set valid endpoint back. Save. Verify redirect to `/clusters/<id>/drivers`.
|
||||
- Open the just-saved instance. Verify the typed form pre-populates correctly.
|
||||
|
||||
5. Live status panel:
|
||||
- In a second browser tab, open the same driver's edit page. Confirm the `DriverStatusPanel` renders state + last-update.
|
||||
- Stop the Modbus sim (`lmxopcua-fix down modbus`). Within ~30s, verify the panel transitions Healthy → Reconnecting / Faulted (depending on driver state).
|
||||
- Bring the sim back up (`lmxopcua-fix up modbus standard`). Verify Healthy is restored.
|
||||
|
||||
6. Reconnect / Restart:
|
||||
- Click "Reconnect" on the status panel. Verify a brief "Reconnecting…" chip + a Healthy state push within 5s.
|
||||
- Click "Restart". Confirm in the dialog. Verify the actor restarts (full state transition).
|
||||
- Verify both buttons are HIDDEN for an unauthorized user (LDAP user without `DriverOperator` role).
|
||||
|
||||
7. Address picker:
|
||||
- Click "Pick address" on the Modbus page. Verify the modal opens.
|
||||
- Builder: select Holding + offset=10 + length=2. Verify the chip shows `4x00010-2`. Click "Use this address" — verify it surfaces in the parent page.
|
||||
- Close the modal. Repeat for one other driver type (e.g. S7) to confirm cross-driver wiring.
|
||||
|
||||
8. Other 8 driver types — smoke each page renders:
|
||||
- Repeat steps 2–4 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an `opc.tcp://` endpoint.
|
||||
|
||||
If any step fails, record the failure mode + Razor / actor log excerpts and reopen for fix before PR ship.
|
||||
|
||||
### 8.4 bUnit harness
|
||||
|
||||
If the AdminUI tests project doesn't already use bUnit, render tests downgrade to logic-only tests on the `@code { }` block; Razor markup is covered by integration tests. Decision deferred to implementation plan.
|
||||
|
||||
## 9. Migration / Sequencing
|
||||
|
||||
Incremental — driver-by-driver swap-over. Each step compile-clean and shippable on its own:
|
||||
|
||||
1. Land 9 Contracts projects + move Options classes. No UI changes.
|
||||
2. Land shared section components (`DriverIdentitySection`, `DriverResilienceSection`, `DriverFormShell`). Wire into existing `DriverEdit.razor` first so they're tested in place.
|
||||
3. Land `DriverTypePicker` + `DriverEditRouter` + `<DynamicComponent>` dispatch.
|
||||
4. Land driver-specific pages one at a time. After each, route list-page links for that driver type only to the new page; leave others on generic editor.
|
||||
5. Delete the generic `DriverEdit.razor` + its route once all 9 typed pages exist.
|
||||
6. Land `DriverStatusHub` + bridge + `<DriverStatusPanel>` (read-only first).
|
||||
7. Land `<DriverTestConnectButton>` + `IDriverProbe` impls + AdminOperationsActor handler.
|
||||
8. Land Reconnect/Restart on the status panel with `DriverOperator` policy.
|
||||
9. Land 9 static address builders inside `<DriverTagPicker>`.
|
||||
|
||||
## 10. Out of scope (follow-ups)
|
||||
|
||||
- Live tag browse for OpcUaClient + Galaxy (Section 6.3).
|
||||
- Historian.Wonderware tag list pulled from store.
|
||||
- Status panel history graphs + per-tag diagnostics (Section 5.5).
|
||||
- Per-driver bespoke controls beyond Reconnect/Restart.
|
||||
- bUnit setup if not already present (Section 8.4) — decide during implementation planning.
|
||||
@@ -0,0 +1,840 @@
|
||||
# AdminUI Driver-Specific Pages Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace `DriverEdit.razor` (generic JSON editor) with typed per-driver pages for all 9 driver types, each with Test Connect, live runtime status panel (Reconnect/Restart), and a driver-specific tag/address picker.
|
||||
|
||||
**Architecture:** 9 new `Driver.<Type>.Contracts` csprojs hold the `Options` POCOs (moved from runtime projects). AdminUI gains 9 thin `ProjectReference`s — no native deps leak. 9 typed `*DriverPage.razor` components share `<DriverFormShell>` + section/picker/status/test components in `Components/Shared/Drivers/`. Test Connect routes through `AdminOperationsActor` to per-driver `IDriverProbe` impls. Live status uses an Akka DistributedPubSub bridge → SignalR hub → Blazor panel (same pattern as the existing `FleetStatusSignalRBridge`).
|
||||
|
||||
**Tech Stack:** .NET 10 Blazor Server, EF Core (SQL Server), Akka.NET (cluster + DistributedPubSub), SignalR, OPC Foundation OPC UA .NET Standard stack, xUnit + Shouldly.
|
||||
|
||||
**Authoritative design:** `docs/plans/2026-05-28-adminui-driver-pages-design.md`. Re-read its sections when a task references them.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Preconditions
|
||||
|
||||
### Task 0.1: Create AdminUI test project (currently absent)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (every later test task depends on this)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj`
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/_PlaceholderTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the new project)
|
||||
|
||||
**Step 1:** Create csproj targeting `net10.0`, `<IsPackable>false</IsPackable>`. Package refs: `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `Shouldly`. Project ref: `..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj`. Copy structure from a peer like `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/*.csproj`.
|
||||
|
||||
**Step 2:** Add a single `_PlaceholderTests.cs` with one passing fact so the project compiles + the test runner discovers something.
|
||||
|
||||
**Step 3:** Add `<Solution><Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj"/></Solution>` to `ZB.MOM.WW.OtOpcUa.slnx` (match the existing element style).
|
||||
|
||||
**Step 4:** Run `dotnet build tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` then `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`. Both succeed.
|
||||
|
||||
**Step 5:** Commit. `test(adminui): scaffold AdminUI.Tests project`
|
||||
|
||||
**Decision (deferred from design §8.4):** *no bUnit*. All Razor render tests degrade to logic-only tests on `@code { }` blocks. Razor markup is covered by the integration tests in Phase 6/7/8.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Contracts Projects (Driver Options → POCO-only siblings)
|
||||
|
||||
**Pattern (apply to every task in this phase):**
|
||||
|
||||
For driver type `<Type>` (folder `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>/`, options file `<Type>DriverOptions.cs`):
|
||||
|
||||
1. Create new csproj `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts/ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts.csproj`:
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<!-- NO ProjectReference. NO PackageReference. Pure POCO. -->
|
||||
</Project>
|
||||
```
|
||||
2. `git mv` the options file from the runtime project into the contracts project. Preserve namespace (`ZB.MOM.WW.OtOpcUa.Driver.<Type>` → keep the same `namespace ZB.MOM.WW.OtOpcUa.Driver.<Type>` declaration so consumers don't change). If the options file `using`s anything that isn't `System.*` or `System.ComponentModel.DataAnnotations`, strip that dep — most options classes are pure POCO already; if any pulls a runtime-only type, leave a `// TODO: extract <type> too` and capture it in a follow-up task here.
|
||||
3. Add `<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts\ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts.csproj" />` to the runtime project's csproj `<ItemGroup>`.
|
||||
4. Add the new contracts csproj to `ZB.MOM.WW.OtOpcUa.slnx`.
|
||||
5. `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>` → clean. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean.
|
||||
6. Commit. `refactor(driver-<type>): extract <Type>DriverOptions to .Contracts`
|
||||
|
||||
### Task 1.1: Modbus contracts
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1.2 – 1.9 (different folders, different csprojs)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj`
|
||||
- Move: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs` → `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj` (add ProjectReference)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
Follow the Phase 1 pattern above.
|
||||
|
||||
### Task 1.2: AbCip contracts
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1.1, 1.3 – 1.9
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj`
|
||||
- Move: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs` → contracts project
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj`, `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Task 1.3: AbLegacy contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/*`, move `AbLegacyDriverOptions.cs`, update `Driver.AbLegacy.csproj` + slnx.
|
||||
|
||||
### Task 1.4: S7 contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/*`, move `S7DriverOptions.cs`, update `Driver.S7.csproj` + slnx.
|
||||
|
||||
### Task 1.5: TwinCAT contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/*`, move `TwinCATDriverOptions.cs`, update `Driver.TwinCAT.csproj` + slnx.
|
||||
|
||||
### Task 1.6: FOCAS contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/*`, move `FocasDriverOptions.cs`, update `Driver.FOCAS.csproj` + slnx.
|
||||
|
||||
### Task 1.7: OpcUaClient contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/*`, move `OpcUaClientDriverOptions.cs`, update `Driver.OpcUaClient.csproj` + slnx.
|
||||
|
||||
### Task 1.8: Galaxy contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/*`, move `Config/GalaxyDriverOptions.cs` (place at root of contracts project — drop the `Config/` subdir), update `Driver.Galaxy.csproj` + slnx.
|
||||
|
||||
### Task 1.9: Wonderware Historian client contracts
|
||||
**Classification:** small · **Estimated implement time:** ~3 min · **Parallelizable with:** 1.1–1.9 (except itself)
|
||||
**Files:** Create `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/*`, move `WonderwareHistorianClientOptions.cs`, update `Driver.Historian.Wonderware.Client.csproj` + slnx.
|
||||
|
||||
### Task 1.10: Validate the full solution + add ProbeTimeout property to each Options class
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (depends on 1.1–1.9)
|
||||
|
||||
**Files:**
|
||||
- Modify: each of the 9 `*DriverOptions.cs` files just moved into the contracts projects.
|
||||
|
||||
**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
|
||||
**Step 2:** Add a `ProbeTimeout` property to each Options class with a driver-appropriate default:
|
||||
|
||||
```csharp
|
||||
/// <summary>Timeout for the AdminUI Test Connect probe. Server-side max = 60s.</summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default {n}s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = <default>;
|
||||
```
|
||||
|
||||
Defaults: Modbus 5, AbCip 5, AbLegacy 5, S7 5, TwinCAT 10, FOCAS 10, OpcUaClient 15, Galaxy 30, Wonderware Historian 15.
|
||||
|
||||
**Step 3:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — clean (any driver runtime code that constructs the Options via positional record syntax may break — fix by using `with { ProbeTimeoutSeconds = N }` or making it a property with default).
|
||||
|
||||
**Step 4:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — all existing tests still pass.
|
||||
|
||||
**Step 5:** Commit. `feat(drivers): expose ProbeTimeoutSeconds on every driver Options class`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Shared section components
|
||||
|
||||
### Task 2.1: DriverFormShell.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2.2, 2.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormShell.razor`
|
||||
|
||||
**Step 1:** Implement panel chrome (`<section class="panel rise">` + `<div class="panel-head">`) with `Title`, `ChildContent`, `Footer` render fragments. Cancel/Save/Delete buttons via parameters: `OnSave` `EventCallback`, `OnCancel` `EventCallback`, `OnDelete` `EventCallback?` (null hides delete). `Busy` bool drives spinner + disabled. Error banner from `Error` string param.
|
||||
|
||||
**Step 2:** Pattern-match the existing `DriverEdit.razor` save bar (lines 116–128) — same visual layout.
|
||||
|
||||
**Step 3:** No code-behind logic; pure presentation.
|
||||
|
||||
**Step 4:** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` — clean.
|
||||
|
||||
**Step 5:** Commit. `feat(adminui): add DriverFormShell shared component`
|
||||
|
||||
### Task 2.2: DriverIdentitySection.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2.1, 2.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor`
|
||||
|
||||
**Step 1:** Component renders the identity fields lifted from `DriverEdit.razor` lines 38–88: `DriverInstanceId` (read-only when not new), `Name`, `NamespaceId` (select from `Namespace[]` passed in), `Enabled`. Bind via `IdentityModel` record passed as `@bind-Value`.
|
||||
|
||||
**Step 2:** Define `IdentityModel` record in the same file's `@code` block: `public sealed record IdentityModel { ... }`. Properties match the existing `FormModel` Identity fields, with their `[Required]` / `[RegularExpression]` attributes preserved.
|
||||
|
||||
**Step 3:** Component takes `IsNew` bool, `Namespaces` list.
|
||||
|
||||
**Step 4:** Build clean.
|
||||
|
||||
**Step 5:** Commit. `feat(adminui): add DriverIdentitySection shared component`
|
||||
|
||||
### Task 2.3: DriverResilienceSection.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 2.1, 2.2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor`
|
||||
|
||||
**Step 1:** For this PR, keep the existing JSON textarea for Polly overrides — typed-form-ifying Polly is out of scope (Section 10 of the design says so implicitly). The component wraps the textarea + help text from `DriverEdit.razor` lines 101–109 in a panel.
|
||||
|
||||
**Step 2:** Bind `[Parameter] public string? ResilienceConfig { get; set; }` + `EventCallback<string?> ResilienceConfigChanged`.
|
||||
|
||||
**Step 3:** Build clean.
|
||||
|
||||
**Step 4:** Commit. `feat(adminui): add DriverResilienceSection shared component`
|
||||
|
||||
### Task 2.4: Wire the three new sections into existing DriverEdit.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (depends on 2.1–2.3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
|
||||
**Step 1:** Replace lines 38–88 with `<DriverIdentitySection @bind-Value="_identityModel" Namespaces="_namespaces" IsNew="IsNew" />`. Wire `_identityModel` to/from `_form` in `OnInitializedAsync` and `SubmitAsync`.
|
||||
|
||||
**Step 2:** Replace lines 101–109 with `<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />`.
|
||||
|
||||
**Step 3:** Wrap the form in `<DriverFormShell Busy="_busy" Error="_error" OnSave="SubmitAsync" OnCancel="@(...)" OnDelete="@(IsNew ? null : DeleteAsync)">`.
|
||||
|
||||
**Step 4:** Smoke test: `dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host` (admin role), open `/clusters/<existing>/drivers/<existing>`, page renders identically to before. (Driver config JSON textarea + identity fields + save bar visually unchanged.)
|
||||
|
||||
**Step 5:** Commit. `refactor(adminui): drive DriverEdit.razor through shared section components`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Router + Type Picker
|
||||
|
||||
### Task 3.1: DriverTypePicker.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3.2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor`
|
||||
|
||||
**Step 1:** `@page "/clusters/{ClusterId}/drivers/new"` (this *replaces* the same route on the existing `DriverEdit.razor` — order matters; we'll yank the old route in Task 3.3).
|
||||
|
||||
**Step 2:** Renders a grid of 9 driver-type cards (`ModbusTcp`, `AbCip`, `AbLegacy`, `S7`, `TwinCat`, `FOCAS`, `OpcUaClient`, `Galaxy`, `Historian.Wonderware`). Each card is a `<a href="/clusters/@ClusterId/drivers/new/<type-slug>">` linking to the typed new-form route. Type slug = lowercase driver-type string (e.g. `modbustcp` → keep human-readable; map slug → DriverType enum-string in a static dictionary in this file).
|
||||
|
||||
**Step 3:** Card content: driver type name, one-line description, an icon (text symbol fine — `[M]`, `[7]`, `[OPC]`, etc., no new images this PR).
|
||||
|
||||
**Step 4:** `<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />` for consistency with peer pages.
|
||||
|
||||
**Step 5:** Build clean. Commit. `feat(adminui): add DriverTypePicker landing page`
|
||||
|
||||
### Task 3.2: DriverEditRouter.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3.1
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor`
|
||||
|
||||
**Step 1:** `@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"` — same edit route as today's `DriverEdit.razor` (will collide; resolve in Task 3.3).
|
||||
|
||||
**Step 2:** In `OnInitializedAsync`: load the `DriverInstance` row, read its `DriverType` string.
|
||||
|
||||
**Step 3:** Map `DriverType` → component type via a static dictionary literal `_componentMap`:
|
||||
```csharp
|
||||
private static readonly IReadOnlyDictionary<string, Type> _componentMap = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase) {
|
||||
["ModbusTcp"] = typeof(ModbusDriverPage),
|
||||
["AbCip"] = typeof(AbCipDriverPage),
|
||||
["AbLegacy"] = typeof(AbLegacyDriverPage),
|
||||
["S7"] = typeof(S7DriverPage),
|
||||
["TwinCat"] = typeof(TwinCatDriverPage),
|
||||
["Focas"] = typeof(FocasDriverPage),
|
||||
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
|
||||
["Galaxy"] = typeof(GalaxyDriverPage),
|
||||
["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage),
|
||||
};
|
||||
```
|
||||
|
||||
**Step 4:** Render `<DynamicComponent Type="_componentMap[_driverType]" Parameters="_params" />` where `_params = new Dictionary<string, object?> { ["ClusterId"] = ClusterId, ["DriverInstanceId"] = DriverInstanceId }`.
|
||||
|
||||
**Step 5:** Until the typed pages exist (Phase 4), the map is empty + this page falls back to a "not yet implemented for type X" notice. Keep route collision deferred until Task 3.3.
|
||||
|
||||
**Step 6:** Build clean. Commit. `feat(adminui): add DriverEditRouter dispatch page`
|
||||
|
||||
### Task 3.3: Resolve route collision — delete old new-route, keep old edit-route until Phase 5
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (depends on 3.1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
|
||||
**Step 1:** Delete line 1 (`@page "/clusters/{ClusterId}/drivers/new"`). Keep line 2 (`@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"`). Until Task 3.4 unhooks it too, the old generic edit page still owns the edit route — `DriverEditRouter.razor` from Task 3.2 stays inert (build fine, but unreachable).
|
||||
|
||||
**Step 2:** Build clean.
|
||||
|
||||
**Step 3:** Smoke test: `/clusters/<id>/drivers/new` now hits `DriverTypePicker.razor`. `/clusters/<id>/drivers/<existing>` still hits `DriverEdit.razor`.
|
||||
|
||||
**Step 4:** Commit. `refactor(adminui): hand /drivers/new to DriverTypePicker`
|
||||
|
||||
### Task 3.4: Hand /drivers/{id} from DriverEdit.razor to DriverEditRouter.razor
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (depends on 3.3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
|
||||
**Step 1:** Delete the remaining `@page` directive — file no longer routes. The router from Task 3.2 owns the route. The DriverEdit `.razor` file stays on disk as a referenceable component used as a fallback inside the router until all 9 typed pages land (Phase 4).
|
||||
|
||||
**Step 2:** Update `DriverEditRouter.razor` `_componentMap` so any driver type *not yet implemented* falls back to `typeof(DriverEdit)` — passing parameters identically. This keeps every existing driver row editable through whichever editor (typed or generic) is available at the time the row is opened.
|
||||
|
||||
**Step 3:** Build clean.
|
||||
|
||||
**Step 4:** Smoke test: open `/clusters/<id>/drivers/<existing-modbus-row>`. Router dispatches → falls back to `DriverEdit.razor` (since `ModbusDriverPage` doesn't exist yet) → page renders as before.
|
||||
|
||||
**Step 5:** Commit. `refactor(adminui): route /drivers/{id} through DriverEditRouter`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Typed driver pages (one per driver)
|
||||
|
||||
**Pattern (apply to every task in this phase):**
|
||||
|
||||
Each `<Type>DriverPage.razor` is a self-contained page with:
|
||||
|
||||
1. Route(s):
|
||||
- `@page "/clusters/{ClusterId}/drivers/new/<type-slug>"` (new path)
|
||||
- The router (Task 3.2) dispatches the edit case — the page itself does NOT declare the edit route; it accepts `[Parameter] public string? DriverInstanceId { get; set; }` and the router passes it.
|
||||
2. Wraps everything in `<DriverFormShell>` (Task 2.1).
|
||||
3. Top: `<DriverIdentitySection>` (Task 2.2).
|
||||
4. Middle: a `<section class="panel">` per logical group of driver options. Each `<InputText>` / `<InputNumber>` / `<InputSelect>` is *explicitly* bound to a property on a form model (`<Type>FormModel`) inside `@code`. Field labels + help text come from the `[Display(Name, Description, GroupName)]` attributes on the `Options` class — but read via `ModelMetadata`, NOT via reflection at render time. (Implementation hint: use a static helper `static string Label<T>(Expression<Func<T,object?>> path)` that pops `[Display]` off at compile-time — simpler is to just hard-code the label in markup and treat `[Display]` as the redundant runtime hint for the API/validator. **Hard-coding labels is the chosen path — keep the page Razor explicit.**)
|
||||
5. Below: `<DriverTestConnectButton DriverType="<Type>" GetConfigJson="@BuildConfigJson" TimeoutSeconds="@_form.ProbeTimeoutSeconds" />` (real component lands in Phase 7; for Phase 4 ship a stub component that just disables the button and shows "Available after Phase 7" — defined in Phase 7 Task 7.5).
|
||||
6. Below: `<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_form.Enabled" />` (only visible in edit mode, not new — stub lands in Phase 6).
|
||||
7. Below: `<DriverResilienceSection>` (Task 2.3).
|
||||
8. Tag picker: opens `<DriverTagPicker>` modal with the driver's picker body (Phase 9).
|
||||
9. Save path: serializes the typed form model to JSON via `JsonSerializer.Serialize(_form.Config, _jsonOpts)`, normalizes (same as today's `NormalizeJson`), upserts the `DriverInstance` row with `RowVersion` opt-concurrency. Match the existing save flow in `DriverEdit.razor:187-257` line-for-line for the upsert mechanics.
|
||||
10. Load path: if `DriverInstanceId != null`, load row, `JsonSerializer.Deserialize<<Type>Options>(row.DriverConfig, _jsonOpts)` where `_jsonOpts = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }`. Wrap into the form model.
|
||||
11. After save, navigate to `/clusters/{ClusterId}/drivers` (the list page — match existing behavior).
|
||||
|
||||
**Per-driver task acceptance:**
|
||||
- Page compiles, routes resolve.
|
||||
- For a new instance: typed form save round-trips to DB; row's `DriverType` is set; `DriverConfig` JSON contains every field shown in the form.
|
||||
- For an edit: page loads existing row; every field populates; save preserves all fields.
|
||||
- Update `DriverEditRouter.razor` `_componentMap` to point this driver type at the new page.
|
||||
- Update `ClusterDrivers.razor` (the list page) — no change needed; it already links via the unified edit route.
|
||||
- Add a unit test in `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/<Type>DriverPageFormSerializationTests.cs` round-tripping `<Type>Options` ↔ JSON ↔ form-model ↔ `<Type>Options`. Use Shouldly.
|
||||
|
||||
### Task 4.1: ModbusDriverPage.razor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 4.2 – 4.9
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor`
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor` (`_componentMap`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (add `ProjectReference` to `Driver.Modbus.Contracts`)
|
||||
|
||||
Follow Phase 4 pattern. Modbus is the largest options class (289 lines); group into panels: **Transport** (endpoint, port, unit ID), **Polling** (interval, batch sizes), **Probe** (probe options + `ProbeTimeoutSeconds`), **Tuning** (timeouts, retries). Read `ModbusDriverOptions.cs` first to enumerate every property.
|
||||
|
||||
### Task 4.2: AbCipDriverPage.razor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 4.1, 4.3 – 4.9
|
||||
|
||||
**Files:** as 4.1, swap `Modbus` → `AbCip`. Add `ProjectReference` to `Driver.AbCip.Contracts`. Read `AbCipDriverOptions.cs` to enumerate fields. PLC family selector (CompactLogix / ControlLogix) is a key field.
|
||||
|
||||
### Task 4.3: AbLegacyDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1, 4.2, 4.4–4.9
|
||||
**Files:** as 4.1 for AbLegacy. Read `AbLegacyDriverOptions.cs`.
|
||||
|
||||
### Task 4.4: S7DriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.3, 4.5–4.9
|
||||
**Files:** as 4.1 for S7. CPU/rack/slot tuple in a Connection panel.
|
||||
|
||||
### Task 4.5: TwinCatDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.4, 4.6–4.9
|
||||
**Files:** as 4.1 for TwinCAT. AMS NetId + port in a Connection panel.
|
||||
|
||||
### Task 4.6: FocasDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.5, 4.7–4.9
|
||||
**Files:** as 4.1 for FOCAS. CNC series + connection params.
|
||||
|
||||
### Task 4.7: OpcUaClientDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.6, 4.8, 4.9
|
||||
**Files:** as 4.1 for OpcUaClient. **Security profile (None / Basic256Sha256-Sign / Basic256Sha256-SignAndEncrypt)** is a dropdown sourced from the same enum the OPC UA Server uses (cross-ref `docs/security.md`). Username/password are `[DataType(DataType.Password)]`.
|
||||
|
||||
### Task 4.8: GalaxyDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.7, 4.9
|
||||
**Files:** as 4.1 for Galaxy. Two panels: **mxaccessgw** (gateway endpoint, API key — password input), **Galaxy** (ClientName, SQL config db connection if exposed in options). API key field is `[DataType(DataType.Password)]`.
|
||||
|
||||
### Task 4.9: HistorianWonderwareDriverPage.razor
|
||||
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** 4.1–4.8
|
||||
**Files:** as 4.1 for the Wonderware Historian (the page covers the *driver*'s view of historian client options — the `WonderwareHistorianClientOptions` lives in the `.Client.Contracts` project from Task 1.9). The driver type-string in DriverInstance is `Historian.Wonderware`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Delete the generic DriverEdit.razor
|
||||
|
||||
### Task 5.1: Remove DriverEdit.razor + fallback in router
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (depends on all 4.x tasks)
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor` (remove fallback)
|
||||
|
||||
**Step 1:** Confirm `_componentMap` in the router has all 9 driver types. Delete the "fallback to DriverEdit" branch.
|
||||
|
||||
**Step 2:** `git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor`.
|
||||
|
||||
**Step 3:** Build clean. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
|
||||
**Step 4:** Smoke test all 9 driver types: open the list page, open one existing row of each type, verify the typed page renders. (For types without existing rows on dev DB, create one via the type picker first.)
|
||||
|
||||
**Step 5:** Commit. `refactor(adminui): retire generic DriverEdit.razon (typed pages cover all 9 drivers)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Live status panel
|
||||
|
||||
### Task 6.1: DriverHealthChanged DPS message
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 6.2 (different folders)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs`
|
||||
|
||||
**Step 1:** Define record `public sealed record DriverHealthChanged(string ClusterId, string DriverInstanceId, string State, DateTime? LastSuccessUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc);` — `State` is the `DriverState` enum string (matches `Healthy` / `Connecting` / `Faulted` / `Unknown` used by `DriverHealth`).
|
||||
|
||||
**Step 2:** Add `[MemoryPackable]` if peer messages in this folder use MemoryPack — match the folder's existing pattern. (Look at `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs` for the canonical pattern.)
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(messages): add DriverHealthChanged DPS contract`
|
||||
|
||||
### Task 6.2: Publish DriverHealthChanged from each driver actor
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min (per driver; bundle = ~15 min — **split this into 6.2a–6.2d if implementer balks**)
|
||||
**Parallelizable with:** none (touches every driver actor)
|
||||
|
||||
**Files:**
|
||||
- Modify: each `<Type>Driver.cs` — find the place that updates `_health` (e.g. `FocasDriver.cs:565+`, `ModbusDriver.cs:1180+`). After every `Volatile.Write(ref _health, ...)`, also publish to DPS topic `driver-health-{ClusterId}` via the injected `DistributedPubSub.Get(_actorSystem).Mediator`.
|
||||
- Find pattern: `Volatile.Write(ref _health,` — every occurrence in `src/Drivers/**/*.cs` not under obj/bin.
|
||||
|
||||
**Step 1:** Inject the publish callback into each driver. The cleanest hook is `IDriverHealthPublisher` (new interface in `Core.Abstractions`), with the Akka-backed impl living in `Runtime` and DI-registered there. Driver constructors take `IDriverHealthPublisher` (nullable for backward compat in tests).
|
||||
|
||||
**Step 2:** After each `_health` write, call `_healthPublisher?.Publish(new DriverHealthChanged(...))`. Pull `ClusterId` + `DriverInstanceId` from the driver's existing identity (every driver already knows its instance ID for telemetry tags).
|
||||
|
||||
**Step 3:** Add `ErrorCount5Min` tracking: a sliding-window counter on the driver — bump on every transition into `Faulted`, decay over 5min. Simple impl: a `Queue<DateTime>` guarded by lock; on read, dequeue entries older than 5min and return `.Count`.
|
||||
|
||||
**Step 4:** `dotnet build` clean. `dotnet test` clean. Driver unit tests may need a no-op `IDriverHealthPublisher` (provide one in Core.Abstractions: `public sealed class NullDriverHealthPublisher : IDriverHealthPublisher { public void Publish(DriverHealthChanged _) { } }`).
|
||||
|
||||
**Step 5:** Commit. `feat(drivers): publish DriverHealthChanged to DPS on every health transition`
|
||||
|
||||
### Task 6.3: DriverStatusHub
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 6.4
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusHub.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs` (register hub)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs` (`MapHub<DriverStatusHub>("/hubs/driverstatus")`)
|
||||
|
||||
**Step 1:** Hub with one method: `Task JoinDriver(string driverInstanceId)` — adds the connection to a group named `driver:{driverInstanceId}`, immediately invokes `Clients.Caller.SendAsync("status", currentSnapshot)`. Inject `IDriverStatusSnapshotStore` (new — Task 6.4) to read the current snapshot.
|
||||
|
||||
**Step 2:** Hub method-name constant: `public const string MethodName = "status";`.
|
||||
|
||||
**Step 3:** `[Microsoft.AspNetCore.Authorization.Authorize]` on the class (same auth as the existing AdminUI hubs).
|
||||
|
||||
**Step 4:** Build clean. Commit. `feat(adminui): add DriverStatusHub`
|
||||
|
||||
### Task 6.4: DriverStatusSignalRBridge + snapshot store
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 6.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusSignalRBridge.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs` (spawn bridge actor on admin-role startup; DI-register snapshot store as singleton)
|
||||
|
||||
**Step 1:** Bridge = Akka `ReceiveActor`. `PreStart` subscribes to DPS topic `driver-health-*` (or one topic + per-cluster filter — pick the same wildcard convention `FleetStatusSignalRBridge` uses). On `DriverHealthChanged msg`: writes to `IDriverStatusSnapshotStore` (latest-snapshot-wins, keyed by instance ID), then `_hub.Clients.Group($"driver:{msg.DriverInstanceId}").SendAsync(DriverStatusHub.MethodName, msg)`.
|
||||
|
||||
**Step 2:** `InMemoryDriverStatusSnapshotStore`: `ConcurrentDictionary<string, DriverHealthChanged> _byInstance;` — `Upsert(msg)` and `TryGet(instanceId, out msg)`.
|
||||
|
||||
**Step 3:** Wire bridge in `AddOtOpcUaSignalRBridges` (or equivalent). Singleton snapshot store. Build clean.
|
||||
|
||||
**Step 4:** Commit. `feat(adminui): add DriverStatusSignalRBridge + snapshot store`
|
||||
|
||||
### Task 6.5: DriverStatusPanel.razor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (consumes 6.3 + 6.4)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor`
|
||||
|
||||
**Step 1:** Parameters: `DriverInstanceId`, `Enabled`. Inject `NavigationManager` for hub URL building, `IServiceProvider` for hub-connection auth.
|
||||
|
||||
**Step 2:** `OnInitializedAsync`: if `Enabled == false`, render "Disabled — not deployed" + skip the hub. Else build a `HubConnection` (`HubConnectionBuilder` → `WithUrl(Nav.ToAbsoluteUri("/hubs/driverstatus"))` → `WithAutomaticReconnect()` → `Build()`), register `On<DriverHealthChanged>("status", ...)`, `StartAsync`, then `InvokeAsync("JoinDriver", DriverInstanceId)`. The handler updates `_snapshot` + `StateHasChanged`.
|
||||
|
||||
**Step 3:** Render: state chip (color-mapped: `Healthy` green, `Connecting` yellow, `Faulted` red, `Unknown` gray) + "last success {humanized timestamp}" + `ErrorCount5Min` badge + collapsible "last error" panel showing `LastError` if set. Visual reuses existing `chip` / `panel` styles from sibling pages.
|
||||
|
||||
**Step 4:** `_lastSnapshotAt = DateTime.UtcNow` on each push; if `(now - _lastSnapshotAt) > 30s` (timer-driven re-render every 5s), add `dim` class to the whole panel.
|
||||
|
||||
**Step 5:** `DisposeAsync`: `await _hub.DisposeAsync()`. Implement `IAsyncDisposable`.
|
||||
|
||||
**Step 6:** Wire into all 9 driver pages. In edit mode (i.e. `DriverInstanceId != null`), render `<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_form.Identity.Enabled" />` above the resilience section.
|
||||
|
||||
**Step 7:** Build clean. Smoke test: bring up `lmxopcua-fix up modbus`, deploy a Modbus driver pointing at the sim, observe `Healthy` push within seconds. Stop the sim, observe `Faulted` push within the driver's poll interval. Commit. `feat(adminui): live driver status panel on every driver page`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Test Connect
|
||||
|
||||
### Task 7.1: IDriverProbe interface + TestDriverConnect message
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 7.2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs`
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnect.cs`
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnectResult.cs`
|
||||
|
||||
**Step 1:** `IDriverProbe`:
|
||||
```csharp
|
||||
public interface IDriverProbe
|
||||
{
|
||||
/// <summary>Driver-type string this probe handles (matches DriverInstance.DriverType).</summary>
|
||||
string DriverType { get; }
|
||||
/// <summary>Run a connection probe. Never mutates; never writes.</summary>
|
||||
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
|
||||
}
|
||||
public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency);
|
||||
```
|
||||
|
||||
**Step 2:** `TestDriverConnect(string DriverType, string ConfigJson, int TimeoutSeconds, Guid CorrelationId)` and `TestDriverConnectResult(bool Ok, string? Message, double? LatencyMs, Guid CorrelationId)`. Match the MemoryPack conventions of peer messages in `Messages/Admin/`.
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(messages,abstractions): add IDriverProbe + TestDriverConnect contract`
|
||||
|
||||
### Task 7.2: AdminOperationsActor handler for TestDriverConnect
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7.1 (separate file; modify late)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs`
|
||||
|
||||
**Step 1:** Add ctor param `IEnumerable<IDriverProbe> probes`. Build `_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase)` in ctor. Update `Props` factory accordingly.
|
||||
|
||||
**Step 2:** `ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync)`. Handler:
|
||||
```csharp
|
||||
private async Task HandleTestDriverConnectAsync(TestDriverConnect msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
if (!_probesByType.TryGetValue(msg.DriverType, out var probe))
|
||||
{
|
||||
replyTo.Tell(new TestDriverConnectResult(false, $"No probe registered for driver type '{msg.DriverType}'", null, msg.CorrelationId));
|
||||
return;
|
||||
}
|
||||
var clampedSec = Math.Clamp(msg.TimeoutSeconds, 1, 60);
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(clampedSec));
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = await probe.ProbeAsync(msg.ConfigJson, TimeSpan.FromSeconds(clampedSec), cts.Token);
|
||||
replyTo.Tell(new TestDriverConnectResult(result.Ok, result.Message, sw.Elapsed.TotalMilliseconds, msg.CorrelationId));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
replyTo.Tell(new TestDriverConnectResult(false, $"Probe timed out after {clampedSec}s", null, msg.CorrelationId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "Probe for {DriverType} threw", msg.DriverType);
|
||||
replyTo.Tell(new TestDriverConnectResult(false, ex.Message, null, msg.CorrelationId));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3:** Update wherever `AdminOperationsActor.Props(...)` is called (search the repo) to pass the new `probes` enumerable. Likely in `Runtime` DI registration — register all `IDriverProbe` impls then resolve `IEnumerable<IDriverProbe>` for the singleton.
|
||||
|
||||
**Step 4:** Build clean. Commit. `feat(adminops): handle TestDriverConnect via per-driver IDriverProbe`
|
||||
|
||||
### Task 7.3: TCP-only probe impls (Modbus, AbCip, AbLegacy, S7)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7.4
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs`
|
||||
|
||||
**Step 1:** Each impl deserializes its own Options class from the configJson, extracts `Endpoint` (host:port — exact field name depends on the Options class), opens a TCP `Socket` with `ConnectAsync(host, port, ct)`, closes immediately. On success: `(true, null, sw.Elapsed)`. On `SocketException`: `(false, ex.SocketErrorCode.ToString(), null)`.
|
||||
|
||||
**Step 2:** Register each as `services.AddSingleton<IDriverProbe, ModbusDriverProbe>()` (and peers) in the driver's existing `*FactoryExtensions.cs` `Add*` method.
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(drivers): TCP-only probes for Modbus, AbCip, AbLegacy, S7`
|
||||
|
||||
### Task 7.4: Specialty probes (FOCAS, TwinCAT, OpcUaClient, Galaxy, Historian.Wonderware)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (per driver; bundle ~15 min — **may need to split into 7.4a–7.4e**)
|
||||
**Parallelizable with:** Task 7.3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs` — calls FOCAS `cnc_allclibhndl3` connect + immediate `cnc_freelibhndl`. Reuses the existing `IFocasClient.Connect`.
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs` — opens an `AmsAddress` and sends an ADS Read State request.
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs` — opens an `OPCFoundation.NetStandard.Opc.Ua.Client.Session` against the configured endpoint with the configured security profile, immediately closes.
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Health/GalaxyDriverProbe.cs` — sends `MxCommand.Ping` to mxaccessgw via the existing gRPC client. (Build on the existing `Health/` folder.)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/HistorianWonderwareDriverProbe.cs` — TCP probe to historian endpoint (historian client uses an MX-style transport; cheap path is a TCP connect to the historian's IPC port + close).
|
||||
|
||||
**Step 1:** Each impl registers in its driver's `Add*` extension.
|
||||
|
||||
**Step 2:** Build clean. `dotnet test` clean. Commit. `feat(drivers): specialty Test Connect probes for FOCAS/TwinCAT/OPCUA/Galaxy/Historian`
|
||||
|
||||
### Task 7.5: DriverTestConnectButton.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (consumes 7.2 + 7.3 + 7.4)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs`
|
||||
|
||||
**Step 1:** `AdminProbeService` — thin wrapper around `IAdminOperationsClient`. Method `Task<TestDriverConnectResult> TestAsync(string driverType, string configJson, int timeoutSeconds, CancellationToken ct)`. Builds the message + Asks + applies a `Task.WhenAny` 65s timeout wall as outer guard. DI-register as scoped.
|
||||
|
||||
**Step 2:** Button component params: `DriverType`, `GetConfigJson` (`Func<string>`), `TimeoutSeconds` (`int`). Renders `<button class="btn btn-outline-primary btn-sm">Test Connect</button>` + inline result chip (green tick + latency, or red x + message). Spinner during in-flight. Auto-clears chip after 30s.
|
||||
|
||||
**Step 3:** On click: invokes `AdminProbeService.TestAsync(DriverType, GetConfigJson(), TimeoutSeconds, ct)` with `CancellationToken.None` (the actor-side timeout already bounds it).
|
||||
|
||||
**Step 4:** Wire into all 9 driver pages by replacing the Phase 4 stub.
|
||||
|
||||
**Step 5:** Build clean. Smoke test: open `/clusters/<id>/drivers/new/modbustcp`, type sim endpoint into form, click Test Connect → green. Wrong port → red within 5s. Black-holed IP → "Probe timed out after 5s".
|
||||
|
||||
**Step 6:** Commit. `feat(adminui): Test Connect button on every driver page`
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Reconnect / Restart
|
||||
|
||||
### Task 8.1: RestartDriver + ReconnectDriver messages + AdminOperationsActor handlers
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8.2 (separate files)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs`
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/ReconnectDriver.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs`
|
||||
|
||||
**Step 1:** Messages: `RestartDriver(string ClusterId, string DriverInstanceId, string ActorByUserName, Guid CorrelationId)` + `RestartDriverResult(bool Ok, string? Message, Guid CorrelationId)`. Same shape for `Reconnect*`.
|
||||
|
||||
**Step 2:** Handlers in the actor. They locate the running driver actor (the existing `DriverHostActor` hierarchy already addresses driver actors by instance ID — find the existing lookup mechanism in `DriverHostActor.cs` / `Runtime` and reuse it). Reconnect = Tell the driver actor a `Reconnect` internal command; Restart = Tell its supervisor to stop+restart the child.
|
||||
|
||||
**Step 3:** Audit-log every call via the existing `ConfigEdits` mechanism — entity type `DriverInstance`, fields `{op: restart|reconnect}`.
|
||||
|
||||
**Step 4:** Build clean. Commit. `feat(adminops): Restart/Reconnect driver operations`
|
||||
|
||||
### Task 8.2: DriverOperator authorization policy
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 8.1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/` — add a `DriverOperator` policy alongside the existing `WriteOperate` / `WriteTune` policies. Map to LDAP group `ot-driver-operator` (or document the chosen group name in `docs/Security.md`).
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/` — register the policy with `AddAuthorizationBuilder().AddPolicy("DriverOperator", p => p.RequireRole("ot-driver-operator"))` (or whatever pattern the existing AdminUI policies use).
|
||||
- Modify: `docs/Security.md` — add a row to the role/policy table.
|
||||
|
||||
**Step 1:** Mirror the shape of the most-similar existing policy (probably `WriteOperate`).
|
||||
|
||||
**Step 2:** Build clean. Commit. `feat(security): add DriverOperator authorization policy`
|
||||
|
||||
### Task 8.3: Wire Reconnect/Restart buttons into DriverStatusPanel
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (depends on 8.1 + 8.2 + 6.5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor`
|
||||
|
||||
**Step 1:** Inject `IAuthorizationService`. In `OnInitializedAsync`, check `AuthorizeAsync(user, null, "DriverOperator")` → set `_canOperate` bool. Render buttons only when `_canOperate && Enabled`.
|
||||
|
||||
**Step 2:** Two buttons:
|
||||
- **Reconnect** — no confirm. Click: spinner on button, set inline "Reconnecting…" chip, invoke `AdminOperationsClient.AskAsync<ReconnectDriverResult>(new ReconnectDriver(...))`. Result chip clears once next `DriverHealthChanged` push arrives.
|
||||
- **Restart** — confirm dialog "Restart driver `<id>`? This briefly interrupts subscriptions." Same flow otherwise.
|
||||
|
||||
**Step 3:** Both buttons disabled (greyed-out) during in-flight ops or during a Test Connect on the same page (publish a simple page-scoped `bool _busyAnything` via the parent driver page → flows to the panel via a parameter).
|
||||
|
||||
**Step 4:** Build clean. Smoke test: Reconnect → see `Connecting → Healthy` transition push in the panel. Restart → confirm → see actor restart. Commit. `feat(adminui): Reconnect/Restart on DriverStatusPanel (DriverOperator-gated)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Static address pickers
|
||||
|
||||
### Task 9.1: DriverTagPicker.razor modal shell
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** 9.2–9.10
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTagPicker.razor`
|
||||
|
||||
**Step 1:** Modal shell. Params: `Visible` (`bool`), `OnClose` (`EventCallback`), `Title` (string, e.g. "Modbus address"), `ChildContent` (`RenderFragment`), `OnPickAddress` (`EventCallback<string>`). Renders a Bootstrap-style `.modal.show` (no JS interop — Razor-managed visibility class). The child fragment is the per-driver picker body.
|
||||
|
||||
**Step 2:** Includes a search box + "Use this address" button at the bottom; "Use" calls `OnPickAddress` with the value currently bound in the child.
|
||||
|
||||
**Step 3:** Build clean. Commit. `feat(adminui): DriverTagPicker modal shell`
|
||||
|
||||
### Task 9.2 – 9.10: Per-driver static picker bodies (9 tasks)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min each
|
||||
**Parallelizable with:** each other (different files)
|
||||
|
||||
For each driver, create `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/<Type>AddressPickerBody.razor`. Per Section 6.2 of the design:
|
||||
|
||||
| Task | Driver | Picker body |
|
||||
|---|---|---|
|
||||
| 9.2 | Modbus | Register-type dropdown (Coil/DiscreteInput/Holding/Input) + offset spinner + length → renders `4x00001-4`. |
|
||||
| 9.3 | AbCip | Tag name + element index; CompactLogix/ControlLogix hint from form. |
|
||||
| 9.4 | AbLegacy | File type (N/B/F/I/O/S/T/C/R) + file number + element. |
|
||||
| 9.5 | S7 | Area (DB/M/I/Q) + db-number + offset + S7 type → `DB10.DBD20:REAL`. |
|
||||
| 9.6 | TwinCat | Free-text ADS variable name + format hint. |
|
||||
| 9.7 | FOCAS | Parameter group dropdown + parameter ID; drives the FOCAS function-code lookup table. |
|
||||
| 9.8 | OpcUaClient | Free-text NodeId field. (Live browse deferred — Section 10.) |
|
||||
| 9.9 | Galaxy | Free-text `tag_name.AttributeName` field. (Live browse deferred.) |
|
||||
| 9.10 | Historian.Wonderware | Tag name + retrieval mode + interval. |
|
||||
|
||||
Each task:
|
||||
|
||||
**Step 1:** Create the per-driver picker body component.
|
||||
|
||||
**Step 2:** Add a small unit test in `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/<Type>AddressBuilderTests.cs` — for the static address builders that compute a string (Modbus, S7, FOCAS), assert input → string output. For free-text bodies (OpcUaClient, Galaxy), test pass-through.
|
||||
|
||||
**Step 3:** Wire into the matching `*DriverPage.razor` — add a "Pick address" button that toggles `<DriverTagPicker>` open with this body as its child.
|
||||
|
||||
**Step 4:** Build clean. Commit per task. `feat(adminui): <Type> address picker`
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — End-to-end verification
|
||||
|
||||
### Task 10.1: DriverTestConnectE2eTests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 10.2, 10.3
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverTestConnectE2eTests.cs`
|
||||
|
||||
**Step 1:** Use the existing Docker fixture pattern from peer tests in this project. Three test methods: Modbus, AbCip, S7 — each starts the corresponding `lmxopcua-fix up <driver>` fixture (the test project's fixture base class handles it) + asserts green probe vs sim, red probe vs wrong port, timeout vs `1.2.3.4:502` (black-holed).
|
||||
|
||||
**Step 2:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter DriverTestConnectE2eTests` — passes.
|
||||
|
||||
**Step 3:** Commit. `test(adminui): E2E Test Connect probes against Docker sims`
|
||||
|
||||
### Task 10.2: DriverReconnectE2eTests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 10.1, 10.3
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverReconnectE2eTests.cs`
|
||||
|
||||
**Step 1:** Start a Modbus driver against the sim, observe `Healthy`, dispatch `ReconnectDriver` via the in-cluster admin ops client, assert `Connecting → Healthy` transitions within 5s.
|
||||
|
||||
**Step 2:** Build + run. Commit. `test(adminui): E2E Reconnect operation`
|
||||
|
||||
### Task 10.3: DriverStatusHubE2eTests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 10.1, 10.2
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverStatusHubE2eTests.cs`
|
||||
|
||||
**Step 1:** Open a SignalR connection to `/hubs/driverstatus`, invoke `JoinDriver`, force a `DriverHealthChanged` via test seam (publish directly to the DPS topic), assert push received within 1s.
|
||||
|
||||
**Step 2:** Build + run. Commit. `test(adminui): E2E DriverStatusHub push`
|
||||
|
||||
### Task 10.4: Manual smoke checklist (documented, not automated)
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-05-28-adminui-driver-pages-design.md` — replace Section 8.3 stub with the actual checklist as run, with timestamps.
|
||||
|
||||
**Step 1:** Run the checklist (Section 8.3 of the design). Tick each item.
|
||||
|
||||
**Step 2:** Commit. `docs(plans): record AdminUI driver pages smoke-test results`
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope (documented follow-ups)
|
||||
|
||||
These are NOT part of this plan. Capture as separate work items after merge:
|
||||
|
||||
- Live OPC UA browse in OpcUaClient picker.
|
||||
- Live Galaxy hierarchy browse in Galaxy picker.
|
||||
- Historian.Wonderware tag list pulled from the historian store.
|
||||
- DriverStatusPanel history graphs + per-tag diagnostics.
|
||||
- Per-driver bespoke controls beyond Reconnect/Restart.
|
||||
- Polly resilience config typed-form (still a JSON textarea this PR).
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting verification (run before final PR)
|
||||
|
||||
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — clean.
|
||||
3. `lmxopcua-fix up modbus`, then run the manual smoke (10.4).
|
||||
4. Review `git diff --stat master..` — confirm scope matches plan (no surprise file changes).
|
||||
5. Confirm `OtOpcUa-docs-issues.md` shows no new XML-doc warnings introduced by the new code (run `commentchecker-aot` on the AdminUI + Drivers/* trees).
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-28-adminui-driver-pages-plan.md",
|
||||
"designPath": "docs/plans/2026-05-28-adminui-driver-pages-design.md",
|
||||
"tasks": [
|
||||
{"id": "0.1", "subject": "Create AdminUI test project + slnx entry + placeholder test", "status": "completed", "commit": "dc12c37"},
|
||||
|
||||
{"id": "1.1", "subject": "Driver.Modbus.Contracts — extract ModbusDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5058a56", "notes": "Has 1 ProjectReference to Modbus.Addressing (sibling zero-dep enum project) — design intent preserved."},
|
||||
{"id": "1.2", "subject": "Driver.AbCip.Contracts — extract AbCipDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "b474d63", "notes": "AbCipDataType enum moved with Options; extensions split into runtime."},
|
||||
{"id": "1.3", "subject": "Driver.AbLegacy.Contracts — extract AbLegacyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "4902295", "notes": "AbLegacyDataType + AbLegacyPlcFamilyProfile also moved; extensions split."},
|
||||
{"id": "1.4", "subject": "Driver.S7.Contracts — extract S7DriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "9f62f2c", "notes": "Parallel S7CpuType enum (7 values) + S7CpuTypeMap in runtime; S7.Cli + 2 tests fixed for type change."},
|
||||
{"id": "1.5", "subject": "Driver.TwinCAT.Contracts — extract TwinCATDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "a88721c", "notes": "TwinCATDataType enum moved; extensions split."},
|
||||
{"id": "1.6", "subject": "Driver.FOCAS.Contracts — extract FocasDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "d892ab9", "notes": "FocasCncSeries + FocasDataType enums moved; extensions split."},
|
||||
{"id": "1.7", "subject": "Driver.OpcUaClient.Contracts — extract OpcUaClientDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5f0e048", "notes": "All 4 enums self-contained in options file; no NuGet types leaked."},
|
||||
{"id": "1.8", "subject": "Driver.Galaxy.Contracts — extract GalaxyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5ffbc42", "notes": "Moved from Config/ subdir to contracts root; namespace preserved."},
|
||||
{"id": "1.9", "subject": "Driver.Historian.Wonderware.Client.Contracts — extract options", "status": "completed", "blockedBy": ["0.1"], "commit": "8c0a320", "notes": "Pure record, primitives only."},
|
||||
{"id": "1.10", "subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation", "status": "completed", "blockedBy": ["1.1","1.2","1.3","1.4","1.5","1.6","1.7","1.8","1.9"], "commit": "f2f6eeb"},
|
||||
|
||||
{"id": "2.1", "subject": "DriverFormShell.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "85af126"},
|
||||
{"id": "2.2", "subject": "DriverIdentitySection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "1ff3875", "notes": "Bonus ValidationMessage tags added."},
|
||||
{"id": "2.3", "subject": "DriverResilienceSection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "a008530"},
|
||||
{"id": "2.4", "subject": "Wire shared sections into existing DriverEdit.razor", "status": "completed", "blockedBy": ["2.1","2.2","2.3"], "commit": "a28f4cd", "notes": "Net -74 lines; zero functional regression."},
|
||||
|
||||
{"id": "3.1", "subject": "DriverTypePicker.razor (route: /drivers/new)", "status": "completed", "blockedBy": ["2.4"], "commit": "c0ce5d0"},
|
||||
{"id": "3.2", "subject": "DriverEditRouter.razor with DynamicComponent dispatch","status": "completed", "blockedBy": ["2.4"], "commit": "55e8bf7"},
|
||||
{"id": "3.3", "subject": "Hand /drivers/new from DriverEdit to DriverTypePicker","status": "completed", "blockedBy": ["3.1"], "commit": "27b3a01", "notes": "Bundled with 3.4 — single commit removed both @page directives."},
|
||||
{"id": "3.4", "subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)", "status": "completed", "blockedBy": ["3.2","3.3"], "commit": "27b3a01"},
|
||||
|
||||
{"id": "4.0", "subject": "AdminUI csproj references all 9 Driver.*.Contracts", "status": "completed", "blockedBy": ["1.10","3.4"], "commit": "7014c93", "notes": "Inserted as a precondition for parallel 4.1-4.9 implementation."},
|
||||
{"id": "4.1", "subject": "ModbusDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a3073d1"},
|
||||
{"id": "4.2", "subject": "AbCipDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dc21cba"},
|
||||
{"id": "4.3", "subject": "AbLegacyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "059a621"},
|
||||
{"id": "4.4", "subject": "S7DriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "5cad9b2"},
|
||||
{"id": "4.5", "subject": "TwinCatDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dfbf679"},
|
||||
{"id": "4.6", "subject": "FocasDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "8149739"},
|
||||
{"id": "4.7", "subject": "OpcUaClientDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "efcc231"},
|
||||
{"id": "4.8", "subject": "GalaxyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a243cfd"},
|
||||
{"id": "4.9", "subject": "HistorianWonderwareDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "2c16062"},
|
||||
{"id": "4.10","subject": "Wire all 9 typed pages into DriverEditRouter._componentMap", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "5f8fa70"},
|
||||
{"id": "4.11","subject": "Fixup: S7 Tags data-loss + missing FormModel tests (post-review)", "status": "completed", "blockedBy": ["4.10"], "commit": "c4086c2"},
|
||||
|
||||
{"id": "5.1", "subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "a971db3"},
|
||||
|
||||
{"id": "6.1", "subject": "DriverHealthChanged DPS message contract", "status": "pending", "blockedBy": ["5.1"]},
|
||||
{"id": "6.2", "subject": "Publish DriverHealthChanged from each driver actor (IDriverHealthPublisher)", "status": "pending", "blockedBy": ["6.1"]},
|
||||
{"id": "6.3", "subject": "DriverStatusHub", "status": "pending", "blockedBy": ["6.1"]},
|
||||
{"id": "6.4", "subject": "DriverStatusSignalRBridge + InMemoryDriverStatusSnapshotStore", "status": "pending", "blockedBy": ["6.2","6.3"]},
|
||||
{"id": "6.5", "subject": "DriverStatusPanel.razor + wire into all 9 driver pages", "status": "pending", "blockedBy": ["6.4"]},
|
||||
|
||||
{"id": "7.1", "subject": "IDriverProbe interface + TestDriverConnect messages", "status": "pending", "blockedBy": ["5.1"]},
|
||||
{"id": "7.2", "subject": "AdminOperationsActor handler for TestDriverConnect", "status": "pending", "blockedBy": ["7.1"]},
|
||||
{"id": "7.3", "subject": "TCP probes (Modbus, AbCip, AbLegacy, S7)", "status": "pending", "blockedBy": ["7.1"]},
|
||||
{"id": "7.4", "subject": "Specialty probes (FOCAS, TwinCAT, OPCUA, Galaxy, Historian)", "status": "pending", "blockedBy": ["7.1"]},
|
||||
{"id": "7.5", "subject": "AdminProbeService + DriverTestConnectButton.razor + wire into pages", "status": "pending", "blockedBy": ["7.2","7.3","7.4"]},
|
||||
|
||||
{"id": "8.1", "subject": "RestartDriver + ReconnectDriver messages + AdminOperationsActor handlers", "status": "pending", "blockedBy": ["6.5","7.5"]},
|
||||
{"id": "8.2", "subject": "DriverOperator authorization policy + docs/Security.md update", "status": "pending", "blockedBy": ["6.5"]},
|
||||
{"id": "8.3", "subject": "Wire Reconnect/Restart buttons into DriverStatusPanel", "status": "pending", "blockedBy": ["8.1","8.2"]},
|
||||
|
||||
{"id": "9.1", "subject": "DriverTagPicker.razor modal shell", "status": "pending", "blockedBy": ["5.1"]},
|
||||
{"id": "9.2", "subject": "Modbus address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.3", "subject": "AbCip address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.4", "subject": "AbLegacy address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.5", "subject": "S7 address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.6", "subject": "TwinCat address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.7", "subject": "FOCAS address picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.8", "subject": "OpcUaClient picker body (free-text NodeId)", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.9", "subject": "Galaxy picker body (free-text tag_name.AttributeName)", "status": "pending", "blockedBy": ["9.1"]},
|
||||
{"id": "9.10","subject": "Historian.Wonderware picker body + unit test", "status": "pending", "blockedBy": ["9.1"]},
|
||||
|
||||
{"id": "10.1", "subject": "DriverTestConnectE2eTests (Modbus/AbCip/S7 vs Docker sims)", "status": "pending", "blockedBy": ["8.3","9.10"]},
|
||||
{"id": "10.2", "subject": "DriverReconnectE2eTests", "status": "pending", "blockedBy": ["8.3","9.10"]},
|
||||
{"id": "10.3", "subject": "DriverStatusHubE2eTests", "status": "pending", "blockedBy": ["8.3","9.10"]},
|
||||
{"id": "10.4", "subject": "Manual smoke checklist (documented)", "status": "pending", "blockedBy": ["10.1","10.2","10.3"]}
|
||||
],
|
||||
"lastUpdated": "2026-05-28"
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
# Live address browsers for OpcUaClient + Galaxy drivers — design
|
||||
|
||||
> **Status:** approved 2026-05-28. Implementation plan to follow via `writing-plans`.
|
||||
> **Builds on:** PR that shipped driver-specific AdminUI pages (commit `0d3ec46`).
|
||||
> Both `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` were
|
||||
> intentionally shipped as static stubs ("enter the string manually") with live
|
||||
> browse deferred to this follow-up.
|
||||
|
||||
**Goal:** Add lazy, ad-hoc browse trees to the OpcUaClient and Galaxy address pickers in the AdminUI, so operators can navigate the remote server's (or galaxy's) hierarchy and pick an address rather than typing it.
|
||||
|
||||
**Architecture:** A new `IDriverBrowser` abstraction registered per driver type (parallel to the runtime's `IDriverProbe`), with implementations housed in sibling `*.Browser` projects under `src/Drivers/`. AdminUI owns the live browse sessions in-process via a `BrowseSessionRegistry` singleton with a 2-minute idle TTL and an `IHostedService` reaper. Razor picker bodies talk to a scoped `IBrowserSessionService`; no actor messages on the hot path.
|
||||
|
||||
**Tech stack:** .NET 10 / Blazor Server / OPCFoundation.NetStandard.Opc.Ua.Client / `ZB.MOM.WW.MxGateway.Client` (sibling repo, lazy-browse API already shipped).
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
### Abstraction
|
||||
|
||||
```csharp
|
||||
// Commons (shared)
|
||||
public interface IDriverBrowser {
|
||||
string DriverType { get; } // "OpcUaClient", "Galaxy", ...
|
||||
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IBrowseSession : IAsyncDisposable {
|
||||
Guid Token { get; }
|
||||
DateTime LastUsedUtc { get; }
|
||||
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct);
|
||||
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct); // empty for OPC UA
|
||||
}
|
||||
|
||||
public sealed record BrowseNode(
|
||||
string NodeId, // address persisted on commit
|
||||
string DisplayName,
|
||||
BrowseNodeKind Kind, // Folder | Leaf
|
||||
bool HasChildrenHint);
|
||||
|
||||
public sealed record AttributeInfo(
|
||||
string Name, // e.g. "DownloadPath"
|
||||
string DriverDataType,
|
||||
bool IsArray,
|
||||
string SecurityClass); // FreeAccess | Operate | Tune | Configure | ViewOnly
|
||||
|
||||
public enum BrowseNodeKind { Folder, Leaf }
|
||||
```
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
1. Razor picker body calls `BrowserSessionService.OpenAsync(driverType, formJson)`
|
||||
2. Service resolves `IDriverBrowser` from DI by driver type, calls `OpenAsync(json)`
|
||||
3. Returns `IBrowseSession`; service registers it in `BrowseSessionRegistry` under a new `Guid` token
|
||||
4. Razor stores token, calls `RootAsync(token)` to populate the initial tree
|
||||
5. Each subsequent expand-click calls `ExpandAsync(token, nodeId)`
|
||||
6. Picker body's `IAsyncDisposable.DisposeAsync` fires `CloseAsync(token)` on tear-down
|
||||
7. `BrowseSessionReaper` (`IHostedService`) ticks every 30s, evicts any session where `(UtcNow - LastUsedUtc) > 2 min`, awaits `DisposeAsync`
|
||||
|
||||
The session genuinely has no value to other cluster nodes — it's tied to one circuit. Hosting it in-process avoids cross-cluster Ask latency on every folder click.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components
|
||||
|
||||
### New projects
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | OPC UA browser impl + session |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/` | Galaxy browser impl + session |
|
||||
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/` | Unit tests (use opc-plc fixture) |
|
||||
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/` | Unit tests (fake transport) |
|
||||
|
||||
Driver-specific browsers live in **sibling** projects so AdminUI doesn't drag the runtime `Driver.*` projects (and their full SDK chains) through a transitive reference.
|
||||
|
||||
### New abstractions
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IDriverBrowser.cs` | Per-driver factory |
|
||||
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs` | Session contract |
|
||||
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs` | + `BrowseNodeKind` enum + `AttributeInfo` |
|
||||
|
||||
### AdminUI plumbing
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/Server/.../AdminUI/Browsing/BrowseSessionRegistry.cs` | Singleton, `ConcurrentDictionary<Guid, IBrowseSession>` |
|
||||
| `src/Server/.../AdminUI/Browsing/BrowseSessionReaper.cs` | `IHostedService`, 30s tick, 2 min idle TTL |
|
||||
| `src/Server/.../AdminUI/Browsing/IBrowserSessionService.cs` | Scoped DI service for Razor |
|
||||
| `src/Server/.../AdminUI/Browsing/BrowserSessionService.cs` | Impl: resolve driver, register session, enforce per-call timeouts |
|
||||
| `src/Server/.../AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor` | Shared lazy tree component with per-node text filter |
|
||||
|
||||
### Modified files
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `src/Server/.../Pickers/OpcUaClientAddressPickerBody.razor` | Add Browse button + DriverBrowseTree; keep manual entry |
|
||||
| `src/Server/.../Pickers/GalaxyAddressPickerBody.razor` | Same shape + side-panel for attribute pick |
|
||||
| `src/Server/.../AdminUI/Program.cs` | Register `IDriverBrowser` services + registry + reaper |
|
||||
| `src/Drivers/.../OpcUaClient.Contracts/NamespaceMap.cs` | Extract from runtime `Driver.OpcUaClient` for shared use |
|
||||
| `ZB.MOM.WW.OtOpcUa.slnx` | Add the four new projects |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data flow
|
||||
|
||||
**Open → tree → pick** (OpcUaClient as worked example; Galaxy identical except attribute side-panel before commit):
|
||||
|
||||
```
|
||||
Razor picker body BrowserSessionService IDriverBrowser Remote
|
||||
| | | |
|
||||
click Browse ────────► OpenAsync(driverType, json) ─► OpenAsync(json) ────────► connect + activate session
|
||||
| ◄──────────────── token (Guid) ◄───── ISession |
|
||||
| | | |
|
||||
render tree ─────────► RootAsync(token) ─────────────► session.RootAsync ─────► BrowseAsync(ObjectsFolder)
|
||||
| ◄──────────────── BrowseNode[] ◄───── refs |
|
||||
| | | |
|
||||
click folder ────────► ExpandAsync(token, nodeId) ──► session.ExpandAsync ───► BrowseAsync(nodeId)
|
||||
| ◄──────────────── BrowseNode[] ◄───── refs |
|
||||
| | | |
|
||||
click leaf + commit ─► CloseAsync(token) ─────────► session.DisposeAsync ───► CloseSession
|
||||
| | | |
|
||||
```
|
||||
|
||||
**Galaxy two-stage attribute pick:** after the user selects an object (Folder) in the tree, the picker body calls `AttributesAsync(token, tagName)` and renders the result as a side-panel. The user picks an attribute; the committed address is `tag_name.AttributeName`.
|
||||
|
||||
**Stable address format:**
|
||||
- OpcUaClient: `nsu=<uri>;<localid>` via `NamespaceMap.ToStableReference` — survives remote namespace-table reorder across restarts
|
||||
- Galaxy: `tag_name` (the globally unique system name) — already stable by definition
|
||||
|
||||
**Per-node text filter:** purely client-side over the already-loaded `node.Children`. No round-trip on filter input.
|
||||
|
||||
---
|
||||
|
||||
## 4. OpcUaClient browser specifics
|
||||
|
||||
### Connection
|
||||
- Reuses `OpcUaClientDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
|
||||
- Builds a **separate** `ApplicationConfiguration` from the runtime driver — PKI root at `%LocalAppData%/OtOpcUa/adminui-browse-pki/` (separate cert store)
|
||||
- `ApplicationName = "OtOpcUa AdminUI Browse"`, `ApplicationUri = "urn:OtOpcUa:AdminUI:Browse"`
|
||||
- Endpoint selection: same `DiscoveryClient.GetEndpointsAsync` → filter `(policy, mode)` as the runtime driver
|
||||
- One endpoint only (no failover) — interactive use; user retries with different URL on failure
|
||||
- Bounded by `OpcUaClientDriverOptions.PerEndpointConnectTimeout` (clamped [5, 30]s)
|
||||
|
||||
### Namespace map
|
||||
- `NamespaceMap` class extracted to `OpcUaClient.Contracts` so both runtime and Browser projects share one impl
|
||||
- Browser builds the map from the live session on open; uses `ToStableReference` for outbound NodeIds; uses `TryResolve` for inbound
|
||||
|
||||
### Lazy browse
|
||||
- One level per click using `Session.BrowseAsync` + `BrowseNextAsync` continuation-point loop
|
||||
- `BrowseDescriptionCollection` filters to `NodeClass.Object | NodeClass.Variable`, `ResultMask = BrowseName | DisplayName | NodeClass`
|
||||
- `BrowseNode.HasChildrenHint = (Kind == Folder)` — heuristic; saves a per-node round-trip
|
||||
- Inside-session calls guarded by `SemaphoreSlim _gate` (same pattern as runtime driver — OPC UA `Session.BrowseAsync` not thread-safe)
|
||||
|
||||
### Cert handling
|
||||
- `AutoAcceptCertificates = true` honored with parity to runtime + log warning + per-session unwire on dispose
|
||||
- `AutoAcceptCertificates = false` + untrusted cert → `OpenAsync` fails with SDK error message in the UI
|
||||
|
||||
### Reconnect handling
|
||||
- None. Browse sessions are short-lived (2 min idle TTL). Keep-alive failure → UI surfaces error chip → user re-clicks Browse.
|
||||
|
||||
---
|
||||
|
||||
## 5. Galaxy browser specifics
|
||||
|
||||
### Connection
|
||||
- Reuses `GalaxyDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
|
||||
- Opens `MxGatewaySession` with `ClientName = "OtOpcUa-AdminUI-Browse"` — distinct from runtime driver's name so the gateway can attribute load
|
||||
- Per-call gateway client built via `session.GalaxyRepository(opts.GalaxyName)`
|
||||
|
||||
### Lazy browse
|
||||
- Root: `client.BrowseAsync(new BrowseChildrenOptions(), ct)` → `IReadOnlyList<LazyBrowseNode>`
|
||||
- Expand: cached `LazyBrowseNode` lookup by `tag_name`, then `node.ExpandAsync(ct)` (gateway client handles paging internally)
|
||||
- No internal gate — `LazyBrowseNode.ExpandAsync` already has its own lock; gateway client is thread-safe across distinct calls
|
||||
|
||||
### Two-stage attribute pick
|
||||
- Galaxy `BrowseNode.Kind` is always `Folder` — leaves don't exist at tree level
|
||||
- When the user clicks an object node, picker body calls `AttributesAsync(token, tagName)` and shows the result as a side-panel listing `(Name, DriverDataType, IsArray, SecurityClass)`
|
||||
- On attribute click, committed address is `$"{tagName}.{attrName}"`
|
||||
- Backing call: either `BrowseChildrenOptions { IncludeAttributes = true }` filtered to the GobjectId, or a dedicated `GetAttributesAsync(GobjectId, ct)` — to be confirmed during plan write against the gateway client surface
|
||||
|
||||
### Filters in v1
|
||||
- Per-node text filter (client-side) for tree navigation
|
||||
- Server-side filters (`TagNameGlob`, `AlarmBearingOnly`, `HistorizedOnly`) deferred to a follow-up — easy to add later without breaking the wire (the session is constructed today with `new BrowseChildrenOptions()`)
|
||||
|
||||
---
|
||||
|
||||
## 6. Error handling, timeouts, TTL
|
||||
|
||||
### Failures
|
||||
- `OpenAsync` → catches `Exception`, logs Info, returns typed `BrowseOpenResult(Ok: false, Message, Token: Empty)`. UI shows red chip with truncated SDK message
|
||||
- `ExpandAsync` / `AttributesAsync` → same shape per-call. Failed branch shows error chip; rest of tree intact; session stays alive
|
||||
- `BrowseSessionNotFoundException` when token unknown (session reaped or never existed)
|
||||
|
||||
### Timeouts
|
||||
- Per-call expand/attributes: **20 s** via `CTS.CreateLinkedTokenSource(callerCt)` in `BrowserSessionService`
|
||||
- Session open: **30 s** ceiling; OPC UA reuses `PerEndpointConnectTimeout` (default 10 s), Galaxy hardcodes 30 s for `MxGatewaySession.OpenAsync`
|
||||
|
||||
### TTL & reaping
|
||||
- `LastUsedUtc` set on every `RootAsync`/`ExpandAsync`/`AttributesAsync`
|
||||
- Reaper: `IHostedService` with `PeriodicTimer(30s)`. On each tick: snapshot keys; for any session with `(UtcNow - LastUsedUtc) > 120s`: `TryRemove` then `await DisposeAsync` outside the dictionary
|
||||
- Concurrent `ExpandAsync` racing eviction → caller catches closed-session error → service translates to `BrowseSessionNotFoundException`
|
||||
- On AdminUI shutdown: `StopAsync` walks the registry once and disposes all sessions
|
||||
|
||||
### Concurrency
|
||||
- `BrowseSessionRegistry` = `ConcurrentDictionary<Guid, IBrowseSession>` — no extra lock
|
||||
- OpcUaClient session serializes browse on `SemaphoreSlim`; Galaxy session relies on its internal locks
|
||||
|
||||
### Component dispose
|
||||
- Razor picker body implements `IAsyncDisposable`
|
||||
- Fires `CloseAsync(token)` fire-and-forget (no await) so circuit teardown isn't blocked by a gRPC roundtrip
|
||||
- Reaper is the safety net if dispose doesn't fire
|
||||
|
||||
### Logging
|
||||
- Serilog. Info at open + close, Debug at close-with-reason (`user-close | idle-ttl | shutdown`), Info on failure
|
||||
- No per-expand logging (noise)
|
||||
|
||||
### Audit trail
|
||||
- None — browse is read-only and doesn't mutate config or driver state (matches probe pattern)
|
||||
|
||||
---
|
||||
|
||||
## 7. Security & auth
|
||||
|
||||
### Role gating
|
||||
- Browse button gated by existing `DriverOperator` LDAP policy — same as Reconnect/Restart in `DriverStatusPanel`
|
||||
- Picker bodies check policy in `OnInitializedAsync` via `IAuthorizationService` and `AuthenticationStateProvider`
|
||||
- Manual entry stays available regardless of role
|
||||
|
||||
### Credentials in JSON
|
||||
- Form JSON posted to `BrowserSessionService.OpenAsync` contains plaintext passwords / API keys — same as the existing `TestDriverConnect` probe
|
||||
- JSON is deserialized into typed Options → used to build SDK config → both released; no `_lastConfigJson` cached field anywhere in the registry or session impls
|
||||
- Browse session tokens are `Guid.NewGuid()` and only ever cross the authenticated Blazor circuit
|
||||
|
||||
### Cert handling
|
||||
- `AutoAcceptCertificates = true` honored with log warning + per-session unwire on dispose
|
||||
- Browse PKI store separate from runtime PKI — browse-time accept doesn't poison the runtime driver's trust store
|
||||
|
||||
### Rate limiting
|
||||
- None. DriverOperator role gating + 2-minute TTL is the budget. A bad actor with DriverOperator already has Reconnect/Restart capability
|
||||
|
||||
### Multi-replica AdminUI
|
||||
- Sticky cookies (already configured via Traefik) pin a user to one replica → `BrowseSessionRegistry` is always co-located with the circuit that created the token
|
||||
- Failover → token invalid on new replica → UI re-opens gracefully
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### Unit tests — per-driver browsers
|
||||
- `tests/Drivers/.../OpcUaClient.Browser.Tests/`: against opc-plc at `opc.tcp://10.100.0.35:50000`. `OpcUaClientBrowseSessionTests`, `OpcUaClientDriverBrowserTests` (bad endpoint, auth rejected, bad JSON)
|
||||
- `tests/Drivers/.../Galaxy.Browser.Tests/`: fake `IGalaxyRepositoryClientTransport` (precedent in gateway-client repo). `GalaxyBrowseSessionTests`, `GalaxyDriverBrowserTests`
|
||||
|
||||
### Unit tests — AdminUI plumbing (added to existing `tests/Server/AdminUI.Tests/`)
|
||||
- `BrowseSessionRegistryTests`: register/get/remove, concurrent registration
|
||||
- `BrowseSessionReaperTests`: virtual time, idle eviction, non-idle preservation, eviction-vs-in-flight-expand race
|
||||
- `BrowserSessionServiceTests`: open→root→expand→close, unknown driver type, per-call timeout enforced
|
||||
|
||||
### Component tests
|
||||
- `DriverBrowseTree` lazy-expand contract with fake `IBrowserSessionService`; per-node filter filters DOM but does not call ExpandAsync; click caching
|
||||
- Picker bodies: Browse button hidden when `!_canOperate`; manual entry still works
|
||||
|
||||
### Integration tests (opt-in, fixture-gated)
|
||||
- `tests/Drivers/.../OpcUaClient.Browser.IntegrationTests/`: end-to-end against opc-plc, 3-level expand + round-trip resolve. Skipped unless `OPCUA_SIM_ENDPOINT` set
|
||||
- No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred)
|
||||
|
||||
### Specific regression tests
|
||||
- Namespace-stable round-trip: open → browse → take returned NodeId string → `ExpandAsync(string)` → must resolve back to same NodeId
|
||||
- TTL reaper racing live ExpandAsync: `TryRemove` while expand is in-flight → safe, translates to `BrowseSessionNotFoundException`
|
||||
|
||||
### Verification at PR time
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean
|
||||
- `dotnet test tests/Server/.../AdminUI.Tests/` green (existing 51 + new ~12)
|
||||
- `dotnet test tests/Drivers/.../OpcUaClient.Browser.Tests/` with `lmxopcua-fix up opcuaclient`
|
||||
- `dotnet test tests/Drivers/.../Galaxy.Browser.Tests/` (no fixture)
|
||||
- Manual smoke: run AdminUI, edit an OpcUaClient driver, click Browse against opc-plc, pick a variable, verify the stored NodeId reads cleanly via Client CLI
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation sequencing (for plan-writing)
|
||||
|
||||
Suggested phase split — each phase shippable + reviewable independently:
|
||||
|
||||
1. **Phase 1 — Abstractions.** Add `IDriverBrowser`, `IBrowseSession`, `BrowseNode`, `AttributeInfo`, `BrowseNodeKind` to Commons. Empty build.
|
||||
2. **Phase 2 — Extract NamespaceMap.** Move from runtime `Driver.OpcUaClient` to `Driver.OpcUaClient.Contracts`; update runtime ref.
|
||||
3. **Phase 3 — OpcUaClient browser.** New `Driver.OpcUaClient.Browser` project; impl + unit tests against opc-plc.
|
||||
4. **Phase 4 — Galaxy browser.** New `Driver.Galaxy.Browser` project; impl + unit tests with fake transport. Confirm attribute-fetch API surface on `GalaxyRepositoryClient`.
|
||||
5. **Phase 5 — AdminUI plumbing.** `BrowseSessionRegistry`, `BrowseSessionReaper`, `BrowserSessionService`, DI wire-up in `Program.cs`. Unit tests.
|
||||
6. **Phase 6 — Shared `DriverBrowseTree.razor`.** Lazy tree component with per-node filter. Component tests with fake service.
|
||||
7. **Phase 7 — Wire pickers.** Update `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` to use `DriverBrowseTree` + DriverOperator gating + (Galaxy) attribute side-panel. Manual smoke test.
|
||||
8. **Phase 8 — Integration test + docs.** Opt-in opc-plc integration suite, design doc cross-references in `docs/`, `CLAUDE.md` (or `docs/security.md`) updates if needed.
|
||||
|
||||
---
|
||||
|
||||
## Decisions table
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | Ad-hoc browse using form JSON | Mirrors `TestDriverConnect` probe; works for new drafts and existing drivers uniformly |
|
||||
| 2 | Tree + lazy load both drivers | Galaxy gateway just shipped `LazyBrowseNode.ExpandAsync` — symmetric UX possible |
|
||||
| 3 | AdminUI-hosted via `IDriverBrowser` factory | Browse is interactive (≥10 calls/session); cross-cluster Ask hop would multiply latency; session has no value to other nodes |
|
||||
| 4 | Sibling `*.Browser` projects | Keep AdminUI from pulling runtime `Driver.*` projects' SDK chains |
|
||||
| 5 | `NamespaceMap` to `OpcUaClient.Contracts` | Shared between runtime + browser, no new project needed |
|
||||
| 6 | Separate browse PKI store | Browse-time cert accept must not poison runtime driver's trust store |
|
||||
| 7 | Per-node client-side text filter (v1) | Quick UX win; server-side filters deferred |
|
||||
| 8 | 2 min idle TTL, 30s reaper tick | Matches typical user cadence; bounds resource exposure |
|
||||
| 9 | 20 s per-call / 30 s open timeouts | Interactive feel; longer hangs almost always mean broken remote |
|
||||
| 10 | DriverOperator role gating | Live remote connection is operationally privileged; matches Reconnect/Restart precedent |
|
||||
| 11 | No audit trail | Browse is read-only; matches probe pattern |
|
||||
| 12 | Galaxy two-stage attribute side-panel | One modal, no extra clicks vs. two-modal flow |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-28-driver-browsers-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Phase 1 — Add IDriverBrowser/IBrowseSession/BrowseNode to Commons", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Phase 2 — Extract NamespaceMap to OpcUaClient.Contracts", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Phase 3a — Scaffold Driver.OpcUaClient.Browser project", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: Phase 3b — Implement OpcUaClientBrowseSession", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Phase 3c — Implement OpcUaClientDriverBrowser factory", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: Phase 3d — OpcUaClient.Browser tests (opc-plc fixture)", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Phase 4a — Scaffold Driver.Galaxy.Browser project", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Phase 4b — Implement GalaxyBrowseSession", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: Phase 4c — Implement GalaxyDriverBrowser factory", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: Phase 4d — Galaxy.Browser tests (fake transport)", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 11: Phase 5a — BrowseSessionRegistry + reaper + service", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 12, "subject": "Task 12: Phase 5b — Wire DI in AddAdminUI()", "status": "pending", "blockedBy": [5, 9, 11]},
|
||||
{"id": 13, "subject": "Task 13: Phase 5c — Tests for registry, reaper, service", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 14, "subject": "Task 14: Phase 6 — Shared DriverBrowseTree.razor", "status": "pending", "blockedBy": [12]},
|
||||
{"id": 15, "subject": "Task 15: Phase 7a — Wire OpcUaClient picker to browser", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16: Phase 7b — Wire Galaxy picker + attribute side-panel", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 17, "subject": "Task 17: Phase 8a — opc-plc integration test", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 18, "subject": "Task 18: Phase 8b — Manual smoke + CLAUDE.md update", "status": "pending", "blockedBy": [13, 15, 16, 17]}
|
||||
],
|
||||
"lastUpdated": "2026-05-28T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Design — Complete AdminUI deferred follow-ups
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (design); implementation plan to follow
|
||||
**Author:** Joseph Doherty (with Claude Code)
|
||||
|
||||
## Background
|
||||
|
||||
The AdminUI carried a family of "deferred / Phase C.2 follow-up" notes. A prior
|
||||
change stripped the stale *rendered roadmap banners* from the cluster list pages.
|
||||
Three remaining note groups were investigated to decide what real work they hide:
|
||||
|
||||
- **Group 1 — driver-page inline notes** ("list-editor coming in a follow-up
|
||||
phase" for tags/devices/endpoints; "typed-form-ifying Polly is a follow-up").
|
||||
→ **Real pending UI work.**
|
||||
- **Group 2 — RoleGrants** ("UI-driven editing of the mapping is deferred — it
|
||||
implies a config-reload mechanism that doesn't exist yet"). → **Real work; half
|
||||
the infra already exists.**
|
||||
- **Group 3 — source comments** (F15 Razor migration, F16 FleetStatusHub bridge,
|
||||
"Phase 4" identity section, `TODO(3.3/3.4)` route collision). → **~90% stale**;
|
||||
the referenced work already shipped (the F16 bridge is wired; the legacy
|
||||
`DriverEdit.razor` no longer exists). Only the Polly typed form is real, and it
|
||||
is already counted in Group 1.
|
||||
|
||||
### Key facts established during exploration
|
||||
|
||||
- **Driver-embedded tag/device lists in `DriverConfig` JSON are the runtime source
|
||||
of truth.** Driver factories deserialize them and poll exactly those rows; the
|
||||
canonical `Tag` table is orthogonal (OPC UA browse-tree only, never read by
|
||||
drivers). So inline editors are meaningful, not redundant — editing them changes
|
||||
what the driver polls on the next publish/reinitialize.
|
||||
- **Resilience** already has a strongly-typed model: `DriverResilienceOptions`
|
||||
(`BulkheadMaxConcurrent`, `BulkheadMaxQueue`, `RecycleIntervalSeconds`,
|
||||
`CapabilityPolicies: {DriverCapability → (TimeoutSeconds, RetryCount,
|
||||
BreakerFailureThreshold)}`) with tier A/B/C defaults via `GetTierDefaults(tier)`
|
||||
and a `DriverResilienceOptionsParser`. The stored JSON is an *override* shape;
|
||||
null/absent keys fall back to tier defaults.
|
||||
- **LDAP role map**: the `LdapGroupRoleMapping` entity + migration +
|
||||
`ILdapGroupRoleMappingService` (CRUD) already exist but are **not wired** into
|
||||
login. `LdapAuthService` still reads the static appsettings `GroupToRole`
|
||||
(`Dictionary<string,string>`). `RoleGrants.razor` is read-only.
|
||||
- **Testing**: no bUnit. Established pattern = test `FromOptions`/`ToOptions`
|
||||
round-trips (xUnit + Shouldly in `AdminUI.Tests`) and services with in-memory EF
|
||||
(`Configuration.Tests`).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Scope:** full build — all real follow-ups in Groups 1 & 2, plus Group 3
|
||||
comment cleanup.
|
||||
- **List-editor UX:** modal-per-row with a shared shell component.
|
||||
- **LDAP reload semantics:** DB-backed, **live on the user's next sign-in**
|
||||
(per-login DB query; no restart, no new infra). appsettings `GroupToRole` becomes
|
||||
a bootstrap **fallback** layer.
|
||||
- **Roles are GLOBAL.** No cluster-level permissions / no per-cluster enforcement
|
||||
(explicitly chosen for simplicity, reversing an earlier cluster-scoping answer).
|
||||
Every `LdapGroupRoleMapping` row is `IsSystemWide=true`, `ClusterId=null`.
|
||||
|
||||
## Workstreams
|
||||
|
||||
### WS1 — Driver collection editors (modal-per-row + shared shell)
|
||||
|
||||
- New generic `CollectionEditor<TRow>` component in `Components/Shared/Drivers/`:
|
||||
compact read-only table + `[+ Add]` / per-row `Edit` / `Delete`, and a Bootstrap
|
||||
modal editing a **working copy** of a row (commit on modal-Save, discard on
|
||||
Cancel). Parameters: `List<TRow> Items` (bound), header fragment, read-only-cells
|
||||
fragment, modal-body fragment, `NewRow` factory, optional `Validate` delegate.
|
||||
- Each driver page swaps its read-only `<pre>` for a `CollectionEditor` supplying
|
||||
its own columns + modal fields. Edits mutate the in-memory `List<T>` already in
|
||||
the page's `FormModel`; the page's existing **Save** serializes it into
|
||||
`DriverConfig` — no new persistence path.
|
||||
- Coverage: tags (Modbus, AbCip, AbLegacy, TwinCAT, S7, FOCAS); devices (AbCip,
|
||||
AbLegacy, TwinCAT, FOCAS); endpoints (OpcUaClient).
|
||||
- **Errors/validation:** required fields, duplicate Name within list,
|
||||
driver-specific address format; delete confirm; list mutates only on valid commit.
|
||||
- **Testing:** per-driver `NewRow` factories + `Validate` methods unit-tested
|
||||
directly; existing `*FormSerializationTests` extended for add/remove via the form
|
||||
model. Modal interaction verified manually via `/run`.
|
||||
|
||||
### WS2 — Resilience typed form
|
||||
|
||||
- Replace the textarea in `DriverResilienceSection.razor` with a typed form bound to
|
||||
a new mutable `ResilienceFormModel` (all fields nullable; null = tier default):
|
||||
bulkhead concurrent/queue, recycle interval, and an 8-capability grid (Read,
|
||||
Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead)
|
||||
of (timeout / retry / breaker-threshold).
|
||||
- `FromJson`/`ToJson` emit only non-null overrides (blank → `null`, preserving the
|
||||
current "null = tier defaults" contract). The section gains a `DriverTier`
|
||||
parameter; each driver page passes its known tier so `GetTierDefaults(tier)`
|
||||
renders as placeholders. A collapsible "raw JSON" view remains as escape hatch.
|
||||
- **Errors:** non-negative / sane-range numeric validation; emitted JSON must
|
||||
re-parse cleanly through `DriverResilienceOptionsParser`.
|
||||
- **Testing:** `ResilienceFormModel` round-trip tests in `AdminUI.Tests` —
|
||||
blank→null, partial-override-preserved, emit→parse-back compatibility.
|
||||
|
||||
### WS3 — Editable LDAP→role map (DB-backed, global, live on next sign-in)
|
||||
|
||||
- `RoleGrants.razor` → full CRUD over `LdapGroupRoleMapping` via the existing
|
||||
`ILdapGroupRoleMappingService`. **Global only**: `IsSystemWide=true`,
|
||||
`ClusterId=null`; no cluster UI. Fields: LDAP group, `AdminRole`
|
||||
(ConfigViewer/ConfigEditor/FleetAdmin), notes. A group may carry several roles
|
||||
(multiple rows). Edit page gated to **FleetAdmin** (add a minimal FleetAdmin
|
||||
authorization policy; confirm existing role-policy plumbing during plan-writing).
|
||||
- Wire the service into `LdapAuthService`: at login → resolve groups →
|
||||
`GetByGroupsAsync` (indexed) → map roles → **merge appsettings `GroupToRole` as a
|
||||
fallback layer** (used when no DB row covers a group). Edits take effect on the
|
||||
user's next sign-in. DB rows authoritative + editable; appsettings entries shown
|
||||
read-only as "fallback."
|
||||
- **Errors:** DB unreachable at login → catch, log, fall back to appsettings;
|
||||
login never blocks. CRUD: no duplicate `(LdapGroup, Role)`; group/role required.
|
||||
- **Testing:** extend `LdapGroupRoleMappingServiceTests` (in-memory EF) for CRUD +
|
||||
dedupe; new `RoleMapper` overload `Map(groups, dbRows, fallbackDict)` unit-tested
|
||||
for merge + fallback precedence + DB-error fallback.
|
||||
|
||||
### WS4 — Cleanup (runs last, after the features exist)
|
||||
|
||||
- **Delete stale comments:** `FleetStatusHub.cs` ("passive channel / until the
|
||||
bridge lands"), `EndpointRouteBuilderExtensions.cs` (F15), `DriverIdentitySection.razor`
|
||||
("Phase 4 / generic DriverEdit"), `DriverEditRouter.razor` + `DriverTypePicker.razor`
|
||||
(`TODO(3.3/3.4)` + the "falls back to legacy DriverEdit" path — verify & clean,
|
||||
legacy file is gone), and update `DriverResilienceSection.razor`'s comment.
|
||||
- **Strip rendered notes** now true: per-driver "list-editor coming in a follow-up
|
||||
phase" notes, the OpcUaClient endpoint note, the resilience "typed-form-ifying
|
||||
Polly is a follow-up" note, and the RoleGrants "UI-driven editing is deferred" note.
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
- **No DB schema change** — `LdapGroupRoleMapping` migration already applied;
|
||||
`DriverConfig`/`ResilienceConfig` columns unchanged.
|
||||
- **Definition of done:** build clean + `dotnet test` green + a `/run` pass
|
||||
exercising the modal editors and role-map CRUD.
|
||||
- **Suggested sequence:** WS1 shared shell + Modbus tags as proof → remaining
|
||||
drivers → WS2 → WS3 → WS4.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-29-adminui-followups.md",
|
||||
"branch": "feat/adminui-followups",
|
||||
"tasks": [
|
||||
{"id": 11, "plan": 1, "subject": "Task 1: Generic CollectionEditor<TRow> component", "status": "pending"},
|
||||
{"id": 12, "plan": 2, "subject": "Task 2: Modbus tag editor (proof) + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 13, "plan": 3, "subject": "Task 3: AbCip device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 14, "plan": 4, "subject": "Task 4: AbLegacy device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 15, "plan": 5, "subject": "Task 5: TwinCAT device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 16, "plan": 6, "subject": "Task 6: FOCAS device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 17, "plan": 7, "subject": "Task 7: S7 tag editor + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 18, "plan": 8, "subject": "Task 8: OpcUaClient endpoint-URL editor + tests", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 19, "plan": 9, "subject": "Task 9: ResilienceFormModel + tests", "status": "pending"},
|
||||
{"id": 20, "plan": 10, "subject": "Task 10: Typed resilience form in DriverResilienceSection", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "plan": 11, "subject": "Task 11: RoleMapper.Merge overload + tests", "status": "pending"},
|
||||
{"id": 22, "plan": 12, "subject": "Task 12: Register ILdapGroupRoleMappingService in DI", "status": "pending"},
|
||||
{"id": 23, "plan": 13, "subject": "Task 13: Wire DB merge into AuthEndpoints.LoginAsync", "status": "pending", "blockedBy": [21, 22]},
|
||||
{"id": 24, "plan": 14, "subject": "Task 14: Add FleetAdmin authorization policy", "status": "pending"},
|
||||
{"id": 25, "plan": 15, "subject": "Task 15: RoleGrants.razor global CRUD (FleetAdmin-gated)", "status": "pending", "blockedBy": [22, 24]},
|
||||
{"id": 26, "plan": 16, "subject": "Task 16: LdapGroupRoleMapping service tests (global CRUD)", "status": "pending"},
|
||||
{"id": 27, "plan": 17, "subject": "Task 17: Delete stale source comments", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 20, 25]},
|
||||
{"id": 28, "plan": 18, "subject": "Task 18: Strip now-true rendered notes", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 25]},
|
||||
{"id": 29, "plan": 19, "subject": "Task 19: Full verification (build + test + /run)", "status": "pending", "blockedBy": [20, 23, 26, 27, 28]}
|
||||
],
|
||||
"lastUpdated": "2026-05-29"
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
# Auth/login alignment with ScadaBridge — design
|
||||
|
||||
> **Status:** approved 2026-05-29. Implementation plan to follow via `writing-plans`.
|
||||
> **Trigger:** browser hitting `http://localhost:9200/` rendered Chrome's `HTTP_RESPONSE_CODE_FAILURE` page because the cookie scheme's `OnRedirectToLogin` event was overridden to return 401 with no body, and the parallel JwtBearer scheme stamped `WWW-Authenticate: Bearer`. ScadaBridge sets `LoginPath` and lets the framework do its built-in browser-vs-AJAX heuristic; OtOpcUa diverged.
|
||||
|
||||
**Goal:** Restore default browser-redirect ergonomics on protected GETs, retire the unused JwtBearer server-side scheme, and externalize cookie config — bringing OtOpcUa's auth structure into parity with ScadaBridge.
|
||||
|
||||
**Architecture:** Single Cookie auth scheme. The JWT keeps minting (via `JwtTokenService`) and validating (in `CookieAuthenticationStateProvider`) as the **cookie payload only**; no `AddJwtBearer`, no parallel `Authorization: Bearer` validation. Cookie config (`Name`, `ExpiryMinutes`, `RequireHttpsCookie`) flows through the existing-but-unused `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step — same pattern ScadaBridge uses.
|
||||
|
||||
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` only (drop `Microsoft.AspNetCore.Authentication.JwtBearer` from the wiring if its only remaining transitive use disappears with this change).
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
### Schemes
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| Cookie (primary) + JwtBearer (parallel) | Cookie only |
|
||||
| `FallbackPolicy` lists both schemes | `FallbackPolicy` lists Cookie only |
|
||||
| `OnRedirectToLogin` overridden to 401 | default behavior: 302 for browsers, 401 for AJAX |
|
||||
| `OnRedirectToAccessDenied` overridden to 403 | default behavior: 302 to `/Account/AccessDenied` (404s today; matches ScadaBridge) |
|
||||
|
||||
### Cookie config — externalized via `OtOpcUaCookieOptions`
|
||||
|
||||
```csharp
|
||||
public sealed class OtOpcUaCookieOptions
|
||||
{
|
||||
public const string SectionName = "Security:Cookie";
|
||||
|
||||
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||
public int ExpiryMinutes { get; set; } = 30;
|
||||
public bool RequireHttpsCookie { get; set; } = true;
|
||||
}
|
||||
```
|
||||
|
||||
Wired into `CookieAuthenticationOptions` via:
|
||||
```csharp
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||
{
|
||||
cookieOpts.Cookie.Name = ourOpts.Value.Name;
|
||||
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(ourOpts.Value.ExpiryMinutes);
|
||||
cookieOpts.SlidingExpiration = true;
|
||||
cookieOpts.Cookie.SecurePolicy = ourOpts.Value.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
if (!ourOpts.Value.RequireHttpsCookie)
|
||||
{
|
||||
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. " +
|
||||
"Cookie travels in cleartext over plain HTTP. Dev-only.");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Endpoint surface — unchanged
|
||||
|
||||
| Path | Auth | Behavior |
|
||||
|---|---|---|
|
||||
| `POST /auth/login` | AllowAnonymous | LDAP auth → SignInAsync(Cookie); JSON callers get 204 / 401 / 503, form posters get 302 + cookie |
|
||||
| `POST /auth/logout` | RequireAuthorization | SignOutAsync(Cookie) |
|
||||
| `GET /auth/ping` | AllowAnonymous (handler-returns 200/401) | Polled by Blazor every 60s |
|
||||
| `POST /auth/token` | RequireAuthorization | Mints JWT for hypothetical external callers (matches ScadaBridge — they keep this even without JwtBearer wired) |
|
||||
|
||||
### Cookie rename
|
||||
|
||||
Old: `OtOpcUa.Auth`. New: `ZB.MOM.WW.OtOpcUa.Auth`. Effect: all sessions in flight at deploy time are invisible to the new handler → users re-prompt for login on next protected GET. No security impact (the old cookie expires per its own sliding window; nothing reads it).
|
||||
|
||||
---
|
||||
|
||||
## 2. Components
|
||||
|
||||
### Files modified
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/Server/.../Security/CookieOptions.cs` | Add `RequireHttpsCookie`; change `Name` default to `ZB.MOM.WW.OtOpcUa.Auth` |
|
||||
| `src/Server/.../Security/ServiceCollectionExtensions.cs` | Drop `using JwtBearer`; delete `ConfigureJwtBearerFromTokenService` class; drop `.AddJwtBearer` + its IPostConfigureOptions registration; drop `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides; add `LoginPath` + `LogoutPath`; add PostConfigure block consuming `OtOpcUaCookieOptions`; remove `JwtBearerDefaults.AuthenticationScheme` from `FallbackPolicy` builder |
|
||||
| `tests/Server/.../Security.Tests/AuthEndpointsIntegrationTests.cs` | Update the `Set-Cookie` assertion on the login-success test from `OtOpcUa.Auth=` → `ZB.MOM.WW.OtOpcUa.Auth=` |
|
||||
|
||||
### Files NOT modified
|
||||
|
||||
| File | Why |
|
||||
|---|---|
|
||||
| `Endpoints/AuthEndpoints.cs` | Endpoint contracts unchanged |
|
||||
| `Jwt/JwtTokenService.cs` | Still mints JWT into cookie payload |
|
||||
| `Blazor/CookieAuthenticationStateProvider.cs` | Still polls `/auth/ping` |
|
||||
| `Ldap/*` | Untouched |
|
||||
| Razor login page | POST target unchanged |
|
||||
| `appsettings*.json` | Defaults are production-safe; no required config edit |
|
||||
|
||||
### Tests added
|
||||
|
||||
Single new file or appended class in `tests/Server/.../Security.Tests/`:
|
||||
|
||||
```csharp
|
||||
public class AuthChallengeTests : AuthEndpointsTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||
{
|
||||
var client = NewClient(allowAutoRedirect: false);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("text/html");
|
||||
var resp = await client.GetAsync("/", Ct);
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Found); // 302
|
||||
resp.Headers.Location!.ToString().ShouldContain("/login");
|
||||
resp.Headers.Location.ToString().ShouldContain("ReturnUrl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Root_anonymous_xhr_GET_returns_401()
|
||||
{
|
||||
var client = NewClient(allowAutoRedirect: false);
|
||||
client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest");
|
||||
var resp = await client.GetAsync("/", Ct);
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
// Framework still writes a Location header alongside the 401 — AJAX clients ignore it.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Framework reality vs. earlier hypothesis:** The ASP.NET Core cookie handler's `IsAjaxRequest` heuristic checks ONLY the `X-Requested-With: XMLHttpRequest` header, NOT the `Accept` content type. A request with `Accept: application/json` but no XHR header is classified as a browser → 302. The third test originally proposed (`Root_anonymous_json_GET_returns_401`) was dropped because it tests behavior the framework doesn't have. ScadaBridge accepts the same framework reality (it doesn't override the heuristic either).
|
||||
|
||||
### Package references
|
||||
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`: remove `<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />` if grep confirms `JwtTokenService` doesn't itself need it (it uses `Microsoft.IdentityModel.Tokens` for validation parameters, separate package).
|
||||
|
||||
---
|
||||
|
||||
## 3. Data flow
|
||||
|
||||
### Anonymous browser hits `/`
|
||||
|
||||
```
|
||||
Browser → GET /
|
||||
Accept: text/html
|
||||
┌──> AuthN: no cookie → unauthenticated
|
||||
├──> AuthZ FallbackPolicy fails
|
||||
└──> Cookie HandleChallengeAsync:
|
||||
- Accept: text/html → browser
|
||||
- 302 Location: /login?ReturnUrl=%2F
|
||||
Browser → GET /login ← redirect followed; login page renders (AllowAnonymous)
|
||||
[user submits form]
|
||||
Browser → POST /auth/login Content-Type: application/x-www-form-urlencoded
|
||||
─── LoginAsync:
|
||||
- LDAP authenticate
|
||||
- SignInAsync(Cookie)
|
||||
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=...
|
||||
- 302 Location: / (or ReturnUrl)
|
||||
Browser → GET / cookie present → AuthZ passes → 200 + Razor render
|
||||
```
|
||||
|
||||
### XHR / fetch hits a protected endpoint without cookie
|
||||
|
||||
```
|
||||
fetch('/api/something') Accept: application/json
|
||||
X-Requested-With: XMLHttpRequest
|
||||
┌──> AuthN: no cookie → unauthenticated
|
||||
├──> AuthZ FallbackPolicy fails
|
||||
└──> Cookie HandleChallengeAsync:
|
||||
- not text/html → API client
|
||||
- 401 (no body, no Location)
|
||||
```
|
||||
|
||||
The cookie handler's built-in `IsAjaxRequest` heuristic is what makes this work — it looks for `X-Requested-With: XMLHttpRequest`. No custom event handler needed. Note: requests with only `Accept: application/json` (no XHR header) are classified as browsers → 302; AJAX callers should set the XHR header to get 401.
|
||||
|
||||
### Logout
|
||||
|
||||
```
|
||||
fetch('/auth/logout', POST) cookie present
|
||||
─── LogoutAsync (RequireAuthorization passes):
|
||||
- SignOutAsync(Cookie)
|
||||
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=; expires=...
|
||||
- 204 (or browser-form: 302 /login)
|
||||
```
|
||||
|
||||
### Old cookie ignored
|
||||
|
||||
Browser holds stale `OtOpcUa.Auth` from a session that predates the deploy. Cookie scheme is now configured for `ZB.MOM.WW.OtOpcUa.Auth` — old cookie is invisible. User treated as anonymous → 302 to `/login`. Old cookie sits in jar until its own sliding window expires (max 30 min); no security risk because nothing reads it.
|
||||
|
||||
### Blazor `/auth/ping` polling
|
||||
|
||||
```
|
||||
CookieAuthenticationStateProvider → GET /auth/ping every 60s
|
||||
cookie present → 200
|
||||
cookie expired/missing → 401
|
||||
Blazor → invalidates auth state → re-render → root [Authorize] fails
|
||||
→ Cookie HandleChallengeAsync → 302 /login
|
||||
```
|
||||
|
||||
Unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error handling
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| Unknown `Accept` (`*/*`, missing, JSON) | Framework default: treated as non-AJAX → 302 to `/login`. The cookie handler's `IsAjaxRequest` only looks at `X-Requested-With`, NOT `Accept`. CLI tools that want a 401 should set `X-Requested-With: XMLHttpRequest`. |
|
||||
| `LoginAsync` bad creds | JSON: `401`. Form: `302 /login?error=…&returnUrl=…`. Handler-returned, unaffected by middleware changes. |
|
||||
| `LoginAsync` LDAP throws | `503 ServiceUnavailable`. Handler-returned. |
|
||||
| `LoginAsync` success | JSON: `204`. Form: `302 /` (or `ReturnUrl`). |
|
||||
| Cookie expires mid-request | Treated as anonymous → 302 to `/login` (browser) or 401 (AJAX). Active users kept alive by `SlidingExpiration = true`. |
|
||||
| `RequireHttpsCookie = false` over HTTPS | Cookie marked `SecurePolicy = SameAsRequest`. Misconfiguration risk; startup logs Warning every boot so it's audible. No validator-refused boot — default is `true`; dev compose explicitly opts out. |
|
||||
| Missing `Security:Cookie` section in config | `.Bind()` no-ops; defaults take over (`Name = ZB.MOM.WW.OtOpcUa.Auth`, `ExpiryMinutes = 30`, `RequireHttpsCookie = true`). Production-safe. |
|
||||
| `[Authorize(Policy="DriverOperator")]` denied for authenticated non-operator | Cookie handler redirects to default `AccessDeniedPath = "/Account/AccessDenied"` which 404s in OtOpcUa. Matches ScadaBridge; rare enough not to be a P0. Follow-up: add a minimal `/access-denied` Razor page. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
### Existing tests pass unchanged
|
||||
|
||||
- `Login_with_invalid_credentials_returns_401` — handler-returned, unaffected
|
||||
- `Login_when_ldap_throws_returns_503` — handler-returned, unaffected
|
||||
- `Ping_anonymous_returns_401` — handler-returned, unaffected
|
||||
- `Ping_after_cookie_login_returns_200` — uses HttpClient cookie container, picks up renamed cookie automatically
|
||||
- `Login_with_cookie_credentials_returns_204_and_sets_cookie` — needs one assertion update (cookie name)
|
||||
|
||||
### Tests added (3 new)
|
||||
|
||||
- `Root_anonymous_browser_GET_redirects_to_login` — asserts 302 + `Location` contains `/login` + `ReturnUrl`
|
||||
- `Root_anonymous_ajax_GET_returns_401` — `X-Requested-With: XMLHttpRequest` → 401, no `Location`
|
||||
(the originally planned `Root_anonymous_json_GET_returns_401` was dropped — see Section 3 framework-reality note above)
|
||||
|
||||
### Removed/orphaned tests
|
||||
|
||||
None expected. The explore phase found no test depending on `ConfigureJwtBearerFromTokenService` or the `WWW-Authenticate: Bearer` response. Grep at plan-write time to confirm.
|
||||
|
||||
### Manual smoke (docker-dev stack)
|
||||
|
||||
1. `http://localhost:9200/` anonymously → expect 302 to `/login?ReturnUrl=%2F` (was: Chrome error page)
|
||||
2. Sign in via the form
|
||||
3. `http://localhost:9200/` authenticated → expect Razor dashboard
|
||||
4. DevTools → Application → Cookies → confirm `ZB.MOM.WW.OtOpcUa.Auth`
|
||||
5. `curl -i http://localhost:9200/` → `302 Found`, Location: `/login?ReturnUrl=%2F`
|
||||
6. `curl -i -H "Accept: application/json" http://localhost:9200/` → `401 Unauthorized`
|
||||
|
||||
### Verification gates at PR time
|
||||
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero new errors (pre-existing 12 unchanged)
|
||||
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green
|
||||
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — all green
|
||||
- Manual Chrome smoke above passes
|
||||
|
||||
---
|
||||
|
||||
## 6. Sequencing (for plan-writing)
|
||||
|
||||
Single-PR feature, but split into reviewable phases:
|
||||
|
||||
1. **Phase 1 — Options class.** Extend `OtOpcUaCookieOptions` with `RequireHttpsCookie` and new `Name` default. Tests unaffected.
|
||||
2. **Phase 2 — Wiring rewrite.** Edit `ServiceCollectionExtensions.cs`: drop JwtBearer, drop event overrides, add `LoginPath`/`LogoutPath`, add PostConfigure consumption of `OtOpcUaCookieOptions`. Update the one existing test assertion. Build + existing Security.Tests green.
|
||||
3. **Phase 3 — New challenge tests.** Add the 3 new redirect/401 tests.
|
||||
4. **Phase 4 — Package cleanup.** Remove `Microsoft.AspNetCore.Authentication.JwtBearer` from csproj if grep confirms no remaining consumer.
|
||||
5. **Phase 5 — Manual smoke + commit.** Restart admin-a/admin-b in docker-dev; verify in Chrome.
|
||||
|
||||
---
|
||||
|
||||
## Decisions table
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | Drop JwtBearer server-side scheme | No in-repo consumer; brought non-redirect 401 + `WWW-Authenticate: Bearer` to browser GETs |
|
||||
| 2 | Keep `JwtTokenService` + `/auth/token` | Token-as-cookie-payload is load-bearing for Blazor; `/auth/token` matches ScadaBridge surface |
|
||||
| 3 | Rename cookie `OtOpcUa.Auth` → `ZB.MOM.WW.OtOpcUa.Auth` | Naming parity with ScadaBridge; one-time forced sign-out acceptable |
|
||||
| 4 | Externalize via existing `OtOpcUaCookieOptions` + PostConfigure | Mirrors ScadaBridge pattern; fixes pre-existing bug where options class was bound but ignored |
|
||||
| 5 | Drop both `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides | Restores framework's browser-vs-AJAX heuristic; ScadaBridge does the same |
|
||||
| 6 | Set `LoginPath = "/login"`, `LogoutPath = "/auth/logout"` | Required for the framework's default redirect to work |
|
||||
| 7 | Accept 404 on `/Account/AccessDenied` for v1 | Matches ScadaBridge; rare path; follow-up to add minimal page |
|
||||
| 8 | Warning-log when `RequireHttpsCookie = false` | Audible misconfig signal; same as ScadaBridge |
|
||||
@@ -0,0 +1,652 @@
|
||||
# Auth/login alignment with ScadaBridge — implementation plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` or `superpowers-extended-cc:subagent-driven-development` to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Match ScadaBridge's single-Cookie auth pattern: drop the unused JwtBearer parallel scheme, restore the framework's default browser-vs-AJAX challenge heuristic, and externalize cookie config through the existing-but-unused `OtOpcUaCookieOptions`.
|
||||
|
||||
**Architecture:** Cookie-only auth. `JwtTokenService` keeps minting JWTs as the cookie payload (Blazor circuit hydration depends on it). Cookie name + idle timeout + HTTPS policy flow through `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step. Endpoint surface (`/auth/login`, `/auth/logout`, `/auth/ping`, `/auth/token`) unchanged.
|
||||
|
||||
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` / xUnit v3 + Shouldly / `Microsoft.AspNetCore.TestHost.TestServer`.
|
||||
|
||||
**Design doc:** `docs/plans/2026-05-29-auth-alignment-design.md` (commit `bc4fce5`). Each task below cites the design section it implements.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
Task 1 (Options class)
|
||||
└─► Task 2 (Wiring rewrite + test assertion update)
|
||||
├─► Task 3 (3 new challenge tests)
|
||||
└─► Task 4 (csproj cleanup)
|
||||
└─► Task 5 (manual smoke + final commit)
|
||||
```
|
||||
|
||||
Tasks 3 and 4 are parallelizable (disjoint files).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Extend `OtOpcUaCookieOptions`
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (Task 2 depends on this)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs`
|
||||
|
||||
**Implements design:** Section 1 (Architecture, "Cookie config — externalized") + Section 2 (Components, file table row 1).
|
||||
|
||||
### Step 1: Replace file contents
|
||||
|
||||
Current file (12 lines):
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
public sealed class OtOpcUaCookieOptions
|
||||
{
|
||||
public const string SectionName = "Security:Cookie";
|
||||
|
||||
/// <summary>Gets or sets the cookie name.</summary>
|
||||
public string Name { get; set; } = "OtOpcUa.Auth";
|
||||
|
||||
/// <summary>Idle sliding window, in minutes (default 30).</summary>
|
||||
public int ExpiryMinutes { get; set; } = 30;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
|
||||
/// <c>Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory></c> step inside
|
||||
/// <c>AddOtOpcUaAuth</c> that copies the values onto <c>CookieAuthenticationOptions</c>.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaCookieOptions
|
||||
{
|
||||
/// <summary>Configuration section name (<c>Security:Cookie</c>).</summary>
|
||||
public const string SectionName = "Security:Cookie";
|
||||
|
||||
/// <summary>
|
||||
/// Auth cookie name. Default uses the <c>ZB.MOM.WW</c> convention; mirrors ScadaBridge's
|
||||
/// <c>ZB.MOM.WW.ScadaBridge.Auth</c>. Changing this invalidates existing sessions on next
|
||||
/// deploy.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||
|
||||
/// <summary>Idle sliding-window length in minutes (default 30).</summary>
|
||||
public int ExpiryMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Require HTTPS for the auth cookie. Default <c>true</c>: cookie is marked
|
||||
/// <c>SecurePolicy = Always</c>. Set to <c>false</c> ONLY for local dev stacks running
|
||||
/// plain HTTP — emits a startup Warning when disabled so the misconfiguration is
|
||||
/// audible.
|
||||
/// </summary>
|
||||
public bool RequireHttpsCookie { get; set; } = true;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Build
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
```
|
||||
Expected: 0 errors, 0 warnings.
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "feat(security): extend OtOpcUaCookieOptions with RequireHttpsCookie + ZB.MOM.WW cookie name default"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- Lines before / after
|
||||
- Build clean
|
||||
- Commit SHA
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `Name` default is `"ZB.MOM.WW.OtOpcUa.Auth"` (NOT `"OtOpcUa.Auth"`)
|
||||
- [ ] `RequireHttpsCookie` field added with default `true` and XML doc explaining the dev-only opt-out
|
||||
- [ ] `ExpiryMinutes` default unchanged at 30
|
||||
- [ ] `SectionName` constant unchanged
|
||||
- [ ] Build clean
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Rewrite auth wiring in `ServiceCollectionExtensions.cs`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Tasks 3 and 4 depend on this)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`
|
||||
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs:93`
|
||||
|
||||
**Implements design:** Section 1 + Section 2 file table rows 2 + 3.
|
||||
|
||||
### Step 1: Read current file
|
||||
|
||||
```bash
|
||||
cat /Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
|
||||
```
|
||||
|
||||
Current shape (relevant excerpt):
|
||||
- `using Microsoft.AspNetCore.Authentication.JwtBearer;` at top
|
||||
- `internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService) : IPostConfigureOptions<JwtBearerOptions>` class (lines ~15-35)
|
||||
- `.AddCookie(o => { ... })` with `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides
|
||||
- `.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { })` chained after AddCookie
|
||||
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>()` after the AddAuthentication block
|
||||
- `FallbackPolicy` builder takes both Cookie + JwtBearer schemes
|
||||
|
||||
### Step 2: Replace the file with the new shape
|
||||
|
||||
The full target file:
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
||||
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
||||
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
||||
/// challenge heuristic).</summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The application configuration root.</param>
|
||||
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
|
||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
.SetApplicationName("OtOpcUa");
|
||||
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||
o.LoginPath = "/login";
|
||||
o.LogoutPath = "/auth/logout";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||
});
|
||||
|
||||
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||
{
|
||||
var v = ourOpts.Value;
|
||||
cookieOpts.Cookie.Name = v.Name;
|
||||
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||
cookieOpts.SlidingExpiration = true;
|
||||
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
if (!v.RequireHttpsCookie)
|
||||
{
|
||||
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
|
||||
"SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
|
||||
"Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
|
||||
}
|
||||
});
|
||||
|
||||
services.AddAuthorization(o =>
|
||||
{
|
||||
o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
// DriverOperator: may issue Reconnect/Restart commands against live driver instances
|
||||
// from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
|
||||
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
||||
o.AddPolicy("DriverOperator", policy =>
|
||||
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
What's gone (vs. the original):
|
||||
- `using Microsoft.AspNetCore.Authentication.JwtBearer;`
|
||||
- `ConfigureJwtBearerFromTokenService` internal class entirely
|
||||
- `.AddJwtBearer(...)` chain after `.AddCookie(...)`
|
||||
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();`
|
||||
- `OnRedirectToLogin` / `OnRedirectToAccessDenied` event overrides
|
||||
- Hardcoded `o.Cookie.Name = "OtOpcUa.Auth"`, `o.SlidingExpiration = true`, `o.ExpireTimeSpan = TimeSpan.FromMinutes(30)`, `o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest`
|
||||
- `JwtBearerDefaults.AuthenticationScheme` from the `FallbackPolicy` builder
|
||||
|
||||
What's added:
|
||||
- `using Microsoft.Extensions.Logging;`
|
||||
- `o.LoginPath = "/login"`, `o.LogoutPath = "/auth/logout"` inside `.AddCookie(...)`
|
||||
- The `services.AddOptions<CookieAuthenticationOptions>(...).Configure<...>(...)` PostConfigure block
|
||||
|
||||
### Step 3: Update the one existing test assertion
|
||||
|
||||
In `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` around line 93:
|
||||
|
||||
```csharp
|
||||
// before
|
||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
|
||||
// after
|
||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
||||
```
|
||||
|
||||
### Step 4: Build + run security tests
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||
```
|
||||
|
||||
Expected: build clean; all Security.Tests pass (the existing 5 AuthEndpointsIntegrationTests + JwtTokenServiceTests + LdapHelperTests + RoleMapperTests).
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "$(cat <<'EOF'
|
||||
refactor(security): drop JwtBearer parallel scheme, externalize cookie config
|
||||
|
||||
Single Cookie auth scheme; framework default challenge restores 302 → /login
|
||||
for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to
|
||||
CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the
|
||||
options class was bound but ignored). Cookie name moves to
|
||||
ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- Net LOC change (additions / deletions)
|
||||
- Build clean
|
||||
- Test count run / passed
|
||||
- Commit SHA
|
||||
- Anything unexpected
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `using Microsoft.AspNetCore.Authentication.JwtBearer;` removed
|
||||
- [ ] `ConfigureJwtBearerFromTokenService` class deleted
|
||||
- [ ] `.AddJwtBearer(...)` call deleted
|
||||
- [ ] `IPostConfigureOptions<JwtBearerOptions>` singleton registration deleted
|
||||
- [ ] `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides deleted
|
||||
- [ ] `LoginPath = "/login"` and `LogoutPath = "/auth/logout"` added inside `.AddCookie(...)`
|
||||
- [ ] PostConfigure block added consuming `OtOpcUaCookieOptions`
|
||||
- [ ] Warning log fires when `RequireHttpsCookie == false`
|
||||
- [ ] `FallbackPolicy` now takes only `CookieAuthenticationDefaults.AuthenticationScheme`
|
||||
- [ ] `DriverOperator` policy unchanged
|
||||
- [ ] Test assertion updated to `ZB.MOM.WW.OtOpcUa.Auth=`
|
||||
- [ ] `dotnet test tests/Server/.../Security.Tests/` all green
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Add browser-vs-AJAX challenge tests
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` (append 3 new test methods + 1 helper)
|
||||
|
||||
**Implements design:** Section 5 "Tests added" + Section 4 "Auth challenge for unknown content type".
|
||||
|
||||
### Context for the implementer
|
||||
|
||||
`AuthEndpointsIntegrationTests` is `IAsyncLifetime`-backed and stands up a `TestServer` with `MapOtOpcUaAuth()` mounted (line 66). The `web.UseEndpoints(e => e.MapOtOpcUaAuth())` wires ONLY the four `/auth/*` endpoints — there is NO root `MapGet("/", ...)` registered. So an anonymous GET to `/` hits the routing pipeline, falls through to a 404 BEFORE auth middleware even challenges.
|
||||
|
||||
**The test harness needs a protected root endpoint.** Add one in `InitializeAsync` inside the `web.UseEndpoints(...)` callback. Then the 3 new tests will exercise the cookie scheme's challenge for that protected route.
|
||||
|
||||
### Step 1: Modify the test host setup
|
||||
|
||||
In `AuthEndpointsIntegrationTests.cs`, change `web.UseEndpoints(...)` (around line 66) from:
|
||||
```csharp
|
||||
app.UseEndpoints(e => e.MapOtOpcUaAuth());
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
app.UseEndpoints(e =>
|
||||
{
|
||||
e.MapOtOpcUaAuth();
|
||||
// Protected root used by AuthChallengeTests below — exercises the cookie
|
||||
// scheme's challenge heuristic without depending on the full Razor host.
|
||||
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Add the three new test methods
|
||||
|
||||
Append at the bottom of the class (before the closing brace), keeping the file's existing summary style and using `TestContext.Current.CancellationToken` via the existing `Ct` property:
|
||||
|
||||
```csharp
|
||||
/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
|
||||
[Fact]
|
||||
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||
{
|
||||
var client = NewClientNoRedirect();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Accept.ParseAdd("text/html");
|
||||
var resp = await client.SendAsync(req, Ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Found);
|
||||
resp.Headers.Location.ShouldNotBeNull();
|
||||
resp.Headers.Location!.OriginalString.ShouldContain("/login");
|
||||
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
|
||||
}
|
||||
|
||||
/// <summary>Anonymous AJAX GET of a protected route returns 401 with no Location.</summary>
|
||||
[Fact]
|
||||
public async Task Root_anonymous_ajax_GET_returns_401()
|
||||
{
|
||||
var client = NewClientNoRedirect();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||
var resp = await client.SendAsync(req, Ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
resp.Headers.Location.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Anonymous JSON GET of a protected route returns 401.</summary>
|
||||
[Fact]
|
||||
public async Task Root_anonymous_json_GET_returns_401()
|
||||
{
|
||||
var client = NewClientNoRedirect();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Accept.ParseAdd("application/json");
|
||||
var resp = await client.SendAsync(req, Ct);
|
||||
|
||||
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add the no-redirect client helper
|
||||
|
||||
Right next to the existing `NewClient()` method (line 82):
|
||||
|
||||
```csharp
|
||||
/// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
|
||||
/// Used by challenge tests so we can assert on the 302 / Location directly.</summary>
|
||||
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
|
||||
{
|
||||
BaseAddress = _server.BaseAddress,
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: Run the tests
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||
```
|
||||
|
||||
Expected: existing 5 tests still pass + 3 new tests pass = 8+ total green.
|
||||
|
||||
**If `Root_anonymous_browser_GET_redirects_to_login` returns 200 instead of 302**: HttpClient is still auto-following redirects. Two fixes to try in order:
|
||||
1. Confirm `NewClientNoRedirect` uses `_server.CreateHandler()` (not `CreateClient()`).
|
||||
2. If still wrong, swap to: `var handler = new HttpClientHandler { AllowAutoRedirect = false };` — but TestServer doesn't expose HttpClientHandler directly. The `CreateHandler()` path SHOULD return a non-redirecting handler; if it doesn't, the implementation may need a `DelegatingHandler` wrapper.
|
||||
|
||||
**If `Root_anonymous_browser_GET_redirects_to_login` returns 401 instead of 302**: the cookie scheme isn't classifying `Accept: text/html` as a browser. Inspect Task 2's changes — `OnRedirectToLogin` may not have been fully removed, OR `LoginPath` was not set, OR an `Accept` parsing issue. Look at the response body — if it's empty + 401, the JwtBearer scheme or the override is still in play.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "test(security): add browser-vs-AJAX challenge tests for root path"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- 3 new tests + 1 helper + modified InitializeAsync
|
||||
- Build clean
|
||||
- Test count: existing N + 3 new = N+3 green
|
||||
- Commit SHA
|
||||
- Anything unexpected (e.g. redirect-following behavior of `_server.CreateHandler()`)
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `MapGet("/", ...).RequireAuthorization()` added inside `web.UseEndpoints(...)`
|
||||
- [ ] `NewClientNoRedirect()` helper added
|
||||
- [ ] 3 new `[Fact]` methods added with `TestContext.Current.CancellationToken` via the `Ct` property
|
||||
- [ ] Each test asserts on the exact status + Location header (or absence)
|
||||
- [ ] All tests green
|
||||
- [ ] Existing 5 tests still pass
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Remove `Microsoft.AspNetCore.Authentication.JwtBearer` package reference
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** Task 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj` (delete one line)
|
||||
- Verify: `Directory.Packages.props` — leave the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ... />` entry in place (other projects may consume it).
|
||||
|
||||
**Implements design:** Section 2 "Package references" + Section 6 phase 4.
|
||||
|
||||
### Step 1: Confirm no remaining consumer in the Security project
|
||||
|
||||
```bash
|
||||
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer\|JwtBearer" \
|
||||
/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ \
|
||||
--include="*.cs"
|
||||
```
|
||||
|
||||
Expected: zero matches. (Task 2 removed all uses.) If there are matches, STOP and report — Task 2 was incomplete.
|
||||
|
||||
### Step 2: Remove the PackageReference
|
||||
|
||||
In `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`, find this line (currently around line 13):
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
||||
```
|
||||
Delete it. **Keep** these:
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
```
|
||||
(`JwtTokenService` consumes those for `TokenValidationParameters` + JWT creation respectively — they're not from the JwtBearer authentication package.)
|
||||
|
||||
### Step 3: Check whether ANY other project still references the package
|
||||
|
||||
```bash
|
||||
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer" \
|
||||
/Users/dohertj2/Desktop/OtOpcUa/src/ /Users/dohertj2/Desktop/OtOpcUa/tests/ \
|
||||
--include="*.csproj"
|
||||
```
|
||||
|
||||
If zero results: also remove the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ...>` line from `Directory.Packages.props` (search for it). If one or more other projects still reference it, leave `Directory.Packages.props` alone.
|
||||
|
||||
### Step 4: Restore + build
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
dotnet restore src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||
```
|
||||
|
||||
Expected: 0 NEW errors. The known pre-existing 12 errors (OpcUaServer.Tests + Runtime.Tests + AbLegacy.Cli + S7.Cli) remain unchanged.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj \
|
||||
Directory.Packages.props # only if you also removed it from Directory.Packages.props
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "chore(security): drop Microsoft.AspNetCore.Authentication.JwtBearer (unused)"
|
||||
```
|
||||
|
||||
If only the csproj changed: omit `Directory.Packages.props` from the add.
|
||||
|
||||
### Output report
|
||||
|
||||
- Was Directory.Packages.props also touched? Justify based on whether other projects still reference the package.
|
||||
- Build clean (0 new errors)
|
||||
- Commit SHA
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] Confirmed zero `Microsoft.AspNetCore.Authentication.JwtBearer` or `JwtBearer` matches in `src/Server/ZB.MOM.WW.OtOpcUa.Security/**/*.cs` before deletion
|
||||
- [ ] PackageReference removed from Security.csproj
|
||||
- [ ] `Microsoft.IdentityModel.Tokens` and `System.IdentityModel.Tokens.Jwt` kept
|
||||
- [ ] Directory.Packages.props touched ONLY if no other project consumes the package
|
||||
- [ ] Full solution build adds zero new errors
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Manual smoke + final commit
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** none (verification + optional cleanup commit)
|
||||
|
||||
**Implements design:** Section 5 "Manual smoke" + Section 6 phase 5.
|
||||
|
||||
### Step 1: Restart the docker-dev cluster
|
||||
|
||||
The admin nodes need to pick up the new `Microsoft.AspNetCore.TestHost`-side code path AND the new cookie name. Since the in-cluster admin processes run a prior build, force a rebuild + recreate:
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||
docker compose -f docker-dev/docker-compose.yml up -d --build admin-a admin-b
|
||||
```
|
||||
|
||||
Wait ~15 s for warm-up. Then:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-dev/docker-compose.yml ps admin-a admin-b
|
||||
```
|
||||
|
||||
Both should show `Up` and `(healthy)` (or `Up` if no healthcheck).
|
||||
|
||||
### Step 2: curl smoke
|
||||
|
||||
```bash
|
||||
# Anonymous browser-shaped GET → 302 to /login with ReturnUrl
|
||||
curl -i -H "Accept: text/html" http://localhost:9200/ 2>&1 | head -12
|
||||
# Expected: HTTP/1.1 302 Found, Location: /login?ReturnUrl=%2F
|
||||
|
||||
# Anonymous AJAX GET → 401
|
||||
curl -i -H "X-Requested-With: XMLHttpRequest" http://localhost:9200/ 2>&1 | head -8
|
||||
# Expected: HTTP/1.1 401 Unauthorized
|
||||
|
||||
# Anonymous JSON GET → 401
|
||||
curl -i -H "Accept: application/json" http://localhost:9200/ 2>&1 | head -8
|
||||
# Expected: HTTP/1.1 401 Unauthorized
|
||||
|
||||
# Login form → 302 with Set-Cookie ZB.MOM.WW.OtOpcUa.Auth
|
||||
curl -i -X POST -d "username=alice&password=alice" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
http://localhost:9200/auth/login 2>&1 | head -15
|
||||
# Expected: HTTP/1.1 302 Found, Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=... (the test stub user may differ — check docker-compose's GLAuth seed for a valid LDAP creds pair)
|
||||
```
|
||||
|
||||
### Step 3: Chrome smoke (via the macbook browser instance from earlier in the session)
|
||||
|
||||
1. Open `http://localhost:9200/` — should redirect to `/login?ReturnUrl=%2F` (not Chrome's error page)
|
||||
2. Sign in via the form
|
||||
3. DevTools → Application → Cookies → confirm cookie name is `ZB.MOM.WW.OtOpcUa.Auth`
|
||||
4. Navigate to `http://localhost:9200/` again — should render the AdminUI dashboard
|
||||
5. Click logout → confirm redirect back to `/login`
|
||||
|
||||
### Step 4: Optional CLAUDE.md update
|
||||
|
||||
If `CLAUDE.md` mentions the old `OtOpcUa.Auth` cookie name anywhere, update to the new `ZB.MOM.WW.OtOpcUa.Auth`. Run:
|
||||
|
||||
```bash
|
||||
grep -n "OtOpcUa\.Auth" /Users/dohertj2/Desktop/OtOpcUa/CLAUDE.md
|
||||
```
|
||||
|
||||
If matches: update them, otherwise skip.
|
||||
|
||||
### Step 5: Final commit (only if Step 4 changed CLAUDE.md)
|
||||
|
||||
```bash
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa add CLAUDE.md
|
||||
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "docs: update cookie name reference in CLAUDE.md"
|
||||
```
|
||||
|
||||
### Output report
|
||||
|
||||
- All 4 curl smoke checks passed?
|
||||
- Chrome smoke passed?
|
||||
- CLAUDE.md changed?
|
||||
- Final SHA on master (if any docs commit)
|
||||
- Commit count since this plan started (vs `bc4fce5`)
|
||||
|
||||
### Self-review checklist
|
||||
|
||||
- [ ] `docker compose up -d --build admin-a admin-b` succeeded
|
||||
- [ ] All 4 curl smoke checks return expected status codes
|
||||
- [ ] Chrome smoke shows redirect to `/login`, then dashboard after auth
|
||||
- [ ] Cookie name in DevTools matches `ZB.MOM.WW.OtOpcUa.Auth`
|
||||
- [ ] No new commits left uncommitted in the working tree
|
||||
|
||||
---
|
||||
|
||||
## Verification gates (apply at end of every task)
|
||||
|
||||
- `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/` — 0 errors
|
||||
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green (existing + new)
|
||||
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — no NEW errors beyond the 12 pre-existing
|
||||
- No untracked files staged accidentally (especially `sql_login.txt`, `pki/`, doc-fix artifacts)
|
||||
|
||||
---
|
||||
|
||||
## Risk hot-spots for reviewers
|
||||
|
||||
1. **TestServer's no-redirect HttpClient.** The plan assumes `new HttpClient(_server.CreateHandler()) { BaseAddress = _server.BaseAddress }` does NOT auto-follow redirects. If it does, the `Root_anonymous_browser_GET_redirects_to_login` test fails with 200 instead of 302. Fix path documented in Task 3 Step 4.
|
||||
2. **Framework default of `Accept: */*` → 302.** Curl's default Accept header is `*/*`, which the framework classifies as browser → 302. Documented behavior, mirrors ScadaBridge; reviewers should not flag the smoke step that uses `Accept: text/html` as redundant — it's the explicit "browser" assertion.
|
||||
3. **Cookie rename invalidates sessions.** The deploy effectively logs every currently-signed-in user out. Document in commit body; the cluster was just restarted on the new API key anyway, so the timing is opportune.
|
||||
4. **`Directory.Packages.props` change is conditional.** Don't touch it if other projects still consume the JwtBearer package. Task 4 has explicit grep guard.
|
||||
5. **`/Account/AccessDenied` 404.** Authenticated users hitting a `DriverOperator`-only route now get a generic 404 page instead of a clean access-denied message. Documented design choice; follow-up to add a Razor page if UX feedback demands it.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-29-auth-alignment-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Extend OtOpcUaCookieOptions", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Rewrite auth wiring + update cookie-name assertion", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Add browser-vs-AJAX challenge tests", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: Remove JwtBearer package reference", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "subject": "Task 5: Manual smoke + final commit", "status": "pending", "blockedBy": [3, 4]}
|
||||
],
|
||||
"lastUpdated": "2026-05-29T00:00:00Z"
|
||||
}
|
||||
+2
-1
@@ -251,7 +251,8 @@ The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.
|
||||
|---|---|
|
||||
| `ConfigViewer` | Read-only access to drafts, generations, audit log, fleet status. |
|
||||
| `ConfigEditor` | ConfigViewer plus draft editing (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. |
|
||||
| `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. |
|
||||
| `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. Also satisfies the `DriverOperator` authorization policy. |
|
||||
| `DriverOperator` | May issue **Reconnect** and **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel`. Gated by the `DriverOperator` named policy in `AddAuthorization` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). Map an LDAP group via `GroupToRole`, e.g. `"ot-driver-operator": "DriverOperator"`. |
|
||||
|
||||
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) and Razor pages gate inline with the role names, e.g. `@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]` on `Deployments.razor`. Nav-menu sections hide via `<AuthorizeView>`.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
|
||||
/// <summary>One node in a driver-agnostic browse tree.</summary>
|
||||
/// <param name="NodeId">Stable identifier passed back to the picker on commit. For OPC UA
|
||||
/// this is the <c>nsu=...;...</c> form; for Galaxy this is the <c>tag_name</c>.</param>
|
||||
/// <param name="DisplayName">Label shown in the tree.</param>
|
||||
/// <param name="Kind">Whether this node terminates the address (Leaf) or has children
|
||||
/// (Folder). Galaxy never returns Leaves; only the attribute side-panel terminates.</param>
|
||||
/// <param name="HasChildrenHint">When true, the UI renders an expand affordance before
|
||||
/// the children have been fetched.</param>
|
||||
public sealed record BrowseNode(
|
||||
string NodeId,
|
||||
string DisplayName,
|
||||
BrowseNodeKind Kind,
|
||||
bool HasChildrenHint);
|
||||
|
||||
/// <summary>Discriminates terminal vs. expandable nodes for UI rendering.</summary>
|
||||
public enum BrowseNodeKind
|
||||
{
|
||||
/// <summary>Expandable — has (or may have) children. UI shows expand affordance.</summary>
|
||||
Folder,
|
||||
/// <summary>Terminal — commit on select.</summary>
|
||||
Leaf,
|
||||
}
|
||||
|
||||
/// <summary>Metadata for an attribute of a Galaxy object (or the equivalent
|
||||
/// per-driver concept). Surfaced in the picker's attribute side-panel.</summary>
|
||||
public sealed record AttributeInfo(
|
||||
string Name,
|
||||
string DriverDataType,
|
||||
bool IsArray,
|
||||
string SecurityClass);
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// A live, one-level-at-a-time browse over a remote address space. Owned by the
|
||||
/// AdminUI <c>BrowseSessionRegistry</c>; disposed by the registry's TTL reaper or
|
||||
/// the picker body on close.
|
||||
/// </summary>
|
||||
public interface IBrowseSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Opaque token identifying this session in the registry.</summary>
|
||||
Guid Token { get; }
|
||||
|
||||
/// <summary>Wall-clock time of the most recent successful call. Refreshed on
|
||||
/// <see cref="RootAsync"/>, <see cref="ExpandAsync"/>, and
|
||||
/// <see cref="AttributesAsync"/>; used by the reaper for idle eviction.</summary>
|
||||
DateTime LastUsedUtc { get; }
|
||||
|
||||
/// <summary>Returns the top-level browse nodes.</summary>
|
||||
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns the direct children of the node identified by
|
||||
/// <paramref name="nodeId"/>.</summary>
|
||||
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
|
||||
/// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
|
||||
/// the attribute side-panel after the user selects an object.</summary>
|
||||
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver factory that opens an ad-hoc browse session against the configuration
|
||||
/// supplied as JSON. Parallels <c>IDriverProbe</c> in the runtime — one implementation
|
||||
/// per driver type, registered in AdminUI DI and indexed by <see cref="DriverType"/>.
|
||||
/// </summary>
|
||||
public interface IDriverBrowser
|
||||
{
|
||||
/// <summary>Driver type key, matching the AdminUI's persisted DriverType string
|
||||
/// (e.g. "OpcUaClient", "Galaxy").</summary>
|
||||
string DriverType { get; }
|
||||
|
||||
/// <summary>Opens a browse session against the supplied configuration.</summary>
|
||||
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||
/// driver would consume.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
|
||||
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -14,4 +14,14 @@ public interface IAdminOperationsClient
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation containing the deployment start result.</returns>
|
||||
Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Generic Ask: forwards <paramref name="message"/> to the AdminOperationsActor
|
||||
/// cluster-singleton proxy and awaits a reply of type <typeparamref name="T"/>.
|
||||
/// The caller is responsible for applying any outer timeout via <paramref name="ct"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Expected reply type.</typeparam>
|
||||
/// <param name="message">The message to send.</param>
|
||||
/// <param name="ct">Cancellation token (caller-controlled timeout).</param>
|
||||
Task<T> AskAsync<T>(object message, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI → AdminOperationsActor: reconnect the driver actor's transport without
|
||||
/// respawning the actor itself. Sends the actor back through its Reconnecting state —
|
||||
/// fast, preserves in-memory state. The driver actor's supervisor performs the work.
|
||||
/// </summary>
|
||||
/// <param name="ClusterId">Cluster scope identifier (for audit).</param>
|
||||
/// <param name="DriverInstanceId">The driver instance to reconnect.</param>
|
||||
/// <param name="ActorByUserName">The authenticated admin user who triggered the reconnect.</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
public sealed record ReconnectDriver(
|
||||
string ClusterId,
|
||||
string DriverInstanceId,
|
||||
string ActorByUserName,
|
||||
Guid CorrelationId);
|
||||
|
||||
/// <summary>Reply for <see cref="ReconnectDriver"/>.</summary>
|
||||
/// <param name="Ok">True iff the operation was dispatched without error.</param>
|
||||
/// <param name="Message">Failure reason; null on success.</param>
|
||||
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
|
||||
public sealed record ReconnectDriverResult(
|
||||
bool Ok,
|
||||
string? Message,
|
||||
Guid CorrelationId);
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Shared DPS topic for driver-control commands (<see cref="RestartDriver"/>,
|
||||
/// <see cref="ReconnectDriver"/>). Publishers (AdminOperationsActor) and subscribers
|
||||
/// (DriverHostActor) reference this single constant so renames can't silently
|
||||
/// desynchronise.
|
||||
/// </summary>
|
||||
public static class DriverControlTopic
|
||||
{
|
||||
public const string Name = "driver-control";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI → AdminOperationsActor: restart the driver actor for one instance.
|
||||
/// A restart fully stops and respawns the actor — loses in-memory state, may briefly
|
||||
/// interrupt active subscriptions. The driver actor's supervisor performs the work.
|
||||
/// </summary>
|
||||
/// <param name="ClusterId">Cluster scope identifier (for audit).</param>
|
||||
/// <param name="DriverInstanceId">The driver instance to restart.</param>
|
||||
/// <param name="ActorByUserName">The authenticated admin user who triggered the restart.</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
public sealed record RestartDriver(
|
||||
string ClusterId,
|
||||
string DriverInstanceId,
|
||||
string ActorByUserName,
|
||||
Guid CorrelationId);
|
||||
|
||||
/// <summary>Reply for <see cref="RestartDriver"/>.</summary>
|
||||
/// <param name="Ok">True iff the operation was dispatched without error.</param>
|
||||
/// <param name="Message">Failure reason; null on success.</param>
|
||||
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
|
||||
public sealed record RestartDriverResult(
|
||||
bool Ok,
|
||||
string? Message,
|
||||
Guid CorrelationId);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI → AdminOperationsActor request: probe one driver type's connection using
|
||||
/// the supplied JSON config. Routed through <c>IAdminOperationsClient</c>; reply is
|
||||
/// <see cref="TestDriverConnectResult"/>.
|
||||
/// </summary>
|
||||
/// <param name="DriverType">Must match an installed <c>IDriverProbe.DriverType</c>.</param>
|
||||
/// <param name="ConfigJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
|
||||
/// <param name="TimeoutSeconds">Per-probe timeout; server clamps to [1, 60].</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
public sealed record TestDriverConnect(
|
||||
string DriverType,
|
||||
string ConfigJson,
|
||||
int TimeoutSeconds,
|
||||
Guid CorrelationId);
|
||||
|
||||
/// <summary>Reply for <see cref="TestDriverConnect"/>.</summary>
|
||||
/// <param name="Ok">True iff the probe succeeded.</param>
|
||||
/// <param name="Message">Failure reason; null on success.</param>
|
||||
/// <param name="LatencyMs">Round-trip latency in milliseconds; null on failure or timeout.</param>
|
||||
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
|
||||
public sealed record TestDriverConnectResult(
|
||||
bool Ok,
|
||||
string? Message,
|
||||
double? LatencyMs,
|
||||
Guid CorrelationId);
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a single driver instance's health, published to the
|
||||
/// <c>driver-health</c> DistributedPubSub topic whenever a driver actor
|
||||
/// transitions state or logs an error. Consumed by
|
||||
/// <c>DriverStatusSignalRBridge</c> in the AdminUI for live status push.
|
||||
/// </summary>
|
||||
/// <param name="ClusterId">Cluster this driver belongs to.</param>
|
||||
/// <param name="DriverInstanceId">Globally-unique driver instance ID.</param>
|
||||
/// <param name="State">DriverState enum as string (Healthy, Faulted, Reconnecting, etc.).</param>
|
||||
/// <param name="LastSuccessfulReadUtc">Most recent successful equipment read; null if never.</param>
|
||||
/// <param name="LastError">Latest error message; null when none.</param>
|
||||
/// <param name="ErrorCount5Min">Number of state-transitions into Faulted in the last 5 minutes.</param>
|
||||
/// <param name="PublishedUtc">Timestamp this snapshot was published.</param>
|
||||
public sealed record DriverHealthChanged(
|
||||
string ClusterId,
|
||||
string DriverInstanceId,
|
||||
string State,
|
||||
DateTime? LastSuccessfulReadUtc,
|
||||
string? LastError,
|
||||
int ErrorCount5Min,
|
||||
DateTime PublishedUtc)
|
||||
{
|
||||
/// <summary>
|
||||
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
|
||||
/// <c>DriverStatusSignalRBridge</c> reference this single constant so renames can't
|
||||
/// silently desynchronise publisher and subscriber.
|
||||
/// </summary>
|
||||
public const string TopicName = "driver-health";
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
@@ -22,6 +23,16 @@ public static class ServiceCollectionExtensions
|
||||
$"Connection string '{ConnectionStringName}' is required. Add it to appsettings.json or the OTOPCUA_CONFIG_CONNECTION env var.");
|
||||
|
||||
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
|
||||
|
||||
// AddDbContextFactory registers only the IDbContextFactory<> — it does NOT also register
|
||||
// a scoped OtOpcUaConfigDbContext. Config services that take the context directly (e.g.
|
||||
// LdapGroupRoleMappingService) need a scoped instance, so bridge one off the factory.
|
||||
services.AddScoped(sp => sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
|
||||
|
||||
// Config-DB services consumed by both the AdminUI (RoleGrants page) and the auth/login
|
||||
// host (AuthEndpoints.LoginAsync). Scoped to match the request/render scope of both callers.
|
||||
services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ public static class DraftValidator
|
||||
|
||||
var compat = ns.Kind switch
|
||||
{
|
||||
NamespaceKind.SystemPlatform => di.DriverType == "Galaxy",
|
||||
NamespaceKind.Equipment => di.DriverType != "Galaxy",
|
||||
NamespaceKind.SystemPlatform => di.DriverType == "GalaxyMxGateway",
|
||||
NamespaceKind.Equipment => di.DriverType != "GalaxyMxGateway",
|
||||
_ => true,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Sink for driver-health state-change notifications. The runtime DI wires the
|
||||
/// Akka-DistributedPubSub-backed implementation; tests and dev-stub paths use
|
||||
/// <see cref="NullDriverHealthPublisher"/> to opt out without changing call sites.
|
||||
/// </summary>
|
||||
public interface IDriverHealthPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes a health snapshot for one driver instance. Implementations must be
|
||||
/// non-blocking and tolerant of being called from any thread.
|
||||
/// </summary>
|
||||
void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop-in no-op for tests and dev-stub paths. Production wires the Akka-backed
|
||||
/// implementation in the Runtime project.
|
||||
/// </summary>
|
||||
public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
|
||||
{
|
||||
/// <summary>Singleton instance.</summary>
|
||||
public static readonly NullDriverHealthPublisher Instance = new();
|
||||
|
||||
private NullDriverHealthPublisher() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min)
|
||||
{ /* no-op */ }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Test-connect probe for one driver type. Implementations deserialize a driver-config
|
||||
/// JSON, attempt a cheap connection (TCP open, OPC UA session, gRPC ping — whatever the
|
||||
/// driver's native protocol supports), and report success/failure with latency. Probes
|
||||
/// MUST NOT mutate any persistent state; the AdminUI invokes them against transient
|
||||
/// config from the typed form, NOT against the persisted DriverInstance row.
|
||||
/// </summary>
|
||||
public interface IDriverProbe
|
||||
{
|
||||
/// <summary>DriverInstance.DriverType string this probe handles. Used for DI lookup.</summary>
|
||||
string DriverType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Run the probe with the supplied config + timeout. Honour <paramref name="ct"/> for
|
||||
/// timeout cancellation. Never throw on connection failure; instead return a result
|
||||
/// with <c>Ok = false</c> + a message.
|
||||
/// </summary>
|
||||
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a single <see cref="IDriverProbe.ProbeAsync"/> call.</summary>
|
||||
/// <param name="Ok">True iff the probe reached its target and the handshake succeeded.</param>
|
||||
/// <param name="Message">Human-readable status; null on success.</param>
|
||||
/// <param name="Latency">Wall-clock duration of the successful probe; null on failure.</param>
|
||||
public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency);
|
||||
@@ -12,20 +12,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
[Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
|
||||
/// <summary>Gets or sets the PCCC file address to subscribe to.</summary>
|
||||
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
/// <summary>Gets or sets the data type of the address.</summary>
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||
"ControlElement (default Int).")]
|
||||
/// <summary>Gets or sets the data type of the address.</summary>
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
/// <summary>Gets or sets the polling interval in milliseconds.</summary>
|
||||
[CommandOption("interval-ms", 'i', Description =
|
||||
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
|
||||
"sub-250ms values.")]
|
||||
/// <summary>Gets or sets the polling interval in milliseconds.</summary>
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -13,14 +13,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
[Command("probe", Description = "Verify the S7 endpoint is reachable and a sample read succeeds.")]
|
||||
public sealed class ProbeCommand : S7CommandBase
|
||||
{
|
||||
/// <summary>Gets or sets the S7 address to probe.</summary>
|
||||
[CommandOption("address", 'a', Description =
|
||||
"Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " +
|
||||
"reserves a fingerprint DB.")]
|
||||
/// <summary>Gets or sets the S7 address to probe.</summary>
|
||||
public string Address { get; init; } = "MW0";
|
||||
|
||||
[CommandOption("type", Description = "Probe data type (default Int16).")]
|
||||
/// <summary>Gets or sets the data type of the probe address.</summary>
|
||||
[CommandOption("type", Description = "Probe data type (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||
|
||||
@@ -24,7 +23,7 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
[CommandOption("cpu", 'c', Description =
|
||||
"S7 CPU family: S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 " +
|
||||
"(default S71500). Determines the ISO-TSAP slot byte.")]
|
||||
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
|
||||
public S7CpuType CpuType { get; init; } = S7CpuType.S71500;
|
||||
|
||||
/// <summary>Gets the rack number.</summary>
|
||||
[CommandOption("rack", Description = "Rack number (default 0 — single-rack).")]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Logix atomic + string data types, plus a <c>Structure</c> marker used when a tag
|
||||
/// references a UDT / predefined structure (Timer, Counter, Control). The concrete UDT
|
||||
/// shape is resolved via the CIP Template Object at discovery time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the shape of <c>ModbusDataType</c>. Atomic Logix names (BOOL / SINT / INT / DINT /
|
||||
/// LINT / REAL / LREAL / STRING / DT) map one-to-one; BIT + BOOL-in-DINT collapse into
|
||||
/// <c>Bool</c> with the <c>.N</c> bit-index carried on the <c>AbCipTagPath</c>
|
||||
/// rather than the data type itself.
|
||||
/// </remarks>
|
||||
public enum AbCipDataType
|
||||
{
|
||||
Bool,
|
||||
SInt, // signed 8-bit
|
||||
Int, // signed 16-bit
|
||||
DInt, // signed 32-bit
|
||||
LInt, // signed 64-bit
|
||||
USInt, // unsigned 8-bit (Logix 5000 post-V21)
|
||||
UInt, // unsigned 16-bit
|
||||
UDInt, // unsigned 32-bit
|
||||
ULInt, // unsigned 64-bit
|
||||
Real, // 32-bit IEEE-754
|
||||
LReal, // 64-bit IEEE-754
|
||||
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
|
||||
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions
|
||||
/// <summary>
|
||||
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
|
||||
/// resolved at discovery time; reads + writes fan out to member Variables unless the
|
||||
/// caller has explicitly opted into whole-UDT decode.
|
||||
/// </summary>
|
||||
Structure,
|
||||
}
|
||||
+12
-2
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
@@ -45,7 +47,7 @@ public sealed class AbCipDriverOptions
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — when <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// via <see cref="Core.Abstractions.IAlarmSource"/>; the driver polls each subscribed
|
||||
/// via <c>IAlarmSource</c>; the driver polls each subscribed
|
||||
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// state transitions. Default <c>false</c> — operators explicitly opt in because
|
||||
/// projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops
|
||||
@@ -74,6 +76,14 @@ public sealed class AbCipDriverOptions
|
||||
/// reads into one. The richer CIP Template Object path remains the long-term fix.
|
||||
/// </summary>
|
||||
public bool EnableDeclarationOnlyUdtGrouping { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -158,7 +168,7 @@ public enum AbCipPlcFamily
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. Enabled by default; the probe reads a cheap tag
|
||||
/// on the PLC at the configured interval to drive <see cref="Core.Abstractions.IHostConnectivityProbe"/>
|
||||
/// on the PLC at the configured interval to drive <c>IHostConnectivityProbe</c>
|
||||
/// state transitions + Admin UI health status.
|
||||
/// </summary>
|
||||
public sealed class AbCipProbeOptions
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO PackageReference. -->
|
||||
</Project>
|
||||
-35
@@ -2,41 +2,6 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Logix atomic + string data types, plus a <see cref="Structure"/> marker used when a tag
|
||||
/// references a UDT / predefined structure (Timer, Counter, Control). The concrete UDT
|
||||
/// shape is resolved via the CIP Template Object at discovery time (see
|
||||
/// <see cref="CipTemplateObjectDecoder"/> + <see cref="AbCipTemplateCache"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the shape of <c>ModbusDataType</c>. Atomic Logix names (BOOL / SINT / INT / DINT /
|
||||
/// LINT / REAL / LREAL / STRING / DT) map one-to-one; BIT + BOOL-in-DINT collapse into
|
||||
/// <see cref="Bool"/> with the <c>.N</c> bit-index carried on the <see cref="AbCipTagPath"/>
|
||||
/// rather than the data type itself.
|
||||
/// </remarks>
|
||||
public enum AbCipDataType
|
||||
{
|
||||
Bool,
|
||||
SInt, // signed 8-bit
|
||||
Int, // signed 16-bit
|
||||
DInt, // signed 32-bit
|
||||
LInt, // signed 64-bit
|
||||
USInt, // unsigned 8-bit (Logix 5000 post-V21)
|
||||
UInt, // unsigned 16-bit
|
||||
UDInt, // unsigned 32-bit
|
||||
ULInt, // unsigned 64-bit
|
||||
Real, // 32-bit IEEE-754
|
||||
LReal, // 64-bit IEEE-754
|
||||
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
|
||||
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions
|
||||
/// <summary>
|
||||
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
|
||||
/// resolved at discovery time; reads + writes fan out to member Variables unless the
|
||||
/// caller has explicitly opted into whole-UDT decode.
|
||||
/// </summary>
|
||||
Structure,
|
||||
}
|
||||
|
||||
/// <summary>Map a Logix atomic type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbCipDataTypeExtensions
|
||||
{
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes
|
||||
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
|
||||
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any CIP bytes —
|
||||
/// a richer EIP session-open probe is a documented follow-up.
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbCip";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
AbCipDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<AbCipDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(AbCipDriverOptions opts)
|
||||
{
|
||||
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
|
||||
var firstDevice = opts.Devices.FirstOrDefault();
|
||||
if (firstDevice is null) return (string.Empty, 0);
|
||||
|
||||
var parsed = AbCipHostAddress.TryParse(firstDevice.HostAddress);
|
||||
if (parsed is null) return (string.Empty, 0);
|
||||
|
||||
return (parsed.Gateway, parsed.Port);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
-21
@@ -1,5 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
@@ -27,22 +25,3 @@ public enum AbLegacyDataType
|
||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||
ControlElement,
|
||||
}
|
||||
|
||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbLegacyDataTypeExtensions
|
||||
{
|
||||
/// <summary>Converts an AbLegacyDataType to the corresponding DriverDataType.</summary>
|
||||
/// <param name="t">The PCCC data type to convert.</param>
|
||||
/// <returns>The mapped driver data type.</returns>
|
||||
public static DriverDataType ToDriverDataType(this AbLegacyDataType t) => t switch
|
||||
{
|
||||
AbLegacyDataType.Bit => DriverDataType.Boolean,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => DriverDataType.Int32,
|
||||
AbLegacyDataType.Long => DriverDataType.Int32, // matches Modbus/AbCip 64→32 gap
|
||||
AbLegacyDataType.Float => DriverDataType.Float32,
|
||||
AbLegacyDataType.String => DriverDataType.String,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
+10
-1
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
@@ -21,6 +22,14 @@ public sealed class AbLegacyDriverOptions
|
||||
|
||||
/// <summary>Gets or sets the default timeout for read/write operations.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 5;
|
||||
}
|
||||
|
||||
public sealed record AbLegacyDeviceOptions(
|
||||
@@ -30,7 +39,7 @@ public sealed record AbLegacyDeviceOptions(
|
||||
|
||||
/// <summary>
|
||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
||||
/// file-address string that parses via <c>AbLegacyAddress.TryParse</c>.
|
||||
/// </summary>
|
||||
public sealed record AbLegacyTagDefinition(
|
||||
string Name,
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO PackageReference. -->
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbLegacyDataTypeExtensions
|
||||
{
|
||||
/// <summary>Converts an AbLegacyDataType to the corresponding DriverDataType.</summary>
|
||||
/// <param name="t">The PCCC data type to convert.</param>
|
||||
/// <returns>The mapped driver data type.</returns>
|
||||
public static DriverDataType ToDriverDataType(this AbLegacyDataType t) => t switch
|
||||
{
|
||||
AbLegacyDataType.Bit => DriverDataType.Boolean,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => DriverDataType.Int32,
|
||||
AbLegacyDataType.Long => DriverDataType.Int32, // matches Modbus/AbCip 64→32 gap
|
||||
AbLegacyDataType.Float => DriverDataType.Float32,
|
||||
AbLegacyDataType.String => DriverDataType.String,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="AbLegacyDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes
|
||||
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
|
||||
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any PCCC bytes —
|
||||
/// a richer EIP session-open probe is a documented follow-up.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbLegacy";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
AbLegacyDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<AbLegacyDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(AbLegacyDriverOptions opts)
|
||||
{
|
||||
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
|
||||
var firstDevice = opts.Devices.FirstOrDefault();
|
||||
if (firstDevice is null) return (string.Empty, 0);
|
||||
|
||||
var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress);
|
||||
if (parsed is null) return (string.Empty, 0);
|
||||
|
||||
return (parsed.Gateway, parsed.Port);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
-18
@@ -1,5 +1,3 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
@@ -24,19 +22,3 @@ public enum FocasDataType
|
||||
/// <summary>ASCII string (alarm text, parameter names, some PMC string areas).</summary>
|
||||
String,
|
||||
}
|
||||
|
||||
public static class FocasDataTypeExtensions
|
||||
{
|
||||
/// <summary>Converts a FOCAS data type to the corresponding driver data type.</summary>
|
||||
/// <param name="t">The FOCAS data type to convert.</param>
|
||||
/// <returns>The equivalent driver data type.</returns>
|
||||
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit => DriverDataType.Boolean,
|
||||
FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 => DriverDataType.Int32,
|
||||
FocasDataType.Float32 => DriverDataType.Float32,
|
||||
FocasDataType.Float64 => DriverDataType.Float64,
|
||||
FocasDataType.String => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
+12
-2
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
@@ -21,6 +23,14 @@ public sealed class FocasDriverOptions
|
||||
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
|
||||
/// <summary>Gets the fixed tree options.</summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 10s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -96,7 +106,7 @@ public sealed class FocasAlarmProjectionOptions
|
||||
|
||||
/// <summary>
|
||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||
/// address validation at <c>FocasDriver.InitializeAsync</c>; leave as
|
||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||
/// </summary>
|
||||
public sealed record FocasDeviceOptions(
|
||||
@@ -106,7 +116,7 @@ public sealed record FocasDeviceOptions(
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
||||
/// address string that parses via <c>FocasAddress.TryParse</c> —
|
||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasTagDefinition(
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO PackageReference. NO ProjectReference. -->
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
public static class FocasDataTypeExtensions
|
||||
{
|
||||
/// <summary>Converts a FOCAS data type to the corresponding driver data type.</summary>
|
||||
/// <param name="t">The FOCAS data type to convert.</param>
|
||||
/// <returns>The equivalent driver data type.</returns>
|
||||
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit => DriverDataType.Boolean,
|
||||
FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 => DriverDataType.Int32,
|
||||
FocasDataType.Float32 => DriverDataType.Float32,
|
||||
FocasDataType.Float64 => DriverDataType.Float64,
|
||||
FocasDataType.String => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the first device's FOCAS Ethernet address + port and closes
|
||||
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
|
||||
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any FOCAS/2 bytes —
|
||||
/// a richer FOCAS handshake (cnc_allclibhndl3) probe is a documented follow-up.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
FocasDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<FocasDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(FocasDriverOptions opts)
|
||||
{
|
||||
// Parse the first device's focas:// address to extract host + port.
|
||||
var firstDevice = opts.Devices.FirstOrDefault();
|
||||
if (firstDevice is null) return (string.Empty, 0);
|
||||
|
||||
var parsed = FocasHostAddress.TryParse(firstDevice.HostAddress);
|
||||
if (parsed is null) return (string.Empty, 0);
|
||||
|
||||
return (parsed.Host, parsed.Port);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Lazy Galaxy browse over <see cref="GalaxyRepositoryClient.BrowseAsync"/>.
|
||||
/// <see cref="RootAsync"/> returns the top-level <see cref="LazyBrowseNode"/>s
|
||||
/// directly from the gateway; <see cref="ExpandAsync"/> fetches the direct children
|
||||
/// of a previously-handed-out node via <see cref="LazyBrowseNode.ExpandAsync"/>
|
||||
/// (one wire call per click, paginated internally by the client). Attribute fetches
|
||||
/// are per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
|
||||
/// Owns the supplied <see cref="GalaxyRepositoryClient"/> and disposes it best-effort.
|
||||
/// </summary>
|
||||
internal sealed class GalaxyBrowseSession : IBrowseSession
|
||||
{
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
private readonly ConcurrentDictionary<string, LazyBrowseNode> _byTagName = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _rootGate = new(1, 1);
|
||||
private volatile bool _disposed;
|
||||
private IReadOnlyList<LazyBrowseNode>? _roots;
|
||||
|
||||
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Wall-clock time of the most recent successful Root/Expand/Attributes call.</summary>
|
||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new session wrapping a connected repository client. The factory
|
||||
/// in <c>GalaxyDriverBrowser</c> constructs the client via
|
||||
/// <see cref="GalaxyRepositoryClient.Create"/> and hands it off here for the
|
||||
/// session's lifetime.
|
||||
/// </summary>
|
||||
/// <param name="client">Galaxy repository client to query for browse and attributes.</param>
|
||||
internal GalaxyBrowseSession(GalaxyRepositoryClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the top-level <see cref="LazyBrowseNode"/>s from the gateway and
|
||||
/// returns them as <see cref="BrowseNode"/>s. Result is cached; a second call
|
||||
/// returns the cached roots without a re-fetch.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _rootGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_roots ??= await _client.BrowseAsync(new BrowseChildrenOptions(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Project(_roots);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rootGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the direct children of the cached node identified by
|
||||
/// <paramref name="nodeId"/> (the object's <c>TagName</c>) via
|
||||
/// <see cref="LazyBrowseNode.ExpandAsync"/>. Throws <see cref="ArgumentException"/>
|
||||
/// if the tag hasn't been handed out by a prior Root/Expand call.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_byTagName.TryGetValue(nodeId, out var node))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Galaxy object '{nodeId}' is not in the current browse-session cache. " +
|
||||
"Re-open the browser or expand its parent first.", nameof(nodeId));
|
||||
}
|
||||
|
||||
await node.ExpandAsync(cancellationToken).ConfigureAwait(false);
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Project(node.Children);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the attributes of the Galaxy object identified by <paramref name="nodeId"/>
|
||||
/// via <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true)</c>.
|
||||
/// Returns an empty list if the gateway has no matching object.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var rows = await _client.DiscoverHierarchyAsync(
|
||||
new DiscoverHierarchyOptions
|
||||
{
|
||||
RootTagName = nodeId,
|
||||
MaxDepth = 0,
|
||||
IncludeAttributes = true,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
var obj = rows.FirstOrDefault();
|
||||
if (obj is null) return Array.Empty<AttributeInfo>();
|
||||
|
||||
var result = new List<AttributeInfo>(obj.Attributes.Count);
|
||||
foreach (var attr in obj.Attributes)
|
||||
{
|
||||
var driverType = !string.IsNullOrEmpty(attr.DataTypeName)
|
||||
? attr.DataTypeName
|
||||
: attr.MxDataType.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
result.Add(new AttributeInfo(
|
||||
Name: attr.AttributeName,
|
||||
DriverDataType: driverType,
|
||||
IsArray: attr.IsArray,
|
||||
SecurityClass: MapSecurityClass(attr.SecurityClassification)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects <see cref="LazyBrowseNode"/>s to <see cref="BrowseNode"/>s, caching
|
||||
/// each by <c>TagName</c> so a subsequent <see cref="ExpandAsync"/> can locate
|
||||
/// it. Galaxy nodes are always <see cref="BrowseNodeKind.Folder"/> — leaves only
|
||||
/// appear in the attribute side-panel.
|
||||
/// </summary>
|
||||
private IReadOnlyList<BrowseNode> Project(IReadOnlyList<LazyBrowseNode> nodes)
|
||||
{
|
||||
var result = new List<BrowseNode>(nodes.Count);
|
||||
foreach (var n in nodes)
|
||||
{
|
||||
_byTagName[n.Object.TagName] = n;
|
||||
var displayName = !string.IsNullOrEmpty(n.Object.ContainedName)
|
||||
? n.Object.ContainedName
|
||||
: n.Object.TagName;
|
||||
result.Add(new BrowseNode(
|
||||
NodeId: n.Object.TagName,
|
||||
DisplayName: displayName,
|
||||
Kind: BrowseNodeKind.Folder,
|
||||
HasChildrenHint: n.HasChildrenHint));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the Galaxy raw security-classification integer to a display string.
|
||||
/// Buckets: 0=FreeAccess, 1=Operate, 2=Tune, 3=Configure, 4=ViewOnly;
|
||||
/// anything else surfaces as <c>Unknown(N)</c>.
|
||||
/// </summary>
|
||||
private static string MapSecurityClass(int raw) => raw switch
|
||||
{
|
||||
0 => "FreeAccess",
|
||||
1 => "Operate",
|
||||
2 => "Tune",
|
||||
3 => "Configure",
|
||||
4 => "ViewOnly",
|
||||
_ => $"Unknown({raw})",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently tears down the underlying repository client. Swallows exceptions
|
||||
/// on shutdown — the registry's reaper may be racing a client-initiated close.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_rootGate.Dispose();
|
||||
try
|
||||
{
|
||||
await _client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: a gateway-side close that hits a torn-down channel is normal.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Opens transient gateway connections for the AdminUI address picker. Mirrors the
|
||||
/// runtime <c>GalaxyDriver.BuildClientOptions</c> pattern so the gateway sees the
|
||||
/// same option shape, but tweaks <see cref="MxGatewayClientOptions.ApiKey"/>
|
||||
/// resolution + the gateway-side client identity so browse sessions are
|
||||
/// distinguishable from the runtime driver's live MX session.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifier used in gateway-side logs / metrics for AdminUI browse sessions.
|
||||
/// Distinct from any runtime driver's <c>MxAccess.ClientName</c> so an operator
|
||||
/// can tell the two apart when triaging.
|
||||
/// </summary>
|
||||
internal const string BrowseClientIdentity = "OtOpcUa-AdminUI-Browse";
|
||||
|
||||
/// <summary>Hard cap on the time we'll wait for the initial gateway handshake.</summary>
|
||||
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||
|
||||
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
||||
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
||||
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
|
||||
{
|
||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Driver type key — matches the AdminUI's persisted "GalaxyMxGateway" value.</summary>
|
||||
// Hardcoded literal: this project references Driver.Galaxy.Contracts, not Driver.Galaxy,
|
||||
// so GalaxyDriverFactoryExtensions.DriverTypeName isn't available here.
|
||||
public string DriverType => "GalaxyMxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a <see cref="GalaxyDriverOptions"/> blob, opens a transient
|
||||
/// <see cref="GalaxyRepositoryClient"/> against the configured gateway endpoint,
|
||||
/// and returns a browse session over it. The session owns the client and disposes
|
||||
/// it on <see cref="IBrowseSession.DisposeAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||
/// driver would consume.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the JSON deserialises to null, when <c>Gateway.Endpoint</c> is empty,
|
||||
/// or when <c>MxAccess.ClientName</c> is empty.
|
||||
/// </exception>
|
||||
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
var opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, JsonOpts)
|
||||
?? throw new InvalidOperationException("Galaxy options deserialized to null.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint))
|
||||
throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint.");
|
||||
|
||||
// The form persists MXAccess identity as ClientName (there is no separate
|
||||
// "galaxy name" knob on the driver — the gateway picks the galaxy via its
|
||||
// own GalaxyRepository config). Refuse a blank ClientName so the gateway side
|
||||
// doesn't see anonymous browse sessions during triage.
|
||||
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
||||
|
||||
var clientOpts = BuildClientOptions(opts.Gateway);
|
||||
|
||||
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||
// an AdminUI cancel still wins early.
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(ConnectBudget);
|
||||
|
||||
GalaxyRepositoryClient? client = null;
|
||||
try
|
||||
{
|
||||
client = GalaxyRepositoryClient.Create(clientOpts);
|
||||
|
||||
// TestConnectionAsync gives the gateway a chance to surface auth / TLS / DNS
|
||||
// failures synchronously inside the connect budget rather than waiting for
|
||||
// the first DiscoverHierarchyAsync call to fail. The client's own
|
||||
// ConnectTimeout already bounds the underlying gRPC handshake; the linked
|
||||
// CTS layered on top guarantees the AdminUI never blocks past 30s.
|
||||
await client.TestConnectionAsync(connectCts.Token).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI Galaxy browse session opened against {Endpoint} (admin-client={Identity}, runtime-client={RuntimeClient})",
|
||||
opts.Gateway.Endpoint, BrowseClientIdentity, opts.MxAccess.ClientName);
|
||||
|
||||
var session = new GalaxyBrowseSession(client);
|
||||
client = null; // Ownership transferred — keep finally from disposing.
|
||||
return session;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (client is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup; the original exception is more useful.
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
||||
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||
/// gateway sees an identical option shape. The API-key reference is resolved
|
||||
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the
|
||||
/// Browser project doesn't reference Driver.Galaxy.
|
||||
/// </summary>
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>env:NAME</c>, <c>file:PATH</c>, and <c>dev:KEY</c> prefixes;
|
||||
/// anything else is treated as a literal cleartext key with a startup warning.
|
||||
/// Slim mirror of <c>GalaxyDriver.ResolveApiKey</c> — the runtime version lives
|
||||
/// in a sibling project the Browser intentionally doesn't reference.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
private string ResolveApiKey(string secretRef)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = secretRef[4..];
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = secretRef[5..];
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
|
||||
}
|
||||
var contents = File.ReadAllText(path).Trim();
|
||||
return !string.IsNullOrEmpty(contents)
|
||||
? contents
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Explicit dev opt-in — no warning, the operator deliberately chose a
|
||||
// cleartext literal (dev box, parity rig).
|
||||
return secretRef[4..];
|
||||
}
|
||||
|
||||
// Back-compat literal arm. An unprefixed string is treated as the literal
|
||||
// API key — but emit a warning so an operator who accidentally committed a
|
||||
// cleartext key into DriverConfig sees it when they open the address picker.
|
||||
_logger.LogWarning(
|
||||
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
|
||||
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
|
||||
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
|
||||
return secretRef;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
|
||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+12
-1
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,7 +17,16 @@ public sealed record GalaxyDriverOptions(
|
||||
GalaxyGatewayOptions Gateway,
|
||||
GalaxyMxAccessOptions MxAccess,
|
||||
GalaxyRepositoryOptions Repository,
|
||||
GalaxyReconnectOptions Reconnect);
|
||||
GalaxyReconnectOptions Reconnect)
|
||||
{
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 30s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO PackageReference. NO ProjectReference. -->
|
||||
</Project>
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
@@ -528,7 +528,7 @@ public sealed class GalaxyDriver
|
||||
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
||||
// Driver.Galaxy-009 fix: guard with ??= so if BuildDefaultHierarchySource later runs
|
||||
// it reuses this client rather than overwriting the field and leaking the first instance.
|
||||
_ownedRepositoryClient ??= MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||
BuildClientOptions(_options.Gateway));
|
||||
|
||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="GalaxyDriverOptions"/>-shaped driver config.
|
||||
/// Parses the <c>Gateway.Endpoint</c> gRPC endpoint (e.g. <c>http://host:5001</c> or
|
||||
/// <c>host:5001</c>), opens a socket and closes immediately. Surfaces a green tick +
|
||||
/// latency on success; red chip + SocketError on failure; "timed out" on the caller's
|
||||
/// cancellation. Does NOT exchange any gRPC frames — a richer gRPC ping probe is a
|
||||
/// documented follow-up.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
// Matches DriverInstance.DriverType strings set by the AdminUI's GalaxyDriverPage.
|
||||
public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
GalaxyDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(GalaxyDriverOptions opts)
|
||||
{
|
||||
var endpoint = opts.Gateway.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint)) return (string.Empty, 0);
|
||||
|
||||
// Try absolute URI first (e.g. "http://hostname:5001" or "https://hostname:5001").
|
||||
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
var host = uri.Host;
|
||||
// Uri.Port is -1 when not specified; default mxaccessgw port is 5001.
|
||||
var port = uri.Port > 0 ? uri.Port : 5001;
|
||||
return (host, port);
|
||||
}
|
||||
|
||||
// Fallback: treat as "host:port" (no scheme).
|
||||
var colonIdx = endpoint.LastIndexOf(':');
|
||||
if (colonIdx > 0 && int.TryParse(endpoint[(colonIdx + 1)..], out var rawPort) && rawPort > 0)
|
||||
return (endpoint[..colonIdx], rawPort);
|
||||
|
||||
// No port found — return the whole string as host with default port.
|
||||
return (endpoint, 5001);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using System.Diagnostics.Metrics;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
@@ -62,7 +62,7 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
|
||||
/// applied value and skip redundant calls.
|
||||
/// </summary>
|
||||
private async Task EnsureSessionIntervalAsync(
|
||||
MxGateway.Client.MxGatewaySession session, int serverHandle, int intervalMs, CancellationToken cancellationToken)
|
||||
ZB.MOM.WW.MxGateway.Client.MxGatewaySession session, int serverHandle, int intervalMs, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_intervalLock)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
|
||||
@@ -13,39 +13,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Vendored mxaccessgw .NET client. Originally consumed via path-based
|
||||
ProjectReference to the sibling repo, but the sibling repo restructured
|
||||
and the MxGateway.Client.csproj path no longer exists. The DLLs in
|
||||
libs/ are the last known-good build (May 2026); they reference proto
|
||||
types from MxGateway.Contracts.dll using the pre-restructure namespace
|
||||
(MxGateway.Contracts.Proto). See libs/README.md for the unwinding plan
|
||||
once the sibling repo restores a client library or we migrate to the
|
||||
new ZB.MOM.WW.MxGateway.Contracts.Proto namespace. -->
|
||||
<Reference Include="MxGateway.Client">
|
||||
<HintPath>libs\MxGateway.Client.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
<Reference Include="MxGateway.Contracts">
|
||||
<HintPath>libs\MxGateway.Contracts.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Transitive deps the vendored MxGateway.Client.dll was actually built
|
||||
against (verified by reflecting GetReferencedAssemblies on the DLL —
|
||||
see libs/README.md). Versions align with the sibling mxaccessgw repo's
|
||||
current Server / Worker projects so binary-compat stays close to what
|
||||
the team uses elsewhere. Pre-Driver.Galaxy-016 the csproj declared
|
||||
`Polly` (the v7 API) instead of `Polly.Core` (the v8 API the DLL was
|
||||
built against) — a package-name mistake, not just a version skew —
|
||||
which would surface as a runtime MissingMethodException the first
|
||||
time the client's retry pipeline ran. -->
|
||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
|
||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
|
||||
<PackageReference Include="Google.Protobuf" />
|
||||
<PackageReference Include="Grpc.Core.Api" />
|
||||
<PackageReference Include="Grpc.Net.Client" />
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,101 +0,0 @@
|
||||
# Vendored MxGateway client DLLs
|
||||
|
||||
This directory holds binary copies of `MxGateway.Client.dll` and
|
||||
`MxGateway.Contracts.dll` from the sibling `mxaccessgw` repo's last known-good
|
||||
build (May 2026). The DLLs are referenced from the driver's csproj as
|
||||
`<Reference HintPath="…" />` items rather than `ProjectReference`.
|
||||
|
||||
## Provenance
|
||||
|
||||
Both DLLs are built from this team's own `mxaccessgw` source tree — they are
|
||||
not third-party binaries. The build commit + checksums below are recorded so
|
||||
future readers can verify the artefacts match the expected source without
|
||||
needing to ask the original author.
|
||||
|
||||
| File | Source commit | SHA-256 |
|
||||
|---|---|---|
|
||||
| `MxGateway.Client.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `3507f770adc8c1b27b2fc4645079c6e4e02d5c65b9545c12d637cd2a080a00bd` |
|
||||
| `MxGateway.Contracts.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `437dc6cb6994c7c4d858c82f69af890732c7ffbfa0463fbd8a63ce7930d251b4` |
|
||||
|
||||
The build commit is the same for both DLLs and is embedded as
|
||||
`AssemblyInformationalVersion` inside each binary — re-verify by running:
|
||||
`ilspycmd <dll> | grep AssemblyInformationalVersion`.
|
||||
|
||||
To re-verify the checksums (e.g. after a clone):
|
||||
```bash
|
||||
sha256sum libs/MxGateway.Client.dll libs/MxGateway.Contracts.dll
|
||||
```
|
||||
|
||||
If either SHA-256 or the embedded source commit no longer matches what's
|
||||
listed above, the artefact has been replaced — verify before trusting.
|
||||
|
||||
## Why vendored
|
||||
|
||||
The sibling `mxaccessgw` repo restructured: the `clients/dotnet/MxGateway.Client`
|
||||
project the driver previously referenced via path-based `ProjectReference` no
|
||||
longer exists, and the proto contracts moved from the `MxGateway.Contracts.Proto`
|
||||
namespace to `ZB.MOM.WW.MxGateway.Contracts.Proto`. The driver's source still
|
||||
expects the pre-restructure namespace, so re-pointing at the new contracts would
|
||||
require a global namespace rename across ~19 driver files PLUS reimplementing
|
||||
the `MxGatewayClient` / `MxGatewaySession` / `GalaxyRepositoryClient` types the
|
||||
old client library provided (the sibling repo dropped the client library
|
||||
entirely, keeping only the contracts).
|
||||
|
||||
Vendoring the binaries unblocked the build in minutes instead of hours, freezes
|
||||
the gateway contract surface at a known-good version, and preserves the option
|
||||
to migrate properly later without an emergency rewrite.
|
||||
|
||||
## What's vendored
|
||||
|
||||
| File | Built against |
|
||||
|---|---|
|
||||
| `MxGateway.Client.dll` | net10.0, references `MxGateway.Contracts.dll` |
|
||||
| `MxGateway.Contracts.dll` | net10.0, proto namespace `MxGateway.Contracts.Proto[.Galaxy]` |
|
||||
|
||||
The NuGet packages the vendored DLLs reference (verified by reflecting
|
||||
`Assembly.GetReferencedAssemblies()` against `MxGateway.Client.dll`) are
|
||||
declared as direct `PackageReference` in the driver csproj — when the dropped
|
||||
`ProjectReference` was in place those packages were transitively provided;
|
||||
with binary references the consumer must declare them explicitly:
|
||||
|
||||
| Package | Reason |
|
||||
|---|---|
|
||||
| `Google.Protobuf` 3.34.1 | Proto message types in `MxGateway.Contracts.dll` |
|
||||
| `Grpc.Core.Api` 2.76.0 | Base gRPC client types in `MxGateway.Client.dll` |
|
||||
| `Grpc.Net.Client` 2.76.0 | HTTP/2 transport used by `MxGatewayClient` |
|
||||
| `Microsoft.Extensions.Logging.Abstractions` 10.0.7 | `ILogger` used by the client |
|
||||
| `Polly.Core` 8.6.6 | Retry pipeline used by `MxGatewayClient` |
|
||||
|
||||
Versions match the sibling mxaccessgw repo's current Server / Worker
|
||||
projects (`ZB.MOM.WW.MxGateway.Server.csproj`,
|
||||
`ZB.MOM.WW.MxGateway.Worker.csproj`) so the runtime versions stay close to
|
||||
what the gateway team uses. The pre-Driver.Galaxy-016 declarations were
|
||||
incorrect — most visibly `Polly 8.5.2` was declared where the DLL actually
|
||||
needs `Polly.Core` (a different package: `Polly` v7 is the older fluent API;
|
||||
`Polly.Core` v8 is the modern resilience-pipeline API the gateway client was
|
||||
built against). A `Polly` reference would have failed at runtime with
|
||||
`MissingMethodException` the first time a retry pipeline ran.
|
||||
|
||||
## Decompiled-source archive
|
||||
|
||||
The vendored DLLs are byte-for-byte the build output. The full source can be
|
||||
recovered with `ilspycmd MxGateway.Client.dll > MxGateway.Client.cs` if a code
|
||||
review or audit needs it.
|
||||
|
||||
## How to unwind
|
||||
|
||||
Either path closes the vendored-binary debt:
|
||||
|
||||
1. **Sibling repo restores `MxGateway.Client.csproj`** (or publishes a NuGet
|
||||
package). Switch the csproj back to a `ProjectReference` / `PackageReference`,
|
||||
delete this directory.
|
||||
2. **Driver migrates to the new `ZB.MOM.WW.MxGateway.Contracts.Proto`
|
||||
namespace.** Global namespace rename across the ~19 consuming source files,
|
||||
plus re-implementing `MxGatewayClient` / `MxGatewaySession` /
|
||||
`GalaxyRepositoryClient` (≈2,200 LoC of behavioural client code) either
|
||||
inlined into this driver or as a fresh sibling library. Delete this
|
||||
directory.
|
||||
|
||||
Either way: when unwinding, also drop the five `PackageReference` lines added
|
||||
to the csproj alongside the `<Reference>` items — the new ProjectReference /
|
||||
PackageReference will provide them transitively again.
|
||||
+11
-1
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Connection options for <see cref="WonderwareHistorianClient"/>.
|
||||
/// Connection options for <c>WonderwareHistorianClient</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -30,4 +32,12 @@ public sealed record WonderwareHistorianClientOptions(
|
||||
|
||||
/// <summary>Gets the effective call timeout, using the default if not explicitly set.</summary>
|
||||
public TimeSpan EffectiveCallTimeout => CallTimeout ?? TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 15s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 15;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO PackageReference. NO ProjectReference. -->
|
||||
</Project>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Driver probe for the <see cref="WonderwareHistorianClientOptions"/>-shaped driver config.
|
||||
/// The Wonderware Historian client communicates over a Windows named pipe (not a TCP socket),
|
||||
/// so a cheap TCP-connect probe is not applicable for this transport. This probe always
|
||||
/// returns a well-formed "not applicable" result so the AdminUI can display a meaningful
|
||||
/// message instead of a red error. A full named-pipe connect + Hello-frame probe is a
|
||||
/// documented follow-up.
|
||||
/// </summary>
|
||||
public sealed class WonderwareHistorianDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "Historian.Wonderware";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
// Validate the config JSON can at least be parsed — surface bad JSON immediately.
|
||||
WonderwareHistorianClientOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(configJson, _opts); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null));
|
||||
}
|
||||
if (opts is null)
|
||||
return Task.FromResult(new DriverProbeResult(false, "Config JSON deserialized to null.", null));
|
||||
|
||||
// The Wonderware Historian sidecar communicates over a Windows named pipe; there is no
|
||||
// TCP endpoint to connect to. A full pipe connect + Hello-frame probe is a follow-up.
|
||||
return Task.FromResult(new DriverProbeResult(
|
||||
false,
|
||||
"TCP probe not applicable for this transport — the Historian sidecar uses a named pipe. " +
|
||||
"A full named-pipe probe is a documented follow-up.",
|
||||
null));
|
||||
}
|
||||
}
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
+10
-2
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class ModbusDriverOptions
|
||||
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
|
||||
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
|
||||
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
|
||||
/// <see cref="IHostConnectivityProbe"/>.
|
||||
/// <c>IHostConnectivityProbe</c>.
|
||||
/// </summary>
|
||||
public ModbusProbeOptions Probe { get; init; } = new();
|
||||
|
||||
@@ -176,6 +176,14 @@ public sealed class ModbusDriverOptions
|
||||
/// attempt; <see cref="ModbusReconnectOptions.MaxDelay"/> caps the geometric growth.
|
||||
/// </summary>
|
||||
public ModbusReconnectOptions Reconnect { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 5;
|
||||
}
|
||||
|
||||
/// <summary>OS-level TCP keep-alive knobs. Set <see cref="Enabled"/>=false to skip entirely.</summary>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- ModbusDriverOptions references enums (ModbusFamily, ModbusRegion, ModbusDataType, etc.)
|
||||
that live in the zero-dependency Addressing project. Addressing itself has no deps
|
||||
and was designed for exactly this use — Admin can reference it without transport-layer deps. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="ModbusDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the configured endpoint and closes immediately. Surfaces a green
|
||||
/// tick + latency on success; red chip + SocketError on failure; "timed out" on the
|
||||
/// caller's cancellation. Does NOT exchange any protocol bytes — richer per-driver
|
||||
/// handshakes are a documented follow-up.
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "ModbusTcp";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ModbusDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<ModbusDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(ModbusDriverOptions opts)
|
||||
=> (opts.Host, opts.Port);
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Live one-level-per-call browse session over a remote OPC UA server. Created by
|
||||
/// <c>OpcUaClientBrowser</c> on picker open and owned by the AdminUI's
|
||||
/// <c>BrowseSessionRegistry</c>; the registry's TTL reaper disposes idle sessions.
|
||||
/// </summary>
|
||||
internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
{
|
||||
private readonly ISession _session;
|
||||
private readonly NamespaceMap _nsMap;
|
||||
private readonly NodeId _rootNodeId;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
|
||||
/// </summary>
|
||||
/// <param name="session">The OPC UA client session to browse against.</param>
|
||||
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
|
||||
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
|
||||
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
|
||||
{
|
||||
_session = session;
|
||||
_nsMap = nsMap;
|
||||
_rootNodeId = rootNodeId;
|
||||
}
|
||||
|
||||
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Wall-clock time of the most recent successful browse call; the reaper uses
|
||||
/// this for idle eviction.</summary>
|
||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Browse one level under the configured root node.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
|
||||
=> BrowseOneLevelAsync(_rootNodeId, cancellationToken);
|
||||
|
||||
/// <summary>Browse one level under the node identified by <paramref name="nodeId"/>,
|
||||
/// which must be a stable reference produced by <see cref="NamespaceMap.ToStableReference"/>
|
||||
/// (or a plain <c>ns=N;…</c> form).</summary>
|
||||
/// <param name="nodeId">Stable reference string for the parent node.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="nodeId"/> cannot be
|
||||
/// resolved against the live session's namespace table.</exception>
|
||||
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
|
||||
throw new ArgumentException(
|
||||
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
|
||||
return BrowseOneLevelAsync(resolved, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The OPC UA picker treats variables as terminal leaves and does not surface
|
||||
/// a per-attribute side-panel, so this always returns empty.</summary>
|
||||
/// <param name="nodeId">Ignored.</param>
|
||||
/// <param name="cancellationToken">Ignored.</param>
|
||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
|
||||
|
||||
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
|
||||
/// given parent node. <see cref="Session.BrowseAsync"/> is not thread-safe, so all calls
|
||||
/// serialize through <see cref="_gate"/>.</summary>
|
||||
private async Task<IReadOnlyList<BrowseNode>> BrowseOneLevelAsync(NodeId parent, CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var descriptions = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = parent,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
|
||||
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
|
||||
| BrowseResultMask.NodeClass),
|
||||
},
|
||||
};
|
||||
|
||||
var resp = await _session.BrowseAsync(
|
||||
requestHeader: null,
|
||||
view: null,
|
||||
requestedMaxReferencesPerNode: 0,
|
||||
nodesToBrowse: descriptions,
|
||||
ct: ct).ConfigureAwait(false);
|
||||
|
||||
if (resp.Results.Count == 0)
|
||||
{
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Array.Empty<BrowseNode>();
|
||||
}
|
||||
|
||||
var result = resp.Results[0];
|
||||
var refs = result.References;
|
||||
|
||||
// Follow browse continuation points so folders larger than the server's per-call
|
||||
// cap aren't silently truncated (same pattern as runtime
|
||||
// Driver.OpcUaClient-003).
|
||||
var cp = result.ContinuationPoint;
|
||||
while (cp is { Length: > 0 })
|
||||
{
|
||||
var next = await _session.BrowseNextAsync(
|
||||
requestHeader: null,
|
||||
releaseContinuationPoints: false,
|
||||
continuationPoints: [cp],
|
||||
ct: ct).ConfigureAwait(false);
|
||||
|
||||
if (next.Results.Count == 0) break;
|
||||
var nextResult = next.Results[0];
|
||||
if (nextResult.References is { Count: > 0 })
|
||||
refs.AddRange(nextResult.References);
|
||||
cp = nextResult.ContinuationPoint;
|
||||
}
|
||||
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
|
||||
var nodes = new List<BrowseNode>(refs.Count);
|
||||
foreach (var rf in refs)
|
||||
nodes.Add(ToBrowseNode(rf));
|
||||
return nodes;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Project a single <see cref="ReferenceDescription"/> into the driver-agnostic
|
||||
/// <see cref="BrowseNode"/> shape, encoding the outbound NodeId in the stable
|
||||
/// <c>nsu=…</c> form so the picker survives a remote namespace-table reorder.</summary>
|
||||
private BrowseNode ToBrowseNode(ReferenceDescription rf)
|
||||
{
|
||||
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
|
||||
var isObject = rf.NodeClass == NodeClass.Object;
|
||||
var displayName = rf.DisplayName?.Text
|
||||
?? rf.BrowseName?.Name
|
||||
?? childId.ToString()
|
||||
?? "(unnamed)";
|
||||
return new BrowseNode(
|
||||
NodeId: _nsMap.ToStableReference(childId),
|
||||
DisplayName: displayName,
|
||||
Kind: isObject ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf,
|
||||
HasChildrenHint: isObject);
|
||||
}
|
||||
|
||||
/// <summary>Idempotent best-effort dispose: closes the underlying session if it's a
|
||||
/// concrete <see cref="Session"/>, disposes it, and disposes the gate. Close errors are
|
||||
/// swallowed because the registry reaper may be racing a remote disconnect.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_session is Session s)
|
||||
{
|
||||
try { await s.CloseAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
try { _session.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
|
||||
/// Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
|
||||
/// trust decisions cannot poison the runtime driver's cert store.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
||||
|
||||
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
||||
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
|
||||
{
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Driver type key — matches the AdminUI's persisted "OpcUaClient" value.</summary>
|
||||
public string DriverType => "OpcUaClient";
|
||||
|
||||
/// <summary>Opens a transient OPC UA session and returns a browse session over it.</summary>
|
||||
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||
/// driver would consume.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
|
||||
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, JsonOpts)
|
||||
?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");
|
||||
|
||||
var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
|
||||
|
||||
if (opts.AuthType == OpcUaAuthType.Certificate)
|
||||
throw new InvalidOperationException(
|
||||
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
|
||||
|
||||
if (opts.AutoAcceptCertificates)
|
||||
_logger.LogWarning(
|
||||
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
|
||||
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
|
||||
"driver's PKI store instead.",
|
||||
endpoint);
|
||||
|
||||
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var identity = BuildBrowseUserIdentity(opts);
|
||||
|
||||
var perEndpointBudget = TimeSpan.FromSeconds(
|
||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(perEndpointBudget);
|
||||
|
||||
var endpointDesc = await SelectEndpointAsync(
|
||||
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
|
||||
var endpointCfg = EndpointConfiguration.Create(appConfig);
|
||||
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
|
||||
|
||||
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
configuredEndpoint,
|
||||
updateBeforeConnect: false,
|
||||
sessionName: "OtOpcUa AdminUI Browse",
|
||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
connectCts.Token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var nsMap = NamespaceMap.FromSession(session);
|
||||
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
||||
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
||||
|
||||
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
||||
try { session.Dispose(); } catch { /* best-effort */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
|
||||
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
|
||||
/// trust decisions don't leak into the deployed driver's cert store.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token for the configuration build.</param>
|
||||
private static async Task<ApplicationConfiguration> BuildBrowseAppConfigurationAsync(CancellationToken ct)
|
||||
{
|
||||
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
|
||||
// locally rather than plumbing a telemetry context all the way through — the browser
|
||||
// emits no per-request telemetry of its own and the SDK's internal fallback is fine
|
||||
// for a transient picker session (mirrors the runtime driver's same suppression).
|
||||
#pragma warning disable CS0618
|
||||
var app = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = "OtOpcUa AdminUI Browse",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
var pkiRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OtOpcUa", "adminui-browse-pki");
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUa AdminUI Browse",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "own"),
|
||||
SubjectName = "CN=OtOpcUa AdminUI Browse",
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "trusted"),
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "issuers"),
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "rejected"),
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = false,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
DisableHiResClock = true,
|
||||
};
|
||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||
app.ApplicationConfiguration = config;
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
|
||||
{
|
||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
opts.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
|
||||
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
||||
/// <param name="appConfig">Application configuration used by the discovery client.</param>
|
||||
/// <param name="url">Remote endpoint URL to query.</param>
|
||||
/// <param name="policy">Required security policy.</param>
|
||||
/// <param name="mode">Required message security mode.</param>
|
||||
/// <param name="ct">Cancellation token for the discovery call.</param>
|
||||
private static async Task<EndpointDescription> SelectEndpointAsync(
|
||||
ApplicationConfiguration appConfig, string url,
|
||||
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
|
||||
{
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
|
||||
var wantedPolicy = MapPolicy(policy);
|
||||
var wantedMode = mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
var match = all.FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
|
||||
if (match is null)
|
||||
{
|
||||
var advertised = string.Join(", ", all.Select(e =>
|
||||
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||
$"Server advertises: {advertised}");
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/// <summary>Convert the driver options enum to the OPC UA policy URI.</summary>
|
||||
/// <param name="p">The driver security policy to map.</param>
|
||||
private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
|
||||
{
|
||||
OpcUaSecurityPolicy.None => SecurityPolicies.None,
|
||||
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
|
||||
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
|
||||
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
|
||||
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
|
||||
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(p)),
|
||||
};
|
||||
|
||||
/// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
|
||||
/// <param name="uri">Full policy URI to shorten.</param>
|
||||
private static string ShortName(string uri) =>
|
||||
uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+1
-1
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// direct parse against the current session.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class NamespaceMap
|
||||
public sealed class NamespaceMap
|
||||
{
|
||||
// index -> URI and URI -> index, as the upstream server published them at connect time.
|
||||
private readonly string[] _uris;
|
||||
+12
-2
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,7 +25,7 @@ public sealed class OpcUaClientDriverOptions
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of candidate endpoint URLs for failover. The driver tries each in
|
||||
/// order at <see cref="OpcUaClientDriver.InitializeAsync"/> and on session drop;
|
||||
/// order at <c>OpcUaClientDriver.InitializeAsync</c> and on session drop;
|
||||
/// the first URL that successfully connects wins. Typical use-case: an OPC UA server
|
||||
/// pair running in hot-standby (primary 4840 + backup 4841) where either can serve
|
||||
/// the same address space. Leave unset (or empty) to use <see cref="EndpointUrl"/>
|
||||
@@ -148,7 +150,7 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// the remote browse path with no UNS conversion.</item>
|
||||
/// </list>
|
||||
/// The driver enforces the choice at startup — see
|
||||
/// <see cref="OpcUaClientDriver.ValidateNamespaceKind"/> — so a misconfiguration fails
|
||||
/// <c>OpcUaClientDriver.ValidateNamespaceKind</c> — so a misconfiguration fails
|
||||
/// draft validation rather than surfacing as a runtime surprise.
|
||||
/// </summary>
|
||||
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; init; } = OpcUaTargetNamespaceKind.Equipment;
|
||||
@@ -164,6 +166,14 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> UnsMappingTable { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 15s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 15;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="OpcUaClientDriverOptions"/>-shaped driver config.
|
||||
/// Parses the first endpoint URL (from <see cref="OpcUaClientDriverOptions.EndpointUrls"/> or
|
||||
/// the convenience <see cref="OpcUaClientDriverOptions.EndpointUrl"/> fallback), opens a
|
||||
/// socket to the OPC UA server host + port and closes immediately. Surfaces a green tick +
|
||||
/// latency on success; red chip + SocketError on failure; "timed out" on the caller's
|
||||
/// cancellation. Does NOT open an OPC UA session — a richer session-open probe is a
|
||||
/// documented follow-up.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "OpcUaClient";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
OpcUaClientDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(OpcUaClientDriverOptions opts)
|
||||
{
|
||||
// EndpointUrls wins over the convenience EndpointUrl when both are set.
|
||||
var endpointUrl = opts.EndpointUrls.FirstOrDefault()
|
||||
?? (string.IsNullOrWhiteSpace(opts.EndpointUrl) ? null : opts.EndpointUrl);
|
||||
if (endpointUrl is null) return (string.Empty, 0);
|
||||
|
||||
// Parse as a URI — opc.tcp://host:port is a valid URI.
|
||||
if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri))
|
||||
return (string.Empty, 0);
|
||||
|
||||
var host = uri.Host;
|
||||
var port = uri.IsDefaultPort ? 4840 : uri.Port;
|
||||
return (host, port);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user