Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd6c0b4d3d | |||
| c6d9b20d9f | |||
| 11de14d12e | |||
| aadbf49678 | |||
| 70d764b063 | |||
| 11bcff6af5 | |||
| de41963587 | |||
| a78b212c95 | |||
| 075c0e69da | |||
| b7f5e887ee | |||
| 933dd1a874 | |||
| c1619d95f5 | |||
| 8ba289f975 | |||
| d0777eee29 | |||
| 83856b7c27 | |||
| c4f315ec90 | |||
| 257caa7bd1 | |||
| 6534875476 | |||
| d2d7730830 | |||
| 2844180865 | |||
| d3ab2bfbaf | |||
| 88e773af36 | |||
| f35ebd7aaf | |||
| 0cbb82e466 | |||
| 7b6884031d | |||
| 7ff7a60ae0 | |||
| 8faa2bf23d | |||
| 2099713ed8 | |||
| c05ffc7b39 | |||
| 60017177cb | |||
| 26bae36f8b | |||
| 368390ea9d | |||
| 8f950722c6 | |||
| 1d729fb0f8 | |||
| 0b99aceacb | |||
| d57b42bcd6 | |||
| 5e87f7e16f | |||
| 695fa6408b | |||
| 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
|
||||
@@ -40,3 +42,9 @@ config_cache*.db
|
||||
|
||||
# Client CLI/UI runtime scratch (last-connected endpoint cache)
|
||||
session.dat
|
||||
|
||||
# Secrets / local credentials — never commit
|
||||
sql_login.txt
|
||||
|
||||
# OPC UA certificate store (runtime PKI: own/trusted/issued/rejected certs + keys)
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/
|
||||
|
||||
@@ -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" />
|
||||
@@ -81,11 +79,11 @@
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageVersion Include="Polly.Core" Version="8.6.6" />
|
||||
<PackageVersion Include="S7netplus" Version="0.20.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
@@ -98,6 +96,18 @@
|
||||
<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.Health" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
<add key="local-mxgw" value="./nuget-packages" />
|
||||
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
<packageSource key="local-mxgw">
|
||||
<package pattern="ZB.MOM.WW.MxGateway.*" />
|
||||
</packageSource>
|
||||
<packageSource key="dohertj2-gitea">
|
||||
<package pattern="ZB.MOM.WW.Health" />
|
||||
<package pattern="ZB.MOM.WW.Health.*" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||
<package pattern="ZB.MOM.WW.Configuration" />
|
||||
<package pattern="ZB.MOM.WW.Auth" />
|
||||
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||
<package pattern="ZB.MOM.WW.Audit" />
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</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"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Alarms D.1 — smoke artifact
|
||||
|
||||
> **Status (2026-05-29): alarm-source leg VERIFIED. Historian-write leg still
|
||||
> pending the Windows sidecar + live AVEVA Historian.**
|
||||
>
|
||||
> **Re-confirmed 2026-05-31** against the same gateway (`http://10.100.0.48:5120`):
|
||||
> the Skip-gated live test passed again, pulling a native `Raise` transition
|
||||
> (`Galaxy!TestArea.TestMachine_001.TestAlarm001`, raw sev 500 → OPC UA 750/High,
|
||||
> category `TestArea`, operator comment `Test alarm #1`) through the production
|
||||
> consumer. Independent re-run, not the original capture.
|
||||
>
|
||||
> This is the D.1 deliverable called for by `docs/plans/alarms-worker-wiring-plan.md`
|
||||
> — captured evidence that a live Galaxy alarm reaches lmxopcua through the native
|
||||
> gateway path (not the sub-attribute fallback). It supersedes the "A.2 blocked"
|
||||
> banners in `alarms-over-gateway.md` / `alarms-worker-wiring-plan.md`, which were
|
||||
> written 2026-04-30 before the gateway's alarm feed was working.
|
||||
|
||||
## What was verified
|
||||
|
||||
The mxaccessgw gateway **does** serve native MxAccess alarms today, and the lmxopcua
|
||||
consumer ingests them with full fidelity — **including operator-comment**, the field
|
||||
the 2026-04-30 plan flagged as "the only v1 regression."
|
||||
|
||||
Verified from the macOS dev box against the live gateway at `http://10.100.0.48:5120`
|
||||
(reachable; `nc -z` succeeds). No acknowledge / no writes were issued — read-only
|
||||
`StreamAlarms`.
|
||||
|
||||
### 1. Gateway boundary — raw `StreamAlarms` (`ZB.MOM.WW.MxGateway.Client`)
|
||||
|
||||
A standalone client streamed the active-alarm snapshot: **20 active alarms**, each
|
||||
carrying native metadata. Sample (one of 20):
|
||||
|
||||
```json
|
||||
{ "alarmFullReference": "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||
"sourceObjectReference": "TestMachine_001.TestAlarm001",
|
||||
"alarmTypeName": "DSC", "severity": 500,
|
||||
"currentState": "ALARM_CONDITION_STATE_ACTIVE", "category": "TestArea",
|
||||
"lastTransitionTimestamp": "2026-05-24T16:04:10.856Z",
|
||||
"operatorComment": "Test alarm #1" }
|
||||
```
|
||||
|
||||
Followed by the `SnapshotComplete` marker. `operatorComment`, `category`, `severity`,
|
||||
`currentState`, and `lastTransitionTimestamp` are all populated.
|
||||
|
||||
### 2. lmxopcua consumer — `GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition`
|
||||
|
||||
The Skip-gated live test
|
||||
`Runtime/GatewayGalaxyAlarmFeedLiveTests.Live_gateway_delivers_native_alarm_transitions_through_the_consumer`
|
||||
wires the real `MxGatewayClient.StreamAlarmsAsync` into the production consumer seam
|
||||
and **passes**. Captured output (`D1_SMOKE_OUT`):
|
||||
|
||||
```
|
||||
# consumer transitions observed: 2+
|
||||
Raise Galaxy!TestArea.TestMachine_001.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-24T16:04:10.856Z
|
||||
Raise Galaxy!TestArea.TestMachine_003.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-07T18:14:00.594Z
|
||||
```
|
||||
|
||||
The consumer preserves `operatorComment` + `category` + transition timestamp and
|
||||
applies the OPC UA severity-bucket mapping (`MxAccessSeverityMapper`: raw 500 →
|
||||
OPC UA 750, bucket `High`).
|
||||
|
||||
### 3. Full chain to the OPC UA Part 9 surface (code-path verified)
|
||||
|
||||
`GalaxyDriver.OnAlarmFeedTransition` maps `GalaxyAlarmTransition` →
|
||||
`AlarmEventArgs`, carrying `OperatorComment`, `OriginalRaiseTimestampUtc`,
|
||||
`AlarmCategory`, and the severity bucket onto `IAlarmSource.OnAlarmEvent`.
|
||||
`AlarmEventArgs` already declares those fields — so the **E.7 contract extension is
|
||||
done**, not pending. The server's Part-9 condition layer consumes `IAlarmSource`
|
||||
via `AlarmSurfaceInvoker` → `GenericDriverNodeManager`. Unit coverage:
|
||||
`GalaxyDriverAlarmSourceTests`, `GatewayGalaxyAlarmFeedTests`.
|
||||
|
||||
## How to re-run
|
||||
|
||||
```bash
|
||||
export MXGW_ENDPOINT="http://10.100.0.48:5120"
|
||||
export GALAXY_MXGW_API_KEY="<dev key from docker-dev/docker-compose.yml>"
|
||||
export D1_SMOKE_OUT="/tmp/d1-consumer-transitions.txt" # optional capture
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests \
|
||||
--filter "FullyQualifiedName~GatewayGalaxyAlarmFeedLiveTests"
|
||||
```
|
||||
|
||||
Without the env vars the test `Skip`s, so normal `dotnet test` runs are unaffected.
|
||||
|
||||
## Not covered here (still open)
|
||||
|
||||
1. **Scripted-alarm historian write-back → AVEVA Historian** (C.1's live leg). The
|
||||
`SdkAlarmHistorianWriteBackend` (real `HistorianAccess.AddStreamedValue` path) is
|
||||
implemented and unit-tested, but its `Live_*` write smoke needs the Windows
|
||||
historian sidecar + a live AVEVA Historian — neither reachable from the macOS dev
|
||||
box. Capture this leg on the Windows parity rig.
|
||||
2. **Running-server → OPC UA A&C client round-trip.** This artifact proves the driver
|
||||
consumer end; it does not exercise a full OtOpcUa server surfacing the condition to
|
||||
an OPC UA client, because the docker-dev stack stubs the Galaxy driver on Linux
|
||||
(`DriverInstanceActor.ShouldStub`). Capture on the Windows parity rig (or a Linux
|
||||
host with `ShouldStub` overridden to point the real driver at the gateway).
|
||||
|
||||
## Mechanism — true MxAccess alarm-event support
|
||||
|
||||
The gateway delivers these alarms via **true MxAccess alarm-event support** in the
|
||||
mxaccessgw .NET client — a real alarm-event subscription, **not** the value-driven
|
||||
sub-attribute fallback. (Confirmed by the gateway maintainer; the client-side stream
|
||||
check above can only observe the resulting feed, which is why this artifact records the
|
||||
mechanism here rather than inferring it.) So A.2 is implemented as originally specified:
|
||||
`MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries genuine native alarm-event metadata, and
|
||||
the operator-comment / original-raise-time / category fields are first-class — not
|
||||
reconstructed from attribute reads.
|
||||
@@ -9,24 +9,41 @@
|
||||
> the new RPCs; the sub-attribute fallback path keeps Galaxy alarms
|
||||
> functional today.
|
||||
>
|
||||
> ⚠️ **Worker-side native alarm subscription blocked on a dev-rig
|
||||
> finding (2026-04-30):** the MXAccess COM Toolkit at
|
||||
> ✅ **UPDATE 2026-05-29 — native alarm feed VERIFIED working; the
|
||||
> 2026-04-30 "blocked" finding below is superseded.** A live
|
||||
> `StreamAlarms` check against the gateway at `10.100.0.48:5120`
|
||||
> returned the active-alarm snapshot (20 alarms) with full native
|
||||
> metadata — `severity`, `category`, `currentState`,
|
||||
> `lastTransitionTimestamp`, **and `operatorComment`** (the field the
|
||||
> note below called "the only v1 regression"). The lmxopcua consumer
|
||||
> (`GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition` →
|
||||
> `AlarmEventArgs` → `IAlarmSource`) ingests it with full fidelity and
|
||||
> the OPC UA severity-bucket mapping applied — proven by the passing
|
||||
> Skip-gated live test `GatewayGalaxyAlarmFeedLiveTests`. `AlarmEventArgs`
|
||||
> already carries operator-comment / original-raise-time / category, so
|
||||
> **E.7 is done too**. See `docs/plans/alarms-d1-smoke-artifact.md` for
|
||||
> the captured evidence. The gateway delivers this via **true MxAccess
|
||||
> alarm-event support** in the mxaccessgw .NET client (a real
|
||||
> alarm-event subscription — **not** the sub-attribute fallback), so A.2
|
||||
> is implemented as originally specified. Still open: the scripted-alarm
|
||||
> → AVEVA Historian write-back live smoke (C.1's `Live_*` leg) and a full
|
||||
> running-server → OPC UA A&C round-trip — both need the Windows parity rig.
|
||||
>
|
||||
> ⚠️ **[SUPERSEDED — kept for history] Worker-side native alarm
|
||||
> subscription blocked on a dev-rig finding (2026-04-30):** the MXAccess
|
||||
> COM Toolkit at
|
||||
> `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
|
||||
> exposes no alarm-event family — only `OnDataChange`,
|
||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange`.
|
||||
> exposed no alarm-event family — only `OnDataChange`,
|
||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange` — and
|
||||
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
|
||||
> assemblies are x64-only and incompatible with the worker's x86
|
||||
> bitness. **Operator decision needed before
|
||||
> `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries any events:** either
|
||||
> accept the value-driven sub-attribute path as the production
|
||||
> architecture (operator-comment fidelity is the only v1 regression)
|
||||
> or add an x64 alarm-helper sub-process alongside the worker. See
|
||||
> `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` in the
|
||||
> mxaccessgw repo for the architectural notes. Live
|
||||
> `aahClientManaged` alarm-event write call site
|
||||
> (`SdkAlarmHistorianWriteBackend` placeholder from PR C.1) and the
|
||||
> D.1 smoke artifact ship once those decisions resolve. The
|
||||
> remainder of this document is preserved as the design record.
|
||||
> assemblies are x64-only vs. the worker's x86 bitness. The operator
|
||||
> decision (accept the value-driven sub-attribute path, or add an x64
|
||||
> alarm-helper sub-process) has since been resolved on the gateway side
|
||||
> — `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` now carries events (verified
|
||||
> above). The C.1 `SdkAlarmHistorianWriteBackend` is **no longer a
|
||||
> placeholder** — it writes through the real
|
||||
> `HistorianAccess.AddStreamedValue` path (only its live-rig write
|
||||
> smoke remains).
|
||||
|
||||
Coordinated epic across two repos:
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Alarms Worker Wiring Plan
|
||||
|
||||
> ✅ **UPDATE 2026-05-29 — the blocker below is RESOLVED on the gateway side; this
|
||||
> plan is largely complete.** A live `StreamAlarms` check against `10.100.0.48:5120`
|
||||
> returns the active-alarm snapshot with full native metadata **including
|
||||
> `operatorComment`**, and the lmxopcua consumer ingests it end-to-end (passing live
|
||||
> test `GatewayGalaxyAlarmFeedLiveTests`). So **A.2 / A.3 / A.4** are functionally done
|
||||
> at the gateway boundary (the worker now emits native alarm transitions and the client
|
||||
> exposes `AcknowledgeAlarm` / `QueryActiveAlarms` RPCs). **C.1** ships real code
|
||||
> (`SdkAlarmHistorianWriteBackend` → `HistorianAccess.AddStreamedValue`). **D.1**'s
|
||||
> alarm-source leg is captured in `docs/plans/alarms-d1-smoke-artifact.md`. Only two
|
||||
> things remain, both needing the Windows parity rig: C.1's live historian-write smoke
|
||||
> and a full running-server → OPC UA A&C round-trip. The per-item detail below is kept
|
||||
> as the historical record of the original blocked state.
|
||||
>
|
||||
> **Context**: The alarms-over-gateway epic shipped 19 PRs across the
|
||||
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
|
||||
> the sub-attribute fallback path keeps Galaxy alarms functional today. Four
|
||||
@@ -16,7 +29,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Dev-rig finding that blocks everything (2026-04-30)
|
||||
## Dev-rig finding that blocks everything (2026-04-30) — [SUPERSEDED 2026-05-29]
|
||||
|
||||
During PR A.2 work the following was discovered on the dev box:
|
||||
|
||||
@@ -318,16 +331,20 @@ fallback as production).
|
||||
|
||||
## Summary of blocks
|
||||
|
||||
| Item | Blocked by | Estimated effort once unblocked |
|
||||
|------|-----------|--------------------------------|
|
||||
| A.2 | Architectural decision (x64 alarm-helper vs. sub-attribute fallback as production) | 2–3 days implementation; 1 day tests |
|
||||
| A.3 | A.2 delivering WorkerEvent bodies | 1–2 days |
|
||||
| A.4 | A.2 (active-alarm query needs AlarmClient session) | 1 day |
|
||||
| C.1 | aahClientManaged SDK access (available on dev box); NOT blocked by A.2 | 1–2 days |
|
||||
| D.1 | A.2 + A.3 + C.1 all passing on parity rig | 0.5 day (smoke + artifact capture) |
|
||||
> **Resolved as of 2026-05-29** — see the update banner at the top and
|
||||
> `docs/plans/alarms-d1-smoke-artifact.md`. Original status table kept for history.
|
||||
|
||||
C.1 can proceed in parallel with A.2 / A.3 since the sidecar's `aahClientManaged`
|
||||
is x64 and does not share the worker bitness constraint.
|
||||
| Item | Status (2026-05-29) | Original block |
|
||||
|------|--------------------|----------------|
|
||||
| A.2 | ✅ **True MxAccess alarm-event support** in the gateway client (real alarm-event subscription, not the sub-attribute fallback); verified via live `StreamAlarms` with operator-comment fidelity | Architectural decision (x64 alarm-helper vs. sub-attribute fallback) |
|
||||
| A.3 | ✅ Dispatch + `AcknowledgeAlarm` RPC present on the client surface | A.2 delivering WorkerEvent bodies |
|
||||
| A.4 | ✅ `QueryActiveAlarms` RPC present on the client surface | A.2 (active-alarm query needs AlarmClient session) |
|
||||
| C.1 | ✅ Code shipped (`AddStreamedValue` path); ⏳ live historian-write smoke needs the Windows rig | aahClientManaged SDK access |
|
||||
| D.1 | ◑ Alarm-source leg captured (`alarms-d1-smoke-artifact.md`); ⏳ historian-write leg + full server→A&C round-trip need the Windows rig | A.2 + A.3 + C.1 all passing on parity rig |
|
||||
|
||||
The gateway delivers operator-comment fidelity through **true MxAccess alarm-event
|
||||
support** in the mxaccessgw .NET client — a real alarm-event subscription, not the
|
||||
value-driven sub-attribute path. The sub-attribute fallback is now legacy.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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>`.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ Running record of v2 dev services on the Windows dev VM. Updated on every instal
|
||||
|---------|---------------------|---------|-----------|------------------------|---------------|--------|
|
||||
| **Central config DB** | Docker container `otopcua-mssql` on the Linux Docker host (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `10.100.0.35:14330` → `1433` (container) — port 14330 retained from the previous local-container setup so connection-string ports don't churn | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` on the Docker host | ✅ Running on Docker host (`/opt/otopcua-mssql/`) since 2026-04-28; carries `project=lmxopcua` label |
|
||||
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box — full ArchestrA + Historian + OI-Server stack | v1 baseline | Local COM via MXAccess (`C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`); Historian via `aaH*` services; SuiteLink via `slssvc` | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ **Fully available — Phase 2 lift unblocked.** 27 ArchestrA / AVEVA / Wonderware services running incl. `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`, `AutoBuild_Service`; full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `aahSupervisor`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `InSQLManualStorage`, `InSQLSystemDriver`, `HistorianSearch-x64`); `slssvc` (Wonderware SuiteLink); `OI-Gateway` install present at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (decision #142 AppServer-via-OI-Gateway smoke test now also unblocked) |
|
||||
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=lmxopcua,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. v2-rebrand to `dc=otopcua,dc=local` is a future cosmetic change |
|
||||
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=zb,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. Dev base DN unified to `dc=zb,dc=local` (Task 1.6) |
|
||||
| OPC Foundation reference server | Not yet built | — | `10.100.0.35:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
|
||||
| FOCAS TCP stub | Not yet built | — | `10.100.0.35:8193` (target) | n/a | — | Pending (built in Phase 5; runs on Docker host) |
|
||||
| Modbus simulator (`otopcua-pymodbus:3.13.0`) | Docker compose at `/opt/otopcua-modbus/` on Docker host | pinned 3.13.0 | `10.100.0.35:5020` | n/a | n/a | Stack staged; bring up with `lmxopcua-fix up modbus <profile>` from this VM |
|
||||
|
||||
@@ -104,8 +104,8 @@ Anonymous OPC UA sessions are denied writes against `Operate`-classified tags by
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"GroupToRole": {
|
||||
"ReadOnly": "ReadOnly",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -67,11 +67,13 @@ public abstract class CommandBase : ICommand
|
||||
/// Executes the command-specific workflow against the configured OPC UA endpoint.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
||||
/// <returns>A value task that represents the asynchronous command execution.</returns>
|
||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ConnectionSettings"/> populated from the current command option values.</returns>
|
||||
protected ConnectionSettings CreateConnectionSettings()
|
||||
{
|
||||
var securityMode = SecurityModeMapper.FromString(Security);
|
||||
@@ -97,6 +99,7 @@ public abstract class CommandBase : ICommand
|
||||
/// and returns both the service and the connection info.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts connection setup for the command.</param>
|
||||
/// <returns>A tuple of the connected <see cref="IOpcUaClientService"/> and the resulting <see cref="ConnectionInfo"/>.</returns>
|
||||
protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
|
||||
+1
-3
@@ -12,9 +12,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultApplicationConfigurationFactory>();
|
||||
|
||||
/// <summary>Creates an OPC UA application configuration from the provided connection settings.</summary>
|
||||
/// <param name="settings">The connection settings to use.</param>
|
||||
/// <param name="ct">Token to cancel the operation.</param>
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
|
||||
{
|
||||
// Resolve the canonical PKI path lazily on first use so constructing a
|
||||
|
||||
@@ -11,10 +11,7 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
|
||||
|
||||
/// <summary>Selects an OPC UA endpoint matching the requested security mode.</summary>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="endpointUrl">The endpoint URL to query.</param>
|
||||
/// <param name="requestedMode">The requested message security mode.</param>
|
||||
/// <inheritdoc />
|
||||
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode)
|
||||
{
|
||||
@@ -53,6 +50,7 @@ internal static class EndpointSelector
|
||||
/// Thrown when no endpoint matches <paramref name="requestedMode"/>; the message lists the
|
||||
/// security mode + policy combinations the server returned so operators can diagnose mismatches.
|
||||
/// </exception>
|
||||
/// <returns>The best matching <see cref="EndpointDescription"/> with its URL rewritten to the requested host.</returns>
|
||||
public static EndpointDescription SelectBest(
|
||||
IEnumerable<EndpointDescription> allEndpoints,
|
||||
string endpointUrl,
|
||||
|
||||
+1
@@ -13,5 +13,6 @@ internal interface IApplicationConfigurationFactory
|
||||
/// </summary>
|
||||
/// <param name="settings">The connection settings to configure.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that resolves to the validated <see cref="ApplicationConfiguration"/>.</returns>
|
||||
Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct = default);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ internal interface IEndpointDiscovery
|
||||
/// <param name="config">The OPC UA application configuration.</param>
|
||||
/// <param name="endpointUrl">The endpoint URL to discover.</param>
|
||||
/// <param name="requestedMode">The requested message security mode.</param>
|
||||
/// <returns>The best matching endpoint description for the requested security mode.</returns>
|
||||
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose current runtime value should be read.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the server read if the client cancels the request.</param>
|
||||
/// <returns>A task that resolves to the current <see cref="DataValue"/> for the node.</returns>
|
||||
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -66,6 +67,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="nodeId">The node whose value should be updated.</param>
|
||||
/// <param name="value">The typed OPC UA data value to write to the server.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the write if the client cancels the request.</param>
|
||||
/// <returns>A task that resolves to the OPC UA <see cref="StatusCode"/> for the write operation.</returns>
|
||||
Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -75,6 +77,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="nodeId">The starting node for the hierarchical browse.</param>
|
||||
/// <param name="nodeClassMask">The node classes that should be returned to the caller.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse request.</param>
|
||||
/// <returns>A task that resolves to a tuple of an optional continuation point and the returned references.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
||||
|
||||
@@ -83,6 +86,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse-next request.</param>
|
||||
/// <returns>A task that resolves to a tuple of an optional next continuation point and the returned references.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct = default);
|
||||
|
||||
@@ -91,6 +95,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the child lookup.</param>
|
||||
/// <returns>A task that resolves to <see langword="true"/> if the node has at least one child; otherwise <see langword="false"/>.</returns>
|
||||
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -101,6 +106,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="endTime">The inclusive end of the requested history window.</param>
|
||||
/// <param name="maxValues">The maximum number of raw samples to return to the client.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the history read.</param>
|
||||
/// <returns>A task that resolves to the ordered list of raw historical data values.</returns>
|
||||
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||
int maxValues, CancellationToken ct = default);
|
||||
|
||||
@@ -113,6 +119,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="aggregateId">The OPC UA aggregate function to evaluate over the history window.</param>
|
||||
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the aggregate history read.</param>
|
||||
/// <returns>A task that resolves to the ordered list of processed aggregate data values.</returns>
|
||||
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||
NodeId aggregateId, double intervalMs, CancellationToken ct = default);
|
||||
|
||||
@@ -121,6 +128,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="publishingIntervalMs">The requested publishing interval for monitored items on the new subscription.</param>
|
||||
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
|
||||
/// <returns>A task that resolves to the newly created <see cref="ISubscriptionAdapter"/>.</returns>
|
||||
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -130,11 +138,13 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="methodId">The method node to invoke.</param>
|
||||
/// <param name="inputArguments">The ordered input arguments supplied to the server method call.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the method invocation.</param>
|
||||
/// <returns>A task that resolves to the list of output arguments returned by the method, or <see langword="null"/> if none.</returns>
|
||||
Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Closes the underlying session gracefully before the adapter is disposed or replaced during failover.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts the close request.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task CloseAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ internal interface ISubscriptionAdapter : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="clientHandle">The client handle returned when the monitored item was created.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the monitored-item removal.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -46,11 +47,13 @@ internal interface ISubscriptionAdapter : IDisposable
|
||||
/// Requests a condition refresh for this subscription.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ConditionRefreshAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all monitored items and deletes the subscription.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts subscription deletion.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task DeleteAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class ClientStoragePaths
|
||||
/// one-shot legacy-folder migration before returning so callers that depend on this
|
||||
/// path (PKI store, settings file) find their existing state at the canonical name.
|
||||
/// </summary>
|
||||
/// <returns>The absolute path to the client's top-level folder under LocalApplicationData.</returns>
|
||||
public static string GetRoot()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
@@ -37,6 +38,7 @@ public static class ClientStoragePaths
|
||||
}
|
||||
|
||||
/// <summary>Subfolder for the application's PKI store — used by both CLI + UI.</summary>
|
||||
/// <returns>The absolute path to the PKI store subfolder.</returns>
|
||||
public static string GetPkiPath() => Path.Combine(GetRoot(), "pki");
|
||||
|
||||
/// <summary>
|
||||
@@ -45,6 +47,7 @@ public static class ClientStoragePaths
|
||||
/// folder existed + was moved to canonical, false when no migration was needed or
|
||||
/// canonical was already present.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the legacy folder was found and moved; <see langword="false"/> when no migration was needed.</returns>
|
||||
public static bool TryRunLegacyMigration()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
|
||||
@@ -24,12 +24,14 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="settings">The endpoint, security, and authentication settings used to establish the session.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the connect workflow.</param>
|
||||
/// <returns>A <see cref="ConnectionInfo"/> describing the active session after a successful connect.</returns>
|
||||
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects from the active OPC UA endpoint and tears down subscriptions owned by the client.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts disconnect cleanup.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task DisconnectAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -37,6 +39,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose value should be retrieved.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the read request.</param>
|
||||
/// <returns>The current <see cref="DataValue"/> including value, status code, and timestamps.</returns>
|
||||
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -45,6 +48,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// <param name="nodeId">The node whose value should be updated.</param>
|
||||
/// <param name="value">The raw value supplied by the CLI or UI workflow.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the write request.</param>
|
||||
/// <returns>The OPC UA <see cref="StatusCode"/> returned by the server for the write operation.</returns>
|
||||
Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -52,6 +56,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="parentNodeId">The node to browse, or <see cref="ObjectIds.ObjectsFolder"/> when omitted.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse request.</param>
|
||||
/// <returns>The list of child nodes discovered under the specified parent.</returns>
|
||||
Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -60,6 +65,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// <param name="nodeId">The node whose value changes should be monitored.</param>
|
||||
/// <param name="intervalMs">The monitored-item sampling and publishing interval in milliseconds.</param>
|
||||
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -67,6 +73,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose live-data subscription should be removed.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the unsubscribe request.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -75,18 +82,21 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// <param name="sourceNodeId">The event source to monitor, or the server object when omitted.</param>
|
||||
/// <param name="intervalMs">The publishing interval in milliseconds for the alarm subscription.</param>
|
||||
/// <param name="ct">The cancellation token that aborts alarm subscription creation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the active alarm subscription.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts alarm subscription cleanup.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests retained alarm conditions again so a client can repopulate its alarm list after reconnecting.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task RequestConditionRefreshAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -111,6 +121,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// <param name="endTime">The inclusive end of the requested history range.</param>
|
||||
/// <param name="maxValues">The maximum number of raw values to return.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the history read.</param>
|
||||
/// <returns>The raw historical <see cref="DataValue"/> samples in the requested range.</returns>
|
||||
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||
int maxValues = 1000, CancellationToken ct = default);
|
||||
|
||||
@@ -123,6 +134,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// <param name="aggregate">The aggregate function the operator selected for processed history.</param>
|
||||
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the processed history request.</param>
|
||||
/// <returns>The processed historical <see cref="DataValue"/> samples computed by the requested aggregate.</returns>
|
||||
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
|
||||
|
||||
@@ -130,6 +142,7 @@ public interface IOpcUaClientService : IDisposable
|
||||
/// Reads redundancy status data such as redundancy mode, service level, and partner endpoint URIs.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token that aborts redundancy inspection.</param>
|
||||
/// <returns>A <see cref="RedundancyInfo"/> snapshot containing redundancy mode, service level, and partner endpoint URIs.</returns>
|
||||
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -73,13 +73,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised when subscribed node values change.</summary>
|
||||
public event EventHandler<DataChangedEventArgs>? DataChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised when an alarm event is received from the server.</summary>
|
||||
public event EventHandler<AlarmEventArgs>? AlarmEvent;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised when the connection state changes.</summary>
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -7,8 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||
/// </summary>
|
||||
public sealed class AvaloniaUiDispatcher : IUiDispatcher
|
||||
{
|
||||
/// <summary>Posts an action to the Avalonia UI thread for execution.</summary>
|
||||
/// <param name="action">The action to execute on the UI thread.</param>
|
||||
/// <inheritdoc />
|
||||
public void Post(Action action)
|
||||
{
|
||||
Dispatcher.UIThread.Post(action);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||
public interface ISettingsService
|
||||
{
|
||||
/// <summary>Loads user settings from persistent storage.</summary>
|
||||
/// <returns>The persisted <see cref="UserSettings"/>, or a default instance if none are saved.</returns>
|
||||
UserSettings Load();
|
||||
/// <summary>Saves user settings to persistent storage.</summary>
|
||||
/// <param name="settings">The settings to save.</param>
|
||||
|
||||
@@ -19,8 +19,7 @@ public sealed class JsonSettingsService : ISettingsService
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>Loads user settings from the settings file.</summary>
|
||||
/// <returns>The loaded user settings, or a new default instance if load fails.</returns>
|
||||
/// <inheritdoc />
|
||||
public UserSettings Load()
|
||||
{
|
||||
try
|
||||
@@ -37,8 +36,7 @@ public sealed class JsonSettingsService : ISettingsService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Saves user settings to the settings file.</summary>
|
||||
/// <param name="settings">The user settings to save.</param>
|
||||
/// <inheritdoc />
|
||||
public void Save(UserSettings settings)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -6,8 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||
/// </summary>
|
||||
public sealed class SynchronousUiDispatcher : IUiDispatcher
|
||||
{
|
||||
/// <summary>Executes the action synchronously on the calling thread.</summary>
|
||||
/// <param name="action">The action to execute.</param>
|
||||
/// <inheritdoc />
|
||||
public void Post(Action action)
|
||||
{
|
||||
action();
|
||||
|
||||
@@ -195,6 +195,7 @@ public partial class AlarmsViewModel : ObservableObject
|
||||
/// <summary>
|
||||
/// Returns the monitored node ID for persistence, or null if not subscribed.
|
||||
/// </summary>
|
||||
/// <returns>The monitored node ID string, or null if not currently subscribed.</returns>
|
||||
public string? GetAlarmSourceNodeId()
|
||||
{
|
||||
return IsSubscribed ? MonitoredNodeIdText : null;
|
||||
|
||||
@@ -30,6 +30,7 @@ public class BrowseTreeViewModel : ObservableObject
|
||||
/// <summary>
|
||||
/// Loads root nodes by browsing with a null parent.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task LoadRootsAsync()
|
||||
{
|
||||
var results = await _service.BrowseAsync();
|
||||
|
||||
@@ -143,6 +143,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
/// </summary>
|
||||
/// <param name="nodeIdStr">The node ID to subscribe to from the browse tree or persisted settings.</param>
|
||||
/// <param name="intervalMs">The monitored-item interval, in milliseconds, for the subscription.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000)
|
||||
{
|
||||
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
|
||||
@@ -176,6 +177,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
/// <param name="nodeIdStr">The root node whose variables should be subscribed recursively.</param>
|
||||
/// <param name="nodeClass">The node class of the starting node so variables can be subscribed immediately.</param>
|
||||
/// <param name="intervalMs">The monitored-item interval, in milliseconds, used for created subscriptions.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs = 1000)
|
||||
{
|
||||
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
|
||||
@@ -211,6 +213,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
/// <summary>
|
||||
/// Returns the node IDs of all active subscriptions for persistence.
|
||||
/// </summary>
|
||||
/// <returns>The list of node ID strings for all currently active subscriptions.</returns>
|
||||
public List<string> GetSubscribedNodeIds()
|
||||
{
|
||||
return ActiveSubscriptions.Select(s => s.NodeId).ToList();
|
||||
@@ -220,6 +223,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
/// Restores subscriptions from a saved list of node IDs.
|
||||
/// </summary>
|
||||
/// <param name="nodeIds">The node IDs persisted from a prior UI session.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
|
||||
{
|
||||
foreach (var nodeId in nodeIds)
|
||||
@@ -232,6 +236,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
/// </summary>
|
||||
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
|
||||
/// <param name="rawValue">The raw text value entered by the operator.</param>
|
||||
/// <returns>A tuple of (success flag, operator-readable message) describing the outcome of the write.</returns>
|
||||
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -43,20 +43,16 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
||||
}
|
||||
|
||||
/// <summary>Gets the local cluster node identifier.</summary>
|
||||
/// <inheritdoc />
|
||||
public CommonsNodeId LocalNode => _localNode;
|
||||
|
||||
/// <summary>Gets the set of roles assigned to the local node.</summary>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<string> LocalRoles => _localRoles;
|
||||
|
||||
/// <summary>Checks if the local node has a specific role.</summary>
|
||||
/// <param name="role">The role name to check.</param>
|
||||
/// <returns>True if the local node has the specified role; otherwise false.</returns>
|
||||
/// <inheritdoc />
|
||||
public bool HasRole(string role) => _localRoles.Contains(role);
|
||||
|
||||
/// <summary>Gets all cluster members that have a specific role.</summary>
|
||||
/// <param name="role">The role name.</param>
|
||||
/// <returns>A read-only list of node IDs with the specified role.</returns>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -68,9 +64,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current leader node for a specific role.</summary>
|
||||
/// <param name="role">The role name.</param>
|
||||
/// <returns>The node ID of the current role leader, or null if no leader is elected.</returns>
|
||||
/// <inheritdoc />
|
||||
public CommonsNodeId? RoleLeader(string role)
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -9,6 +9,7 @@ public static class RoleParser
|
||||
|
||||
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
|
||||
/// <param name="raw">The raw role string to parse.</param>
|
||||
/// <returns>An array of validated, distinct, lower-cased role names; empty array when the input is null or whitespace.</returns>
|
||||
public static string[] Parse(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
|
||||
|
||||
@@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="configuration">The application configuration containing cluster options.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<AkkaClusterOptions>()
|
||||
@@ -45,6 +46,7 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The Akka configuration builder to configure.</param>
|
||||
/// <param name="serviceProvider">The service provider for resolving cluster options.</param>
|
||||
/// <returns>The same <see cref="AkkaConfigurationBuilder"/> for chaining.</returns>
|
||||
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
|
||||
this AkkaConfigurationBuilder builder,
|
||||
IServiceProvider serviceProvider)
|
||||
|
||||
@@ -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,37 @@
|
||||
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>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that resolves to the list of top-level browse nodes.</returns>
|
||||
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns the direct children of the node identified by
|
||||
/// <paramref name="nodeId"/>.</summary>
|
||||
/// <param name="nodeId">The identifier of the node whose children to expand.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that resolves to the list of direct child nodes.</returns>
|
||||
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>
|
||||
/// <param name="nodeId">The identifier of the node whose attributes to retrieve.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that resolves to the list of attribute descriptors for the node.</returns>
|
||||
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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>
|
||||
/// <returns>A task containing the opened browse session.</returns>
|
||||
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public interface IAlarmActorStateStore
|
||||
/// <summary>Saves the alarm actor state snapshot.</summary>
|
||||
/// <param name="snapshot">The state snapshot to persist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -41,14 +42,10 @@ public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
|
||||
{
|
||||
public static readonly NullAlarmActorStateStore Instance = new();
|
||||
private NullAlarmActorStateStore() { }
|
||||
/// <summary>Always returns null, indicating no persisted state.</summary>
|
||||
/// <param name="alarmId">The alarm identifier (unused).</param>
|
||||
/// <param name="ct">Cancellation token (unused).</param>
|
||||
/// <inheritdoc />
|
||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
|
||||
Task.FromResult<AlarmActorStateSnapshot?>(null);
|
||||
/// <summary>Completes immediately without persisting anything.</summary>
|
||||
/// <param name="snapshot">The state snapshot (ignored).</param>
|
||||
/// <param name="ct">Cancellation token (unused).</param>
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -43,11 +43,7 @@ public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
public static readonly NullVirtualTagEvaluator Instance = new();
|
||||
private NullVirtualTagEvaluator() { }
|
||||
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> for every evaluation.</summary>
|
||||
/// <param name="virtualTagId">The virtual tag identifier (ignored).</param>
|
||||
/// <param name="expression">The expression string (ignored).</param>
|
||||
/// <param name="dependencies">The variable dependencies (ignored).</param>
|
||||
/// <returns>Always returns <see cref="VirtualTagEvalResult.NoChange"/>.</returns>
|
||||
/// <inheritdoc />
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> VirtualTagEvalResult.NoChange;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,15 @@ 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>
|
||||
/// <returns>A task that resolves to the reply of type <typeparamref name="T"/>.</returns>
|
||||
Task<T> AskAsync<T>(object message, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ public interface IFleetDiagnosticsClient
|
||||
/// <summary>Gets diagnostics for the specified node.</summary>
|
||||
/// <param name="nodeId">The node ID to retrieve diagnostics for.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that resolves to the diagnostics snapshot for the specified node.</returns>
|
||||
Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, 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);
|
||||
@@ -1,17 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster-broadcast audit event consumed by the <c>AuditWriterActor</c> singleton, which
|
||||
/// batches and idempotently inserts into <c>ConfigAuditLog</c>.
|
||||
/// </summary>
|
||||
public sealed record AuditEvent(
|
||||
Guid EventId,
|
||||
string Category,
|
||||
string Action,
|
||||
string Actor,
|
||||
DateTime OccurredAtUtc,
|
||||
string? DetailsJson,
|
||||
NodeId SourceNode,
|
||||
CorrelationId 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";
|
||||
}
|
||||
@@ -69,6 +69,7 @@ public static class OtOpcUaTelemetry
|
||||
/// null when no listener is attached so the call site stays cheap on undecorated builds.
|
||||
/// </summary>
|
||||
/// <param name="deploymentId">The deployment identifier to tag the span with.</param>
|
||||
/// <returns>The started <see cref="Activity"/>, or null when no listener is attached.</returns>
|
||||
public static Activity? StartDeployApplySpan(string deploymentId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
|
||||
@@ -77,6 +78,7 @@ public static class OtOpcUaTelemetry
|
||||
}
|
||||
|
||||
/// <summary>Span wrapping a full OPC UA address-space rebuild (Phase7 plan → apply).</summary>
|
||||
/// <returns>The started <see cref="Activity"/>, or null when no listener is attached.</returns>
|
||||
public static Activity? StartAddressSpaceRebuildSpan()
|
||||
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
|
||||
}
|
||||
|
||||
@@ -22,37 +22,22 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
|
||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||
|
||||
/// <summary>Writes a value to the OPC UA address space through the inner sink.</summary>
|
||||
/// <param name="nodeId">The node ID of the variable.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="quality">The OPC UA quality value.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Writes an alarm state through the inner sink.</summary>
|
||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||
/// <param name="active">Whether the alarm is active.</param>
|
||||
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||
|
||||
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
|
||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
|
||||
/// <summary>Ensures a variable exists in the address space through the inner sink.</summary>
|
||||
/// <param name="variableNodeId">The node ID of the variable.</param>
|
||||
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
||||
/// <param name="displayName">The display name of the variable.</param>
|
||||
/// <param name="dataType">The OPC UA data type of the variable.</param>
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
||||
|
||||
/// <summary>Rebuilds the address space through the inner sink.</summary>
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
||||
public void SetInner(IServiceLevelPublisher? inner) =>
|
||||
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
||||
|
||||
/// <summary>Publishes a service level value to the inner publisher.</summary>
|
||||
/// <param name="serviceLevel">The service level to publish.</param>
|
||||
/// <inheritdoc />
|
||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
public readonly record struct CorrelationId(Guid Value)
|
||||
{
|
||||
/// <summary>Creates a new CorrelationId with a randomly generated GUID.</summary>
|
||||
/// <returns>A new <see cref="CorrelationId"/> backed by a random GUID.</returns>
|
||||
public static CorrelationId NewId() => new(Guid.NewGuid());
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value.ToString("N");
|
||||
/// <summary>Parses a lowercase hex string without hyphens into a CorrelationId.</summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <returns>A <see cref="CorrelationId"/> parsed from the supplied string.</returns>
|
||||
public static CorrelationId Parse(string s) => new(Guid.ParseExact(s, "N"));
|
||||
/// <summary>Attempts to parse a lowercase hex string without hyphens into a CorrelationId.</summary>
|
||||
/// <param name="s">The string to parse, or null.</param>
|
||||
/// <param name="id">The resulting CorrelationId if parsing succeeds.</param>
|
||||
/// <returns><see langword="true"/> if parsing succeeded; otherwise <see langword="false"/>.</returns>
|
||||
public static bool TryParse(string? s, out CorrelationId id)
|
||||
{
|
||||
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -41,4 +41,10 @@ public sealed class ConfigAuditLog
|
||||
/// <summary>Correlation ID from <c>AuditEvent.CorrelationId</c> so an audit row joins to its
|
||||
/// originating request/workflow. Nullable for the same backfill reason as <see cref="EventId"/>.</summary>
|
||||
public Guid? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>Normalized outcome from <c>AuditEvent.Outcome</c> (the canonical
|
||||
/// <c>ZB.MOM.WW.Audit.AuditOutcome</c>: <c>Success</c> | <c>Failure</c> | <c>Denied</c>),
|
||||
/// stored as its enum member name. Nullable so pre-Outcome rows backfill cleanly and the
|
||||
/// bespoke stored-procedure audit path (which does not derive an outcome) writes NULL.</summary>
|
||||
public string? Outcome { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,20 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
/// <see cref="Entities.NodeAcl"/> joined against LDAP group memberships directly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// <para>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Task 1.7 standardized the member names on the canonical control-plane role vocabulary
|
||||
/// (<c>ZB.MOM.WW.Auth</c> <c>CanonicalRole</c>): <c>ConfigViewer → Viewer</c>,
|
||||
/// <c>ConfigEditor → Designer</c>, <c>FleetAdmin → Administrator</c>. The appsettings-only
|
||||
/// <c>DriverOperator</c> string role likewise became <c>Operator</c>. These members persist
|
||||
/// as their string names (EF <c>HasConversion<string></c>); the rename is paired with
|
||||
/// a data migration (<c>CanonicalizeAdminRoles</c>) that rewrites existing rows. This is a
|
||||
/// rename, not a permission change — enforcement semantics are preserved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum AdminRole
|
||||
{
|
||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history.</summary>
|
||||
ConfigViewer,
|
||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history. (Canonical: Viewer; was ConfigViewer.)</summary>
|
||||
Viewer,
|
||||
|
||||
/// <summary>Can author drafts + submit for publish.</summary>
|
||||
ConfigEditor,
|
||||
/// <summary>Can author drafts + submit for publish. (Canonical: Designer; was ConfigEditor.)</summary>
|
||||
Designer,
|
||||
|
||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions.</summary>
|
||||
FleetAdmin,
|
||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions. (Canonical: Administrator; was FleetAdmin.)</summary>
|
||||
Administrator,
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@ public interface ILocalConfigCache
|
||||
/// <summary>Stores a generation snapshot in the local cache.</summary>
|
||||
/// <param name="snapshot">The generation snapshot to store.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
||||
/// <summary>Removes old generations, keeping only the most recent N.</summary>
|
||||
/// <param name="clusterId">The cluster identifier.</param>
|
||||
/// <param name="keepLatest">The number of latest generations to keep.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the most recent snapshot for the specified cluster.</summary>
|
||||
/// <param name="clusterId">The cluster ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -58,9 +56,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Stores a snapshot in the cache.</summary>
|
||||
/// <param name="snapshot">The snapshot to store.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <inheritdoc />
|
||||
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -89,10 +85,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes old generation snapshots, keeping only the latest ones.</summary>
|
||||
/// <param name="clusterId">The cluster ID.</param>
|
||||
/// <param name="keepLatest">Number of latest generations to keep.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <inheritdoc />
|
||||
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
+1755
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Task 1.7 — canonicalizes the control-plane admin role VALUES persisted in the
|
||||
/// <c>LdapGroupRoleMapping.Role</c> column. The column stores the <c>AdminRole</c> enum
|
||||
/// member name as a string (EF <c>HasConversion<string></c>, <c>nvarchar(32)</c>);
|
||||
/// renaming the enum members (<c>ConfigViewer → Viewer</c>, <c>ConfigEditor → Designer</c>,
|
||||
/// <c>FleetAdmin → Administrator</c>) therefore requires rewriting existing rows so the C#
|
||||
/// enum and the stored strings stay in sync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a pure DATA migration: the schema (column type, length, indexes) is unchanged,
|
||||
/// so the model snapshot is byte-identical to the prior migration. The new canonical strings
|
||||
/// ("Viewer" = 6, "Designer" = 8, "Administrator" = 13 chars) all fit the existing
|
||||
/// <c>nvarchar(32)</c> column. Enforcement semantics are preserved — it is a rename only.
|
||||
/// </remarks>
|
||||
public partial class CanonicalizeAdminRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Viewer' WHERE [Role] = N'ConfigViewer';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Designer' WHERE [Role] = N'ConfigEditor';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Administrator' WHERE [Role] = N'FleetAdmin';");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'FleetAdmin' WHERE [Role] = N'Administrator';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigEditor' WHERE [Role] = N'Designer';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigViewer' WHERE [Role] = N'Viewer';");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1759
File diff suppressed because it is too large
Load Diff
+35
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Task 2.2 — adds the nullable <c>Outcome</c> column to <c>ConfigAuditLog</c> for the
|
||||
/// canonical <c>ZB.MOM.WW.Audit.AuditOutcome</c> (stored as its enum member name,
|
||||
/// <c>nvarchar(16)</c>, mirroring how <c>AdminRole</c> is persisted). Purely additive:
|
||||
/// nullable with no backfill, so existing rows and the bespoke stored-procedure audit
|
||||
/// path (which does not derive an outcome) keep writing NULL.
|
||||
/// </summary>
|
||||
public partial class AddConfigAuditLogOutcome : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Outcome",
|
||||
table: "ConfigAuditLog",
|
||||
type: "nvarchar(16)",
|
||||
maxLength: 16,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Outcome",
|
||||
table: "ConfigAuditLog");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -186,6 +186,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("Principal")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
|
||||
@@ -445,6 +445,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.EventId);
|
||||
e.Property(x => x.CorrelationId);
|
||||
// Stored as the AuditOutcome enum member name (mirrors AdminRole's string storage):
|
||||
// "Success" | "Failure" | "Denied" all fit nvarchar(16). Nullable for legacy + SP-path rows.
|
||||
e.Property(x => x.Outcome).HasMaxLength(16);
|
||||
|
||||
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
|
||||
.IsDescending(false, true)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Shared query for the cluster-scoped audit view. Audit rows reach <c>ConfigAuditLog</c> by two
|
||||
/// paths that stamp different columns:
|
||||
/// <list type="bullet">
|
||||
/// <item>the bespoke stored-procedure path stamps <c>ClusterId</c> directly;</item>
|
||||
/// <item>the structured <c>AuditWriterActor</c> path stamps <c>NodeId</c> (leaving
|
||||
/// <c>ClusterId</c> null).</item>
|
||||
/// </list>
|
||||
/// A cluster-scoped view must surface both, so this query matches rows whose <c>ClusterId</c>
|
||||
/// equals the cluster <em>or</em> whose <c>NodeId</c> belongs to a node in the cluster
|
||||
/// (membership from <see cref="ClusterNode"/>: <c>NodeId → ClusterId</c>).
|
||||
/// </summary>
|
||||
public static class ClusterAuditQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the newest <paramref name="pageSize"/> audit rows visible for
|
||||
/// <paramref name="clusterId"/>, newest first. Executes one query to resolve the cluster's
|
||||
/// node IDs, then one filtered query against <c>ConfigAuditLog</c>.
|
||||
/// </summary>
|
||||
/// <param name="db">The config database context.</param>
|
||||
/// <param name="clusterId">The cluster whose audit rows to fetch.</param>
|
||||
/// <param name="pageSize">Maximum number of rows to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The matching audit rows, newest first.</returns>
|
||||
public static async Task<List<ConfigAuditLog>> ForClusterAsync(
|
||||
OtOpcUaConfigDbContext db, string clusterId, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var nodeIds = await db.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == clusterId)
|
||||
.Select(n => n.NodeId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return await db.ConfigAuditLogs.AsNoTracking()
|
||||
.Where(a => a.ClusterId == clusterId
|
||||
|| (a.ClusterId == null && a.NodeId != null && nodeIds.Contains(a.NodeId)))
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@ public interface ILdapGroupRoleMappingService
|
||||
/// </remarks>
|
||||
/// <param name="ldapGroups">The LDAP groups to search for.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task resolving to the list of mappings whose LDAP group matches any of the provided groups.</returns>
|
||||
Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Enumerate every mapping; Admin UI listing only.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task resolving to all LDAP group role mappings.</returns>
|
||||
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Create a new grant.</summary>
|
||||
@@ -39,11 +41,13 @@ public interface ILdapGroupRoleMappingService
|
||||
/// </exception>
|
||||
/// <param name="row">The LDAP group role mapping to create.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task resolving to the newly created <see cref="LdapGroupRoleMapping"/> with any DB-assigned values populated.</returns>
|
||||
Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Delete a mapping by its surrogate key.</summary>
|
||||
/// <param name="id">The unique identifier of the mapping to delete.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous delete operation.</returns>
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
/// </summary>
|
||||
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
|
||||
{
|
||||
/// <summary>Gets LDAP group role mappings for the specified groups.</summary>
|
||||
/// <param name="ldapGroups">The LDAP group names to query.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The matching role mappings.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ public static class DraftValidator
|
||||
/// Validates a draft snapshot and returns all validation errors found in a single pass.
|
||||
/// </summary>
|
||||
/// <param name="draft">The draft snapshot to validate.</param>
|
||||
/// <returns>A read-only list of all validation errors found; empty if the draft is valid.</returns>
|
||||
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
@@ -147,6 +148,7 @@ public static class DraftValidator
|
||||
|
||||
/// <summary>Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
|
||||
/// <param name="uuid">The equipment UUID to derive the ID from.</param>
|
||||
/// <returns>The derived equipment ID string in the form <c>EQ-xxxxxxxxxxxx</c>.</returns>
|
||||
public static string DeriveEquipmentId(Guid uuid) =>
|
||||
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
|
||||
|
||||
@@ -172,8 +174,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,
|
||||
};
|
||||
|
||||
@@ -203,6 +205,7 @@ public static class DraftValidator
|
||||
/// </remarks>
|
||||
/// <param name="cluster">The server cluster to validate.</param>
|
||||
/// <param name="clusterNodes">The cluster nodes to validate against the cluster configuration.</param>
|
||||
/// <returns>A read-only list of all validation errors found; empty if the topology is valid.</returns>
|
||||
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||
ServerCluster cluster,
|
||||
IReadOnlyList<ClusterNode> clusterNodes)
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class DriverTypeRegistry
|
||||
|
||||
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
|
||||
/// <param name="driverType">The driver type name to look up.</param>
|
||||
/// <returns>The <see cref="DriverTypeMetadata"/> registered for the specified type name.</returns>
|
||||
public DriverTypeMetadata Get(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
@@ -69,6 +70,7 @@ public sealed class DriverTypeRegistry
|
||||
|
||||
/// <summary>Try to look up a driver type by name. Returns null if unknown (no exception).</summary>
|
||||
/// <param name="driverType">The driver type name to look up.</param>
|
||||
/// <returns>The matching <see cref="DriverTypeMetadata"/>, or <c>null</c> if not registered.</returns>
|
||||
public DriverTypeMetadata? TryGet(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
@@ -76,6 +78,7 @@ public sealed class DriverTypeRegistry
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of all registered driver types.</summary>
|
||||
/// <returns>A read-only collection of all currently registered driver type metadata entries.</returns>
|
||||
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <param name="endUtc">The end of the time range in UTC.</param>
|
||||
/// <param name="maxValuesPerNode">The maximum number of values to return per node.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task resolving to a <see cref="HistoryReadResult"/> containing the raw samples.</returns>
|
||||
Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -46,6 +47,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <param name="interval">The interval for bucketing samples.</param>
|
||||
/// <param name="aggregate">The aggregation function to apply to each bucket.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task resolving to a <see cref="HistoryReadResult"/> containing the processed interval samples.</returns>
|
||||
Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -63,6 +65,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <param name="fullReference">The full reference of the tag to read.</param>
|
||||
/// <param name="timestampsUtc">The list of timestamps to read values at.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task resolving to a <see cref="HistoryReadResult"/> with one sample per requested timestamp.</returns>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
@@ -93,6 +96,7 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <param name="endUtc">The end of the time range in UTC.</param>
|
||||
/// <param name="maxEvents">The maximum number of events to return, or a non-positive value to use the default backend cap.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task resolving to a <see cref="HistoricalEventsResult"/> containing historical alarm and event records.</returns>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
@@ -104,5 +108,6 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
|
||||
/// observation; never blocks on backend I/O.
|
||||
/// </summary>
|
||||
/// <returns>The current <see cref="HistorianHealthSnapshot"/> for this data source.</returns>
|
||||
HistorianHealthSnapshot GetHealthSnapshot();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public interface IAddressSpaceBuilder
|
||||
/// </summary>
|
||||
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
|
||||
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
|
||||
/// <returns>A child builder scoped to inside this folder.</returns>
|
||||
IAddressSpaceBuilder Folder(string browseName, string displayName);
|
||||
|
||||
/// <summary>
|
||||
@@ -27,6 +28,7 @@ public interface IAddressSpaceBuilder
|
||||
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
|
||||
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
|
||||
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
|
||||
/// <returns>An opaque handle for the registered variable.</returns>
|
||||
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +58,7 @@ public interface IVariableHandle
|
||||
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
||||
/// </summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
/// <returns>A sink that receives alarm lifecycle transitions for this condition.</returns>
|
||||
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public interface IAlarmSource
|
||||
/// </summary>
|
||||
/// <param name="sourceNodeIds">The driver node IDs to subscribe to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that resolves to an opaque <see cref="IAlarmSubscriptionHandle"/> for the new subscription.</returns>
|
||||
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -20,11 +21,13 @@ public interface IAlarmSource
|
||||
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
|
||||
/// <param name="handle">The subscription handle returned from <see cref="SubscribeAlarmsAsync"/>.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
|
||||
/// <param name="acknowledgements">The batch of alarm acknowledgement requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -23,6 +23,7 @@ public interface IDriver
|
||||
/// <summary>Initialize the driver from its <c>DriverConfig</c> JSON; open connections; prepare for first use.</summary>
|
||||
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -37,13 +38,16 @@ public interface IDriver
|
||||
/// </remarks>
|
||||
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
|
||||
/// <returns>The current driver health snapshot.</returns>
|
||||
DriverHealth GetHealth();
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +60,7 @@ public interface IDriver
|
||||
/// allocation tracking". Tier C drivers (process-isolated) report through the same
|
||||
/// interface but the cache-flush is internal to their host.
|
||||
/// </remarks>
|
||||
/// <returns>The approximate driver-attributable memory footprint in bytes.</returns>
|
||||
long GetMemoryFootprint();
|
||||
|
||||
/// <summary>
|
||||
@@ -63,5 +68,6 @@ public interface IDriver
|
||||
/// Required-for-correctness state must NOT be flushed.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,8 @@ public sealed class NullDriverFactory : IDriverFactory
|
||||
public static readonly NullDriverFactory Instance = new();
|
||||
private NullDriverFactory() { }
|
||||
|
||||
/// <summary>Creates a driver (always returns null in this null implementation).</summary>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
/// <param name="driverInstanceId">The driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>Always returns null.</returns>
|
||||
/// <inheritdoc />
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
|
||||
/// <summary>Gets the collection of supported driver types (empty in this null implementation).</summary>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
/// <param name="clusterId">The cluster identifier the driver instance belongs to.</param>
|
||||
/// <param name="driverInstanceId">The unique identifier of the driver instance.</param>
|
||||
/// <param name="health">The current health state of the driver instance.</param>
|
||||
/// <param name="errorCount5Min">Number of errors recorded in the past 5 minutes.</param>
|
||||
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,31 @@
|
||||
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>
|
||||
/// <param name="configJson">Driver configuration JSON; same shape the runtime driver consumes.</param>
|
||||
/// <param name="timeout">Maximum duration for the probe attempt.</param>
|
||||
/// <param name="ct">Cancellation token for the probe operation.</param>
|
||||
/// <returns>A task containing the probe result with success status and optional latency.</returns>
|
||||
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);
|
||||
@@ -22,5 +22,6 @@ public interface IDriverSupervisor
|
||||
/// </summary>
|
||||
/// <param name="reason">Human-readable reason — flows into the supervisor's logs.</param>
|
||||
/// <param name="cancellationToken">Cancels the recycle request; an in-flight restart is not interrupted.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task RecycleAsync(string reason, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ public interface IHistoryProvider
|
||||
/// <c>HistorianDataSource</c>). The asymmetry is intentional — Core.Abstractions-006.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <returns>A task that resolves to the historical events result for the requested window.</returns>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
|
||||
/// Wonderware Alarm & Events log) override. Modbus / the OPC UA Client driver stay
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IHostConnectivityProbe
|
||||
/// Snapshot of host-level connectivity. The Core uses this to drive Bad-quality
|
||||
/// fan-out scoped to the affected host's subtree (not the whole driver namespace).
|
||||
/// </summary>
|
||||
/// <returns>A snapshot list of per-host connectivity statuses.</returns>
|
||||
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
|
||||
|
||||
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
|
||||
|
||||
@@ -13,5 +13,6 @@ public interface ITagDiscovery
|
||||
/// </summary>
|
||||
/// <param name="builder">The address space builder to stream discovered nodes into.</param>
|
||||
/// <param name="cancellationToken">A cancellation token for the discovery operation.</param>
|
||||
/// <returns>A task that represents the asynchronous discovery operation.</returns>
|
||||
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public interface IWritable
|
||||
/// </summary>
|
||||
/// <param name="writes">Pairs of full reference + value to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token; the driver should abort the batch if cancelled.</param>
|
||||
/// <returns>A task that resolves to one <see cref="WriteResult"/> per requested write, in the same order.</returns>
|
||||
Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -41,6 +41,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
|
||||
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>Initializes a new poll-group engine with the supplied reader, change callback, interval floor, and optional error sink.</summary>
|
||||
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
|
||||
/// order as the input references.</param>
|
||||
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
|
||||
@@ -68,6 +69,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// <summary>Register a new polled subscription and start its background loop.</summary>
|
||||
/// <param name="fullReferences">The list of tag references to poll.</param>
|
||||
/// <param name="publishingInterval">The desired polling interval; will be clamped to the configured minimum.</param>
|
||||
/// <returns>A subscription handle that can be passed to <see cref="Unsubscribe"/> to cancel the loop.</returns>
|
||||
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
@@ -207,6 +209,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
|
||||
/// <returns>A value task that represents the asynchronous dispose operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Cancel all loops first so they can all start winding down in parallel.
|
||||
@@ -253,7 +256,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
|
||||
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId => $"poll-sub-{Id}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ public interface IAlarmHistorianSink
|
||||
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
|
||||
/// <param name="evt">The alarm historian event to enqueue.</param>
|
||||
/// <param name="cancellationToken">A cancellation token for async operations.</param>
|
||||
/// <returns>A task that represents the asynchronous enqueue operation.</returns>
|
||||
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Snapshot of current queue depth + drain health.</summary>
|
||||
/// <returns>A snapshot of the current queue depth and drain state.</returns>
|
||||
HistorianSinkStatus GetStatus();
|
||||
}
|
||||
|
||||
@@ -97,6 +99,7 @@ public interface IAlarmHistorianWriter
|
||||
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
|
||||
/// <param name="batch">The batch of alarm historian events to write.</param>
|
||||
/// <param name="cancellationToken">A cancellation token for async operations.</param>
|
||||
/// <returns>A task that resolves to one write outcome per event, in the same order as the batch.</returns>
|
||||
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
/// </remarks>
|
||||
/// <param name="evt">The alarm historian event to enqueue.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is null) throw new ArgumentNullException(nameof(evt));
|
||||
@@ -345,6 +346,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
/// connections per tick, each paying the open + PRAGMA cost.
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task DrainOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -490,7 +492,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current status of the historian sink including queue depth and drain state.</summary>
|
||||
/// <inheritdoc />
|
||||
public HistorianSinkStatus GetStatus()
|
||||
{
|
||||
// Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
|
||||
@@ -534,6 +536,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
|
||||
/// <returns>The number of rows moved back to the active queue.</returns>
|
||||
public int RetryDeadLettered()
|
||||
{
|
||||
using var conn = OpenConnection();
|
||||
|
||||
@@ -97,6 +97,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// copy under the gate. (Core.ScriptedAlarms-013.)
|
||||
/// </remarks>
|
||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
||||
/// <returns>The live read-cache dictionary for the alarm, or <see langword="null"/> if not yet allocated.</returns>
|
||||
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
|
||||
|
||||
@@ -113,6 +114,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// (Core.ScriptedAlarms-013.)
|
||||
/// </remarks>
|
||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
||||
/// <returns>The reusable <see cref="AlarmPredicateContext"/> for the alarm, or <see langword="null"/> if not yet allocated.</returns>
|
||||
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
||||
@@ -175,6 +177,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="definitions">The alarm definitions to load.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
|
||||
@@ -306,10 +309,12 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
|
||||
/// </summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <returns>The current <see cref="AlarmConditionState"/> for the alarm, or <see langword="null"/> if the alarm is unknown.</returns>
|
||||
public AlarmConditionState? GetState(string alarmId)
|
||||
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
|
||||
|
||||
/// <summary>Gets the current persisted state for all loaded alarms.</summary>
|
||||
/// <returns>A snapshot collection of all current alarm condition states.</returns>
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
@@ -318,6 +323,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user performing the acknowledgment.</param>
|
||||
/// <param name="comment">An optional comment to attach to the acknowledgment.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
|
||||
|
||||
@@ -326,6 +332,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user performing the confirmation.</param>
|
||||
/// <param name="comment">An optional comment to attach to the confirmation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
|
||||
|
||||
@@ -333,6 +340,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the shelve operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
|
||||
|
||||
@@ -341,6 +349,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user performing the shelve operation.</param>
|
||||
/// <param name="unshelveAtUtc">The UTC time at which the shelve will automatically expire.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
|
||||
|
||||
@@ -348,6 +357,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the unshelve operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
|
||||
|
||||
@@ -355,6 +365,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the enable operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
|
||||
|
||||
@@ -362,6 +373,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the disable operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
|
||||
|
||||
@@ -370,6 +382,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="user">The user adding the comment.</param>
|
||||
/// <param name="text">The comment text.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public static class DependencyExtractor
|
||||
/// paths, or a list of rejection messages if non-literal paths were used.
|
||||
/// </summary>
|
||||
/// <param name="scriptSource">The script source code to analyze.</param>
|
||||
/// <returns>The extracted dependency paths, or rejection messages for unsupported patterns.</returns>
|
||||
public static DependencyExtractionResult Extract(string scriptSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptSource))
|
||||
|
||||
@@ -41,6 +41,7 @@ public abstract class ScriptContext
|
||||
/// right upstream tags at load time.
|
||||
/// </remarks>
|
||||
/// <param name="path">The literal tag path to read.</param>
|
||||
/// <returns>The current <see cref="DataValueSnapshot"/> for the tag, including value, quality, and timestamp.</returns>
|
||||
public abstract DataValueSnapshot GetTag(string path);
|
||||
|
||||
/// <summary>
|
||||
@@ -81,6 +82,7 @@ public abstract class ScriptContext
|
||||
/// <param name="current">The current value to check.</param>
|
||||
/// <param name="previous">The previous value to compare against.</param>
|
||||
/// <param name="tolerance">The minimum difference threshold for a change to be detected.</param>
|
||||
/// <returns><see langword="true"/> when the absolute difference between current and previous exceeds tolerance.</returns>
|
||||
public static bool Deadband(double current, double previous, double tolerance)
|
||||
=> Math.Abs(current - previous) > tolerance;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
|
||||
/// <summary>Compiles user script source into an evaluator.</summary>
|
||||
/// <param name="scriptSource">The user script source code to compile.</param>
|
||||
/// <returns>A compiled <see cref="ScriptEvaluator{TContext, TResult}"/> ready to invoke.</returns>
|
||||
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
||||
{
|
||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||
@@ -173,6 +174,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
/// <summary>Runs the script against an already-constructed context.</summary>
|
||||
/// <param name="context">The script context.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that resolves to the script's return value.</returns>
|
||||
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
|
||||
|
||||
@@ -43,6 +43,7 @@ public static class ScriptSandbox
|
||||
/// to resolve <c>ctx.GetTag(...)</c> calls.
|
||||
/// </summary>
|
||||
/// <param name="contextType">The concrete script context type to use for compilation.</param>
|
||||
/// <returns>The sandbox configuration for compiling scripts with the given context type.</returns>
|
||||
public static SandboxConfig Build(Type contextType)
|
||||
{
|
||||
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
||||
|
||||
@@ -156,6 +156,7 @@ public sealed class DependencyGraph
|
||||
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
|
||||
/// exists. Implemented via Kahn's algorithm.
|
||||
/// </summary>
|
||||
/// <returns>A list of node IDs in topological evaluation order.</returns>
|
||||
public IReadOnlyList<string> TopologicalSort()
|
||||
{
|
||||
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
|
||||
@@ -205,6 +206,7 @@ public sealed class DependencyGraph
|
||||
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
|
||||
/// rejection pass so operators see all of them, not just one at a time.
|
||||
/// </summary>
|
||||
/// <returns>A list of strongly-connected components that form cycles; empty if the graph is acyclic.</returns>
|
||||
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
|
||||
{
|
||||
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user