Compare commits
105 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 |
@@ -21,6 +21,8 @@ desktop.ini
|
|||||||
# NuGet
|
# NuGet
|
||||||
packages/
|
packages/
|
||||||
*.nupkg
|
*.nupkg
|
||||||
|
# … but DO track repo-local feed for mxaccessgw client (not yet on public nuget.org).
|
||||||
|
!nuget-packages/*.nupkg
|
||||||
|
|
||||||
# Certificates
|
# Certificates
|
||||||
*.pfx
|
*.pfx
|
||||||
@@ -40,3 +42,9 @@ config_cache*.db
|
|||||||
|
|
||||||
# Client CLI/UI runtime scratch (last-connected endpoint cache)
|
# Client CLI/UI runtime scratch (last-connected endpoint cache)
|
||||||
session.dat
|
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 -- 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
|
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`.
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
<PackageVersion Include="libplctag" Version="1.5.2" />
|
<PackageVersion Include="libplctag" Version="1.5.2" />
|
||||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||||
<PackageVersion Include="MessagePack" Version="2.5.187" />
|
<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.Authorization" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||||
@@ -80,11 +79,11 @@
|
|||||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||||
<PackageVersion Include="Polly.Core" Version="8.6.6" />
|
<PackageVersion Include="Polly.Core" Version="8.6.6" />
|
||||||
<PackageVersion Include="S7netplus" Version="0.20.0" />
|
<PackageVersion Include="S7netplus" Version="0.20.0" />
|
||||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.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.Console" Version="6.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
@@ -97,5 +96,18 @@
|
|||||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||||
<PackageVersion Include="xunit.v3" Version="1.1.0" />
|
<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>
|
</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,6 +21,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/src/Drivers/">
|
<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/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.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/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/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj" />
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<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.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/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.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>
|
||||||
<Folder Name="/src/Drivers/Driver CLIs/">
|
<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" />
|
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
|
||||||
@@ -80,6 +82,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/Drivers/">
|
<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.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.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.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" />
|
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
|
||||||
@@ -97,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.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.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.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>
|
||||||
<Folder Name="/tests/Drivers/Driver CLIs/">
|
<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" />
|
<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__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
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:
|
admin-b:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -115,7 +115,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
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:
|
driver-a:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -129,7 +129,7 @@ services:
|
|||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
# 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:
|
ports:
|
||||||
- "4840:4840"
|
- "4840:4840"
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ services:
|
|||||||
Cluster__PublicHostname: "driver-b"
|
Cluster__PublicHostname: "driver-b"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4841:4840"
|
- "4841:4840"
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
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:
|
ports:
|
||||||
- "4842:4840"
|
- "4842:4840"
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
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:
|
ports:
|
||||||
- "4843:4840"
|
- "4843:4840"
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
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:
|
ports:
|
||||||
- "4844:4840"
|
- "4844:4840"
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
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:
|
ports:
|
||||||
- "4845:4840"
|
- "4845:4840"
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ services:
|
|||||||
- --providers.file.watch=true
|
- --providers.file.watch=true
|
||||||
- --api.insecure=true
|
- --api.insecure=true
|
||||||
ports:
|
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
|
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
|
||||||
volumes:
|
volumes:
|
||||||
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
|
|||||||
@@ -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
|
> the new RPCs; the sub-attribute fallback path keeps Galaxy alarms
|
||||||
> functional today.
|
> functional today.
|
||||||
>
|
>
|
||||||
> ⚠️ **Worker-side native alarm subscription blocked on a dev-rig
|
> ✅ **UPDATE 2026-05-29 — native alarm feed VERIFIED working; the
|
||||||
> finding (2026-04-30):** the MXAccess COM Toolkit at
|
> 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`
|
> `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
|
||||||
> exposes no alarm-event family — only `OnDataChange`,
|
> exposed no alarm-event family — only `OnDataChange`,
|
||||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange`.
|
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange` — and
|
||||||
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
|
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
|
||||||
> assemblies are x64-only and incompatible with the worker's x86
|
> assemblies are x64-only vs. the worker's x86 bitness. The operator
|
||||||
> bitness. **Operator decision needed before
|
> decision (accept the value-driven sub-attribute path, or add an x64
|
||||||
> `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries any events:** either
|
> alarm-helper sub-process) has since been resolved on the gateway side
|
||||||
> accept the value-driven sub-attribute path as the production
|
> — `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` now carries events (verified
|
||||||
> architecture (operator-comment fidelity is the only v1 regression)
|
> above). The C.1 `SdkAlarmHistorianWriteBackend` is **no longer a
|
||||||
> or add an x64 alarm-helper sub-process alongside the worker. See
|
> placeholder** — it writes through the real
|
||||||
> `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` in the
|
> `HistorianAccess.AddStreamedValue` path (only its live-rig write
|
||||||
> mxaccessgw repo for the architectural notes. Live
|
> smoke remains).
|
||||||
> `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.
|
|
||||||
|
|
||||||
Coordinated epic across two repos:
|
Coordinated epic across two repos:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Alarms Worker Wiring Plan
|
# 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
|
> **Context**: The alarms-over-gateway epic shipped 19 PRs across the
|
||||||
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
|
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
|
||||||
> the sub-attribute fallback path keeps Galaxy alarms functional today. Four
|
> 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:
|
During PR A.2 work the following was discovered on the dev box:
|
||||||
|
|
||||||
@@ -318,16 +331,20 @@ fallback as production).
|
|||||||
|
|
||||||
## Summary of blocks
|
## Summary of blocks
|
||||||
|
|
||||||
| Item | Blocked by | Estimated effort once unblocked |
|
> **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.
|
||||||
| 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) |
|
|
||||||
|
|
||||||
C.1 can proceed in parallel with A.2 / A.3 since the sidecar's `aahClientManaged`
|
| Item | Status (2026-05-29) | Original block |
|
||||||
is x64 and does not share the worker bitness constraint.
|
|------|--------------------|----------------|
|
||||||
|
| 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| **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) |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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,
|
"Enabled": true,
|
||||||
"Server": "localhost",
|
"Server": "localhost",
|
||||||
"Port": 3893,
|
"Port": 3893,
|
||||||
"SearchBase": "dc=lmxopcua,dc=local",
|
"SearchBase": "dc=zb,dc=local",
|
||||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||||
"ServiceAccountPassword": "serviceaccount123",
|
"ServiceAccountPassword": "serviceaccount123",
|
||||||
"GroupToRole": {
|
"GroupToRole": {
|
||||||
"ReadOnly": "ReadOnly",
|
"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.
|
/// Executes the command-specific workflow against the configured OPC UA endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="console">The CLI console used for output and cancellation handling.</param>
|
/// <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);
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
|
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="ConnectionSettings"/> populated from the current command option values.</returns>
|
||||||
protected ConnectionSettings CreateConnectionSettings()
|
protected ConnectionSettings CreateConnectionSettings()
|
||||||
{
|
{
|
||||||
var securityMode = SecurityModeMapper.FromString(Security);
|
var securityMode = SecurityModeMapper.FromString(Security);
|
||||||
@@ -97,6 +99,7 @@ public abstract class CommandBase : ICommand
|
|||||||
/// and returns both the service and the connection info.
|
/// and returns both the service and the connection info.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts connection setup for the command.</param>
|
/// <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(
|
protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-3
@@ -12,9 +12,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
|||||||
{
|
{
|
||||||
private static readonly ILogger Logger = Log.ForContext<DefaultApplicationConfigurationFactory>();
|
private static readonly ILogger Logger = Log.ForContext<DefaultApplicationConfigurationFactory>();
|
||||||
|
|
||||||
/// <summary>Creates an OPC UA application configuration from the provided connection settings.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="settings">The connection settings to use.</param>
|
|
||||||
/// <param name="ct">Token to cancel the operation.</param>
|
|
||||||
public async Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
|
public async Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Resolve the canonical PKI path lazily on first use so constructing a
|
// 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>();
|
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
|
||||||
|
|
||||||
/// <summary>Selects an OPC UA endpoint matching the requested security mode.</summary>
|
/// <inheritdoc />
|
||||||
/// <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>
|
|
||||||
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||||
MessageSecurityMode requestedMode)
|
MessageSecurityMode requestedMode)
|
||||||
{
|
{
|
||||||
@@ -53,6 +50,7 @@ internal static class EndpointSelector
|
|||||||
/// Thrown when no endpoint matches <paramref name="requestedMode"/>; the message lists the
|
/// Thrown when no endpoint matches <paramref name="requestedMode"/>; the message lists the
|
||||||
/// security mode + policy combinations the server returned so operators can diagnose mismatches.
|
/// security mode + policy combinations the server returned so operators can diagnose mismatches.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
|
/// <returns>The best matching <see cref="EndpointDescription"/> with its URL rewritten to the requested host.</returns>
|
||||||
public static EndpointDescription SelectBest(
|
public static EndpointDescription SelectBest(
|
||||||
IEnumerable<EndpointDescription> allEndpoints,
|
IEnumerable<EndpointDescription> allEndpoints,
|
||||||
string endpointUrl,
|
string endpointUrl,
|
||||||
|
|||||||
+1
@@ -13,5 +13,6 @@ internal interface IApplicationConfigurationFactory
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="settings">The connection settings to configure.</param>
|
/// <param name="settings">The connection settings to configure.</param>
|
||||||
/// <param name="ct">Cancellation token for the operation.</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);
|
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="config">The OPC UA application configuration.</param>
|
||||||
/// <param name="endpointUrl">The endpoint URL to discover.</param>
|
/// <param name="endpointUrl">The endpoint URL to discover.</param>
|
||||||
/// <param name="requestedMode">The requested message security mode.</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,
|
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||||
MessageSecurityMode requestedMode);
|
MessageSecurityMode requestedMode);
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,7 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="nodeId">The node whose current runtime value should be read.</param>
|
/// <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>
|
/// <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);
|
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -66,6 +67,7 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// <param name="nodeId">The node whose value should be updated.</param>
|
/// <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="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>
|
/// <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);
|
Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -75,6 +77,7 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// <param name="nodeId">The starting node for the hierarchical browse.</param>
|
/// <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="nodeClassMask">The node classes that should be returned to the caller.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the browse request.</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(
|
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||||
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -83,6 +86,7 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
|
/// <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>
|
/// <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(
|
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||||
byte[] continuationPoint, CancellationToken ct = default);
|
byte[] continuationPoint, CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
|
/// <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>
|
/// <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);
|
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -101,6 +106,7 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// <param name="endTime">The inclusive end of the requested history window.</param>
|
/// <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="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>
|
/// <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,
|
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
int maxValues, CancellationToken ct = default);
|
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="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="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the aggregate history read.</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,
|
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
NodeId aggregateId, double intervalMs, CancellationToken ct = default);
|
NodeId aggregateId, double intervalMs, CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -121,6 +128,7 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="publishingIntervalMs">The requested publishing interval for monitored items on the new subscription.</param>
|
/// <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>
|
/// <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);
|
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -130,11 +138,13 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// <param name="methodId">The method node to invoke.</param>
|
/// <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="inputArguments">The ordered input arguments supplied to the server method call.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the method invocation.</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);
|
Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the underlying session gracefully before the adapter is disposed or replaced during failover.
|
/// Closes the underlying session gracefully before the adapter is disposed or replaced during failover.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts the close request.</param>
|
/// <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);
|
Task CloseAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ internal interface ISubscriptionAdapter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="clientHandle">The client handle returned when the monitored item was created.</param>
|
/// <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>
|
/// <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);
|
Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -46,11 +47,13 @@ internal interface ISubscriptionAdapter : IDisposable
|
|||||||
/// Requests a condition refresh for this subscription.
|
/// Requests a condition refresh for this subscription.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
|
/// <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);
|
Task ConditionRefreshAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes all monitored items and deletes the subscription.
|
/// Removes all monitored items and deletes the subscription.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts subscription deletion.</param>
|
/// <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);
|
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
|
/// 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.
|
/// path (PKI store, settings file) find their existing state at the canonical name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The absolute path to the client's top-level folder under LocalApplicationData.</returns>
|
||||||
public static string GetRoot()
|
public static string GetRoot()
|
||||||
{
|
{
|
||||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
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>
|
/// <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");
|
public static string GetPkiPath() => Path.Combine(GetRoot(), "pki");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,6 +47,7 @@ public static class ClientStoragePaths
|
|||||||
/// folder existed + was moved to canonical, false when no migration was needed or
|
/// folder existed + was moved to canonical, false when no migration was needed or
|
||||||
/// canonical was already present.
|
/// canonical was already present.
|
||||||
/// </summary>
|
/// </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()
|
public static bool TryRunLegacyMigration()
|
||||||
{
|
{
|
||||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="settings">The endpoint, security, and authentication settings used to establish the session.</param>
|
/// <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>
|
/// <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);
|
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disconnects from the active OPC UA endpoint and tears down subscriptions owned by the client.
|
/// Disconnects from the active OPC UA endpoint and tears down subscriptions owned by the client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts disconnect cleanup.</param>
|
/// <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);
|
Task DisconnectAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -37,6 +39,7 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="nodeId">The node whose value should be retrieved.</param>
|
/// <param name="nodeId">The node whose value should be retrieved.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the read request.</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);
|
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,6 +48,7 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// <param name="nodeId">The node whose value should be updated.</param>
|
/// <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="value">The raw value supplied by the CLI or UI workflow.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the write request.</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);
|
Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -52,6 +56,7 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="parentNodeId">The node to browse, or <see cref="ObjectIds.ObjectsFolder"/> when omitted.</param>
|
/// <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>
|
/// <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);
|
Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -60,6 +65,7 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// <param name="nodeId">The node whose value changes should be monitored.</param>
|
/// <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="intervalMs">The monitored-item sampling and publishing interval in milliseconds.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts subscription creation.</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);
|
Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,6 +73,7 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="nodeId">The node whose live-data subscription should be removed.</param>
|
/// <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>
|
/// <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);
|
Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <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="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="intervalMs">The publishing interval in milliseconds for the alarm subscription.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts alarm subscription creation.</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);
|
Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes the active alarm subscription.
|
/// Removes the active alarm subscription.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts alarm subscription cleanup.</param>
|
/// <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);
|
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Requests retained alarm conditions again so a client can repopulate its alarm list after reconnecting.
|
/// Requests retained alarm conditions again so a client can repopulate its alarm list after reconnecting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
|
/// <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);
|
Task RequestConditionRefreshAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -111,6 +121,7 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
/// <param name="endTime">The inclusive end of the requested history range.</param>
|
/// <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="maxValues">The maximum number of raw values to return.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the history read.</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,
|
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
int maxValues = 1000, CancellationToken ct = default);
|
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="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="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
|
||||||
/// <param name="ct">The cancellation token that aborts the processed history request.</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,
|
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
|
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.
|
/// Reads redundancy status data such as redundancy mode, service level, and partner endpoint URIs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">The cancellation token that aborts redundancy inspection.</param>
|
/// <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);
|
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Raised when subscribed node values change.</summary>
|
||||||
public event EventHandler<DataChangedEventArgs>? DataChanged;
|
public event EventHandler<DataChangedEventArgs>? DataChanged;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Raised when an alarm event is received from the server.</summary>
|
||||||
public event EventHandler<AlarmEventArgs>? AlarmEvent;
|
public event EventHandler<AlarmEventArgs>? AlarmEvent;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Raised when the connection state changes.</summary>
|
||||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AvaloniaUiDispatcher : IUiDispatcher
|
public sealed class AvaloniaUiDispatcher : IUiDispatcher
|
||||||
{
|
{
|
||||||
/// <summary>Posts an action to the Avalonia UI thread for execution.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="action">The action to execute on the UI thread.</param>
|
|
||||||
public void Post(Action action)
|
public void Post(Action action)
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(action);
|
Dispatcher.UIThread.Post(action);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
|||||||
public interface ISettingsService
|
public interface ISettingsService
|
||||||
{
|
{
|
||||||
/// <summary>Loads user settings from persistent storage.</summary>
|
/// <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();
|
UserSettings Load();
|
||||||
/// <summary>Saves user settings to persistent storage.</summary>
|
/// <summary>Saves user settings to persistent storage.</summary>
|
||||||
/// <param name="settings">The settings to save.</param>
|
/// <param name="settings">The settings to save.</param>
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ public sealed class JsonSettingsService : ISettingsService
|
|||||||
WriteIndented = true
|
WriteIndented = true
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Loads user settings from the settings file.</summary>
|
/// <inheritdoc />
|
||||||
/// <returns>The loaded user settings, or a new default instance if load fails.</returns>
|
|
||||||
public UserSettings Load()
|
public UserSettings Load()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -37,8 +36,7 @@ public sealed class JsonSettingsService : ISettingsService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Saves user settings to the settings file.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="settings">The user settings to save.</param>
|
|
||||||
public void Save(UserSettings settings)
|
public void Save(UserSettings settings)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SynchronousUiDispatcher : IUiDispatcher
|
public sealed class SynchronousUiDispatcher : IUiDispatcher
|
||||||
{
|
{
|
||||||
/// <summary>Executes the action synchronously on the calling thread.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="action">The action to execute.</param>
|
|
||||||
public void Post(Action action)
|
public void Post(Action action)
|
||||||
{
|
{
|
||||||
action();
|
action();
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the monitored node ID for persistence, or null if not subscribed.
|
/// Returns the monitored node ID for persistence, or null if not subscribed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The monitored node ID string, or null if not currently subscribed.</returns>
|
||||||
public string? GetAlarmSourceNodeId()
|
public string? GetAlarmSourceNodeId()
|
||||||
{
|
{
|
||||||
return IsSubscribed ? MonitoredNodeIdText : null;
|
return IsSubscribed ? MonitoredNodeIdText : null;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public class BrowseTreeViewModel : ObservableObject
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads root nodes by browsing with a null parent.
|
/// Loads root nodes by browsing with a null parent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task LoadRootsAsync()
|
public async Task LoadRootsAsync()
|
||||||
{
|
{
|
||||||
var results = await _service.BrowseAsync();
|
var results = await _service.BrowseAsync();
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="nodeIdStr">The node ID to subscribe to from the browse tree or persisted settings.</param>
|
/// <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>
|
/// <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)
|
public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000)
|
||||||
{
|
{
|
||||||
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
|
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="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="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>
|
/// <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)
|
public Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs = 1000)
|
||||||
{
|
{
|
||||||
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
|
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
|
||||||
@@ -211,6 +213,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the node IDs of all active subscriptions for persistence.
|
/// Returns the node IDs of all active subscriptions for persistence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The list of node ID strings for all currently active subscriptions.</returns>
|
||||||
public List<string> GetSubscribedNodeIds()
|
public List<string> GetSubscribedNodeIds()
|
||||||
{
|
{
|
||||||
return ActiveSubscriptions.Select(s => s.NodeId).ToList();
|
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.
|
/// Restores subscriptions from a saved list of node IDs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="nodeIds">The node IDs persisted from a prior UI session.</param>
|
/// <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)
|
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
|
||||||
{
|
{
|
||||||
foreach (var nodeId in nodeIds)
|
foreach (var nodeId in nodeIds)
|
||||||
@@ -232,6 +236,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
|
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
|
||||||
/// <param name="rawValue">The raw text value entered by the operator.</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)
|
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -43,20 +43,16 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the local cluster node identifier.</summary>
|
/// <inheritdoc />
|
||||||
public CommonsNodeId LocalNode => _localNode;
|
public CommonsNodeId LocalNode => _localNode;
|
||||||
|
|
||||||
/// <summary>Gets the set of roles assigned to the local node.</summary>
|
/// <inheritdoc />
|
||||||
public IReadOnlySet<string> LocalRoles => _localRoles;
|
public IReadOnlySet<string> LocalRoles => _localRoles;
|
||||||
|
|
||||||
/// <summary>Checks if the local node has a specific role.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="role">The role name to check.</param>
|
|
||||||
/// <returns>True if the local node has the specified role; otherwise false.</returns>
|
|
||||||
public bool HasRole(string role) => _localRoles.Contains(role);
|
public bool HasRole(string role) => _localRoles.Contains(role);
|
||||||
|
|
||||||
/// <summary>Gets all cluster members that have a specific role.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="role">The role name.</param>
|
|
||||||
/// <returns>A read-only list of node IDs with the specified role.</returns>
|
|
||||||
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
|
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -68,9 +64,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the current leader node for a specific role.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="role">The role name.</param>
|
|
||||||
/// <returns>The node ID of the current role leader, or null if no leader is elected.</returns>
|
|
||||||
public CommonsNodeId? RoleLeader(string role)
|
public CommonsNodeId? RoleLeader(string role)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public static class RoleParser
|
|||||||
|
|
||||||
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
|
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
|
||||||
/// <param name="raw">The raw role string to parse.</param>
|
/// <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)
|
public static string[] Parse(string? raw)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
|
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The service collection to configure.</param>
|
/// <param name="services">The service collection to configure.</param>
|
||||||
/// <param name="configuration">The application configuration containing cluster options.</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)
|
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddOptions<AkkaClusterOptions>()
|
services.AddOptions<AkkaClusterOptions>()
|
||||||
@@ -45,6 +46,7 @@ public static class ServiceCollectionExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="builder">The Akka configuration builder to configure.</param>
|
/// <param name="builder">The Akka configuration builder to configure.</param>
|
||||||
/// <param name="serviceProvider">The service provider for resolving cluster options.</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(
|
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
|
||||||
this AkkaConfigurationBuilder builder,
|
this AkkaConfigurationBuilder builder,
|
||||||
IServiceProvider serviceProvider)
|
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>
|
/// <summary>Saves the alarm actor state snapshot.</summary>
|
||||||
/// <param name="snapshot">The state snapshot to persist.</param>
|
/// <param name="snapshot">The state snapshot to persist.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +42,10 @@ public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
|
|||||||
{
|
{
|
||||||
public static readonly NullAlarmActorStateStore Instance = new();
|
public static readonly NullAlarmActorStateStore Instance = new();
|
||||||
private NullAlarmActorStateStore() { }
|
private NullAlarmActorStateStore() { }
|
||||||
/// <summary>Always returns null, indicating no persisted state.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="alarmId">The alarm identifier (unused).</param>
|
|
||||||
/// <param name="ct">Cancellation token (unused).</param>
|
|
||||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
|
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
|
||||||
Task.FromResult<AlarmActorStateSnapshot?>(null);
|
Task.FromResult<AlarmActorStateSnapshot?>(null);
|
||||||
/// <summary>Completes immediately without persisting anything.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="snapshot">The state snapshot (ignored).</param>
|
|
||||||
/// <param name="ct">Cancellation token (unused).</param>
|
|
||||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,7 @@ public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
|
|||||||
{
|
{
|
||||||
public static readonly NullVirtualTagEvaluator Instance = new();
|
public static readonly NullVirtualTagEvaluator Instance = new();
|
||||||
private NullVirtualTagEvaluator() { }
|
private NullVirtualTagEvaluator() { }
|
||||||
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> for every evaluation.</summary>
|
/// <inheritdoc />
|
||||||
/// <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>
|
|
||||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||||
=> VirtualTagEvalResult.NoChange;
|
=> VirtualTagEvalResult.NoChange;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ public interface IAdminOperationsClient
|
|||||||
/// <typeparam name="T">Expected reply type.</typeparam>
|
/// <typeparam name="T">Expected reply type.</typeparam>
|
||||||
/// <param name="message">The message to send.</param>
|
/// <param name="message">The message to send.</param>
|
||||||
/// <param name="ct">Cancellation token (caller-controlled timeout).</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);
|
Task<T> AskAsync<T>(object message, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ public interface IFleetDiagnosticsClient
|
|||||||
/// <summary>Gets diagnostics for the specified node.</summary>
|
/// <summary>Gets diagnostics for the specified node.</summary>
|
||||||
/// <param name="nodeId">The node ID to retrieve diagnostics for.</param>
|
/// <param name="nodeId">The node ID to retrieve diagnostics for.</param>
|
||||||
/// <param name="ct">The cancellation token.</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);
|
Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -69,6 +69,7 @@ public static class OtOpcUaTelemetry
|
|||||||
/// null when no listener is attached so the call site stays cheap on undecorated builds.
|
/// null when no listener is attached so the call site stays cheap on undecorated builds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="deploymentId">The deployment identifier to tag the span with.</param>
|
/// <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)
|
public static Activity? StartDeployApplySpan(string deploymentId)
|
||||||
{
|
{
|
||||||
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
|
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>
|
/// <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()
|
public static Activity? StartAddressSpaceRebuildSpan()
|
||||||
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
|
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,37 +22,22 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
|
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
|
||||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||||
|
|
||||||
/// <summary>Writes a value to the OPC UA address space through the inner sink.</summary>
|
/// <inheritdoc />
|
||||||
/// <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>
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||||
|
|
||||||
/// <summary>Writes an alarm state through the inner sink.</summary>
|
/// <inheritdoc />
|
||||||
/// <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>
|
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||||
|
|
||||||
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
|
/// <inheritdoc />
|
||||||
/// <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>
|
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||||
|
|
||||||
/// <summary>Ensures a variable exists in the address space through the inner sink.</summary>
|
/// <inheritdoc />
|
||||||
/// <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>
|
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
||||||
|
|
||||||
/// <summary>Rebuilds the address space through the inner sink.</summary>
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
|||||||
public void SetInner(IServiceLevelPublisher? inner) =>
|
public void SetInner(IServiceLevelPublisher? inner) =>
|
||||||
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
||||||
|
|
||||||
/// <summary>Publishes a service level value to the inner publisher.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="serviceLevel">The service level to publish.</param>
|
|
||||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
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)
|
public readonly record struct CorrelationId(Guid Value)
|
||||||
{
|
{
|
||||||
/// <summary>Creates a new CorrelationId with a randomly generated GUID.</summary>
|
/// <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());
|
public static CorrelationId NewId() => new(Guid.NewGuid());
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override string ToString() => Value.ToString("N");
|
public override string ToString() => Value.ToString("N");
|
||||||
/// <summary>Parses a lowercase hex string without hyphens into a CorrelationId.</summary>
|
/// <summary>Parses a lowercase hex string without hyphens into a CorrelationId.</summary>
|
||||||
/// <param name="s">The string to parse.</param>
|
/// <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"));
|
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>
|
/// <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="s">The string to parse, or null.</param>
|
||||||
/// <param name="id">The resulting CorrelationId if parsing succeeds.</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)
|
public static bool TryParse(string? s, out CorrelationId id)
|
||||||
{
|
{
|
||||||
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
|
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka"/>
|
<PackageReference Include="Akka"/>
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -41,4 +41,10 @@ public sealed class ConfigAuditLog
|
|||||||
/// <summary>Correlation ID from <c>AuditEvent.CorrelationId</c> so an audit row joins to its
|
/// <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>
|
/// originating request/workflow. Nullable for the same backfill reason as <see cref="EventId"/>.</summary>
|
||||||
public Guid? CorrelationId { get; set; }
|
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.
|
/// <see cref="Entities.NodeAcl"/> joined against LDAP group memberships directly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
/// <para>
|
||||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||||
/// admin-role claim path.
|
/// 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>
|
/// </remarks>
|
||||||
public enum AdminRole
|
public enum AdminRole
|
||||||
{
|
{
|
||||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history.</summary>
|
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history. (Canonical: Viewer; was ConfigViewer.)</summary>
|
||||||
ConfigViewer,
|
Viewer,
|
||||||
|
|
||||||
/// <summary>Can author drafts + submit for publish.</summary>
|
/// <summary>Can author drafts + submit for publish. (Canonical: Designer; was ConfigEditor.)</summary>
|
||||||
ConfigEditor,
|
Designer,
|
||||||
|
|
||||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions.</summary>
|
/// <summary>Full Admin UI privileges including publish + fleet-admin actions. (Canonical: Administrator; was FleetAdmin.)</summary>
|
||||||
FleetAdmin,
|
Administrator,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ public interface ILocalConfigCache
|
|||||||
/// <summary>Stores a generation snapshot in the local cache.</summary>
|
/// <summary>Stores a generation snapshot in the local cache.</summary>
|
||||||
/// <param name="snapshot">The generation snapshot to store.</param>
|
/// <param name="snapshot">The generation snapshot to store.</param>
|
||||||
/// <param name="ct">The cancellation token.</param>
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
||||||
/// <summary>Removes old generations, keeping only the most recent N.</summary>
|
/// <summary>Removes old generations, keeping only the most recent N.</summary>
|
||||||
/// <param name="clusterId">The cluster identifier.</param>
|
/// <param name="clusterId">The cluster identifier.</param>
|
||||||
/// <param name="keepLatest">The number of latest generations to keep.</param>
|
/// <param name="keepLatest">The number of latest generations to keep.</param>
|
||||||
/// <param name="ct">The cancellation token.</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);
|
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>
|
/// <inheritdoc />
|
||||||
/// <param name="clusterId">The cluster ID.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
@@ -58,9 +56,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
|||||||
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stores a snapshot in the cache.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="snapshot">The snapshot to store.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
@@ -89,10 +85,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Removes old generation snapshots, keeping only the latest ones.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="clusterId">The cluster ID.</param>
|
|
||||||
/// <param name="keepLatest">Number of latest generations to keep.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
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)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Outcome")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("nvarchar(16)");
|
||||||
|
|
||||||
b.Property<string>("Principal")
|
b.Property<string>("Principal")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
|
|||||||
@@ -445,6 +445,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
|
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
|
||||||
e.Property(x => x.EventId);
|
e.Property(x => x.EventId);
|
||||||
e.Property(x => x.CorrelationId);
|
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 })
|
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
|
||||||
.IsDescending(false, true)
|
.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.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
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.");
|
$"Connection string '{ConnectionStringName}' is required. Add it to appsettings.json or the OTOPCUA_CONFIG_CONNECTION env var.");
|
||||||
|
|
||||||
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
|
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;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ public interface ILdapGroupRoleMappingService
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="ldapGroups">The LDAP groups to search for.</param>
|
/// <param name="ldapGroups">The LDAP groups to search for.</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</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(
|
Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken);
|
IEnumerable<string> ldapGroups, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Enumerate every mapping; Admin UI listing only.</summary>
|
/// <summary>Enumerate every mapping; Admin UI listing only.</summary>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task resolving to all LDAP group role mappings.</returns>
|
||||||
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Create a new grant.</summary>
|
/// <summary>Create a new grant.</summary>
|
||||||
@@ -39,11 +41,13 @@ public interface ILdapGroupRoleMappingService
|
|||||||
/// </exception>
|
/// </exception>
|
||||||
/// <param name="row">The LDAP group role mapping to create.</param>
|
/// <param name="row">The LDAP group role mapping to create.</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</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);
|
Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Delete a mapping by its surrogate key.</summary>
|
/// <summary>Delete a mapping by its surrogate key.</summary>
|
||||||
/// <param name="id">The unique identifier of the mapping to delete.</param>
|
/// <param name="id">The unique identifier of the mapping to delete.</param>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous delete operation.</returns>
|
||||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
|
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
|
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
|
||||||
{
|
{
|
||||||
/// <summary>Gets LDAP group role mappings for the specified groups.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ldapGroups">The LDAP group names to query.</param>
|
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
|
||||||
/// <returns>The matching role mappings.</returns>
|
|
||||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
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.
|
/// Validates a draft snapshot and returns all validation errors found in a single pass.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="draft">The draft snapshot to validate.</param>
|
/// <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)
|
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
|
||||||
{
|
{
|
||||||
var errors = new List<ValidationError>();
|
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>
|
/// <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>
|
/// <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) =>
|
public static string DeriveEquipmentId(Guid uuid) =>
|
||||||
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
|
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
|
||||||
|
|
||||||
@@ -172,8 +174,8 @@ public static class DraftValidator
|
|||||||
|
|
||||||
var compat = ns.Kind switch
|
var compat = ns.Kind switch
|
||||||
{
|
{
|
||||||
NamespaceKind.SystemPlatform => di.DriverType == "Galaxy",
|
NamespaceKind.SystemPlatform => di.DriverType == "GalaxyMxGateway",
|
||||||
NamespaceKind.Equipment => di.DriverType != "Galaxy",
|
NamespaceKind.Equipment => di.DriverType != "GalaxyMxGateway",
|
||||||
_ => true,
|
_ => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,6 +205,7 @@ public static class DraftValidator
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="cluster">The server cluster to validate.</param>
|
/// <param name="cluster">The server cluster to validate.</param>
|
||||||
/// <param name="clusterNodes">The cluster nodes to validate against the cluster configuration.</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(
|
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||||
ServerCluster cluster,
|
ServerCluster cluster,
|
||||||
IReadOnlyList<ClusterNode> clusterNodes)
|
IReadOnlyList<ClusterNode> clusterNodes)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public sealed class DriverTypeRegistry
|
|||||||
|
|
||||||
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
|
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
|
||||||
/// <param name="driverType">The driver type name to look up.</param>
|
/// <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)
|
public DriverTypeMetadata Get(string driverType)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(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>
|
/// <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>
|
/// <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)
|
public DriverTypeMetadata? TryGet(string driverType)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||||
@@ -76,6 +78,7 @@ public sealed class DriverTypeRegistry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Snapshot of all registered driver types.</summary>
|
/// <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();
|
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="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="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>
|
/// <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(
|
Task<HistoryReadResult> ReadRawAsync(
|
||||||
string fullReference,
|
string fullReference,
|
||||||
DateTime startUtc,
|
DateTime startUtc,
|
||||||
@@ -46,6 +47,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// <param name="interval">The interval for bucketing samples.</param>
|
/// <param name="interval">The interval for bucketing samples.</param>
|
||||||
/// <param name="aggregate">The aggregation function to apply to each bucket.</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>
|
/// <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(
|
Task<HistoryReadResult> ReadProcessedAsync(
|
||||||
string fullReference,
|
string fullReference,
|
||||||
DateTime startUtc,
|
DateTime startUtc,
|
||||||
@@ -63,6 +65,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// <param name="fullReference">The full reference of the tag to read.</param>
|
/// <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="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>
|
/// <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(
|
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||||
string fullReference,
|
string fullReference,
|
||||||
IReadOnlyList<DateTime> timestampsUtc,
|
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="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="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>
|
/// <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(
|
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||||
string? sourceName,
|
string? sourceName,
|
||||||
DateTime startUtc,
|
DateTime startUtc,
|
||||||
@@ -104,5 +108,6 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
|
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
|
||||||
/// observation; never blocks on backend I/O.
|
/// observation; never blocks on backend I/O.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The current <see cref="HistorianHealthSnapshot"/> for this data source.</returns>
|
||||||
HistorianHealthSnapshot GetHealthSnapshot();
|
HistorianHealthSnapshot GetHealthSnapshot();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public interface IAddressSpaceBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
|
/// <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>
|
/// <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);
|
IAddressSpaceBuilder Folder(string browseName, string displayName);
|
||||||
|
|
||||||
/// <summary>
|
/// <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="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="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
|
||||||
/// <param name="attributeInfo">Driver-side metadata for the variable.</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);
|
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -56,6 +58,7 @@ public interface IVariableHandle
|
|||||||
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="info">The alarm condition information.</param>
|
/// <param name="info">The alarm condition information.</param>
|
||||||
|
/// <returns>A sink that receives alarm lifecycle transitions for this condition.</returns>
|
||||||
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IAlarmSource
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sourceNodeIds">The driver node IDs to subscribe to.</param>
|
/// <param name="sourceNodeIds">The driver node IDs to subscribe to.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</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(
|
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||||
IReadOnlyList<string> sourceNodeIds,
|
IReadOnlyList<string> sourceNodeIds,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
@@ -20,11 +21,13 @@ public interface IAlarmSource
|
|||||||
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
|
/// <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="handle">The subscription handle returned from <see cref="SubscribeAlarmsAsync"/>.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</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);
|
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
|
/// <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="acknowledgements">The batch of alarm acknowledgement requests.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task AcknowledgeAsync(
|
Task AcknowledgeAsync(
|
||||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||||
CancellationToken cancellationToken);
|
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>
|
/// <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="driverConfigJson">The driver configuration as JSON.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</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);
|
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -37,13 +38,16 @@ public interface IDriver
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
|
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</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);
|
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task ShutdownAsync(CancellationToken cancellationToken);
|
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
|
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
|
||||||
|
/// <returns>The current driver health snapshot.</returns>
|
||||||
DriverHealth GetHealth();
|
DriverHealth GetHealth();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -56,6 +60,7 @@ public interface IDriver
|
|||||||
/// allocation tracking". Tier C drivers (process-isolated) report through the same
|
/// allocation tracking". Tier C drivers (process-isolated) report through the same
|
||||||
/// interface but the cache-flush is internal to their host.
|
/// interface but the cache-flush is internal to their host.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <returns>The approximate driver-attributable memory footprint in bytes.</returns>
|
||||||
long GetMemoryFootprint();
|
long GetMemoryFootprint();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -63,5 +68,6 @@ public interface IDriver
|
|||||||
/// Required-for-correctness state must NOT be flushed.
|
/// Required-for-correctness state must NOT be flushed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
|
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,8 @@ public sealed class NullDriverFactory : IDriverFactory
|
|||||||
public static readonly NullDriverFactory Instance = new();
|
public static readonly NullDriverFactory Instance = new();
|
||||||
private NullDriverFactory() { }
|
private NullDriverFactory() { }
|
||||||
|
|
||||||
/// <summary>Creates a driver (always returns null in this null implementation).</summary>
|
/// <inheritdoc />
|
||||||
/// <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>
|
|
||||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
|
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>();
|
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ public interface IDriverHealthPublisher
|
|||||||
/// Publishes a health snapshot for one driver instance. Implementations must be
|
/// Publishes a health snapshot for one driver instance. Implementations must be
|
||||||
/// non-blocking and tolerant of being called from any thread.
|
/// non-blocking and tolerant of being called from any thread.
|
||||||
/// </summary>
|
/// </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(
|
void Publish(
|
||||||
string clusterId,
|
string clusterId,
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public interface IDriverProbe
|
|||||||
/// timeout cancellation. Never throw on connection failure; instead return a result
|
/// timeout cancellation. Never throw on connection failure; instead return a result
|
||||||
/// with <c>Ok = false</c> + a message.
|
/// with <c>Ok = false</c> + a message.
|
||||||
/// </summary>
|
/// </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);
|
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ public interface IDriverSupervisor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="reason">Human-readable reason — flows into the supervisor's logs.</param>
|
/// <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>
|
/// <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);
|
Task RecycleAsync(string reason, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ public interface IHistoryProvider
|
|||||||
/// <c>HistorianDataSource</c>). The asymmetry is intentional — Core.Abstractions-006.
|
/// <c>HistorianDataSource</c>). The asymmetry is intentional — Core.Abstractions-006.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="cancellationToken">Request cancellation.</param>
|
/// <param name="cancellationToken">Request cancellation.</param>
|
||||||
|
/// <returns>A task that resolves to the historical events result for the requested window.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
|
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
|
||||||
/// Wonderware Alarm & Events log) override. Modbus / the OPC UA Client driver stay
|
/// 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
|
/// 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).
|
/// fan-out scoped to the affected host's subtree (not the whole driver namespace).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A snapshot list of per-host connectivity statuses.</returns>
|
||||||
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
|
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
|
||||||
|
|
||||||
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
|
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ public interface ITagDiscovery
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="builder">The address space builder to stream discovered nodes into.</param>
|
/// <param name="builder">The address space builder to stream discovered nodes into.</param>
|
||||||
/// <param name="cancellationToken">A cancellation token for the discovery operation.</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);
|
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public interface IWritable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="writes">Pairs of full reference + value to write.</param>
|
/// <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>
|
/// <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(
|
Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
IReadOnlyList<WriteRequest> writes,
|
IReadOnlyList<WriteRequest> writes,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
|||||||
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
|
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
|
||||||
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
|
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
|
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
|
||||||
/// order as the input references.</param>
|
/// order as the input references.</param>
|
||||||
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
|
/// <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>
|
/// <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="fullReferences">The list of tag references to poll.</param>
|
||||||
/// <param name="publishingInterval">The desired polling interval; will be clamped to the configured minimum.</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)
|
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||||
@@ -207,6 +209,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
|
/// <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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
// Cancel all loops first so they can all start winding down in parallel.
|
// 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
|
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||||
{
|
{
|
||||||
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
|
/// <inheritdoc />
|
||||||
public string DiagnosticId => $"poll-sub-{Id}";
|
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>
|
/// <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="evt">The alarm historian event to enqueue.</param>
|
||||||
/// <param name="cancellationToken">A cancellation token for async operations.</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);
|
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Snapshot of current queue depth + drain health.</summary>
|
/// <summary>Snapshot of current queue depth + drain health.</summary>
|
||||||
|
/// <returns>A snapshot of the current queue depth and drain state.</returns>
|
||||||
HistorianSinkStatus GetStatus();
|
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>
|
/// <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="batch">The batch of alarm historian events to write.</param>
|
||||||
/// <param name="cancellationToken">A cancellation token for async operations.</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(
|
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
|
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="evt">The alarm historian event to enqueue.</param>
|
/// <param name="evt">The alarm historian event to enqueue.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</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)
|
public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (evt is null) throw new ArgumentNullException(nameof(evt));
|
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.
|
/// connections per tick, each paying the open + PRAGMA cost.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="ct">Cancellation token for the operation.</param>
|
/// <param name="ct">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task DrainOnceAsync(CancellationToken ct)
|
public async Task DrainOnceAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
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()
|
public HistorianSinkStatus GetStatus()
|
||||||
{
|
{
|
||||||
// Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
|
// 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>
|
/// <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()
|
public int RetryDeadLettered()
|
||||||
{
|
{
|
||||||
using var conn = OpenConnection();
|
using var conn = OpenConnection();
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
/// copy under the gate. (Core.ScriptedAlarms-013.)
|
/// copy under the gate. (Core.ScriptedAlarms-013.)
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
/// <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)
|
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
|
||||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
|
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
/// (Core.ScriptedAlarms-013.)
|
/// (Core.ScriptedAlarms-013.)
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
/// <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)
|
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
|
||||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
|
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
|
||||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
||||||
@@ -175,6 +177,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="definitions">The alarm definitions to load.</param>
|
/// <param name="definitions">The alarm definitions to load.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
|
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.
|
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="alarmId">The alarm identifier.</param>
|
/// <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)
|
public AlarmConditionState? GetState(string alarmId)
|
||||||
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
|
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
|
||||||
|
|
||||||
/// <summary>Gets the current persisted state for all loaded alarms.</summary>
|
/// <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()
|
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
=> _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="user">The user performing the acknowledgment.</param>
|
||||||
/// <param name="comment">An optional comment to attach to the acknowledgment.</param>
|
/// <param name="comment">An optional comment to attach to the acknowledgment.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
|
=> 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="user">The user performing the confirmation.</param>
|
||||||
/// <param name="comment">An optional comment to attach to the confirmation.</param>
|
/// <param name="comment">An optional comment to attach to the confirmation.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
|
=> 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="alarmId">The alarm identifier.</param>
|
||||||
/// <param name="user">The user performing the shelve operation.</param>
|
/// <param name="user">The user performing the shelve operation.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
|
=> 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="user">The user performing the shelve operation.</param>
|
||||||
/// <param name="unshelveAtUtc">The UTC time at which the shelve will automatically expire.</param>
|
/// <param name="unshelveAtUtc">The UTC time at which the shelve will automatically expire.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
|
=> 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="alarmId">The alarm identifier.</param>
|
||||||
/// <param name="user">The user performing the unshelve operation.</param>
|
/// <param name="user">The user performing the unshelve operation.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
|
=> 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="alarmId">The alarm identifier.</param>
|
||||||
/// <param name="user">The user performing the enable operation.</param>
|
/// <param name="user">The user performing the enable operation.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
|
=> 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="alarmId">The alarm identifier.</param>
|
||||||
/// <param name="user">The user performing the disable operation.</param>
|
/// <param name="user">The user performing the disable operation.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
|
=> 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="user">The user adding the comment.</param>
|
||||||
/// <param name="text">The comment text.</param>
|
/// <param name="text">The comment text.</param>
|
||||||
/// <param name="ct">The cancellation token.</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)
|
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
|
||||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
|
=> 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.
|
/// paths, or a list of rejection messages if non-literal paths were used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="scriptSource">The script source code to analyze.</param>
|
/// <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)
|
public static DependencyExtractionResult Extract(string scriptSource)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(scriptSource))
|
if (string.IsNullOrWhiteSpace(scriptSource))
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public abstract class ScriptContext
|
|||||||
/// right upstream tags at load time.
|
/// right upstream tags at load time.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="path">The literal tag path to read.</param>
|
/// <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);
|
public abstract DataValueSnapshot GetTag(string path);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -81,6 +82,7 @@ public abstract class ScriptContext
|
|||||||
/// <param name="current">The current value to check.</param>
|
/// <param name="current">The current value to check.</param>
|
||||||
/// <param name="previous">The previous value to compare against.</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>
|
/// <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)
|
public static bool Deadband(double current, double previous, double tolerance)
|
||||||
=> Math.Abs(current - previous) > 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>
|
/// <summary>Compiles user script source into an evaluator.</summary>
|
||||||
/// <param name="scriptSource">The user script source code to compile.</param>
|
/// <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)
|
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
||||||
{
|
{
|
||||||
if (scriptSource is null) throw new ArgumentNullException(nameof(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>
|
/// <summary>Runs the script against an already-constructed context.</summary>
|
||||||
/// <param name="context">The script context.</param>
|
/// <param name="context">The script context.</param>
|
||||||
/// <param name="ct">Cancellation token for the operation.</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)
|
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
|
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public static class ScriptSandbox
|
|||||||
/// to resolve <c>ctx.GetTag(...)</c> calls.
|
/// to resolve <c>ctx.GetTag(...)</c> calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="contextType">The concrete script context type to use for compilation.</param>
|
/// <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)
|
public static SandboxConfig Build(Type contextType)
|
||||||
{
|
{
|
||||||
if (contextType is null) throw new ArgumentNullException(nameof(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
|
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
|
||||||
/// exists. Implemented via Kahn's algorithm.
|
/// exists. Implemented via Kahn's algorithm.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A list of node IDs in topological evaluation order.</returns>
|
||||||
public IReadOnlyList<string> TopologicalSort()
|
public IReadOnlyList<string> TopologicalSort()
|
||||||
{
|
{
|
||||||
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
|
// 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
|
/// 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.
|
/// rejection pass so operators see all of them, not just one at a time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A list of strongly-connected components that form cycles; empty if the graph is acyclic.</returns>
|
||||||
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
|
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
|
||||||
{
|
{
|
||||||
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
|
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public interface ITagUpstreamSource
|
|||||||
/// when the path isn't configured.
|
/// when the path isn't configured.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path">The tag path to read.</param>
|
/// <param name="path">The tag path to read.</param>
|
||||||
|
/// <returns>The last-known value and quality snapshot for the tag.</returns>
|
||||||
DataValueSnapshot ReadTag(string path);
|
DataValueSnapshot ReadTag(string path);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,5 +41,6 @@ public interface ITagUpstreamSource
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path">The tag path to subscribe to.</param>
|
/// <param name="path">The tag path to subscribe to.</param>
|
||||||
/// <param name="observer">The callback to invoke when the value changes.</param>
|
/// <param name="observer">The callback to invoke when the value changes.</param>
|
||||||
|
/// <returns>An <see cref="IDisposable"/> that cancels the subscription when disposed.</returns>
|
||||||
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ public sealed class VirtualTagEngine : IDisposable
|
|||||||
/// default. Also called after a config reload.
|
/// default. Also called after a config reload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">Cancellation token to stop evaluation.</param>
|
/// <param name="ct">Cancellation token to stop evaluation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task EvaluateAllAsync(CancellationToken ct = default)
|
public async Task EvaluateAllAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
EnsureLoaded();
|
EnsureLoaded();
|
||||||
@@ -212,6 +213,7 @@ public sealed class VirtualTagEngine : IDisposable
|
|||||||
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
|
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
|
||||||
/// <param name="path">Path of the virtual tag to evaluate.</param>
|
/// <param name="path">Path of the virtual tag to evaluate.</param>
|
||||||
/// <param name="ct">Cancellation token to stop evaluation.</param>
|
/// <param name="ct">Cancellation token to stop evaluation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
|
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
EnsureLoaded();
|
EnsureLoaded();
|
||||||
@@ -226,6 +228,7 @@ public sealed class VirtualTagEngine : IDisposable
|
|||||||
/// evaluation result.
|
/// evaluation result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path">Path of the tag to read.</param>
|
/// <param name="path">Path of the tag to read.</param>
|
||||||
|
/// <returns>The most recently cached value and quality for the tag path.</returns>
|
||||||
public DataValueSnapshot Read(string path)
|
public DataValueSnapshot Read(string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
@@ -242,6 +245,7 @@ public sealed class VirtualTagEngine : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path">Path of the tag to subscribe to.</param>
|
/// <param name="path">Path of the tag to subscribe to.</param>
|
||||||
/// <param name="observer">Callback invoked with the tag path and new value on each evaluation.</param>
|
/// <param name="observer">Callback invoked with the tag path and new value on each evaluation.</param>
|
||||||
|
/// <returns>An <see cref="IDisposable"/> that cancels the subscription when disposed.</returns>
|
||||||
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
|
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
|
||||||
{
|
{
|
||||||
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
|
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public sealed record AuthorizationDecision(
|
|||||||
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
|
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
|
||||||
|
|
||||||
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
|
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
|
||||||
|
/// <returns>An <see cref="AuthorizationDecision"/> with <see cref="AuthorizationVerdict.NotGranted"/> and empty provenance.</returns>
|
||||||
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
|
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
|
||||||
|
|
||||||
/// <summary>Allow with the list of grants that matched.</summary>
|
/// <summary>Allow with the list of grants that matched.</summary>
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ public interface IPermissionEvaluator
|
|||||||
/// <param name="session">The user session containing resolved LDAP groups and roles.</param>
|
/// <param name="session">The user session containing resolved LDAP groups and roles.</param>
|
||||||
/// <param name="operation">The OPC UA operation being requested.</param>
|
/// <param name="operation">The OPC UA operation being requested.</param>
|
||||||
/// <param name="scope">The node address scope being accessed.</param>
|
/// <param name="scope">The node address scope being accessed.</param>
|
||||||
|
/// <returns>An <see cref="AuthorizationDecision"/> indicating whether the operation is allowed.</returns>
|
||||||
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
|
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class PermissionTrie
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="scope">The node scope to match permissions for.</param>
|
/// <param name="scope">The node scope to match permissions for.</param>
|
||||||
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
|
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
|
||||||
|
/// <returns>The list of grants that apply to the given scope for any of the session's LDAP groups.</returns>
|
||||||
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
|
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(scope);
|
ArgumentNullException.ThrowIfNull(scope);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public static class PermissionTrieBuilder
|
|||||||
/// Core-011 production hazard. The callback fires only when <paramref name="scopePaths"/>
|
/// Core-011 production hazard. The callback fires only when <paramref name="scopePaths"/>
|
||||||
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
|
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <returns>An immutable <see cref="PermissionTrie"/> for the given cluster and generation.</returns>
|
||||||
public static PermissionTrie Build(
|
public static PermissionTrie Build(
|
||||||
string clusterId,
|
string clusterId,
|
||||||
long generationId,
|
long generationId,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public sealed class PermissionTrieCache
|
|||||||
|
|
||||||
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
|
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
|
||||||
/// <param name="clusterId">The cluster identifier.</param>
|
/// <param name="clusterId">The cluster identifier.</param>
|
||||||
|
/// <returns>The current-generation trie, or null if nothing is installed for the cluster.</returns>
|
||||||
public PermissionTrie? GetTrie(string clusterId)
|
public PermissionTrie? GetTrie(string clusterId)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||||
@@ -43,6 +44,7 @@ public sealed class PermissionTrieCache
|
|||||||
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
|
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
|
||||||
/// <param name="clusterId">The cluster identifier.</param>
|
/// <param name="clusterId">The cluster identifier.</param>
|
||||||
/// <param name="generationId">The generation identifier.</param>
|
/// <param name="generationId">The generation identifier.</param>
|
||||||
|
/// <returns>The trie for the specified cluster and generation, or null if not cached.</returns>
|
||||||
public PermissionTrie? GetTrie(string clusterId, long generationId)
|
public PermissionTrie? GetTrie(string clusterId, long generationId)
|
||||||
{
|
{
|
||||||
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
|
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
|
||||||
@@ -51,6 +53,7 @@ public sealed class PermissionTrieCache
|
|||||||
|
|
||||||
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
|
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
|
||||||
/// <param name="clusterId">The cluster identifier.</param>
|
/// <param name="clusterId">The cluster identifier.</param>
|
||||||
|
/// <returns>The current generation ID, or null if no trie is installed for the cluster.</returns>
|
||||||
public long? CurrentGenerationId(string clusterId)
|
public long? CurrentGenerationId(string clusterId)
|
||||||
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
|
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
|
||||||
|
|
||||||
@@ -111,11 +114,13 @@ public sealed class PermissionTrieCache
|
|||||||
|
|
||||||
/// <summary>Creates a cluster entry from a single trie.</summary>
|
/// <summary>Creates a cluster entry from a single trie.</summary>
|
||||||
/// <param name="trie">The permission trie to create the entry from.</param>
|
/// <param name="trie">The permission trie to create the entry from.</param>
|
||||||
|
/// <returns>A new <see cref="ClusterEntry"/> containing the single trie as the current generation.</returns>
|
||||||
public static ClusterEntry FromSingle(PermissionTrie trie) =>
|
public static ClusterEntry FromSingle(PermissionTrie trie) =>
|
||||||
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
|
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
|
||||||
|
|
||||||
/// <summary>Creates a new entry with an additional trie, updating current if it's newer.</summary>
|
/// <summary>Creates a new entry with an additional trie, updating current if it's newer.</summary>
|
||||||
/// <param name="trie">The new permission trie to add.</param>
|
/// <param name="trie">The new permission trie to add.</param>
|
||||||
|
/// <returns>A new <see cref="ClusterEntry"/> with the trie added and the current pointer updated if the new generation is newer.</returns>
|
||||||
public ClusterEntry WithAdditional(PermissionTrie trie)
|
public ClusterEntry WithAdditional(PermissionTrie trie)
|
||||||
{
|
{
|
||||||
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
|
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
|
|||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Authorizes an operation against the user's session and node scope.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="session">The user's authorization session.</param>
|
|
||||||
/// <param name="operation">The OPC UA operation to authorize.</param>
|
|
||||||
/// <param name="scope">The target node scope.</param>
|
|
||||||
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
|
|
||||||
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
|
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(session);
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user