Compare commits
80 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 |
@@ -42,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/
|
||||||
|
|||||||
@@ -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,7 +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.Client" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" 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>
|
||||||
@@ -1,7 +1,28 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<configuration>
|
<configuration>
|
||||||
<packageSources>
|
<packageSources>
|
||||||
|
<clear />
|
||||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
<add key="local-mxgw" value="./nuget-packages" />
|
<add key="local-mxgw" value="./nuget-packages" />
|
||||||
|
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||||
</packageSources>
|
</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>
|
</configuration>
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,14 +16,22 @@ public interface IBrowseSession : IAsyncDisposable
|
|||||||
DateTime LastUsedUtc { get; }
|
DateTime LastUsedUtc { get; }
|
||||||
|
|
||||||
/// <summary>Returns the top-level browse nodes.</summary>
|
/// <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);
|
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Returns the direct children of the node identified by
|
/// <summary>Returns the direct children of the node identified by
|
||||||
/// <paramref name="nodeId"/>.</summary>
|
/// <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);
|
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
|
/// <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
|
/// 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>
|
/// 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);
|
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ public interface IDriverBrowser
|
|||||||
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
|
||||||
/// driver would consume.</param>
|
/// driver would consume.</param>
|
||||||
/// <param name="cancellationToken">Cancellation for the connect phase only.</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);
|
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>
|
||||||
|
/// <para>
|
||||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||||
/// admin-role claim path.
|
/// 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);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public sealed record UserAuthorizationState
|
|||||||
/// whenever this is true.
|
/// whenever this is true.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="utcNow">The current UTC time.</param>
|
/// <param name="utcNow">The current UTC time.</param>
|
||||||
|
/// <returns><c>true</c> when the state exceeds its maximum staleness ceiling.</returns>
|
||||||
public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
|
public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,6 +73,7 @@ public sealed record UserAuthorizationState
|
|||||||
/// call still evaluates against the cached memberships.
|
/// call still evaluates against the cached memberships.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="utcNow">The current UTC time.</param>
|
/// <param name="utcNow">The current UTC time.</param>
|
||||||
|
/// <returns><c>true</c> when a background refresh should be initiated but the current cached memberships are still usable.</returns>
|
||||||
public bool NeedsRefresh(DateTime utcNow) =>
|
public bool NeedsRefresh(DateTime utcNow) =>
|
||||||
!IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
|
!IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public sealed class DriverFactoryRegistry
|
|||||||
/// missing-assembly deployment doesn't take down the whole server.
|
/// missing-assembly deployment doesn't take down the whole server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="driverType">The driver type to look up.</param>
|
/// <param name="driverType">The driver type to look up.</param>
|
||||||
|
/// <returns>The registered factory delegate, or <see langword="null"/> if no factory was registered for the type.</returns>
|
||||||
public Func<string, string, IDriver>? TryGet(string driverType)
|
public Func<string, string, IDriver>? TryGet(string driverType)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||||
@@ -75,6 +76,7 @@ public sealed class DriverFactoryRegistry
|
|||||||
/// case upstream; we don't double-surface that failure here.
|
/// case upstream; we don't double-surface that failure here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="driverType">The driver type to look up.</param>
|
/// <param name="driverType">The driver type to look up.</param>
|
||||||
|
/// <returns>The registered <see cref="DriverTier"/>, or <see cref="DriverTier.A"/> if the type is unknown.</returns>
|
||||||
public DriverTier GetTier(string driverType)
|
public DriverTier GetTier(string driverType)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||||
|
|||||||
@@ -20,16 +20,13 @@ public sealed class DriverFactoryRegistryAdapter : IDriverFactory
|
|||||||
_registry = registry;
|
_registry = registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Attempts to create a driver instance by type and configuration.</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>
|
|
||||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||||
{
|
{
|
||||||
var factory = _registry.TryGet(driverType);
|
var factory = _registry.TryGet(driverType);
|
||||||
return factory?.Invoke(driverInstanceId, driverConfigJson);
|
return factory?.Invoke(driverInstanceId, driverConfigJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the collection of supported driver type names.</summary>
|
/// <inheritdoc />
|
||||||
public IReadOnlyCollection<string> SupportedTypes => _registry.RegisteredTypes;
|
public IReadOnlyCollection<string> SupportedTypes => _registry.RegisteredTypes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class DriverHost : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>Gets the health status of a registered driver.</summary>
|
/// <summary>Gets the health status of a registered driver.</summary>
|
||||||
/// <param name="driverInstanceId">The driver instance identifier to query.</param>
|
/// <param name="driverInstanceId">The driver instance identifier to query.</param>
|
||||||
|
/// <returns>The driver health if the driver is registered; otherwise null.</returns>
|
||||||
public DriverHealth? GetHealth(string driverInstanceId)
|
public DriverHealth? GetHealth(string driverInstanceId)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -33,6 +34,7 @@ public sealed class DriverHost : IAsyncDisposable
|
|||||||
/// startup. Returns null when the driver is not registered.
|
/// startup. Returns null when the driver is not registered.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="driverInstanceId">The driver instance identifier to look up.</param>
|
/// <param name="driverInstanceId">The driver instance identifier to look up.</param>
|
||||||
|
/// <returns>The driver instance if registered; otherwise null.</returns>
|
||||||
public IDriver? GetDriver(string driverInstanceId)
|
public IDriver? GetDriver(string driverInstanceId)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -47,6 +49,7 @@ public sealed class DriverHost : IAsyncDisposable
|
|||||||
/// <param name="driver">The driver instance to register.</param>
|
/// <param name="driver">The driver instance to register.</param>
|
||||||
/// <param name="driverConfigJson">The configuration JSON for the driver.</param>
|
/// <param name="driverConfigJson">The configuration JSON for the driver.</param>
|
||||||
/// <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 RegisterAsync(IDriver driver, string driverConfigJson, CancellationToken ct)
|
public async Task RegisterAsync(IDriver driver, string driverConfigJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(driver);
|
ArgumentNullException.ThrowIfNull(driver);
|
||||||
@@ -70,6 +73,7 @@ public sealed class DriverHost : IAsyncDisposable
|
|||||||
/// <summary>Unregisters a driver and calls shutdown.</summary>
|
/// <summary>Unregisters a driver and calls shutdown.</summary>
|
||||||
/// <param name="driverInstanceId">The driver instance identifier to unregister.</param>
|
/// <param name="driverInstanceId">The driver instance identifier to unregister.</param>
|
||||||
/// <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 UnregisterAsync(string driverInstanceId, CancellationToken ct)
|
public async Task UnregisterAsync(string driverInstanceId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
IDriver? driver;
|
IDriver? driver;
|
||||||
@@ -84,6 +88,7 @@ public sealed class DriverHost : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Disposes the driver host and all registered drivers.</summary>
|
/// <summary>Disposes the driver host and all registered drivers.</summary>
|
||||||
|
/// <returns>A value task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
List<IDriver> snapshot;
|
List<IDriver> snapshot;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public static class DriverHealthReport
|
|||||||
{
|
{
|
||||||
/// <summary>Compute the fleet-wide readiness verdict from per-driver states.</summary>
|
/// <summary>Compute the fleet-wide readiness verdict from per-driver states.</summary>
|
||||||
/// <param name="drivers">The list of per-driver health snapshots to aggregate.</param>
|
/// <param name="drivers">The list of per-driver health snapshots to aggregate.</param>
|
||||||
|
/// <returns>The fleet-wide <see cref="ReadinessVerdict"/> derived from all driver states.</returns>
|
||||||
public static ReadinessVerdict Aggregate(IReadOnlyList<DriverHealthSnapshot> drivers)
|
public static ReadinessVerdict Aggregate(IReadOnlyList<DriverHealthSnapshot> drivers)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(drivers);
|
ArgumentNullException.ThrowIfNull(drivers);
|
||||||
@@ -54,6 +55,7 @@ public static class DriverHealthReport
|
|||||||
/// return per the Stream C.1 state matrix.
|
/// return per the Stream C.1 state matrix.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="verdict">The readiness verdict to map to HTTP status.</param>
|
/// <param name="verdict">The readiness verdict to map to HTTP status.</param>
|
||||||
|
/// <returns>The HTTP status code (200 or 503) corresponding to the verdict.</returns>
|
||||||
public static int HttpStatus(ReadinessVerdict verdict) => verdict switch
|
public static int HttpStatus(ReadinessVerdict verdict) => verdict switch
|
||||||
{
|
{
|
||||||
ReadinessVerdict.Healthy => 200,
|
ReadinessVerdict.Healthy => 200,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public static class LogContextEnricher
|
|||||||
/// <param name="driverType">The driver type name.</param>
|
/// <param name="driverType">The driver type name.</param>
|
||||||
/// <param name="capability">The driver capability being invoked.</param>
|
/// <param name="capability">The driver capability being invoked.</param>
|
||||||
/// <param name="correlationId">The correlation ID for tracing the call.</param>
|
/// <param name="correlationId">The correlation ID for tracing the call.</param>
|
||||||
|
/// <returns>A scope that pops the pushed properties when disposed.</returns>
|
||||||
public static IDisposable Push(string driverInstanceId, string driverType, DriverCapability capability, string correlationId)
|
public static IDisposable Push(string driverInstanceId, string driverType, DriverCapability capability, string correlationId)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
@@ -40,6 +41,7 @@ public static class LogContextEnricher
|
|||||||
/// 12-hex-char slice of a GUID — long enough for log correlation, short enough to
|
/// 12-hex-char slice of a GUID — long enough for log correlation, short enough to
|
||||||
/// scan visually.
|
/// scan visually.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A 12-character hex string suitable for log correlation.</returns>
|
||||||
public static string NewCorrelationId() => Guid.NewGuid().ToString("N")[..12];
|
public static string NewCorrelationId() => Guid.NewGuid().ToString("N")[..12];
|
||||||
|
|
||||||
private sealed class CompositeScope : IDisposable
|
private sealed class CompositeScope : IDisposable
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ public static class EquipmentNodeWalker
|
|||||||
/// wants an opaque non-JSON reference.
|
/// wants an opaque non-JSON reference.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="tagConfig">The tag configuration JSON or string.</param>
|
/// <param name="tagConfig">The tag configuration JSON or string.</param>
|
||||||
|
/// <returns>The value of the <c>FullName</c> field from the JSON, or the raw <paramref name="tagConfig"/> string as a fallback.</returns>
|
||||||
internal static string ExtractFullName(string tagConfig)
|
internal static string ExtractFullName(string tagConfig)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="builder">The address space builder to populate.</param>
|
/// <param name="builder">The address space builder to populate.</param>
|
||||||
/// <param name="ct">The cancellation token.</param>
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous address space build operation.</returns>
|
||||||
public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(builder);
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
@@ -111,23 +112,15 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
|
|||||||
IAddressSpaceBuilder inner,
|
IAddressSpaceBuilder inner,
|
||||||
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
|
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
|
||||||
{
|
{
|
||||||
/// <summary>Adds a folder to the address space.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="browseName">The browse name of the folder node.</param>
|
|
||||||
/// <param name="displayName">The display name of the folder node.</param>
|
|
||||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
|
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
|
||||||
|
|
||||||
/// <summary>Adds a variable to the address space.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="browseName">The browse name of the variable node.</param>
|
|
||||||
/// <param name="displayName">The display name of the variable node.</param>
|
|
||||||
/// <param name="attributeInfo">Metadata describing the variable's data type and properties.</param>
|
|
||||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||||
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
|
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
|
||||||
|
|
||||||
/// <summary>Adds a property to the address space.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="browseName">The browse name of the property node.</param>
|
|
||||||
/// <param name="dataType">The OPC UA data type of the property.</param>
|
|
||||||
/// <param name="value">The initial value of the property, or null.</param>
|
|
||||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||||
=> inner.AddProperty(browseName, dataType, value);
|
=> inner.AddProperty(browseName, dataType, value);
|
||||||
}
|
}
|
||||||
@@ -136,11 +129,10 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
|
|||||||
IVariableHandle inner,
|
IVariableHandle inner,
|
||||||
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
|
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
|
||||||
{
|
{
|
||||||
/// <summary>Gets the full reference for the variable.</summary>
|
/// <inheritdoc />
|
||||||
public string FullReference => inner.FullReference;
|
public string FullReference => inner.FullReference;
|
||||||
|
|
||||||
/// <summary>Marks the variable as an alarm condition and registers its sink.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="info">Configuration for the alarm condition.</param>
|
|
||||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||||
{
|
{
|
||||||
var sink = inner.MarkAsAlarmCondition(info);
|
var sink = inner.MarkAsAlarmCondition(info);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user