81 Commits

Author SHA1 Message Date
Joseph Doherty 695fa6408b docs(alarms): record native alarms verified working; add D.1 smoke
v2-ci / build (push) Failing after 47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
The 2026-04-30 alarm plan banners claimed worker-side native alarm
subscription was blocked on a COM-bitness finding. That's stale: the
mxaccessgw .NET client now has true MxAccess alarm-event support, and a
live StreamAlarms check (+ new Skip-gated GatewayGalaxyAlarmFeedLiveTests
through the lmxopcua consumer) confirms native alarms — operator comment,
category, severity, timestamps — flow end-to-end. Reconcile both plan docs
to reality and add docs/plans/alarms-d1-smoke-artifact.md as the D.1
alarm-source deliverable. Historian-write live smoke + full server->A&C
round-trip remain (Windows parity rig only).
2026-05-31 09:59:01 -04:00
Joseph Doherty 61193629b6 fix(adminui): wire Test Connect probes + live panels on admin-only nodes
v2-ci / build (push) Failing after 36s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Both bugs surfaced only on split-role deployments (the MAIN cluster's
admin-only nodes), where the AdminUI runs without the driver role.

- Test Connect returned "No probe registered" for every driver: the
  IDriverProbe set was registered only under the driver role, but the
  admin-operations singleton that consumes it is pinned to admin. Extract
  AddOtOpcUaDriverProbes() (idempotent via TryAddEnumerable) and call it
  in the hasAdmin path too.

- Live driver-status/alerts/script-log panels showed "SignalR error:
  Connection refused": these Blazor Server components opened a HubConnection
  to their own hub via the browser's public URL, which server-side code
  can't reach behind Traefik (host :9200 -> container :9000). Read the
  in-process source directly instead -- DriverStatus via
  IDriverStatusSnapshotStore.SnapshotChanged, Alerts/ScriptLog via a new
  IInProcessBroadcaster<T>. Fleet status was unaffected (reads DB/ActorSystem).

Adds unit tests for probe registration, the snapshot-store event, and the
broadcaster.
2026-05-29 16:38:32 -04:00
Joseph Doherty e3a27422a1 fix(adminui): Galaxy editor 500 — read DriverConfig case-insensitively + null-safe FromRecord
v2-ci / build (push) Failing after 39s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
GalaxyDriverPage deserialized DriverConfig with case-sensitive camelCase opts, but the
persisted/seeded config is PascalCase (the runtime reads it case-insensitively). So all four
nested option records read as null -> FromRecord NRE (HTTP 500) on edit, and the form would
have shown defaults instead of the real config (risking a clobber on save). Fix: add
PropertyNameCaseInsensitive=true (matches the runtime) so real values load, plus null-coalesce
the nested records in FromRecord as defense-in-depth. Regression test asserts the seeded
PascalCase config loads its real values.
2026-05-29 12:45:44 -04:00
Joseph Doherty 32d7fd7cc9 fix(galaxy): complete PR 7.2 rename — use canonical GalaxyMxGateway driver type
v2-ci / build (push) Failing after 48s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
The driver/factory/seed use 'GalaxyMxGateway' (legacy 'Galaxy' was retired),
but the AdminUI editor router, GalaxyDriverPage, address picker, identity
dropdown, the Galaxy browser/probe, and DraftValidator still keyed on 'Galaxy'.
Result: the seeded GalaxyMxGateway driver couldn't be edited ('no editor
registered'), UI-created Galaxy drivers wrote a type with no factory, and a
SystemPlatform-bound GalaxyMxGateway driver failed publish validation.
Align all stragglers to GalaxyMxGateway (+ failing-test-first DraftValidator
coverage). ShouldStub's 'Galaxy' legacy safety-net left intact.
2026-05-29 12:31:55 -04:00
Joseph Doherty de666b24c3 test: fix Galaxy-tag Phase7 test fixtures + S7 CLI enum; add MaterialiseGalaxyTags coverage
v2-ci / build (push) Failing after 38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Completes the test side of the in-progress Galaxy-tag workstream:
- Phase7ApplierTests / Phase7ApplierHierarchyTests: supply the now-required
  Galaxy-tag args to Phase7Plan / Phase7CompositionResult.
- Add genuine coverage for Phase7Applier.MaterialiseGalaxyTags (folder-per-distinct-path,
  variable-per-tag node-id derivation, folder dedupe) + added-Galaxy-tags-trigger-rebuild.
- S7.Cli.Tests: use the project's S7CpuType (CLI option type) instead of S7.Net.CpuType.
Whole solution now builds 0/0; OpcUaServer.Tests 52, S7.Cli.Tests 36 green.
2026-05-29 12:18:01 -04:00
Joseph Doherty a4fb97aef8 chore(docker-dev): remap Traefik to host port 9200
v2-ci / build (push) Failing after 2m6s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Host :80 collides with the sister scadabridge-traefik dev stack; bind the
OtOpcUa Traefik :80 entrypoint to host 9200 instead (admin UI now at
http://localhost:9200). Dashboard already on 8089 to avoid the same clash.
2026-05-29 12:09:21 -04:00
Joseph Doherty da4634d67e fix(tests,cli): implement IOpcUaAddressSpaceSink.EnsureVariable in test fakes; fix CLI CS1587
v2-ci / build (push) Failing after 44s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Resolves the 12 reported build errors (7 CS0535 sink fakes + 5 CLI CS1587).
Runtime.Tests green (74). NOTE: OpcUaServer.Tests still has pre-existing CS7036
errors from the in-progress Galaxy-tag workstream (Phase7Plan/Phase7CompositionResult
new required params) — separate, test-only, not addressed here.
2026-05-29 10:19:32 -04:00
Joseph Doherty 869be660fd fix(adminui): strip stale Phase C.2 / rebuild-plan roadmap notes from cluster list pages
v2-ci / build (push) Failing after 49s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Removes the internal-roadmap deferral banners (the original request that
seeded this work); kept the genuinely useful operator descriptions.
2026-05-29 10:12:15 -04:00
Joseph Doherty a8916c3e08 docs(adminui): correct stale follow-up source comments (F15/F16/Phase4/TODO 3.3-3.4)
v2-ci / build (push) Failing after 46s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-05-29 10:00:58 -04:00
Joseph Doherty 79b2345834 fix(adminui): disable RoleGrants buttons during save (review) 2026-05-29 09:58:05 -04:00
Joseph Doherty 4df5b849ac fix(security): let OperationCanceledException propagate from login role merge (review) 2026-05-29 09:56:09 -04:00
Joseph Doherty a58151e99e feat(adminui): editable DB-backed LDAP role map (global, FleetAdmin-gated) 2026-05-29 09:55:07 -04:00
Joseph Doherty 1fd093d95d test(config): global LdapGroupRoleMapping CRUD 2026-05-29 09:52:47 -04:00
Joseph Doherty f210f09caf feat(security): merge DB-backed LDAP role grants into login claims 2026-05-29 09:51:22 -04:00
Joseph Doherty 042f3b6a65 feat(security): add FleetAdmin authorization policy 2026-05-29 09:48:31 -04:00
Joseph Doherty bc40388914 chore(di): register ILdapGroupRoleMappingService 2026-05-29 09:47:10 -04:00
Joseph Doherty b719194046 feat(security): RoleMapper.Merge — additive DB-backed role grants 2026-05-29 09:43:12 -04:00
Joseph Doherty 7570df76d3 feat(adminui): editable OpcUaClient endpoint URL list via CollectionEditor 2026-05-29 09:41:09 -04:00
Joseph Doherty 244949caa3 feat(adminui): editable S7 tag list via CollectionEditor 2026-05-29 09:37:12 -04:00
Joseph Doherty a5a0d06dbe feat(adminui): editable FOCAS device + tag lists via CollectionEditor 2026-05-29 09:33:53 -04:00
Joseph Doherty 6882761f4c feat(adminui): editable TwinCAT device + tag lists via CollectionEditor 2026-05-29 09:29:57 -04:00
Joseph Doherty 15f3797f1e feat(adminui): editable AbLegacy device + tag lists via CollectionEditor 2026-05-29 09:26:25 -04:00
Joseph Doherty 534d670b21 feat(adminui): editable AbCip device + tag lists via CollectionEditor 2026-05-29 09:22:51 -04:00
Joseph Doherty b351a81c8f fix(adminui): preserve un-edited Modbus tag fields across edit (review)
Capture the original ModbusTagDefinition as _source in ModbusTagRow and
rewrite ToDefinition() to use 'with {}', so StringByteOrder, ArrayCount,
Deadband, UnitId, and CoalesceProhibited survive a load→edit→save cycle.
2026-05-29 09:18:36 -04:00
Joseph Doherty f655efc570 feat(adminui): typed resilience override form replaces JSON textarea 2026-05-29 09:15:54 -04:00
Joseph Doherty c4116e54c9 feat(adminui): editable Modbus tag list via CollectionEditor 2026-05-29 09:14:06 -04:00
Joseph Doherty c3fec1426c fix(adminui): case-insensitive resilience policy keys + malformed-json test (review) 2026-05-29 09:10:41 -04:00
Joseph Doherty a2761e4b98 fix(adminui): key CollectionEditor rows by identity (code review) 2026-05-29 09:08:02 -04:00
Joseph Doherty 4a469fbe06 feat(adminui): typed resilience override form model + tests 2026-05-29 09:06:45 -04:00
Joseph Doherty e2fa6754bb feat(adminui): add generic CollectionEditor<TRow> modal list editor 2026-05-29 09:03:03 -04:00
Joseph Doherty b76561a780 docs(adminui): implementation plan + task persistence for deferred follow-ups
19 tasks across WS1 (driver collection editors), WS2 (typed resilience
form), WS3 (editable DB-backed LDAP role map, global), WS4 (cleanup).
2026-05-29 08:59:55 -04:00
Joseph Doherty c49fccbe0c docs(adminui): design for completing deferred follow-ups
Driver collection editors (modal-per-row shared shell), resilience typed
form, editable DB-backed LDAP->role map (global roles, live on next
sign-in), and stale-comment/note cleanup. Roles intentionally global —
no per-cluster permissions.
2026-05-29 08:45:50 -04:00
Joseph Doherty 5622e51006 fix(adminui): clean up dev-migration note on Home page
Removed the F15 follow-up annotation that was visible to end users.
Replaced with a one-line orientation pointer to the nav.
2026-05-29 08:02:57 -04:00
Joseph Doherty 9e479ce675 test(security): fix Logout_clears_the_cookie
v2-ci / build (push) Failing after 44s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Two pre-existing test bugs surfaced by the auth-alignment branch:
 - Test wanted the 204/JSON contract but never sent Accept:
   application/json — endpoint correctly returned 302 (form POST).
 - Cookie-name assertion still used OtOpcUa.Auth= (now
   ZB.MOM.WW.OtOpcUa.Auth= since the Task 1 default change).

Endpoint behavior is intentional and untouched.
2026-05-29 08:01:26 -04:00
Joseph Doherty af691f3291 fix(security): correct challenge tests to match framework reality
ASP.NET Core's cookie-handler IsAjaxRequest heuristic only checks
X-Requested-With (not Accept). Drop the third test (Accept: application/json
was assumed to → 401 but actually → 302) and the Location.ShouldBeNull
assertion on the XHR test (framework still writes Location alongside 401;
clients ignore it). Renamed _ajax_ → _xhr_ for accuracy. Design doc
updated to match.
2026-05-29 07:58:18 -04:00
Joseph Doherty 453340e71e test(security): add browser-vs-AJAX challenge tests for root path
Adds protected MapGet("/") in the test host plus three [Fact] methods
exercising the cookie scheme's challenge heuristic for the root route:
browser (Accept: text/html), AJAX (X-Requested-With: XMLHttpRequest),
and JSON (Accept: application/json) callers. Also adds a no-redirect
HttpClient helper so the 302 + Location can be asserted directly.
2026-05-29 07:56:15 -04:00
Joseph Doherty b64d670303 style(security): use Authorization namespace import (code-review cleanup) 2026-05-29 07:51:29 -04:00
Joseph Doherty c83e9397e6 chore(security): drop Microsoft.AspNetCore.Authentication.JwtBearer (unused) 2026-05-29 07:50:47 -04:00
Joseph Doherty 74b9218a92 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.
2026-05-29 07:47:58 -04:00
Joseph Doherty 532e9933f3 feat(security): extend OtOpcUaCookieOptions with RequireHttpsCookie + ZB.MOM.WW cookie name default 2026-05-29 07:44:33 -04:00
Joseph Doherty ee8add4416 docs: implementation plan for auth/login alignment with ScadaBridge
5 tasks following Section 6 of the approved design (bc4fce5). Tasks 3 and 4
parallelizable. Each task carries Classification + Estimated implement time
+ Parallelizable-with metadata for subagent dispatch.
2026-05-29 07:43:11 -04:00
Joseph Doherty bc4fce5fbe docs: design for auth/login alignment with ScadaBridge
Removes the JwtBearer parallel scheme + non-redirect 401 challenge that left
browsers staring at Chrome's HTTP_RESPONSE_CODE_FAILURE page on protected
GETs. JWT keeps minting (cookie payload only); cookie config flows through
the existing-but-unused OtOpcUaCookieOptions via PostConfigure (same pattern
ScadaBridge uses).
2026-05-29 07:39:11 -04:00
Joseph Doherty 7a0b8525a9 chore(docker-dev): rotate GALAXY_MXGW_API_KEY default to new credential
Replaces the old fallback (mxgw_otopcua_…UY_NKlBl3) with the freshly issued
mxgw_otopcua2_GI7-… on all 8 host services. Gateway endpoint stays at
http://10.100.0.48:5120 (seed-clusters.sql already points there). Operators
who set GALAXY_MXGW_API_KEY in their shell continue to override the default
unchanged.
2026-05-29 07:18:23 -04:00
Joseph Doherty 560b327ee1 refactor(galaxy): migrate to ZB.MOM.WW.MxGateway.* nupkg packages
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Imports the freshly-rebuilt ZB.MOM.WW.MxGateway.Client + ZB.MOM.WW.MxGateway.Contracts
nupkgs (0.1.0) from /tmp/mxgw-dist. Replaces the vendored libs/ DLLs and the
pre-restructure MxGateway.* namespaces across the runtime Galaxy driver,
Galaxy.Browser, and their tests.

Key changes:
- nuget-packages/ added as a local feed via NuGet.config; .gitignore exempts it
  from the *.nupkg rule so the packages are tracked
- Directory.Packages.props pins both packages at 0.1.0
- 4 csprojs swap <Reference HintPath="libs/...dll"/> for <PackageReference/>
- 36 .cs files renamed `using MxGateway.*` -> `using ZB.MOM.WW.MxGateway.*`
- libs/ removed (vendored DLLs + README.md)

GalaxyBrowseSession rewritten around the new lazy API:
- RootAsync calls GalaxyRepositoryClient.BrowseAsync (returns LazyBrowseNodes)
  and caches them by TagName instead of bulk-fetching the whole hierarchy
- ExpandAsync looks up the cached LazyBrowseNode and calls its ExpandAsync,
  giving true one-wire-call-per-click instead of in-memory parent/child scan
- _byGobjectId + _hasChildrenSet dropped (LazyBrowseNode carries HasChildrenHint)
- AttributesAsync unchanged (already uses DiscoverHierarchyAsync MaxDepth=0)

Tests: Galaxy.Tests 245/245, Galaxy.Browser.Tests 10/10, AdminUI.Tests 66/66.
Pre-existing 12 solution errors unchanged (test sinks + Cli XML comments).
2026-05-29 07:14:18 -04:00
Joseph Doherty d1b6cff085 docs: link driver-browsers design from CLAUDE.md 2026-05-28 16:23:28 -04:00
Joseph Doherty ef17d2e595 fix(adminui): picker DisposeAsync is fire-and-forget per design 2026-05-28 16:21:24 -04:00
Joseph Doherty e439100937 fix(adminui): DriverBrowseTree uses local field, not parameter mutation 2026-05-28 16:18:58 -04:00
Joseph Doherty 7c9621040e feat(adminui): wire Galaxy picker to live browser + attribute side-panel 2026-05-28 16:17:34 -04:00
Joseph Doherty 1b0baf7025 feat(adminui): wire OpcUaClient picker to live browser 2026-05-28 16:16:37 -04:00
Joseph Doherty f31af0093f test(opcuaclient.browser): opc-plc integration round-trip 2026-05-28 16:13:43 -04:00
Joseph Doherty 6e365ef1a9 feat(adminui): shared lazy DriverBrowseTree component with per-node filter 2026-05-28 16:13:03 -04:00
Joseph Doherty 1dbd3b2a6d feat(adminui): register browse services in AddAdminUI 2026-05-28 16:11:13 -04:00
Joseph Doherty 48c3c56073 test(galaxy.browser): unit + fake-transport session coverage 2026-05-28 16:07:13 -04:00
Joseph Doherty 5475ab2aa3 test(opcuaclient.browser): unit + opc-plc live coverage 2026-05-28 16:04:25 -04:00
Joseph Doherty 1a143beeb9 feat(galaxy.browser): add transient gateway-connection factory
GalaxyDriverBrowser opens an ad-hoc GalaxyRepositoryClient from the
AdminUI's persisted Galaxy options and hands it to a GalaxyBrowseSession
for the address picker. Mirrors GalaxyDriver.BuildClientOptions field-
for-field so the gateway sees an identical option shape, with API-key
resolution inlined (env:/file:/dev: prefixes) so the Browser project
needn't take a hard reference on Driver.Galaxy.

Connect phase runs under a 30s budget linked to the caller's CT and
includes a TestConnectionAsync call so auth/TLS/DNS failures surface
inside the budget instead of waiting for the first DiscoverHierarchy
round-trip. On any post-Create exception the client is disposed before
the throw propagates.

Refactored GalaxyBrowseSession to take only GalaxyRepositoryClient —
browse never needs MxGatewaySession (that's only for live subscribe/
write paths), and constructing one outside the runtime driver isn't
straightforward. The session now disposes _client in DisposeAsync; the
_session field/parameter is gone.
2026-05-28 15:59:57 -04:00
Joseph Doherty 641b2ecbcf fix(opcuaclient.browser): volatile _disposed for cross-thread visibility 2026-05-28 15:54:33 -04:00
Joseph Doherty 09d1bbac00 feat(opcuaclient.browser): add transient-session factory 2026-05-28 15:53:17 -04:00
Joseph Doherty b869af2b3d fix(galaxy.browser): volatile _disposed, RootAsync gate, O(1) child hint 2026-05-28 15:51:31 -04:00
Joseph Doherty 56be42913c feat(opcuaclient.browser): add lazy browse session impl 2026-05-28 15:48:56 -04:00
Joseph Doherty dc8a2dd52c test(adminui): browse session registry, reaper, service 2026-05-28 15:44:20 -04:00
Joseph Doherty d605d0b20d feat(galaxy.browser): add lazy browse session with attribute fetch 2026-05-28 15:42:19 -04:00
Joseph Doherty 85676db3a5 feat(opcuaclient.browser): scaffold project + slnx entry 2026-05-28 15:39:14 -04:00
Joseph Doherty bec2988309 feat(adminui): in-process browse session registry + TTL reaper + service 2026-05-28 15:36:19 -04:00
Joseph Doherty 7cd5cde315 refactor(opcuaclient): move NamespaceMap to Contracts, make public
Browser project (Phase 3) needs to share namespace-stable address encoding
with the runtime driver. Move keeps the same namespace, so existing usages
in OpcUaClientDriver compile unchanged.
2026-05-28 15:35:21 -04:00
Joseph Doherty 7c92297d0e feat(galaxy.browser): scaffold project + slnx entry 2026-05-28 15:35:14 -04:00
Joseph Doherty 81f09a7054 feat(commons): add IDriverBrowser/IBrowseSession/BrowseNode abstractions 2026-05-28 15:32:01 -04:00
Joseph Doherty c962b86bde docs: implementation plan for driver browsers (OpcUaClient + Galaxy)
18-task plan following Section 9 of the approved design. Phases 3 & 4
parallelizable. Each task carries Classification + Estimated implement
time + Parallelizable-with metadata to drive subagent dispatch.
2026-05-28 15:29:40 -04:00
Joseph Doherty fcd0b9b355 docs: design for live address browsers (OpcUaClient + Galaxy)
Approved design for the deferred follow-up from PR #f9fc7dd's driver-pages
work. Lazy tree browse via per-driver IDriverBrowser registered in AdminUI
DI, sessions held in-process with TTL reaper. Detailed sequencing for the
writing-plans handoff is in section 9.
2026-05-28 15:19:52 -04:00
Joseph Doherty 0d3ec46c14 fix(adminui): capture audit username at click time, not at panel init
v2-ci / build (push) Failing after 48s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
DriverStatusPanel previously cached the username in a field at
OnInitializedAsync and forwarded the cached value into RestartDriver
/ ReconnectDriver messages. A token refresh or claim change mid-
circuit would land the stale name in the audit ConfigEdit row.
Re-reads AuthenticationStateProvider at button-click time so the
audit entry reflects the current principal.
2026-05-28 11:58:12 -04:00
Joseph Doherty 662f3f9f5c refactor(driver-pages): address Phase 6/8 deep-review findings
v2-ci / build (push) Failing after 32s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
- Topic-name drift fix: DriverHealthChanged.TopicName and
  DriverControlTopic.Name now live on the message contracts in
  Commons. AkkaDriverHealthPublisher, DriverStatusSignalRBridge,
  DriverHostActor, and AdminOperationsActor all delegate to the
  single constant so a rename can't silently desynchronise
  publisher and subscriber.
- DriverStatusPanel._opResultClearTimer switched from
  System.Timers.Timer to System.Threading.Timer + awaited
  DisposeAsync. Prevents an in-flight 8s clear-callback from
  invoking StateHasChanged on a component whose hub has already
  been released.
- PublishHealthSnapshot deduplicates against the last published
  (state, lastSuccess, lastError, errorCount) fingerprint. The
  30s heartbeat no longer floods the SignalR layer with identical
  Healthy snapshots — newly-joined clients still warm up via the
  snapshot store on JoinDriver.
2026-05-28 11:52:20 -04:00
Joseph Doherty dcd2509548 refactor(driver-pages): address post-review follow-ups
- DriverInstanceSpec carries ClusterId from the deployment artifact;
  DriverHostActor threads the real cluster identity into
  DriverInstanceActor instead of the local NodeId. Old pre-PR
  artifacts without a ClusterId field fall back to the NodeId so
  in-flight deployments keep working.
- DriverHostActor.ChildEntry holds the full DriverInstanceSpec
  (was only carrying DriverType + LastConfigJson). Restart respawns
  preserve RowId, Name, Enabled, ClusterId — no placeholder values.
- Drop the unnecessary _faultLock on DriverInstanceActor — every
  read/write site runs inside an Akka message handler which is
  single-threaded per actor instance.
- DriverStatusPanel.DisposeAsync awaits Timer.DisposeAsync so an
  in-flight 5s tick can't invoke StateHasChanged on a component
  whose hub has already been torn down.
2026-05-28 11:41:46 -04:00
Joseph Doherty 64e4726fff docs(plans): mark all 48 driver-pages tasks complete in persistence file
Records final commit hashes + notes per task. Persistence file mirrors
the 43-commit branch state so future sessions can resume from the
correct checkpoint via /superpowers-extended-cc:executing-plans.
2026-05-28 11:32:45 -04:00
Joseph Doherty 494da22cd1 test(adminui): E2E scaffolding for Test Connect + Reconnect + Status hub
- DriverTestConnectE2eTests: 3 scenarios (sim/wrong-port/black-hole)
  against the Modbus Docker fixture. Sim + wrong-port skip if fixture
  unreachable; black-hole uses ModbusDriverProbe directly (no fixture).
- DriverReconnectE2eTests: message round-trip through AdminOperationsActor
  cluster singleton — Ok=true + audit write, without live driver side effect.
- DriverStatusHubE2eTests: bridge-mocked fallback — spawns
  DriverStatusSignalRBridge in the harness ActorSystem with a mock
  IHubContext, publishes DriverHealthChanged to the driver-health DPS
  topic, asserts store upsert + hub SendAsync call.
- DockerFixtureAvailability helper: TCP-connect probe for skip guards.
- Moq 4.20.72 added to central package management for hub mocking.
- Design doc §8.3 replaced with concrete pre-ship operator runbook.
2026-05-28 11:31:12 -04:00
Joseph Doherty 063005fefa feat(adminui): DriverTagPicker modal + 9 static address builders
- DriverTagPicker shell: modal chrome + per-driver picker body
  rendered as ChildContent.
- 9 picker bodies (Modbus/AbCip/AbLegacy/S7/TwinCat/FOCAS/
  OpcUaClient/Galaxy/Historian.Wonderware). 5 have computed
  builder logic + unit tests; 4 are free-text passthroughs
  (live browse for OPC UA + Galaxy is a documented follow-up).
- Each typed driver page gets a "Pick address" button that opens
  the modal with the matching body. Picked address surfaces in
  the modal footer for manual copy — no JS interop in v1.
2026-05-28 11:21:33 -04:00
Joseph Doherty ffcc8d1065 feat(adminui): Reconnect/Restart on DriverStatusPanel (DriverOperator-gated)
- RestartDriver / ReconnectDriver messages + AdminOperationsActor
  handlers (broadcast via driver-control DPS topic; audited via
  ConfigEdits).
- DriverHostActor subscribes to driver-control; locates the
  matching child DriverInstanceActor and stops+respawns it
  (Restart) or sends it a ForceReconnect internal message
  (Reconnect — re-enters Reconnecting state without full stop).
  DriverInstanceSpec constructor call uses named args to handle
  the full 6-parameter signature.
- New DriverOperator authorization policy mapped to DriverOperator
  or FleetAdmin role; documented in docs/security.md. Map LDAP
  group via GroupToRole (e.g. "ot-driver-operator": "DriverOperator").
- DriverStatusPanel renders Reconnect + Restart buttons when the
  user holds the DriverOperator policy (hidden otherwise). Restart
  requires an in-page Razor confirm block (no JS confirm, keeps
  SignalR event loop unblocked). Both buttons show a spinner and
  are disabled during in-flight; result chip auto-clears after 8s.
  Username sourced from AuthenticationStateProvider.

Reconnect resolves to "ForceReconnect" (re-enter Reconnecting,
not full stop+respawn) — transport drops and retries while actor
and in-memory state are preserved. All DriverInstanceActor states
handle ForceReconnect safely (no-op when already in transition).
2026-05-28 11:14:04 -04:00
Joseph Doherty 4b374fd177 feat(adminui): Test Connect button on every typed driver page
- AdminProbeService routes TestDriverConnect through
  IAdminOperationsClient with a 65s outer guard (actor side already
  clamps to [1,60]).
- Added generic AskAsync<T> to IAdminOperationsClient interface and
  AdminOperationsClient impl, delegating straight to the Akka proxy.
- DriverTestConnectButton renders the button + inline result chip,
  auto-clears after 30s, disables during in-flight.
- Wired into all 9 typed driver pages directly under the
  identity section. Sources timeout from the form's
  ProbeTimeoutSeconds; sources config JSON from the form's
  current Options (operator can test BEFORE saving).
2026-05-28 11:02:49 -04:00
Joseph Doherty 54f0dbddb9 fix(drivers): align probe DriverType strings with AdminUI keys
ModbusDriverProbe.DriverType was "Modbus" but the AdminUI's
ModbusDriverPage persists DriverInstance.DriverType = "ModbusTcp".
GalaxyDriverProbe used the runtime DriverTypeName constant
("GalaxyMxGateway") but the AdminUI saves "Galaxy". The probe DI
lookup is case-insensitive but not name-insensitive, so Test
Connect would fail to find a probe for these two drivers.
2026-05-28 10:55:15 -04:00
Joseph Doherty c19d124e89 feat(drivers): TCP-connect IDriverProbe for all 9 driver types
Cheap-and-fast probe: open TCP socket to the configured endpoint,
close immediately. Surfaces SocketError on failure, latency on
success, "timed out" on caller cancel. Sufficient for the AdminUI
Test Connect "can we reach the host?" question. Richer protocol-
level probes (OPC UA session open, FOCAS handshake, gRPC ping)
are a documented follow-up. Each probe registered as
AddSingleton<IDriverProbe, X> in DriverFactoryBootstrap so they
flow through DI into AdminOperationsActor.

Historian.Wonderware returns a clean "TCP probe not applicable"
result because it communicates over a Windows named pipe, not TCP.
Also adds OpcUaClient + Historian.Wonderware.Client project
references to Host.csproj (both were missing from the driver
ItemGroup).
2026-05-28 10:53:42 -04:00
Joseph Doherty f3f328c25c feat(adminops): IDriverProbe + TestDriverConnect actor handler
- IDriverProbe abstraction in Core.Abstractions; one impl per driver
  type, resolved by DriverType string. Phase 7.3 + 7.4 add concrete
  probes for the 9 supported driver types.
- TestDriverConnect / TestDriverConnectResult messages.
- AdminOperationsActor.HandleTestDriverConnectAsync looks up the probe
  by DriverType, runs it with a [1,60]s clamped timeout, and returns
  success/latency or failure/message. Probes that throw or time out
  surface as soft failures.
2026-05-28 10:44:00 -04:00
Joseph Doherty 4584612a1a feat(adminui): DriverStatusPanel + wire into 9 typed pages
Live panel subscribed to the /hubs/driverstatus SignalR feed —
renders state chip, last-success age, 5-min error count, last
error message. Auto-reconnect; dimmed when no push arrives for 30s.
Hidden for new instances (nothing deployed yet); shown read-only
on every edit-mode page. Reconnect/Restart buttons land in Phase 8.
2026-05-28 10:29:43 -04:00
Joseph Doherty 4203b84d51 feat(runtime): publish DriverHealthChanged via DriverInstanceActor
- IDriverHealthPublisher in Core.Abstractions + NullDriverHealthPublisher
  no-op for tests/dev-stub paths.
- AkkaDriverHealthPublisher in Runtime forwards to the cluster-wide
  `driver-health` DPS topic.
- DriverInstanceActor instrumented to publish snapshots on every
  observable state change + a periodic 30s heartbeat so the AdminUI
  snapshot store warms up for newly-joined SignalR clients.
- Sliding 5-minute Faulted-count tracked per actor via Queue<DateTime>.
- DriverHostActor.SpawnChild threads clusterId (_localNode.Value) and
  the health publisher down to every DriverInstanceActor child.
- ServiceCollectionExtensions.AddOtOpcUaRuntime registers
  AkkaDriverHealthPublisher as IDriverHealthPublisher singleton.
2026-05-28 10:22:44 -04:00
218 changed files with 13502 additions and 840 deletions
+2
View File
@@ -21,6 +21,8 @@ desktop.ini
# NuGet # NuGet
packages/ packages/
*.nupkg *.nupkg
# … but DO track repo-local feed for mxaccessgw client (not yet on public nuget.org).
!nuget-packages/*.nupkg
# Certificates # Certificates
*.pfx *.pfx
+2
View File
@@ -150,3 +150,5 @@ dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tc
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode"
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500 dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500
``` ```
Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`.
+4 -5
View File
@@ -1,9 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Akka" Version="1.5.62" /> <PackageVersion Include="Akka" Version="1.5.62" />
<PackageVersion Include="Akka.Cluster" Version="1.5.62" /> <PackageVersion Include="Akka.Cluster" Version="1.5.62" />
@@ -35,7 +33,6 @@
<PackageVersion Include="libplctag" Version="1.5.2" /> <PackageVersion Include="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" />
@@ -73,6 +70,7 @@
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" /> <PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" /> <PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" /> <PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
@@ -98,6 +96,7 @@
<PackageVersion Include="xunit" Version="2.9.2" /> <PackageVersion Include="xunit" 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.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
</ItemGroup> </ItemGroup>
</Project>
</Project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="local-mxgw" value="./nuget-packages" />
</packageSources>
</configuration>
+5
View File
@@ -21,6 +21,7 @@
</Folder> </Folder>
<Folder Name="/src/Drivers/"> <Folder Name="/src/Drivers/">
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj" />
@@ -40,6 +41,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
</Folder> </Folder>
<Folder Name="/src/Drivers/Driver CLIs/"> <Folder Name="/src/Drivers/Driver CLIs/">
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" /> <Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
@@ -80,6 +82,7 @@
</Folder> </Folder>
<Folder Name="/tests/Drivers/"> <Folder Name="/tests/Drivers/">
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
@@ -97,6 +100,8 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj" />
</Folder> </Folder>
<Folder Name="/tests/Drivers/Driver CLIs/"> <Folder Name="/tests/Drivers/Driver CLIs/">
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj" /> <Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj" />
+9 -9
View File
@@ -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
@@ -238,12 +238,48 @@ The picker slot is wired so swapping a static builder for a live browser later i
- `DriverReconnectE2eTests` — start a driver, click Reconnect, assert `Connecting → Healthy` transition within N seconds. - `DriverReconnectE2eTests` — start a driver, click Reconnect, assert `Connecting → Healthy` transition within N seconds.
- `DriverStatusHubE2eTests` — open hub, force state change, assert push arrives within 1s. - `DriverStatusHubE2eTests` — open hub, force state change, assert push arrives within 1s.
### 8.3 Manual smoke (documented; run before PR ship) ### 8.3 Manual smoke (run before PR ship)
1. `lmxopcua-fix up modbus`. Operator on the dev VM with Docker fixtures available:
2. Create a Modbus driver via the new page, Test Connect → green.
3. Status panel in second browser tab; click Reconnect in first; observe push in second. 1. Pre-flight:
4. Repeat for Galaxy (mxaccessgw) and OPC UA reference server. - `lmxopcua-fix up modbus standard` — Modbus sim running on `10.100.0.35:5020`.
- AdminUI deployed and reachable.
- LDAP user has the `DriverOperator` (or `FleetAdmin`) role.
2. Type picker:
- Navigate to `/clusters/<id>/drivers/new`. Verify 9 driver-type cards render.
- Click "ModbusTcp". Verify the typed form opens on `/clusters/<id>/drivers/new/modbustcp`.
3. Test Connect (form-driven, no save):
- Fill in Host=`10.100.0.35`, Port=`5020`, leave defaults otherwise.
- Click "Test Connect". Verify green chip + latency < 100ms.
- Change port to `9999`. Click again. Verify red chip with "ConnectionRefused" or similar.
- Change host to `1.2.3.4`. Click again. Within (default 5s) the chip shows "Probe timed out after 5s".
4. Save + edit:
- Set valid endpoint back. Save. Verify redirect to `/clusters/<id>/drivers`.
- Open the just-saved instance. Verify the typed form pre-populates correctly.
5. Live status panel:
- In a second browser tab, open the same driver's edit page. Confirm the `DriverStatusPanel` renders state + last-update.
- Stop the Modbus sim (`lmxopcua-fix down modbus`). Within ~30s, verify the panel transitions Healthy → Reconnecting / Faulted (depending on driver state).
- Bring the sim back up (`lmxopcua-fix up modbus standard`). Verify Healthy is restored.
6. Reconnect / Restart:
- Click "Reconnect" on the status panel. Verify a brief "Reconnecting…" chip + a Healthy state push within 5s.
- Click "Restart". Confirm in the dialog. Verify the actor restarts (full state transition).
- Verify both buttons are HIDDEN for an unauthorized user (LDAP user without `DriverOperator` role).
7. Address picker:
- Click "Pick address" on the Modbus page. Verify the modal opens.
- Builder: select Holding + offset=10 + length=2. Verify the chip shows `4x00010-2`. Click "Use this address" — verify it surfaces in the parent page.
- Close the modal. Repeat for one other driver type (e.g. S7) to confirm cross-driver wiring.
8. Other 8 driver types — smoke each page renders:
- Repeat steps 24 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an `opc.tcp://` endpoint.
If any step fails, record the failure mode + Razor / actor log excerpts and reopen for fix before PR ship.
### 8.4 bUnit harness ### 8.4 bUnit harness
@@ -2,40 +2,43 @@
"planPath": "docs/plans/2026-05-28-adminui-driver-pages-plan.md", "planPath": "docs/plans/2026-05-28-adminui-driver-pages-plan.md",
"designPath": "docs/plans/2026-05-28-adminui-driver-pages-design.md", "designPath": "docs/plans/2026-05-28-adminui-driver-pages-design.md",
"tasks": [ "tasks": [
{"id": "0.1", "subject": "Create AdminUI test project + slnx entry + placeholder test", "status": "pending"}, {"id": "0.1", "subject": "Create AdminUI test project + slnx entry + placeholder test", "status": "completed", "commit": "dc12c37"},
{"id": "1.1", "subject": "Driver.Modbus.Contracts — extract ModbusDriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.1", "subject": "Driver.Modbus.Contracts — extract ModbusDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5058a56", "notes": "Has 1 ProjectReference to Modbus.Addressing (sibling zero-dep enum project) — design intent preserved."},
{"id": "1.2", "subject": "Driver.AbCip.Contracts — extract AbCipDriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.2", "subject": "Driver.AbCip.Contracts — extract AbCipDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "b474d63", "notes": "AbCipDataType enum moved with Options; extensions split into runtime."},
{"id": "1.3", "subject": "Driver.AbLegacy.Contracts — extract AbLegacyDriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.3", "subject": "Driver.AbLegacy.Contracts — extract AbLegacyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "4902295", "notes": "AbLegacyDataType + AbLegacyPlcFamilyProfile also moved; extensions split."},
{"id": "1.4", "subject": "Driver.S7.Contracts — extract S7DriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.4", "subject": "Driver.S7.Contracts — extract S7DriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "9f62f2c", "notes": "Parallel S7CpuType enum (7 values) + S7CpuTypeMap in runtime; S7.Cli + 2 tests fixed for type change."},
{"id": "1.5", "subject": "Driver.TwinCAT.Contracts — extract TwinCATDriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.5", "subject": "Driver.TwinCAT.Contracts — extract TwinCATDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "a88721c", "notes": "TwinCATDataType enum moved; extensions split."},
{"id": "1.6", "subject": "Driver.FOCAS.Contracts — extract FocasDriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.6", "subject": "Driver.FOCAS.Contracts — extract FocasDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "d892ab9", "notes": "FocasCncSeries + FocasDataType enums moved; extensions split."},
{"id": "1.7", "subject": "Driver.OpcUaClient.Contracts — extract OpcUaClientDriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.7", "subject": "Driver.OpcUaClient.Contracts — extract OpcUaClientDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5f0e048", "notes": "All 4 enums self-contained in options file; no NuGet types leaked."},
{"id": "1.8", "subject": "Driver.Galaxy.Contracts — extract GalaxyDriverOptions", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.8", "subject": "Driver.Galaxy.Contracts — extract GalaxyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5ffbc42", "notes": "Moved from Config/ subdir to contracts root; namespace preserved."},
{"id": "1.9", "subject": "Driver.Historian.Wonderware.Client.Contracts — extract options", "status": "pending", "blockedBy": ["0.1"]}, {"id": "1.9", "subject": "Driver.Historian.Wonderware.Client.Contracts — extract options", "status": "completed", "blockedBy": ["0.1"], "commit": "8c0a320", "notes": "Pure record, primitives only."},
{"id": "1.10", "subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation", "status": "pending", "blockedBy": ["1.1","1.2","1.3","1.4","1.5","1.6","1.7","1.8","1.9"]}, {"id": "1.10", "subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation", "status": "completed", "blockedBy": ["1.1","1.2","1.3","1.4","1.5","1.6","1.7","1.8","1.9"], "commit": "f2f6eeb"},
{"id": "2.1", "subject": "DriverFormShell.razor", "status": "pending", "blockedBy": ["0.1"]}, {"id": "2.1", "subject": "DriverFormShell.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "85af126"},
{"id": "2.2", "subject": "DriverIdentitySection.razor", "status": "pending", "blockedBy": ["0.1"]}, {"id": "2.2", "subject": "DriverIdentitySection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "1ff3875", "notes": "Bonus ValidationMessage tags added."},
{"id": "2.3", "subject": "DriverResilienceSection.razor", "status": "pending", "blockedBy": ["0.1"]}, {"id": "2.3", "subject": "DriverResilienceSection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "a008530"},
{"id": "2.4", "subject": "Wire shared sections into existing DriverEdit.razor", "status": "pending", "blockedBy": ["2.1","2.2","2.3"]}, {"id": "2.4", "subject": "Wire shared sections into existing DriverEdit.razor", "status": "completed", "blockedBy": ["2.1","2.2","2.3"], "commit": "a28f4cd", "notes": "Net -74 lines; zero functional regression."},
{"id": "3.1", "subject": "DriverTypePicker.razor (route: /drivers/new)", "status": "pending", "blockedBy": ["2.4"]}, {"id": "3.1", "subject": "DriverTypePicker.razor (route: /drivers/new)", "status": "completed", "blockedBy": ["2.4"], "commit": "c0ce5d0"},
{"id": "3.2", "subject": "DriverEditRouter.razor with DynamicComponent dispatch","status": "pending", "blockedBy": ["2.4"]}, {"id": "3.2", "subject": "DriverEditRouter.razor with DynamicComponent dispatch","status": "completed", "blockedBy": ["2.4"], "commit": "55e8bf7"},
{"id": "3.3", "subject": "Hand /drivers/new from DriverEdit to DriverTypePicker","status": "pending", "blockedBy": ["3.1"]}, {"id": "3.3", "subject": "Hand /drivers/new from DriverEdit to DriverTypePicker","status": "completed", "blockedBy": ["3.1"], "commit": "27b3a01", "notes": "Bundled with 3.4 — single commit removed both @page directives."},
{"id": "3.4", "subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)", "status": "pending", "blockedBy": ["3.2","3.3"]}, {"id": "3.4", "subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)", "status": "completed", "blockedBy": ["3.2","3.3"], "commit": "27b3a01"},
{"id": "4.1", "subject": "ModbusDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.0", "subject": "AdminUI csproj references all 9 Driver.*.Contracts", "status": "completed", "blockedBy": ["1.10","3.4"], "commit": "7014c93", "notes": "Inserted as a precondition for parallel 4.1-4.9 implementation."},
{"id": "4.2", "subject": "AbCipDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.1", "subject": "ModbusDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a3073d1"},
{"id": "4.3", "subject": "AbLegacyDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.2", "subject": "AbCipDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dc21cba"},
{"id": "4.4", "subject": "S7DriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.3", "subject": "AbLegacyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "059a621"},
{"id": "4.5", "subject": "TwinCatDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.4", "subject": "S7DriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "5cad9b2"},
{"id": "4.6", "subject": "FocasDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.5", "subject": "TwinCatDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dfbf679"},
{"id": "4.7", "subject": "OpcUaClientDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.6", "subject": "FocasDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "8149739"},
{"id": "4.8", "subject": "GalaxyDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.7", "subject": "OpcUaClientDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "efcc231"},
{"id": "4.9", "subject": "HistorianWonderwareDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]}, {"id": "4.8", "subject": "GalaxyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a243cfd"},
{"id": "4.9", "subject": "HistorianWonderwareDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "2c16062"},
{"id": "4.10","subject": "Wire all 9 typed pages into DriverEditRouter._componentMap", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "5f8fa70"},
{"id": "4.11","subject": "Fixup: S7 Tags data-loss + missing FormModel tests (post-review)", "status": "completed", "blockedBy": ["4.10"], "commit": "c4086c2"},
{"id": "5.1", "subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter", "status": "pending", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"]}, {"id": "5.1", "subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "a971db3"},
{"id": "6.1", "subject": "DriverHealthChanged DPS message contract", "status": "pending", "blockedBy": ["5.1"]}, {"id": "6.1", "subject": "DriverHealthChanged DPS message contract", "status": "pending", "blockedBy": ["5.1"]},
{"id": "6.2", "subject": "Publish DriverHealthChanged from each driver actor (IDriverHealthPublisher)", "status": "pending", "blockedBy": ["6.1"]}, {"id": "6.2", "subject": "Publish DriverHealthChanged from each driver actor (IDriverHealthPublisher)", "status": "pending", "blockedBy": ["6.1"]},
@@ -0,0 +1,313 @@
# Live address browsers for OpcUaClient + Galaxy drivers — design
> **Status:** approved 2026-05-28. Implementation plan to follow via `writing-plans`.
> **Builds on:** PR that shipped driver-specific AdminUI pages (commit `0d3ec46`).
> Both `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` were
> intentionally shipped as static stubs ("enter the string manually") with live
> browse deferred to this follow-up.
**Goal:** Add lazy, ad-hoc browse trees to the OpcUaClient and Galaxy address pickers in the AdminUI, so operators can navigate the remote server's (or galaxy's) hierarchy and pick an address rather than typing it.
**Architecture:** A new `IDriverBrowser` abstraction registered per driver type (parallel to the runtime's `IDriverProbe`), with implementations housed in sibling `*.Browser` projects under `src/Drivers/`. AdminUI owns the live browse sessions in-process via a `BrowseSessionRegistry` singleton with a 2-minute idle TTL and an `IHostedService` reaper. Razor picker bodies talk to a scoped `IBrowserSessionService`; no actor messages on the hot path.
**Tech stack:** .NET 10 / Blazor Server / OPCFoundation.NetStandard.Opc.Ua.Client / `ZB.MOM.WW.MxGateway.Client` (sibling repo, lazy-browse API already shipped).
---
## 1. Architecture
### Abstraction
```csharp
// Commons (shared)
public interface IDriverBrowser {
string DriverType { get; } // "OpcUaClient", "Galaxy", ...
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken ct);
}
public interface IBrowseSession : IAsyncDisposable {
Guid Token { get; }
DateTime LastUsedUtc { get; }
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct);
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct);
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct); // empty for OPC UA
}
public sealed record BrowseNode(
string NodeId, // address persisted on commit
string DisplayName,
BrowseNodeKind Kind, // Folder | Leaf
bool HasChildrenHint);
public sealed record AttributeInfo(
string Name, // e.g. "DownloadPath"
string DriverDataType,
bool IsArray,
string SecurityClass); // FreeAccess | Operate | Tune | Configure | ViewOnly
public enum BrowseNodeKind { Folder, Leaf }
```
### Session lifecycle
1. Razor picker body calls `BrowserSessionService.OpenAsync(driverType, formJson)`
2. Service resolves `IDriverBrowser` from DI by driver type, calls `OpenAsync(json)`
3. Returns `IBrowseSession`; service registers it in `BrowseSessionRegistry` under a new `Guid` token
4. Razor stores token, calls `RootAsync(token)` to populate the initial tree
5. Each subsequent expand-click calls `ExpandAsync(token, nodeId)`
6. Picker body's `IAsyncDisposable.DisposeAsync` fires `CloseAsync(token)` on tear-down
7. `BrowseSessionReaper` (`IHostedService`) ticks every 30s, evicts any session where `(UtcNow - LastUsedUtc) > 2 min`, awaits `DisposeAsync`
The session genuinely has no value to other cluster nodes — it's tied to one circuit. Hosting it in-process avoids cross-cluster Ask latency on every folder click.
---
## 2. Components
### New projects
| Path | Purpose |
|---|---|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | OPC UA browser impl + session |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/` | Galaxy browser impl + session |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/` | Unit tests (use opc-plc fixture) |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/` | Unit tests (fake transport) |
Driver-specific browsers live in **sibling** projects so AdminUI doesn't drag the runtime `Driver.*` projects (and their full SDK chains) through a transitive reference.
### New abstractions
| Path | Purpose |
|---|---|
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IDriverBrowser.cs` | Per-driver factory |
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs` | Session contract |
| `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/BrowseNode.cs` | + `BrowseNodeKind` enum + `AttributeInfo` |
### AdminUI plumbing
| Path | Purpose |
|---|---|
| `src/Server/.../AdminUI/Browsing/BrowseSessionRegistry.cs` | Singleton, `ConcurrentDictionary<Guid, IBrowseSession>` |
| `src/Server/.../AdminUI/Browsing/BrowseSessionReaper.cs` | `IHostedService`, 30s tick, 2 min idle TTL |
| `src/Server/.../AdminUI/Browsing/IBrowserSessionService.cs` | Scoped DI service for Razor |
| `src/Server/.../AdminUI/Browsing/BrowserSessionService.cs` | Impl: resolve driver, register session, enforce per-call timeouts |
| `src/Server/.../AdminUI/Components/Shared/Drivers/DriverBrowseTree.razor` | Shared lazy tree component with per-node text filter |
### Modified files
| Path | Change |
|---|---|
| `src/Server/.../Pickers/OpcUaClientAddressPickerBody.razor` | Add Browse button + DriverBrowseTree; keep manual entry |
| `src/Server/.../Pickers/GalaxyAddressPickerBody.razor` | Same shape + side-panel for attribute pick |
| `src/Server/.../AdminUI/Program.cs` | Register `IDriverBrowser` services + registry + reaper |
| `src/Drivers/.../OpcUaClient.Contracts/NamespaceMap.cs` | Extract from runtime `Driver.OpcUaClient` for shared use |
| `ZB.MOM.WW.OtOpcUa.slnx` | Add the four new projects |
---
## 3. Data flow
**Open → tree → pick** (OpcUaClient as worked example; Galaxy identical except attribute side-panel before commit):
```
Razor picker body BrowserSessionService IDriverBrowser Remote
| | | |
click Browse ────────► OpenAsync(driverType, json) ─► OpenAsync(json) ────────► connect + activate session
| ◄──────────────── token (Guid) ◄───── ISession |
| | | |
render tree ─────────► RootAsync(token) ─────────────► session.RootAsync ─────► BrowseAsync(ObjectsFolder)
| ◄──────────────── BrowseNode[] ◄───── refs |
| | | |
click folder ────────► ExpandAsync(token, nodeId) ──► session.ExpandAsync ───► BrowseAsync(nodeId)
| ◄──────────────── BrowseNode[] ◄───── refs |
| | | |
click leaf + commit ─► CloseAsync(token) ─────────► session.DisposeAsync ───► CloseSession
| | | |
```
**Galaxy two-stage attribute pick:** after the user selects an object (Folder) in the tree, the picker body calls `AttributesAsync(token, tagName)` and renders the result as a side-panel. The user picks an attribute; the committed address is `tag_name.AttributeName`.
**Stable address format:**
- OpcUaClient: `nsu=<uri>;<localid>` via `NamespaceMap.ToStableReference` — survives remote namespace-table reorder across restarts
- Galaxy: `tag_name` (the globally unique system name) — already stable by definition
**Per-node text filter:** purely client-side over the already-loaded `node.Children`. No round-trip on filter input.
---
## 4. OpcUaClient browser specifics
### Connection
- Reuses `OpcUaClientDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
- Builds a **separate** `ApplicationConfiguration` from the runtime driver — PKI root at `%LocalAppData%/OtOpcUa/adminui-browse-pki/` (separate cert store)
- `ApplicationName = "OtOpcUa AdminUI Browse"`, `ApplicationUri = "urn:OtOpcUa:AdminUI:Browse"`
- Endpoint selection: same `DiscoveryClient.GetEndpointsAsync` → filter `(policy, mode)` as the runtime driver
- One endpoint only (no failover) — interactive use; user retries with different URL on failure
- Bounded by `OpcUaClientDriverOptions.PerEndpointConnectTimeout` (clamped [5, 30]s)
### Namespace map
- `NamespaceMap` class extracted to `OpcUaClient.Contracts` so both runtime and Browser projects share one impl
- Browser builds the map from the live session on open; uses `ToStableReference` for outbound NodeIds; uses `TryResolve` for inbound
### Lazy browse
- One level per click using `Session.BrowseAsync` + `BrowseNextAsync` continuation-point loop
- `BrowseDescriptionCollection` filters to `NodeClass.Object | NodeClass.Variable`, `ResultMask = BrowseName | DisplayName | NodeClass`
- `BrowseNode.HasChildrenHint = (Kind == Folder)` — heuristic; saves a per-node round-trip
- Inside-session calls guarded by `SemaphoreSlim _gate` (same pattern as runtime driver — OPC UA `Session.BrowseAsync` not thread-safe)
### Cert handling
- `AutoAcceptCertificates = true` honored with parity to runtime + log warning + per-session unwire on dispose
- `AutoAcceptCertificates = false` + untrusted cert → `OpenAsync` fails with SDK error message in the UI
### Reconnect handling
- None. Browse sessions are short-lived (2 min idle TTL). Keep-alive failure → UI surfaces error chip → user re-clicks Browse.
---
## 5. Galaxy browser specifics
### Connection
- Reuses `GalaxyDriverOptions` (deserialize with `UnmappedMemberHandling.Skip`)
- Opens `MxGatewaySession` with `ClientName = "OtOpcUa-AdminUI-Browse"` — distinct from runtime driver's name so the gateway can attribute load
- Per-call gateway client built via `session.GalaxyRepository(opts.GalaxyName)`
### Lazy browse
- Root: `client.BrowseAsync(new BrowseChildrenOptions(), ct)``IReadOnlyList<LazyBrowseNode>`
- Expand: cached `LazyBrowseNode` lookup by `tag_name`, then `node.ExpandAsync(ct)` (gateway client handles paging internally)
- No internal gate — `LazyBrowseNode.ExpandAsync` already has its own lock; gateway client is thread-safe across distinct calls
### Two-stage attribute pick
- Galaxy `BrowseNode.Kind` is always `Folder` — leaves don't exist at tree level
- When the user clicks an object node, picker body calls `AttributesAsync(token, tagName)` and shows the result as a side-panel listing `(Name, DriverDataType, IsArray, SecurityClass)`
- On attribute click, committed address is `$"{tagName}.{attrName}"`
- Backing call: either `BrowseChildrenOptions { IncludeAttributes = true }` filtered to the GobjectId, or a dedicated `GetAttributesAsync(GobjectId, ct)` — to be confirmed during plan write against the gateway client surface
### Filters in v1
- Per-node text filter (client-side) for tree navigation
- Server-side filters (`TagNameGlob`, `AlarmBearingOnly`, `HistorizedOnly`) deferred to a follow-up — easy to add later without breaking the wire (the session is constructed today with `new BrowseChildrenOptions()`)
---
## 6. Error handling, timeouts, TTL
### Failures
- `OpenAsync` → catches `Exception`, logs Info, returns typed `BrowseOpenResult(Ok: false, Message, Token: Empty)`. UI shows red chip with truncated SDK message
- `ExpandAsync` / `AttributesAsync` → same shape per-call. Failed branch shows error chip; rest of tree intact; session stays alive
- `BrowseSessionNotFoundException` when token unknown (session reaped or never existed)
### Timeouts
- Per-call expand/attributes: **20 s** via `CTS.CreateLinkedTokenSource(callerCt)` in `BrowserSessionService`
- Session open: **30 s** ceiling; OPC UA reuses `PerEndpointConnectTimeout` (default 10 s), Galaxy hardcodes 30 s for `MxGatewaySession.OpenAsync`
### TTL & reaping
- `LastUsedUtc` set on every `RootAsync`/`ExpandAsync`/`AttributesAsync`
- Reaper: `IHostedService` with `PeriodicTimer(30s)`. On each tick: snapshot keys; for any session with `(UtcNow - LastUsedUtc) > 120s`: `TryRemove` then `await DisposeAsync` outside the dictionary
- Concurrent `ExpandAsync` racing eviction → caller catches closed-session error → service translates to `BrowseSessionNotFoundException`
- On AdminUI shutdown: `StopAsync` walks the registry once and disposes all sessions
### Concurrency
- `BrowseSessionRegistry` = `ConcurrentDictionary<Guid, IBrowseSession>` — no extra lock
- OpcUaClient session serializes browse on `SemaphoreSlim`; Galaxy session relies on its internal locks
### Component dispose
- Razor picker body implements `IAsyncDisposable`
- Fires `CloseAsync(token)` fire-and-forget (no await) so circuit teardown isn't blocked by a gRPC roundtrip
- Reaper is the safety net if dispose doesn't fire
### Logging
- Serilog. Info at open + close, Debug at close-with-reason (`user-close | idle-ttl | shutdown`), Info on failure
- No per-expand logging (noise)
### Audit trail
- None — browse is read-only and doesn't mutate config or driver state (matches probe pattern)
---
## 7. Security & auth
### Role gating
- Browse button gated by existing `DriverOperator` LDAP policy — same as Reconnect/Restart in `DriverStatusPanel`
- Picker bodies check policy in `OnInitializedAsync` via `IAuthorizationService` and `AuthenticationStateProvider`
- Manual entry stays available regardless of role
### Credentials in JSON
- Form JSON posted to `BrowserSessionService.OpenAsync` contains plaintext passwords / API keys — same as the existing `TestDriverConnect` probe
- JSON is deserialized into typed Options → used to build SDK config → both released; no `_lastConfigJson` cached field anywhere in the registry or session impls
- Browse session tokens are `Guid.NewGuid()` and only ever cross the authenticated Blazor circuit
### Cert handling
- `AutoAcceptCertificates = true` honored with log warning + per-session unwire on dispose
- Browse PKI store separate from runtime PKI — browse-time accept doesn't poison the runtime driver's trust store
### Rate limiting
- None. DriverOperator role gating + 2-minute TTL is the budget. A bad actor with DriverOperator already has Reconnect/Restart capability
### Multi-replica AdminUI
- Sticky cookies (already configured via Traefik) pin a user to one replica → `BrowseSessionRegistry` is always co-located with the circuit that created the token
- Failover → token invalid on new replica → UI re-opens gracefully
---
## 8. Testing
### Unit tests — per-driver browsers
- `tests/Drivers/.../OpcUaClient.Browser.Tests/`: against opc-plc at `opc.tcp://10.100.0.35:50000`. `OpcUaClientBrowseSessionTests`, `OpcUaClientDriverBrowserTests` (bad endpoint, auth rejected, bad JSON)
- `tests/Drivers/.../Galaxy.Browser.Tests/`: fake `IGalaxyRepositoryClientTransport` (precedent in gateway-client repo). `GalaxyBrowseSessionTests`, `GalaxyDriverBrowserTests`
### Unit tests — AdminUI plumbing (added to existing `tests/Server/AdminUI.Tests/`)
- `BrowseSessionRegistryTests`: register/get/remove, concurrent registration
- `BrowseSessionReaperTests`: virtual time, idle eviction, non-idle preservation, eviction-vs-in-flight-expand race
- `BrowserSessionServiceTests`: open→root→expand→close, unknown driver type, per-call timeout enforced
### Component tests
- `DriverBrowseTree` lazy-expand contract with fake `IBrowserSessionService`; per-node filter filters DOM but does not call ExpandAsync; click caching
- Picker bodies: Browse button hidden when `!_canOperate`; manual entry still works
### Integration tests (opt-in, fixture-gated)
- `tests/Drivers/.../OpcUaClient.Browser.IntegrationTests/`: end-to-end against opc-plc, 3-level expand + round-trip resolve. Skipped unless `OPCUA_SIM_ENDPOINT` set
- No Galaxy integration suite in v1 (requires wonder-app-vd03; deferred)
### Specific regression tests
- Namespace-stable round-trip: open → browse → take returned NodeId string → `ExpandAsync(string)` → must resolve back to same NodeId
- TTL reaper racing live ExpandAsync: `TryRemove` while expand is in-flight → safe, translates to `BrowseSessionNotFoundException`
### Verification at PR time
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean
- `dotnet test tests/Server/.../AdminUI.Tests/` green (existing 51 + new ~12)
- `dotnet test tests/Drivers/.../OpcUaClient.Browser.Tests/` with `lmxopcua-fix up opcuaclient`
- `dotnet test tests/Drivers/.../Galaxy.Browser.Tests/` (no fixture)
- Manual smoke: run AdminUI, edit an OpcUaClient driver, click Browse against opc-plc, pick a variable, verify the stored NodeId reads cleanly via Client CLI
---
## 9. Implementation sequencing (for plan-writing)
Suggested phase split — each phase shippable + reviewable independently:
1. **Phase 1 — Abstractions.** Add `IDriverBrowser`, `IBrowseSession`, `BrowseNode`, `AttributeInfo`, `BrowseNodeKind` to Commons. Empty build.
2. **Phase 2 — Extract NamespaceMap.** Move from runtime `Driver.OpcUaClient` to `Driver.OpcUaClient.Contracts`; update runtime ref.
3. **Phase 3 — OpcUaClient browser.** New `Driver.OpcUaClient.Browser` project; impl + unit tests against opc-plc.
4. **Phase 4 — Galaxy browser.** New `Driver.Galaxy.Browser` project; impl + unit tests with fake transport. Confirm attribute-fetch API surface on `GalaxyRepositoryClient`.
5. **Phase 5 — AdminUI plumbing.** `BrowseSessionRegistry`, `BrowseSessionReaper`, `BrowserSessionService`, DI wire-up in `Program.cs`. Unit tests.
6. **Phase 6 — Shared `DriverBrowseTree.razor`.** Lazy tree component with per-node filter. Component tests with fake service.
7. **Phase 7 — Wire pickers.** Update `OpcUaClientAddressPickerBody.razor` and `GalaxyAddressPickerBody.razor` to use `DriverBrowseTree` + DriverOperator gating + (Galaxy) attribute side-panel. Manual smoke test.
8. **Phase 8 — Integration test + docs.** Opt-in opc-plc integration suite, design doc cross-references in `docs/`, `CLAUDE.md` (or `docs/security.md`) updates if needed.
---
## Decisions table
| # | Decision | Rationale |
|---|---|---|
| 1 | Ad-hoc browse using form JSON | Mirrors `TestDriverConnect` probe; works for new drafts and existing drivers uniformly |
| 2 | Tree + lazy load both drivers | Galaxy gateway just shipped `LazyBrowseNode.ExpandAsync` — symmetric UX possible |
| 3 | AdminUI-hosted via `IDriverBrowser` factory | Browse is interactive (≥10 calls/session); cross-cluster Ask hop would multiply latency; session has no value to other nodes |
| 4 | Sibling `*.Browser` projects | Keep AdminUI from pulling runtime `Driver.*` projects' SDK chains |
| 5 | `NamespaceMap` to `OpcUaClient.Contracts` | Shared between runtime + browser, no new project needed |
| 6 | Separate browse PKI store | Browse-time cert accept must not poison runtime driver's trust store |
| 7 | Per-node client-side text filter (v1) | Quick UX win; server-side filters deferred |
| 8 | 2 min idle TTL, 30s reaper tick | Matches typical user cadence; bounds resource exposure |
| 9 | 20 s per-call / 30 s open timeouts | Interactive feel; longer hangs almost always mean broken remote |
| 10 | DriverOperator role gating | Live remote connection is operationally privileged; matches Reconnect/Restart precedent |
| 11 | No audit trail | Browse is read-only; matches probe pattern |
| 12 | Galaxy two-stage attribute side-panel | One modal, no extra clicks vs. two-modal flow |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
{
"planPath": "docs/plans/2026-05-28-driver-browsers-plan.md",
"tasks": [
{"id": 1, "subject": "Task 1: Phase 1 — Add IDriverBrowser/IBrowseSession/BrowseNode to Commons", "status": "pending"},
{"id": 2, "subject": "Task 2: Phase 2 — Extract NamespaceMap to OpcUaClient.Contracts", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Phase 3a — Scaffold Driver.OpcUaClient.Browser project", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Phase 3b — Implement OpcUaClientBrowseSession", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Phase 3c — Implement OpcUaClientDriverBrowser factory", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Phase 3d — OpcUaClient.Browser tests (opc-plc fixture)", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: Phase 4a — Scaffold Driver.Galaxy.Browser project", "status": "pending", "blockedBy": [1]},
{"id": 8, "subject": "Task 8: Phase 4b — Implement GalaxyBrowseSession", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: Phase 4c — Implement GalaxyDriverBrowser factory", "status": "pending", "blockedBy": [8]},
{"id": 10, "subject": "Task 10: Phase 4d — Galaxy.Browser tests (fake transport)", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 11: Phase 5a — BrowseSessionRegistry + reaper + service", "status": "pending", "blockedBy": [1]},
{"id": 12, "subject": "Task 12: Phase 5b — Wire DI in AddAdminUI()", "status": "pending", "blockedBy": [5, 9, 11]},
{"id": 13, "subject": "Task 13: Phase 5c — Tests for registry, reaper, service", "status": "pending", "blockedBy": [11]},
{"id": 14, "subject": "Task 14: Phase 6 — Shared DriverBrowseTree.razor", "status": "pending", "blockedBy": [12]},
{"id": 15, "subject": "Task 15: Phase 7a — Wire OpcUaClient picker to browser", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 16: Phase 7b — Wire Galaxy picker + attribute side-panel", "status": "pending", "blockedBy": [14]},
{"id": 17, "subject": "Task 17: Phase 8a — opc-plc integration test", "status": "pending", "blockedBy": [6]},
{"id": 18, "subject": "Task 18: Phase 8b — Manual smoke + CLAUDE.md update", "status": "pending", "blockedBy": [13, 15, 16, 17]}
],
"lastUpdated": "2026-05-28T00:00:00Z"
}
@@ -0,0 +1,132 @@
# Design — Complete AdminUI deferred follow-ups
**Date:** 2026-05-29
**Status:** Approved (design); implementation plan to follow
**Author:** Joseph Doherty (with Claude Code)
## Background
The AdminUI carried a family of "deferred / Phase C.2 follow-up" notes. A prior
change stripped the stale *rendered roadmap banners* from the cluster list pages.
Three remaining note groups were investigated to decide what real work they hide:
- **Group 1 — driver-page inline notes** ("list-editor coming in a follow-up
phase" for tags/devices/endpoints; "typed-form-ifying Polly is a follow-up").
**Real pending UI work.**
- **Group 2 — RoleGrants** ("UI-driven editing of the mapping is deferred — it
implies a config-reload mechanism that doesn't exist yet"). → **Real work; half
the infra already exists.**
- **Group 3 — source comments** (F15 Razor migration, F16 FleetStatusHub bridge,
"Phase 4" identity section, `TODO(3.3/3.4)` route collision). → **~90% stale**;
the referenced work already shipped (the F16 bridge is wired; the legacy
`DriverEdit.razor` no longer exists). Only the Polly typed form is real, and it
is already counted in Group 1.
### Key facts established during exploration
- **Driver-embedded tag/device lists in `DriverConfig` JSON are the runtime source
of truth.** Driver factories deserialize them and poll exactly those rows; the
canonical `Tag` table is orthogonal (OPC UA browse-tree only, never read by
drivers). So inline editors are meaningful, not redundant — editing them changes
what the driver polls on the next publish/reinitialize.
- **Resilience** already has a strongly-typed model: `DriverResilienceOptions`
(`BulkheadMaxConcurrent`, `BulkheadMaxQueue`, `RecycleIntervalSeconds`,
`CapabilityPolicies: {DriverCapability → (TimeoutSeconds, RetryCount,
BreakerFailureThreshold)}`) with tier A/B/C defaults via `GetTierDefaults(tier)`
and a `DriverResilienceOptionsParser`. The stored JSON is an *override* shape;
null/absent keys fall back to tier defaults.
- **LDAP role map**: the `LdapGroupRoleMapping` entity + migration +
`ILdapGroupRoleMappingService` (CRUD) already exist but are **not wired** into
login. `LdapAuthService` still reads the static appsettings `GroupToRole`
(`Dictionary<string,string>`). `RoleGrants.razor` is read-only.
- **Testing**: no bUnit. Established pattern = test `FromOptions`/`ToOptions`
round-trips (xUnit + Shouldly in `AdminUI.Tests`) and services with in-memory EF
(`Configuration.Tests`).
## Decisions
- **Scope:** full build — all real follow-ups in Groups 1 & 2, plus Group 3
comment cleanup.
- **List-editor UX:** modal-per-row with a shared shell component.
- **LDAP reload semantics:** DB-backed, **live on the user's next sign-in**
(per-login DB query; no restart, no new infra). appsettings `GroupToRole` becomes
a bootstrap **fallback** layer.
- **Roles are GLOBAL.** No cluster-level permissions / no per-cluster enforcement
(explicitly chosen for simplicity, reversing an earlier cluster-scoping answer).
Every `LdapGroupRoleMapping` row is `IsSystemWide=true`, `ClusterId=null`.
## Workstreams
### WS1 — Driver collection editors (modal-per-row + shared shell)
- New generic `CollectionEditor<TRow>` component in `Components/Shared/Drivers/`:
compact read-only table + `[+ Add]` / per-row `Edit` / `Delete`, and a Bootstrap
modal editing a **working copy** of a row (commit on modal-Save, discard on
Cancel). Parameters: `List<TRow> Items` (bound), header fragment, read-only-cells
fragment, modal-body fragment, `NewRow` factory, optional `Validate` delegate.
- Each driver page swaps its read-only `<pre>` for a `CollectionEditor` supplying
its own columns + modal fields. Edits mutate the in-memory `List<T>` already in
the page's `FormModel`; the page's existing **Save** serializes it into
`DriverConfig` — no new persistence path.
- Coverage: tags (Modbus, AbCip, AbLegacy, TwinCAT, S7, FOCAS); devices (AbCip,
AbLegacy, TwinCAT, FOCAS); endpoints (OpcUaClient).
- **Errors/validation:** required fields, duplicate Name within list,
driver-specific address format; delete confirm; list mutates only on valid commit.
- **Testing:** per-driver `NewRow` factories + `Validate` methods unit-tested
directly; existing `*FormSerializationTests` extended for add/remove via the form
model. Modal interaction verified manually via `/run`.
### WS2 — Resilience typed form
- Replace the textarea in `DriverResilienceSection.razor` with a typed form bound to
a new mutable `ResilienceFormModel` (all fields nullable; null = tier default):
bulkhead concurrent/queue, recycle interval, and an 8-capability grid (Read,
Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead)
of (timeout / retry / breaker-threshold).
- `FromJson`/`ToJson` emit only non-null overrides (blank → `null`, preserving the
current "null = tier defaults" contract). The section gains a `DriverTier`
parameter; each driver page passes its known tier so `GetTierDefaults(tier)`
renders as placeholders. A collapsible "raw JSON" view remains as escape hatch.
- **Errors:** non-negative / sane-range numeric validation; emitted JSON must
re-parse cleanly through `DriverResilienceOptionsParser`.
- **Testing:** `ResilienceFormModel` round-trip tests in `AdminUI.Tests`
blank→null, partial-override-preserved, emit→parse-back compatibility.
### WS3 — Editable LDAP→role map (DB-backed, global, live on next sign-in)
- `RoleGrants.razor` → full CRUD over `LdapGroupRoleMapping` via the existing
`ILdapGroupRoleMappingService`. **Global only**: `IsSystemWide=true`,
`ClusterId=null`; no cluster UI. Fields: LDAP group, `AdminRole`
(ConfigViewer/ConfigEditor/FleetAdmin), notes. A group may carry several roles
(multiple rows). Edit page gated to **FleetAdmin** (add a minimal FleetAdmin
authorization policy; confirm existing role-policy plumbing during plan-writing).
- Wire the service into `LdapAuthService`: at login → resolve groups →
`GetByGroupsAsync` (indexed) → map roles → **merge appsettings `GroupToRole` as a
fallback layer** (used when no DB row covers a group). Edits take effect on the
user's next sign-in. DB rows authoritative + editable; appsettings entries shown
read-only as "fallback."
- **Errors:** DB unreachable at login → catch, log, fall back to appsettings;
login never blocks. CRUD: no duplicate `(LdapGroup, Role)`; group/role required.
- **Testing:** extend `LdapGroupRoleMappingServiceTests` (in-memory EF) for CRUD +
dedupe; new `RoleMapper` overload `Map(groups, dbRows, fallbackDict)` unit-tested
for merge + fallback precedence + DB-error fallback.
### WS4 — Cleanup (runs last, after the features exist)
- **Delete stale comments:** `FleetStatusHub.cs` ("passive channel / until the
bridge lands"), `EndpointRouteBuilderExtensions.cs` (F15), `DriverIdentitySection.razor`
("Phase 4 / generic DriverEdit"), `DriverEditRouter.razor` + `DriverTypePicker.razor`
(`TODO(3.3/3.4)` + the "falls back to legacy DriverEdit" path — verify & clean,
legacy file is gone), and update `DriverResilienceSection.razor`'s comment.
- **Strip rendered notes** now true: per-driver "list-editor coming in a follow-up
phase" notes, the OpcUaClient endpoint note, the resilience "typed-form-ifying
Polly is a follow-up" note, and the RoleGrants "UI-driven editing is deferred" note.
## Cross-cutting
- **No DB schema change** — `LdapGroupRoleMapping` migration already applied;
`DriverConfig`/`ResilienceConfig` columns unchanged.
- **Definition of done:** build clean + `dotnet test` green + a `/run` pass
exercising the modal editors and role-map CRUD.
- **Suggested sequence:** WS1 shared shell + Modbus tags as proof → remaining
drivers → WS2 → WS3 → WS4.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
{
"planPath": "docs/plans/2026-05-29-adminui-followups.md",
"branch": "feat/adminui-followups",
"tasks": [
{"id": 11, "plan": 1, "subject": "Task 1: Generic CollectionEditor<TRow> component", "status": "pending"},
{"id": 12, "plan": 2, "subject": "Task 2: Modbus tag editor (proof) + tests", "status": "pending", "blockedBy": [11]},
{"id": 13, "plan": 3, "subject": "Task 3: AbCip device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 14, "plan": 4, "subject": "Task 4: AbLegacy device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 15, "plan": 5, "subject": "Task 5: TwinCAT device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 16, "plan": 6, "subject": "Task 6: FOCAS device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 17, "plan": 7, "subject": "Task 7: S7 tag editor + tests", "status": "pending", "blockedBy": [11]},
{"id": 18, "plan": 8, "subject": "Task 8: OpcUaClient endpoint-URL editor + tests", "status": "pending", "blockedBy": [11]},
{"id": 19, "plan": 9, "subject": "Task 9: ResilienceFormModel + tests", "status": "pending"},
{"id": 20, "plan": 10, "subject": "Task 10: Typed resilience form in DriverResilienceSection", "status": "pending", "blockedBy": [19]},
{"id": 21, "plan": 11, "subject": "Task 11: RoleMapper.Merge overload + tests", "status": "pending"},
{"id": 22, "plan": 12, "subject": "Task 12: Register ILdapGroupRoleMappingService in DI", "status": "pending"},
{"id": 23, "plan": 13, "subject": "Task 13: Wire DB merge into AuthEndpoints.LoginAsync", "status": "pending", "blockedBy": [21, 22]},
{"id": 24, "plan": 14, "subject": "Task 14: Add FleetAdmin authorization policy", "status": "pending"},
{"id": 25, "plan": 15, "subject": "Task 15: RoleGrants.razor global CRUD (FleetAdmin-gated)", "status": "pending", "blockedBy": [22, 24]},
{"id": 26, "plan": 16, "subject": "Task 16: LdapGroupRoleMapping service tests (global CRUD)", "status": "pending"},
{"id": 27, "plan": 17, "subject": "Task 17: Delete stale source comments", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 20, 25]},
{"id": 28, "plan": 18, "subject": "Task 18: Strip now-true rendered notes", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 25]},
{"id": 29, "plan": 19, "subject": "Task 19: Full verification (build + test + /run)", "status": "pending", "blockedBy": [20, 23, 26, 27, 28]}
],
"lastUpdated": "2026-05-29"
}
@@ -0,0 +1,273 @@
# Auth/login alignment with ScadaBridge — design
> **Status:** approved 2026-05-29. Implementation plan to follow via `writing-plans`.
> **Trigger:** browser hitting `http://localhost:9200/` rendered Chrome's `HTTP_RESPONSE_CODE_FAILURE` page because the cookie scheme's `OnRedirectToLogin` event was overridden to return 401 with no body, and the parallel JwtBearer scheme stamped `WWW-Authenticate: Bearer`. ScadaBridge sets `LoginPath` and lets the framework do its built-in browser-vs-AJAX heuristic; OtOpcUa diverged.
**Goal:** Restore default browser-redirect ergonomics on protected GETs, retire the unused JwtBearer server-side scheme, and externalize cookie config — bringing OtOpcUa's auth structure into parity with ScadaBridge.
**Architecture:** Single Cookie auth scheme. The JWT keeps minting (via `JwtTokenService`) and validating (in `CookieAuthenticationStateProvider`) as the **cookie payload only**; no `AddJwtBearer`, no parallel `Authorization: Bearer` validation. Cookie config (`Name`, `ExpiryMinutes`, `RequireHttpsCookie`) flows through the existing-but-unused `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step — same pattern ScadaBridge uses.
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` only (drop `Microsoft.AspNetCore.Authentication.JwtBearer` from the wiring if its only remaining transitive use disappears with this change).
---
## 1. Architecture
### Schemes
| Before | After |
|---|---|
| Cookie (primary) + JwtBearer (parallel) | Cookie only |
| `FallbackPolicy` lists both schemes | `FallbackPolicy` lists Cookie only |
| `OnRedirectToLogin` overridden to 401 | default behavior: 302 for browsers, 401 for AJAX |
| `OnRedirectToAccessDenied` overridden to 403 | default behavior: 302 to `/Account/AccessDenied` (404s today; matches ScadaBridge) |
### Cookie config — externalized via `OtOpcUaCookieOptions`
```csharp
public sealed class OtOpcUaCookieOptions
{
public const string SectionName = "Security:Cookie";
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
public int ExpiryMinutes { get; set; } = 30;
public bool RequireHttpsCookie { get; set; } = true;
}
```
Wired into `CookieAuthenticationOptions` via:
```csharp
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
{
cookieOpts.Cookie.Name = ourOpts.Value.Name;
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(ourOpts.Value.ExpiryMinutes);
cookieOpts.SlidingExpiration = true;
cookieOpts.Cookie.SecurePolicy = ourOpts.Value.RequireHttpsCookie
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
if (!ourOpts.Value.RequireHttpsCookie)
{
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. " +
"Cookie travels in cleartext over plain HTTP. Dev-only.");
}
});
```
### Endpoint surface — unchanged
| Path | Auth | Behavior |
|---|---|---|
| `POST /auth/login` | AllowAnonymous | LDAP auth → SignInAsync(Cookie); JSON callers get 204 / 401 / 503, form posters get 302 + cookie |
| `POST /auth/logout` | RequireAuthorization | SignOutAsync(Cookie) |
| `GET /auth/ping` | AllowAnonymous (handler-returns 200/401) | Polled by Blazor every 60s |
| `POST /auth/token` | RequireAuthorization | Mints JWT for hypothetical external callers (matches ScadaBridge — they keep this even without JwtBearer wired) |
### Cookie rename
Old: `OtOpcUa.Auth`. New: `ZB.MOM.WW.OtOpcUa.Auth`. Effect: all sessions in flight at deploy time are invisible to the new handler → users re-prompt for login on next protected GET. No security impact (the old cookie expires per its own sliding window; nothing reads it).
---
## 2. Components
### Files modified
| File | Change |
|---|---|
| `src/Server/.../Security/CookieOptions.cs` | Add `RequireHttpsCookie`; change `Name` default to `ZB.MOM.WW.OtOpcUa.Auth` |
| `src/Server/.../Security/ServiceCollectionExtensions.cs` | Drop `using JwtBearer`; delete `ConfigureJwtBearerFromTokenService` class; drop `.AddJwtBearer` + its IPostConfigureOptions registration; drop `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides; add `LoginPath` + `LogoutPath`; add PostConfigure block consuming `OtOpcUaCookieOptions`; remove `JwtBearerDefaults.AuthenticationScheme` from `FallbackPolicy` builder |
| `tests/Server/.../Security.Tests/AuthEndpointsIntegrationTests.cs` | Update the `Set-Cookie` assertion on the login-success test from `OtOpcUa.Auth=``ZB.MOM.WW.OtOpcUa.Auth=` |
### Files NOT modified
| File | Why |
|---|---|
| `Endpoints/AuthEndpoints.cs` | Endpoint contracts unchanged |
| `Jwt/JwtTokenService.cs` | Still mints JWT into cookie payload |
| `Blazor/CookieAuthenticationStateProvider.cs` | Still polls `/auth/ping` |
| `Ldap/*` | Untouched |
| Razor login page | POST target unchanged |
| `appsettings*.json` | Defaults are production-safe; no required config edit |
### Tests added
Single new file or appended class in `tests/Server/.../Security.Tests/`:
```csharp
public class AuthChallengeTests : AuthEndpointsTestBase
{
[Fact]
public async Task Root_anonymous_browser_GET_redirects_to_login()
{
var client = NewClient(allowAutoRedirect: false);
client.DefaultRequestHeaders.Accept.ParseAdd("text/html");
var resp = await client.GetAsync("/", Ct);
resp.StatusCode.ShouldBe(HttpStatusCode.Found); // 302
resp.Headers.Location!.ToString().ShouldContain("/login");
resp.Headers.Location.ToString().ShouldContain("ReturnUrl");
}
[Fact]
public async Task Root_anonymous_xhr_GET_returns_401()
{
var client = NewClient(allowAutoRedirect: false);
client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest");
var resp = await client.GetAsync("/", Ct);
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
// Framework still writes a Location header alongside the 401 — AJAX clients ignore it.
}
}
```
**Framework reality vs. earlier hypothesis:** The ASP.NET Core cookie handler's `IsAjaxRequest` heuristic checks ONLY the `X-Requested-With: XMLHttpRequest` header, NOT the `Accept` content type. A request with `Accept: application/json` but no XHR header is classified as a browser → 302. The third test originally proposed (`Root_anonymous_json_GET_returns_401`) was dropped because it tests behavior the framework doesn't have. ScadaBridge accepts the same framework reality (it doesn't override the heuristic either).
### Package references
`src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`: remove `<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />` if grep confirms `JwtTokenService` doesn't itself need it (it uses `Microsoft.IdentityModel.Tokens` for validation parameters, separate package).
---
## 3. Data flow
### Anonymous browser hits `/`
```
Browser → GET /
Accept: text/html
┌──> AuthN: no cookie → unauthenticated
├──> AuthZ FallbackPolicy fails
└──> Cookie HandleChallengeAsync:
- Accept: text/html → browser
- 302 Location: /login?ReturnUrl=%2F
Browser → GET /login ← redirect followed; login page renders (AllowAnonymous)
[user submits form]
Browser → POST /auth/login Content-Type: application/x-www-form-urlencoded
─── LoginAsync:
- LDAP authenticate
- SignInAsync(Cookie)
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=...
- 302 Location: / (or ReturnUrl)
Browser → GET / cookie present → AuthZ passes → 200 + Razor render
```
### XHR / fetch hits a protected endpoint without cookie
```
fetch('/api/something') Accept: application/json
X-Requested-With: XMLHttpRequest
┌──> AuthN: no cookie → unauthenticated
├──> AuthZ FallbackPolicy fails
└──> Cookie HandleChallengeAsync:
- not text/html → API client
- 401 (no body, no Location)
```
The cookie handler's built-in `IsAjaxRequest` heuristic is what makes this work — it looks for `X-Requested-With: XMLHttpRequest`. No custom event handler needed. Note: requests with only `Accept: application/json` (no XHR header) are classified as browsers → 302; AJAX callers should set the XHR header to get 401.
### Logout
```
fetch('/auth/logout', POST) cookie present
─── LogoutAsync (RequireAuthorization passes):
- SignOutAsync(Cookie)
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=; expires=...
- 204 (or browser-form: 302 /login)
```
### Old cookie ignored
Browser holds stale `OtOpcUa.Auth` from a session that predates the deploy. Cookie scheme is now configured for `ZB.MOM.WW.OtOpcUa.Auth` — old cookie is invisible. User treated as anonymous → 302 to `/login`. Old cookie sits in jar until its own sliding window expires (max 30 min); no security risk because nothing reads it.
### Blazor `/auth/ping` polling
```
CookieAuthenticationStateProvider → GET /auth/ping every 60s
cookie present → 200
cookie expired/missing → 401
Blazor → invalidates auth state → re-render → root [Authorize] fails
→ Cookie HandleChallengeAsync → 302 /login
```
Unchanged.
---
## 4. Error handling
| Surface | Behavior |
|---|---|
| Unknown `Accept` (`*/*`, missing, JSON) | Framework default: treated as non-AJAX → 302 to `/login`. The cookie handler's `IsAjaxRequest` only looks at `X-Requested-With`, NOT `Accept`. CLI tools that want a 401 should set `X-Requested-With: XMLHttpRequest`. |
| `LoginAsync` bad creds | JSON: `401`. Form: `302 /login?error=…&returnUrl=…`. Handler-returned, unaffected by middleware changes. |
| `LoginAsync` LDAP throws | `503 ServiceUnavailable`. Handler-returned. |
| `LoginAsync` success | JSON: `204`. Form: `302 /` (or `ReturnUrl`). |
| Cookie expires mid-request | Treated as anonymous → 302 to `/login` (browser) or 401 (AJAX). Active users kept alive by `SlidingExpiration = true`. |
| `RequireHttpsCookie = false` over HTTPS | Cookie marked `SecurePolicy = SameAsRequest`. Misconfiguration risk; startup logs Warning every boot so it's audible. No validator-refused boot — default is `true`; dev compose explicitly opts out. |
| Missing `Security:Cookie` section in config | `.Bind()` no-ops; defaults take over (`Name = ZB.MOM.WW.OtOpcUa.Auth`, `ExpiryMinutes = 30`, `RequireHttpsCookie = true`). Production-safe. |
| `[Authorize(Policy="DriverOperator")]` denied for authenticated non-operator | Cookie handler redirects to default `AccessDeniedPath = "/Account/AccessDenied"` which 404s in OtOpcUa. Matches ScadaBridge; rare enough not to be a P0. Follow-up: add a minimal `/access-denied` Razor page. |
---
## 5. Testing
### Existing tests pass unchanged
- `Login_with_invalid_credentials_returns_401` — handler-returned, unaffected
- `Login_when_ldap_throws_returns_503` — handler-returned, unaffected
- `Ping_anonymous_returns_401` — handler-returned, unaffected
- `Ping_after_cookie_login_returns_200` — uses HttpClient cookie container, picks up renamed cookie automatically
- `Login_with_cookie_credentials_returns_204_and_sets_cookie` — needs one assertion update (cookie name)
### Tests added (3 new)
- `Root_anonymous_browser_GET_redirects_to_login` — asserts 302 + `Location` contains `/login` + `ReturnUrl`
- `Root_anonymous_ajax_GET_returns_401``X-Requested-With: XMLHttpRequest` → 401, no `Location`
(the originally planned `Root_anonymous_json_GET_returns_401` was dropped — see Section 3 framework-reality note above)
### Removed/orphaned tests
None expected. The explore phase found no test depending on `ConfigureJwtBearerFromTokenService` or the `WWW-Authenticate: Bearer` response. Grep at plan-write time to confirm.
### Manual smoke (docker-dev stack)
1. `http://localhost:9200/` anonymously → expect 302 to `/login?ReturnUrl=%2F` (was: Chrome error page)
2. Sign in via the form
3. `http://localhost:9200/` authenticated → expect Razor dashboard
4. DevTools → Application → Cookies → confirm `ZB.MOM.WW.OtOpcUa.Auth`
5. `curl -i http://localhost:9200/``302 Found`, Location: `/login?ReturnUrl=%2F`
6. `curl -i -H "Accept: application/json" http://localhost:9200/``401 Unauthorized`
### Verification gates at PR time
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero new errors (pre-existing 12 unchanged)
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — all green
- Manual Chrome smoke above passes
---
## 6. Sequencing (for plan-writing)
Single-PR feature, but split into reviewable phases:
1. **Phase 1 — Options class.** Extend `OtOpcUaCookieOptions` with `RequireHttpsCookie` and new `Name` default. Tests unaffected.
2. **Phase 2 — Wiring rewrite.** Edit `ServiceCollectionExtensions.cs`: drop JwtBearer, drop event overrides, add `LoginPath`/`LogoutPath`, add PostConfigure consumption of `OtOpcUaCookieOptions`. Update the one existing test assertion. Build + existing Security.Tests green.
3. **Phase 3 — New challenge tests.** Add the 3 new redirect/401 tests.
4. **Phase 4 — Package cleanup.** Remove `Microsoft.AspNetCore.Authentication.JwtBearer` from csproj if grep confirms no remaining consumer.
5. **Phase 5 — Manual smoke + commit.** Restart admin-a/admin-b in docker-dev; verify in Chrome.
---
## Decisions table
| # | Decision | Rationale |
|---|---|---|
| 1 | Drop JwtBearer server-side scheme | No in-repo consumer; brought non-redirect 401 + `WWW-Authenticate: Bearer` to browser GETs |
| 2 | Keep `JwtTokenService` + `/auth/token` | Token-as-cookie-payload is load-bearing for Blazor; `/auth/token` matches ScadaBridge surface |
| 3 | Rename cookie `OtOpcUa.Auth``ZB.MOM.WW.OtOpcUa.Auth` | Naming parity with ScadaBridge; one-time forced sign-out acceptable |
| 4 | Externalize via existing `OtOpcUaCookieOptions` + PostConfigure | Mirrors ScadaBridge pattern; fixes pre-existing bug where options class was bound but ignored |
| 5 | Drop both `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides | Restores framework's browser-vs-AJAX heuristic; ScadaBridge does the same |
| 6 | Set `LoginPath = "/login"`, `LogoutPath = "/auth/logout"` | Required for the framework's default redirect to work |
| 7 | Accept 404 on `/Account/AccessDenied` for v1 | Matches ScadaBridge; rare path; follow-up to add minimal page |
| 8 | Warning-log when `RequireHttpsCookie = false` | Audible misconfig signal; same as ScadaBridge |
@@ -0,0 +1,652 @@
# Auth/login alignment with ScadaBridge — implementation plan
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` or `superpowers-extended-cc:subagent-driven-development` to implement this plan task-by-task.
**Goal:** Match ScadaBridge's single-Cookie auth pattern: drop the unused JwtBearer parallel scheme, restore the framework's default browser-vs-AJAX challenge heuristic, and externalize cookie config through the existing-but-unused `OtOpcUaCookieOptions`.
**Architecture:** Cookie-only auth. `JwtTokenService` keeps minting JWTs as the cookie payload (Blazor circuit hydration depends on it). Cookie name + idle timeout + HTTPS policy flow through `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step. Endpoint surface (`/auth/login`, `/auth/logout`, `/auth/ping`, `/auth/token`) unchanged.
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` / xUnit v3 + Shouldly / `Microsoft.AspNetCore.TestHost.TestServer`.
**Design doc:** `docs/plans/2026-05-29-auth-alignment-design.md` (commit `bc4fce5`). Each task below cites the design section it implements.
---
## Sequencing
```
Task 1 (Options class)
└─► Task 2 (Wiring rewrite + test assertion update)
├─► Task 3 (3 new challenge tests)
└─► Task 4 (csproj cleanup)
└─► Task 5 (manual smoke + final commit)
```
Tasks 3 and 4 are parallelizable (disjoint files).
---
## Task 1 — Extend `OtOpcUaCookieOptions`
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** none (Task 2 depends on this)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs`
**Implements design:** Section 1 (Architecture, "Cookie config — externalized") + Section 2 (Components, file table row 1).
### Step 1: Replace file contents
Current file (12 lines):
```csharp
namespace ZB.MOM.WW.OtOpcUa.Security;
public sealed class OtOpcUaCookieOptions
{
public const string SectionName = "Security:Cookie";
/// <summary>Gets or sets the cookie name.</summary>
public string Name { get; set; } = "OtOpcUa.Auth";
/// <summary>Idle sliding window, in minutes (default 30).</summary>
public int ExpiryMinutes { get; set; } = 30;
}
```
Replace with:
```csharp
namespace ZB.MOM.WW.OtOpcUa.Security;
/// <summary>
/// Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
/// <c>Configure&lt;IOptions&lt;OtOpcUaCookieOptions&gt;, ILoggerFactory&gt;</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"
}
+100
View File
@@ -0,0 +1,100 @@
# Alarms D.1 — smoke artifact
> **Status (2026-05-29): alarm-source leg VERIFIED. Historian-write leg still
> pending the Windows sidecar + live AVEVA Historian.**
>
> 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.
+33 -16
View File
@@ -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:
+27 -10
View File
@@ -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) | 23 days implementation; 1 day tests |
| A.3 | A.2 delivering WorkerEvent bodies | 12 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 | 12 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.
--- ---
+2 -1
View File
@@ -251,7 +251,8 @@ The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.
|---|---| |---|---|
| `ConfigViewer` | Read-only access to drafts, generations, audit log, fleet status. | | `ConfigViewer` | Read-only access to drafts, generations, audit log, fleet status. |
| `ConfigEditor` | ConfigViewer plus draft editing (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. | | `ConfigEditor` | ConfigViewer plus draft editing (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. |
| `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. | | `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. Also satisfies the `DriverOperator` authorization policy. |
| `DriverOperator` | May issue **Reconnect** and **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel`. Gated by the `DriverOperator` named policy in `AddAuthorization` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). Map an LDAP group via `GroupToRole`, e.g. `"ot-driver-operator": "DriverOperator"`. |
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) and Razor pages gate inline with the role names, e.g. `@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]` on `Deployments.razor`. Nav-menu sections hide via `<AuthorizeView>`. In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) and Razor pages gate inline with the role names, e.g. `@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]` on `Deployments.razor`. Nav-menu sections hide via `<AuthorizeView>`.
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>One node in a driver-agnostic browse tree.</summary>
/// <param name="NodeId">Stable identifier passed back to the picker on commit. For OPC UA
/// this is the <c>nsu=...;...</c> form; for Galaxy this is the <c>tag_name</c>.</param>
/// <param name="DisplayName">Label shown in the tree.</param>
/// <param name="Kind">Whether this node terminates the address (Leaf) or has children
/// (Folder). Galaxy never returns Leaves; only the attribute side-panel terminates.</param>
/// <param name="HasChildrenHint">When true, the UI renders an expand affordance before
/// the children have been fetched.</param>
public sealed record BrowseNode(
string NodeId,
string DisplayName,
BrowseNodeKind Kind,
bool HasChildrenHint);
/// <summary>Discriminates terminal vs. expandable nodes for UI rendering.</summary>
public enum BrowseNodeKind
{
/// <summary>Expandable — has (or may have) children. UI shows expand affordance.</summary>
Folder,
/// <summary>Terminal — commit on select.</summary>
Leaf,
}
/// <summary>Metadata for an attribute of a Galaxy object (or the equivalent
/// per-driver concept). Surfaced in the picker's attribute side-panel.</summary>
public sealed record AttributeInfo(
string Name,
string DriverDataType,
bool IsArray,
string SecurityClass);
@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>
/// A live, one-level-at-a-time browse over a remote address space. Owned by the
/// AdminUI <c>BrowseSessionRegistry</c>; disposed by the registry's TTL reaper or
/// the picker body on close.
/// </summary>
public interface IBrowseSession : IAsyncDisposable
{
/// <summary>Opaque token identifying this session in the registry.</summary>
Guid Token { get; }
/// <summary>Wall-clock time of the most recent successful call. Refreshed on
/// <see cref="RootAsync"/>, <see cref="ExpandAsync"/>, and
/// <see cref="AttributesAsync"/>; used by the reaper for idle eviction.</summary>
DateTime LastUsedUtc { get; }
/// <summary>Returns the top-level browse nodes.</summary>
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
/// <summary>Returns the direct children of the node identified by
/// <paramref name="nodeId"/>.</summary>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
/// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
/// the attribute side-panel after the user selects an object.</summary>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>
/// Per-driver factory that opens an ad-hoc browse session against the configuration
/// supplied as JSON. Parallels <c>IDriverProbe</c> in the runtime — one implementation
/// per driver type, registered in AdminUI DI and indexed by <see cref="DriverType"/>.
/// </summary>
public interface IDriverBrowser
{
/// <summary>Driver type key, matching the AdminUI's persisted DriverType string
/// (e.g. "OpcUaClient", "Galaxy").</summary>
string DriverType { get; }
/// <summary>Opens a browse session against the supplied configuration.</summary>
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
}
@@ -14,4 +14,14 @@ public interface IAdminOperationsClient
/// <param name="ct">The cancellation token.</param> /// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation containing the deployment start result.</returns> /// <returns>A task representing the asynchronous operation containing the deployment start result.</returns>
Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct); Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
/// <summary>
/// Generic Ask: forwards <paramref name="message"/> to the AdminOperationsActor
/// cluster-singleton proxy and awaits a reply of type <typeparamref name="T"/>.
/// The caller is responsible for applying any outer timeout via <paramref name="ct"/>.
/// </summary>
/// <typeparam name="T">Expected reply type.</typeparam>
/// <param name="message">The message to send.</param>
/// <param name="ct">Cancellation token (caller-controlled timeout).</param>
Task<T> AskAsync<T>(object message, CancellationToken ct);
} }
@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
/// <summary>
/// AdminUI → AdminOperationsActor: reconnect the driver actor's transport without
/// respawning the actor itself. Sends the actor back through its Reconnecting state —
/// fast, preserves in-memory state. The driver actor's supervisor performs the work.
/// </summary>
/// <param name="ClusterId">Cluster scope identifier (for audit).</param>
/// <param name="DriverInstanceId">The driver instance to reconnect.</param>
/// <param name="ActorByUserName">The authenticated admin user who triggered the reconnect.</param>
/// <param name="CorrelationId">Round-trip correlation token.</param>
public sealed record ReconnectDriver(
string ClusterId,
string DriverInstanceId,
string ActorByUserName,
Guid CorrelationId);
/// <summary>Reply for <see cref="ReconnectDriver"/>.</summary>
/// <param name="Ok">True iff the operation was dispatched without error.</param>
/// <param name="Message">Failure reason; null on success.</param>
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
public sealed record ReconnectDriverResult(
bool Ok,
string? Message,
Guid CorrelationId);
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
/// <summary>
/// Shared DPS topic for driver-control commands (<see cref="RestartDriver"/>,
/// <see cref="ReconnectDriver"/>). Publishers (AdminOperationsActor) and subscribers
/// (DriverHostActor) reference this single constant so renames can't silently
/// desynchronise.
/// </summary>
public static class DriverControlTopic
{
public const string Name = "driver-control";
}
/// <summary>
/// AdminUI → AdminOperationsActor: restart the driver actor for one instance.
/// A restart fully stops and respawns the actor — loses in-memory state, may briefly
/// interrupt active subscriptions. The driver actor's supervisor performs the work.
/// </summary>
/// <param name="ClusterId">Cluster scope identifier (for audit).</param>
/// <param name="DriverInstanceId">The driver instance to restart.</param>
/// <param name="ActorByUserName">The authenticated admin user who triggered the restart.</param>
/// <param name="CorrelationId">Round-trip correlation token.</param>
public sealed record RestartDriver(
string ClusterId,
string DriverInstanceId,
string ActorByUserName,
Guid CorrelationId);
/// <summary>Reply for <see cref="RestartDriver"/>.</summary>
/// <param name="Ok">True iff the operation was dispatched without error.</param>
/// <param name="Message">Failure reason; null on success.</param>
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
public sealed record RestartDriverResult(
bool Ok,
string? Message,
Guid CorrelationId);
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
/// <summary>
/// AdminUI → AdminOperationsActor request: probe one driver type's connection using
/// the supplied JSON config. Routed through <c>IAdminOperationsClient</c>; reply is
/// <see cref="TestDriverConnectResult"/>.
/// </summary>
/// <param name="DriverType">Must match an installed <c>IDriverProbe.DriverType</c>.</param>
/// <param name="ConfigJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
/// <param name="TimeoutSeconds">Per-probe timeout; server clamps to [1, 60].</param>
/// <param name="CorrelationId">Round-trip correlation token.</param>
public sealed record TestDriverConnect(
string DriverType,
string ConfigJson,
int TimeoutSeconds,
Guid CorrelationId);
/// <summary>Reply for <see cref="TestDriverConnect"/>.</summary>
/// <param name="Ok">True iff the probe succeeded.</param>
/// <param name="Message">Failure reason; null on success.</param>
/// <param name="LatencyMs">Round-trip latency in milliseconds; null on failure or timeout.</param>
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
public sealed record TestDriverConnectResult(
bool Ok,
string? Message,
double? LatencyMs,
Guid CorrelationId);
@@ -20,4 +20,12 @@ public sealed record DriverHealthChanged(
DateTime? LastSuccessfulReadUtc, DateTime? LastSuccessfulReadUtc,
string? LastError, string? LastError,
int ErrorCount5Min, int ErrorCount5Min,
DateTime PublishedUtc); DateTime PublishedUtc)
{
/// <summary>
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
/// <c>DriverStatusSignalRBridge</c> reference this single constant so renames can't
/// silently desynchronise publisher and subscriber.
/// </summary>
public const string TopicName = "driver-health";
}
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.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;
} }
} }
@@ -172,8 +172,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,
}; };
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Sink for driver-health state-change notifications. The runtime DI wires the
/// Akka-DistributedPubSub-backed implementation; tests and dev-stub paths use
/// <see cref="NullDriverHealthPublisher"/> to opt out without changing call sites.
/// </summary>
public interface IDriverHealthPublisher
{
/// <summary>
/// Publishes a health snapshot for one driver instance. Implementations must be
/// non-blocking and tolerant of being called from any thread.
/// </summary>
void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min);
}
/// <summary>
/// Drop-in no-op for tests and dev-stub paths. Production wires the Akka-backed
/// implementation in the Runtime project.
/// </summary>
public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
{
/// <summary>Singleton instance.</summary>
public static readonly NullDriverHealthPublisher Instance = new();
private NullDriverHealthPublisher() { }
/// <inheritdoc />
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min)
{ /* no-op */ }
}
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Test-connect probe for one driver type. Implementations deserialize a driver-config
/// JSON, attempt a cheap connection (TCP open, OPC UA session, gRPC ping — whatever the
/// driver's native protocol supports), and report success/failure with latency. Probes
/// MUST NOT mutate any persistent state; the AdminUI invokes them against transient
/// config from the typed form, NOT against the persisted DriverInstance row.
/// </summary>
public interface IDriverProbe
{
/// <summary>DriverInstance.DriverType string this probe handles. Used for DI lookup.</summary>
string DriverType { get; }
/// <summary>
/// Run the probe with the supplied config + timeout. Honour <paramref name="ct"/> for
/// timeout cancellation. Never throw on connection failure; instead return a result
/// with <c>Ok = false</c> + a message.
/// </summary>
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
}
/// <summary>Outcome of a single <see cref="IDriverProbe.ProbeAsync"/> call.</summary>
/// <param name="Ok">True iff the probe reached its target and the handshake succeeded.</param>
/// <param name="Message">Human-readable status; null on success.</param>
/// <param name="Latency">Wall-clock duration of the successful probe; null on failure.</param>
public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency);
@@ -12,20 +12,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
[Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")] [Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : AbLegacyCommandBase public sealed class SubscribeCommand : AbLegacyCommandBase
{ {
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
/// <summary>Gets or sets the PCCC file address to subscribe to.</summary> /// <summary>Gets or sets the PCCC file address to subscribe to.</summary>
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!; public string Address { get; init; } = default!;
/// <summary>Gets or sets the data type of the address.</summary>
[CommandOption("type", 't', Description = [CommandOption("type", 't', Description =
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " + "Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
"ControlElement (default Int).")] "ControlElement (default Int).")]
/// <summary>Gets or sets the data type of the address.</summary>
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int; public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
/// <summary>Gets or sets the polling interval in milliseconds.</summary>
[CommandOption("interval-ms", 'i', Description = [CommandOption("interval-ms", 'i', Description =
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " + "Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
"sub-250ms values.")] "sub-250ms values.")]
/// <summary>Gets or sets the polling interval in milliseconds.</summary>
public int IntervalMs { get; init; } = 1000; public int IntervalMs { get; init; } = 1000;
/// <inheritdoc /> /// <inheritdoc />
@@ -13,14 +13,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
[Command("probe", Description = "Verify the S7 endpoint is reachable and a sample read succeeds.")] [Command("probe", Description = "Verify the S7 endpoint is reachable and a sample read succeeds.")]
public sealed class ProbeCommand : S7CommandBase public sealed class ProbeCommand : S7CommandBase
{ {
/// <summary>Gets or sets the S7 address to probe.</summary>
[CommandOption("address", 'a', Description = [CommandOption("address", 'a', Description =
"Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " + "Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " +
"reserves a fingerprint DB.")] "reserves a fingerprint DB.")]
/// <summary>Gets or sets the S7 address to probe.</summary>
public string Address { get; init; } = "MW0"; public string Address { get; init; } = "MW0";
[CommandOption("type", Description = "Probe data type (default Int16).")]
/// <summary>Gets or sets the data type of the probe address.</summary> /// <summary>Gets or sets the data type of the probe address.</summary>
[CommandOption("type", Description = "Probe data type (default Int16).")]
public S7DataType DataType { get; init; } = S7DataType.Int16; public S7DataType DataType { get; init; } = S7DataType.Int16;
/// <inheritdoc /> /// <inheritdoc />
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any CIP bytes —
/// a richer EIP session-open probe is a documented follow-up.
/// </summary>
public sealed class AbCipDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "AbCip";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
AbCipDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<AbCipDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(AbCipDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = AbCipHostAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
return (parsed.Gateway, parsed.Port);
}
}
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="AbLegacyDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any PCCC bytes —
/// a richer EIP session-open probe is a documented follow-up.
/// </summary>
public sealed class AbLegacyDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "AbLegacy";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
AbLegacyDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<AbLegacyDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
return (parsed.Gateway, parsed.Port);
}
}
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's FOCAS Ethernet address + port and closes
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any FOCAS/2 bytes —
/// a richer FOCAS handshake (cnc_allclibhndl3) probe is a documented follow-up.
/// </summary>
public sealed class FocasDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "FOCAS";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
FocasDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<FocasDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(FocasDriverOptions opts)
{
// Parse the first device's focas:// address to extract host + port.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = FocasHostAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
return (parsed.Host, parsed.Port);
}
}
@@ -0,0 +1,179 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
/// <summary>
/// Lazy Galaxy browse over <see cref="GalaxyRepositoryClient.BrowseAsync"/>.
/// <see cref="RootAsync"/> returns the top-level <see cref="LazyBrowseNode"/>s
/// directly from the gateway; <see cref="ExpandAsync"/> fetches the direct children
/// of a previously-handed-out node via <see cref="LazyBrowseNode.ExpandAsync"/>
/// (one wire call per click, paginated internally by the client). Attribute fetches
/// are per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
/// Owns the supplied <see cref="GalaxyRepositoryClient"/> and disposes it best-effort.
/// </summary>
internal sealed class GalaxyBrowseSession : IBrowseSession
{
private readonly GalaxyRepositoryClient _client;
private readonly ConcurrentDictionary<string, LazyBrowseNode> _byTagName = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _rootGate = new(1, 1);
private volatile bool _disposed;
private IReadOnlyList<LazyBrowseNode>? _roots;
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Wall-clock time of the most recent successful Root/Expand/Attributes call.</summary>
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <summary>
/// Initializes a new session wrapping a connected repository client. The factory
/// in <c>GalaxyDriverBrowser</c> constructs the client via
/// <see cref="GalaxyRepositoryClient.Create"/> and hands it off here for the
/// session's lifetime.
/// </summary>
/// <param name="client">Galaxy repository client to query for browse and attributes.</param>
internal GalaxyBrowseSession(GalaxyRepositoryClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <summary>
/// Fetches the top-level <see cref="LazyBrowseNode"/>s from the gateway and
/// returns them as <see cref="BrowseNode"/>s. Result is cached; a second call
/// returns the cached roots without a re-fetch.
/// </summary>
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _rootGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_roots ??= await _client.BrowseAsync(new BrowseChildrenOptions(), cancellationToken)
.ConfigureAwait(false);
LastUsedUtc = DateTime.UtcNow;
return Project(_roots);
}
finally
{
_rootGate.Release();
}
}
/// <summary>
/// Fetches the direct children of the cached node identified by
/// <paramref name="nodeId"/> (the object's <c>TagName</c>) via
/// <see cref="LazyBrowseNode.ExpandAsync"/>. Throws <see cref="ArgumentException"/>
/// if the tag hasn't been handed out by a prior Root/Expand call.
/// </summary>
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_byTagName.TryGetValue(nodeId, out var node))
{
throw new ArgumentException(
$"Galaxy object '{nodeId}' is not in the current browse-session cache. " +
"Re-open the browser or expand its parent first.", nameof(nodeId));
}
await node.ExpandAsync(cancellationToken).ConfigureAwait(false);
LastUsedUtc = DateTime.UtcNow;
return Project(node.Children);
}
/// <summary>
/// Fetches the attributes of the Galaxy object identified by <paramref name="nodeId"/>
/// via <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true)</c>.
/// Returns an empty list if the gateway has no matching object.
/// </summary>
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var rows = await _client.DiscoverHierarchyAsync(
new DiscoverHierarchyOptions
{
RootTagName = nodeId,
MaxDepth = 0,
IncludeAttributes = true,
}, cancellationToken).ConfigureAwait(false);
LastUsedUtc = DateTime.UtcNow;
var obj = rows.FirstOrDefault();
if (obj is null) return Array.Empty<AttributeInfo>();
var result = new List<AttributeInfo>(obj.Attributes.Count);
foreach (var attr in obj.Attributes)
{
var driverType = !string.IsNullOrEmpty(attr.DataTypeName)
? attr.DataTypeName
: attr.MxDataType.ToString(System.Globalization.CultureInfo.InvariantCulture);
result.Add(new AttributeInfo(
Name: attr.AttributeName,
DriverDataType: driverType,
IsArray: attr.IsArray,
SecurityClass: MapSecurityClass(attr.SecurityClassification)));
}
return result;
}
/// <summary>
/// Projects <see cref="LazyBrowseNode"/>s to <see cref="BrowseNode"/>s, caching
/// each by <c>TagName</c> so a subsequent <see cref="ExpandAsync"/> can locate
/// it. Galaxy nodes are always <see cref="BrowseNodeKind.Folder"/> — leaves only
/// appear in the attribute side-panel.
/// </summary>
private IReadOnlyList<BrowseNode> Project(IReadOnlyList<LazyBrowseNode> nodes)
{
var result = new List<BrowseNode>(nodes.Count);
foreach (var n in nodes)
{
_byTagName[n.Object.TagName] = n;
var displayName = !string.IsNullOrEmpty(n.Object.ContainedName)
? n.Object.ContainedName
: n.Object.TagName;
result.Add(new BrowseNode(
NodeId: n.Object.TagName,
DisplayName: displayName,
Kind: BrowseNodeKind.Folder,
HasChildrenHint: n.HasChildrenHint));
}
return result;
}
/// <summary>
/// Maps the Galaxy raw security-classification integer to a display string.
/// Buckets: 0=FreeAccess, 1=Operate, 2=Tune, 3=Configure, 4=ViewOnly;
/// anything else surfaces as <c>Unknown(N)</c>.
/// </summary>
private static string MapSecurityClass(int raw) => raw switch
{
0 => "FreeAccess",
1 => "Operate",
2 => "Tune",
3 => "Configure",
4 => "ViewOnly",
_ => $"Unknown({raw})",
};
/// <summary>
/// Idempotently tears down the underlying repository client. Swallows exceptions
/// on shutdown — the registry's reaper may be racing a client-initiated close.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_rootGate.Dispose();
try
{
await _client.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best-effort: a gateway-side close that hits a torn-down channel is normal.
}
}
}
@@ -0,0 +1,194 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
/// <summary>
/// Opens transient gateway connections for the AdminUI address picker. Mirrors the
/// runtime <c>GalaxyDriver.BuildClientOptions</c> pattern so the gateway sees the
/// same option shape, but tweaks <see cref="MxGatewayClientOptions.ApiKey"/>
/// resolution + the gateway-side client identity so browse sessions are
/// distinguishable from the runtime driver's live MX session.
/// </summary>
public sealed class GalaxyDriverBrowser : IDriverBrowser
{
/// <summary>
/// Identifier used in gateway-side logs / metrics for AdminUI browse sessions.
/// Distinct from any runtime driver's <c>MxAccess.ClientName</c> so an operator
/// can tell the two apart when triaging.
/// </summary>
internal const string BrowseClientIdentity = "OtOpcUa-AdminUI-Browse";
/// <summary>Hard cap on the time we'll wait for the initial gateway handshake.</summary>
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
};
private readonly ILogger<GalaxyDriverBrowser> _logger;
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
{
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
}
/// <summary>Driver type key — matches the AdminUI's persisted "GalaxyMxGateway" value.</summary>
// Hardcoded literal: this project references Driver.Galaxy.Contracts, not Driver.Galaxy,
// so GalaxyDriverFactoryExtensions.DriverTypeName isn't available here.
public string DriverType => "GalaxyMxGateway";
/// <summary>
/// Deserializes a <see cref="GalaxyDriverOptions"/> blob, opens a transient
/// <see cref="GalaxyRepositoryClient"/> against the configured gateway endpoint,
/// and returns a browse session over it. The session owns the client and disposes
/// it on <see cref="IBrowseSession.DisposeAsync"/>.
/// </summary>
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
/// <exception cref="InvalidOperationException">
/// Thrown when the JSON deserialises to null, when <c>Gateway.Endpoint</c> is empty,
/// or when <c>MxAccess.ClientName</c> is empty.
/// </exception>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
var opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, JsonOpts)
?? throw new InvalidOperationException("Galaxy options deserialized to null.");
if (string.IsNullOrWhiteSpace(opts.Gateway.Endpoint))
throw new InvalidOperationException("Galaxy browser requires Gateway.Endpoint.");
// The form persists MXAccess identity as ClientName (there is no separate
// "galaxy name" knob on the driver — the gateway picks the galaxy via its
// own GalaxyRepository config). Refuse a blank ClientName so the gateway side
// doesn't see anonymous browse sessions during triage.
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
var clientOpts = BuildClientOptions(opts.Gateway);
// 30s wall-clock budget for the connect phase, linked to the caller's token so
// an AdminUI cancel still wins early.
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(ConnectBudget);
GalaxyRepositoryClient? client = null;
try
{
client = GalaxyRepositoryClient.Create(clientOpts);
// TestConnectionAsync gives the gateway a chance to surface auth / TLS / DNS
// failures synchronously inside the connect budget rather than waiting for
// the first DiscoverHierarchyAsync call to fail. The client's own
// ConnectTimeout already bounds the underlying gRPC handshake; the linked
// CTS layered on top guarantees the AdminUI never blocks past 30s.
await client.TestConnectionAsync(connectCts.Token).ConfigureAwait(false);
_logger.LogInformation(
"AdminUI Galaxy browse session opened against {Endpoint} (admin-client={Identity}, runtime-client={RuntimeClient})",
opts.Gateway.Endpoint, BrowseClientIdentity, opts.MxAccess.ClientName);
var session = new GalaxyBrowseSession(client);
client = null; // Ownership transferred — keep finally from disposing.
return session;
}
catch
{
if (client is not null)
{
try
{
await client.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best-effort cleanup; the original exception is more useful.
}
}
throw;
}
}
/// <summary>
/// Build the gateway client options from the form's Gateway section. Mirrors the
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
/// gateway sees an identical option shape. The API-key reference is resolved
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the
/// Browser project doesn't reference Driver.Galaxy.
/// </summary>
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
StreamTimeout = gw.StreamTimeoutSeconds > 0
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
: null,
};
/// <summary>
/// Resolves <c>env:NAME</c>, <c>file:PATH</c>, and <c>dev:KEY</c> prefixes;
/// anything else is treated as a literal cleartext key with a startup warning.
/// Slim mirror of <c>GalaxyDriver.ResolveApiKey</c> — the runtime version lives
/// in a sibling project the Browser intentionally doesn't reference.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
private string ResolveApiKey(string secretRef)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it when they open the address picker.
_logger.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,5 +1,5 @@
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,5 +1,5 @@
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
@@ -528,7 +528,7 @@ public sealed class GalaxyDriver
// If discovery hasn't run yet, build the client here so the watcher has a target. // If discovery hasn't run yet, build the client here so the watcher has a target.
// Driver.Galaxy-009 fix: guard with ??= so if BuildDefaultHierarchySource later runs // Driver.Galaxy-009 fix: guard with ??= so if BuildDefaultHierarchySource later runs
// it reuses this client rather than overwriting the field and leaking the first instance. // it reuses this client rather than overwriting the field and leaking the first instance.
_ownedRepositoryClient ??= MxGateway.Client.GalaxyRepositoryClient.Create( _ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
BuildClientOptions(_options.Gateway)); BuildClientOptions(_options.Gateway));
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient); var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
@@ -0,0 +1,86 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="GalaxyDriverOptions"/>-shaped driver config.
/// Parses the <c>Gateway.Endpoint</c> gRPC endpoint (e.g. <c>http://host:5001</c> or
/// <c>host:5001</c>), opens a socket and closes immediately. Surfaces a green tick +
/// latency on success; red chip + SocketError on failure; "timed out" on the caller's
/// cancellation. Does NOT exchange any gRPC frames — a richer gRPC ping probe is a
/// documented follow-up.
/// </summary>
public sealed class GalaxyDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
// Matches DriverInstance.DriverType strings set by the AdminUI's GalaxyDriverPage.
public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName;
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
GalaxyDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(GalaxyDriverOptions opts)
{
var endpoint = opts.Gateway.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint)) return (string.Empty, 0);
// Try absolute URI first (e.g. "http://hostname:5001" or "https://hostname:5001").
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
var host = uri.Host;
// Uri.Port is -1 when not specified; default mxaccessgw port is 5001.
var port = uri.Port > 0 ? uri.Port : 5001;
return (host, port);
}
// Fallback: treat as "host:port" (no scheme).
var colonIdx = endpoint.LastIndexOf(':');
if (colonIdx > 0 && int.TryParse(endpoint[(colonIdx + 1)..], out var rawPort) && rawPort > 0)
return (endpoint[..colonIdx], rawPort);
// No port found — return the whole string as host with default port.
return (endpoint, 5001);
}
}
@@ -2,7 +2,7 @@ using System.Diagnostics.Metrics;
using System.Threading.Channels; using System.Threading.Channels;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,7 +1,7 @@
using System.Diagnostics.Metrics; using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,8 +1,8 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check. // Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -62,7 +62,7 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
/// applied value and skip redundant calls. /// applied value and skip redundant calls.
/// </summary> /// </summary>
private async Task EnsureSessionIntervalAsync( private async Task EnsureSessionIntervalAsync(
MxGateway.Client.MxGatewaySession session, int serverHandle, int intervalMs, CancellationToken cancellationToken) ZB.MOM.WW.MxGateway.Client.MxGatewaySession session, int serverHandle, int intervalMs, CancellationToken cancellationToken)
{ {
lock (_intervalLock) lock (_intervalLock)
{ {
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -19,34 +19,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Vendored mxaccessgw .NET client. Originally consumed via path-based <PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
ProjectReference to the sibling repo, but the sibling repo restructured <PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
and the MxGateway.Client.csproj path no longer exists. The DLLs in
libs/ are the last known-good build (May 2026); they reference proto
types from MxGateway.Contracts.dll using the pre-restructure namespace
(MxGateway.Contracts.Proto). See libs/README.md for the unwinding plan
once the sibling repo restores a client library or we migrate to the
new ZB.MOM.WW.MxGateway.Contracts.Proto namespace. -->
<Reference Include="MxGateway.Client">
<HintPath>libs\MxGateway.Client.dll</HintPath>
<Private>true</Private>
</Reference>
<Reference Include="MxGateway.Contracts">
<HintPath>libs\MxGateway.Contracts.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Transitive deps the vendored MxGateway.Client.dll was actually built
against (verified by reflecting GetReferencedAssemblies on the DLL —
see libs/README.md). Versions align with the sibling mxaccessgw repo's
current Server / Worker projects so binary-compat stays close to what
the team uses elsewhere. Pre-Driver.Galaxy-016 the csproj declared
`Polly` (the v7 API) instead of `Polly.Core` (the v8 API the DLL was
built against) — a package-name mistake, not just a version skew —
which would surface as a runtime MissingMethodException the first
time the client's retry pipeline ran. -->
<PackageReference Include="Google.Protobuf" /> <PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Core.Api" /> <PackageReference Include="Grpc.Core.Api" />
<PackageReference Include="Grpc.Net.Client" /> <PackageReference Include="Grpc.Net.Client" />
@@ -1,101 +0,0 @@
# Vendored MxGateway client DLLs
This directory holds binary copies of `MxGateway.Client.dll` and
`MxGateway.Contracts.dll` from the sibling `mxaccessgw` repo's last known-good
build (May 2026). The DLLs are referenced from the driver's csproj as
`<Reference HintPath="…" />` items rather than `ProjectReference`.
## Provenance
Both DLLs are built from this team's own `mxaccessgw` source tree — they are
not third-party binaries. The build commit + checksums below are recorded so
future readers can verify the artefacts match the expected source without
needing to ask the original author.
| File | Source commit | SHA-256 |
|---|---|---|
| `MxGateway.Client.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `3507f770adc8c1b27b2fc4645079c6e4e02d5c65b9545c12d637cd2a080a00bd` |
| `MxGateway.Contracts.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `437dc6cb6994c7c4d858c82f69af890732c7ffbfa0463fbd8a63ce7930d251b4` |
The build commit is the same for both DLLs and is embedded as
`AssemblyInformationalVersion` inside each binary — re-verify by running:
`ilspycmd <dll> | grep AssemblyInformationalVersion`.
To re-verify the checksums (e.g. after a clone):
```bash
sha256sum libs/MxGateway.Client.dll libs/MxGateway.Contracts.dll
```
If either SHA-256 or the embedded source commit no longer matches what's
listed above, the artefact has been replaced — verify before trusting.
## Why vendored
The sibling `mxaccessgw` repo restructured: the `clients/dotnet/MxGateway.Client`
project the driver previously referenced via path-based `ProjectReference` no
longer exists, and the proto contracts moved from the `MxGateway.Contracts.Proto`
namespace to `ZB.MOM.WW.MxGateway.Contracts.Proto`. The driver's source still
expects the pre-restructure namespace, so re-pointing at the new contracts would
require a global namespace rename across ~19 driver files PLUS reimplementing
the `MxGatewayClient` / `MxGatewaySession` / `GalaxyRepositoryClient` types the
old client library provided (the sibling repo dropped the client library
entirely, keeping only the contracts).
Vendoring the binaries unblocked the build in minutes instead of hours, freezes
the gateway contract surface at a known-good version, and preserves the option
to migrate properly later without an emergency rewrite.
## What's vendored
| File | Built against |
|---|---|
| `MxGateway.Client.dll` | net10.0, references `MxGateway.Contracts.dll` |
| `MxGateway.Contracts.dll` | net10.0, proto namespace `MxGateway.Contracts.Proto[.Galaxy]` |
The NuGet packages the vendored DLLs reference (verified by reflecting
`Assembly.GetReferencedAssemblies()` against `MxGateway.Client.dll`) are
declared as direct `PackageReference` in the driver csproj — when the dropped
`ProjectReference` was in place those packages were transitively provided;
with binary references the consumer must declare them explicitly:
| Package | Reason |
|---|---|
| `Google.Protobuf` 3.34.1 | Proto message types in `MxGateway.Contracts.dll` |
| `Grpc.Core.Api` 2.76.0 | Base gRPC client types in `MxGateway.Client.dll` |
| `Grpc.Net.Client` 2.76.0 | HTTP/2 transport used by `MxGatewayClient` |
| `Microsoft.Extensions.Logging.Abstractions` 10.0.7 | `ILogger` used by the client |
| `Polly.Core` 8.6.6 | Retry pipeline used by `MxGatewayClient` |
Versions match the sibling mxaccessgw repo's current Server / Worker
projects (`ZB.MOM.WW.MxGateway.Server.csproj`,
`ZB.MOM.WW.MxGateway.Worker.csproj`) so the runtime versions stay close to
what the gateway team uses. The pre-Driver.Galaxy-016 declarations were
incorrect — most visibly `Polly 8.5.2` was declared where the DLL actually
needs `Polly.Core` (a different package: `Polly` v7 is the older fluent API;
`Polly.Core` v8 is the modern resilience-pipeline API the gateway client was
built against). A `Polly` reference would have failed at runtime with
`MissingMethodException` the first time a retry pipeline ran.
## Decompiled-source archive
The vendored DLLs are byte-for-byte the build output. The full source can be
recovered with `ilspycmd MxGateway.Client.dll > MxGateway.Client.cs` if a code
review or audit needs it.
## How to unwind
Either path closes the vendored-binary debt:
1. **Sibling repo restores `MxGateway.Client.csproj`** (or publishes a NuGet
package). Switch the csproj back to a `ProjectReference` / `PackageReference`,
delete this directory.
2. **Driver migrates to the new `ZB.MOM.WW.MxGateway.Contracts.Proto`
namespace.** Global namespace rename across the ~19 consuming source files,
plus re-implementing `MxGatewayClient` / `MxGatewaySession` /
`GalaxyRepositoryClient` (≈2,200 LoC of behavioural client code) either
inlined into this driver or as a fresh sibling library. Delete this
directory.
Either way: when unwinding, also drop the five `PackageReference` lines added
to the csproj alongside the `<Reference>` items — the new ProjectReference /
PackageReference will provide them transitively again.
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
/// <summary>
/// Driver probe for the <see cref="WonderwareHistorianClientOptions"/>-shaped driver config.
/// The Wonderware Historian client communicates over a Windows named pipe (not a TCP socket),
/// so a cheap TCP-connect probe is not applicable for this transport. This probe always
/// returns a well-formed "not applicable" result so the AdminUI can display a meaningful
/// message instead of a red error. A full named-pipe connect + Hello-frame probe is a
/// documented follow-up.
/// </summary>
public sealed class WonderwareHistorianDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "Historian.Wonderware";
/// <inheritdoc />
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
// Validate the config JSON can at least be parsed — surface bad JSON immediately.
WonderwareHistorianClientOptions? opts;
try { opts = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(configJson, _opts); }
catch (Exception ex)
{
return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null));
}
if (opts is null)
return Task.FromResult(new DriverProbeResult(false, "Config JSON deserialized to null.", null));
// The Wonderware Historian sidecar communicates over a Windows named pipe; there is no
// TCP endpoint to connect to. A full pipe connect + Hello-frame probe is a follow-up.
return Task.FromResult(new DriverProbeResult(
false,
"TCP probe not applicable for this transport — the Historian sidecar uses a named pipe. " +
"A full named-pipe probe is a documented follow-up.",
null));
}
}
@@ -0,0 +1,63 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="ModbusDriverOptions"/>-shaped driver config.
/// Opens a socket to the configured endpoint and closes immediately. Surfaces a green
/// tick + latency on success; red chip + SocketError on failure; "timed out" on the
/// caller's cancellation. Does NOT exchange any protocol bytes — richer per-driver
/// handshakes are a documented follow-up.
/// </summary>
public sealed class ModbusDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "ModbusTcp";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
ModbusDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<ModbusDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(ModbusDriverOptions opts)
=> (opts.Host, opts.Port);
}
@@ -0,0 +1,178 @@
using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
/// <summary>
/// Live one-level-per-call browse session over a remote OPC UA server. Created by
/// <c>OpcUaClientBrowser</c> on picker open and owned by the AdminUI's
/// <c>BrowseSessionRegistry</c>; the registry's TTL reaper disposes idle sessions.
/// </summary>
internal sealed class OpcUaClientBrowseSession : IBrowseSession
{
private readonly ISession _session;
private readonly NamespaceMap _nsMap;
private readonly NodeId _rootNodeId;
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _disposed;
/// <summary>
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
/// </summary>
/// <param name="session">The OPC UA client session to browse against.</param>
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
{
_session = session;
_nsMap = nsMap;
_rootNodeId = rootNodeId;
}
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Wall-clock time of the most recent successful browse call; the reaper uses
/// this for idle eviction.</summary>
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <summary>Browse one level under the configured root node.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
=> BrowseOneLevelAsync(_rootNodeId, cancellationToken);
/// <summary>Browse one level under the node identified by <paramref name="nodeId"/>,
/// which must be a stable reference produced by <see cref="NamespaceMap.ToStableReference"/>
/// (or a plain <c>ns=N;…</c> form).</summary>
/// <param name="nodeId">Stable reference string for the parent node.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="nodeId"/> cannot be
/// resolved against the live session's namespace table.</exception>
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
throw new ArgumentException(
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
return BrowseOneLevelAsync(resolved, cancellationToken);
}
/// <summary>The OPC UA picker treats variables as terminal leaves and does not surface
/// a per-attribute side-panel, so this always returns empty.</summary>
/// <param name="nodeId">Ignored.</param>
/// <param name="cancellationToken">Ignored.</param>
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
/// given parent node. <see cref="Session.BrowseAsync"/> is not thread-safe, so all calls
/// serialize through <see cref="_gate"/>.</summary>
private async Task<IReadOnlyList<BrowseNode>> BrowseOneLevelAsync(NodeId parent, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var descriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = parent,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
| BrowseResultMask.NodeClass),
},
};
var resp = await _session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse: descriptions,
ct: ct).ConfigureAwait(false);
if (resp.Results.Count == 0)
{
LastUsedUtc = DateTime.UtcNow;
return Array.Empty<BrowseNode>();
}
var result = resp.Results[0];
var refs = result.References;
// Follow browse continuation points so folders larger than the server's per-call
// cap aren't silently truncated (same pattern as runtime
// Driver.OpcUaClient-003).
var cp = result.ContinuationPoint;
while (cp is { Length: > 0 })
{
var next = await _session.BrowseNextAsync(
requestHeader: null,
releaseContinuationPoints: false,
continuationPoints: [cp],
ct: ct).ConfigureAwait(false);
if (next.Results.Count == 0) break;
var nextResult = next.Results[0];
if (nextResult.References is { Count: > 0 })
refs.AddRange(nextResult.References);
cp = nextResult.ContinuationPoint;
}
LastUsedUtc = DateTime.UtcNow;
var nodes = new List<BrowseNode>(refs.Count);
foreach (var rf in refs)
nodes.Add(ToBrowseNode(rf));
return nodes;
}
finally
{
_gate.Release();
}
}
/// <summary>Project a single <see cref="ReferenceDescription"/> into the driver-agnostic
/// <see cref="BrowseNode"/> shape, encoding the outbound NodeId in the stable
/// <c>nsu=…</c> form so the picker survives a remote namespace-table reorder.</summary>
private BrowseNode ToBrowseNode(ReferenceDescription rf)
{
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
var isObject = rf.NodeClass == NodeClass.Object;
var displayName = rf.DisplayName?.Text
?? rf.BrowseName?.Name
?? childId.ToString()
?? "(unnamed)";
return new BrowseNode(
NodeId: _nsMap.ToStableReference(childId),
DisplayName: displayName,
Kind: isObject ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf,
HasChildrenHint: isObject);
}
/// <summary>Idempotent best-effort dispose: closes the underlying session if it's a
/// concrete <see cref="Session"/>, disposes it, and disposes the gate. Close errors are
/// swallowed because the registry reaper may be racing a remote disconnect.</summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_session is Session s)
{
try { await s.CloseAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
}
try { _session.Dispose(); }
catch { /* best-effort */ }
try { _gate.Dispose(); }
catch { /* best-effort */ }
}
}
@@ -0,0 +1,234 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
/// <summary>
/// Opens transient OPC UA sessions from form-supplied JSON for the AdminUI address picker.
/// Mirrors the runtime driver's connect path but with a separate PKI store so browse-time
/// trust decisions cannot poison the runtime driver's cert store.
/// </summary>
public sealed class OpcUaClientDriverBrowser : IDriverBrowser
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
PropertyNameCaseInsensitive = true,
};
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
{
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
}
/// <summary>Driver type key — matches the AdminUI's persisted "OpcUaClient" value.</summary>
public string DriverType => "OpcUaClient";
/// <summary>Opens a transient OPC UA session and returns a browse session over it.</summary>
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, JsonOpts)
?? throw new InvalidOperationException("OpcUaClient options deserialized to null.");
var endpoint = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls[0] : opts.EndpointUrl;
if (string.IsNullOrWhiteSpace(endpoint))
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
if (opts.AuthType == OpcUaAuthType.Certificate)
throw new InvalidOperationException(
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
if (opts.AutoAcceptCertificates)
_logger.LogWarning(
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
"driver's PKI store instead.",
endpoint);
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
var identity = BuildBrowseUserIdentity(opts);
var perEndpointBudget = TimeSpan.FromSeconds(
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(perEndpointBudget);
var endpointDesc = await SelectEndpointAsync(
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
var endpointCfg = EndpointConfiguration.Create(appConfig);
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
appConfig,
configuredEndpoint,
updateBeforeConnect: false,
sessionName: "OtOpcUa AdminUI Browse",
(uint)opts.SessionTimeout.TotalMilliseconds,
identity,
preferredLocales: null,
connectCts.Token).ConfigureAwait(false);
try
{
var nsMap = NamespaceMap.FromSession(session);
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
? ObjectIds.ObjectsFolder
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
_logger.LogInformation(
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
endpoint, opts.SecurityPolicy, opts.SecurityMode);
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
}
catch
{
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
try { session.Dispose(); } catch { /* best-effort */ }
throw;
}
}
/// <summary>
/// Build a minimal in-memory ApplicationConfiguration using a SEPARATE PKI root from
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
/// trust decisions don't leak into the deployed driver's cert store.
/// </summary>
/// <param name="ct">Cancellation token for the configuration build.</param>
private static async Task<ApplicationConfiguration> BuildBrowseAppConfigurationAsync(CancellationToken ct)
{
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
// locally rather than plumbing a telemetry context all the way through — the browser
// emits no per-request telemetry of its own and the SDK's internal fallback is fine
// for a transient picker session (mirrors the runtime driver's same suppression).
#pragma warning disable CS0618
var app = new ApplicationInstance
{
ApplicationName = "OtOpcUa AdminUI Browse",
ApplicationType = ApplicationType.Client,
};
#pragma warning restore CS0618
var pkiRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OtOpcUa", "adminui-browse-pki");
var config = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa AdminUI Browse",
ApplicationType = ApplicationType.Client,
ApplicationUri = "urn:OtOpcUa:AdminUI:Browse",
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
SubjectName = "CN=OtOpcUa AdminUI Browse",
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "trusted"),
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuers"),
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = false,
},
TransportQuotas = new TransportQuotas { OperationTimeout = 30_000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
DisableHiResClock = true,
};
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
app.ApplicationConfiguration = config;
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
.ConfigureAwait(false);
return config;
}
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
/// <param name="opts">Driver options carrying the form's auth fields.</param>
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
opts.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
/// <param name="appConfig">Application configuration used by the discovery client.</param>
/// <param name="url">Remote endpoint URL to query.</param>
/// <param name="policy">Required security policy.</param>
/// <param name="mode">Required message security mode.</param>
/// <param name="ct">Cancellation token for the discovery call.</param>
private static async Task<EndpointDescription> SelectEndpointAsync(
ApplicationConfiguration appConfig, string url,
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
{
using var client = await DiscoveryClient.CreateAsync(
appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false);
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
var wantedPolicy = MapPolicy(policy);
var wantedMode = mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
var match = all.FirstOrDefault(e =>
e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode);
if (match is null)
{
var advertised = string.Join(", ", all.Select(e =>
$"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
throw new InvalidOperationException(
$"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
$"Server advertises: {advertised}");
}
return match;
}
/// <summary>Convert the driver options enum to the OPC UA policy URI.</summary>
/// <param name="p">The driver security policy to map.</param>
private static string MapPolicy(OpcUaSecurityPolicy p) => p switch
{
OpcUaSecurityPolicy.None => SecurityPolicies.None,
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
_ => throw new ArgumentOutOfRangeException(nameof(p)),
};
/// <summary>Render an OPC UA security-policy URI as its short suffix for diag messages.</summary>
/// <param name="uri">Full policy URI to shorten.</param>
private static string ShortName(string uri) =>
uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)";
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// direct parse against the current session. /// direct parse against the current session.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal sealed class NamespaceMap public sealed class NamespaceMap
{ {
// index -> URI and URI -> index, as the upstream server published them at connect time. // index -> URI and URI -> index, as the upstream server published them at connect time.
private readonly string[] _uris; private readonly string[] _uris;
@@ -5,5 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. --> <ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,78 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="OpcUaClientDriverOptions"/>-shaped driver config.
/// Parses the first endpoint URL (from <see cref="OpcUaClientDriverOptions.EndpointUrls"/> or
/// the convenience <see cref="OpcUaClientDriverOptions.EndpointUrl"/> fallback), opens a
/// socket to the OPC UA server host + port and closes immediately. Surfaces a green tick +
/// latency on success; red chip + SocketError on failure; "timed out" on the caller's
/// cancellation. Does NOT open an OPC UA session — a richer session-open probe is a
/// documented follow-up.
/// </summary>
public sealed class OpcUaClientDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "OpcUaClient";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
OpcUaClientDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(OpcUaClientDriverOptions opts)
{
// EndpointUrls wins over the convenience EndpointUrl when both are set.
var endpointUrl = opts.EndpointUrls.FirstOrDefault()
?? (string.IsNullOrWhiteSpace(opts.EndpointUrl) ? null : opts.EndpointUrl);
if (endpointUrl is null) return (string.Empty, 0);
// Parse as a URI — opc.tcp://host:port is a valid URI.
if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri))
return (string.Empty, 0);
var host = uri.Host;
var port = uri.IsDefaultPort ? 4840 : uri.Port;
return (host, port);
}
}
@@ -0,0 +1,63 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="S7DriverOptions"/>-shaped driver config.
/// Opens a socket to the configured host + ISO-on-TCP port 102 and closes immediately.
/// Surfaces a green tick + latency on success; red chip + SocketError on failure; "timed
/// out" on the caller's cancellation. Does NOT exchange any S7comm bytes — a richer
/// ISO-on-TCP connection probe is a documented follow-up.
/// </summary>
public sealed class S7DriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "S7";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
S7DriverOptions? opts;
try { opts = JsonSerializer.Deserialize<S7DriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port);
}
@@ -0,0 +1,84 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="TwinCATDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's AMS router host (first four octets of the AMS Net ID)
/// on the AMS port from the address and closes immediately. Surfaces a green tick + latency
/// on success; red chip + SocketError on failure; "timed out" on the caller's cancellation.
/// Does NOT exchange any ADS bytes — a richer ADS-state probe is a documented follow-up.
/// </summary>
/// <remarks>
/// AMS Net ID format is six dot-separated octets (e.g. <c>192.168.1.10.1.1</c>); the first
/// four are typically the host IPv4 address by Beckhoff convention, but the AMS router
/// resolves the real IP route server-side. The probe uses the first-four-octet heuristic
/// which is reliable for the overwhelming majority of production deployments.
/// </remarks>
public sealed class TwinCATDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "TwinCAT";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
TwinCATDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<TwinCATDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(TwinCATDriverOptions opts)
{
// Parse the first device's ads:// address. AMS Net ID is six-octet; by Beckhoff
// convention the first four octets are the host IPv4. Extract those as the TCP target.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = TwinCATAmsAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
// NetId = "a.b.c.d.e.f" — take the first 4 octets as the host IP.
var parts = parsed.NetId.Split('.');
if (parts.Length < 4) return (string.Empty, 0);
var hostIp = string.Join('.', parts[0], parts[1], parts[2], parts[3]);
return (hostIp, parsed.Port);
}
}
@@ -0,0 +1,67 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Background service that periodically evicts idle browse sessions from the
/// <see cref="BrowseSessionRegistry"/>. Each tick takes a snapshot of the registry,
/// removes any session that has been idle longer than <see cref="IdleTtl"/>, then
/// disposes the evicted instance OUTSIDE the dictionary — so concurrent expand
/// calls racing eviction fail cleanly via <see cref="BrowseSessionNotFoundException"/>.
/// </summary>
public sealed class BrowseSessionReaper(
BrowseSessionRegistry registry,
ILogger<BrowseSessionReaper> logger) : BackgroundService
{
/// <summary>How long a session may be untouched before it becomes eligible for eviction.</summary>
public static readonly TimeSpan IdleTtl = TimeSpan.FromMinutes(2);
/// <summary>How often the reaper checks the registry.</summary>
public static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try { await ReapOnceAsync(stoppingToken).ConfigureAwait(false); }
catch (Exception ex)
{
logger.LogWarning(ex, "Browse-session reaper iteration failed; will retry next tick.");
}
}
await DrainAllAsync().ConfigureAwait(false);
}
/// <summary>Evicts every session whose <see cref="Commons.Browsing.IBrowseSession.LastUsedUtc"/>
/// is older than <see cref="IdleTtl"/>. Internal so tests can drive a tick directly.</summary>
internal async Task ReapOnceAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
foreach (var (token, session) in registry.Snapshot())
{
if (now - session.LastUsedUtc < IdleTtl) continue;
if (!registry.TryRemove(token, out var taken)) continue;
try { await taken.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
logger.LogDebug(ex,
"Best-effort dispose of idle-evicted browse session {Token} failed.", token);
}
logger.LogDebug("Browse session {Token} closed reason=idle-ttl", token);
}
}
private async Task DrainAllAsync()
{
foreach (var (token, session) in registry.Snapshot())
{
if (!registry.TryRemove(token, out var taken)) continue;
try { await taken.DisposeAsync().ConfigureAwait(false); } catch { }
logger.LogDebug("Browse session {Token} closed reason=shutdown", token);
}
}
}
@@ -0,0 +1,30 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Singleton in-process directory of live <see cref="IBrowseSession"/> instances,
/// keyed by <see cref="IBrowseSession.Token"/>. Concurrency is provided by the
/// underlying <see cref="ConcurrentDictionary{TKey, TValue}"/> alone — there are no
/// additional locks; callers must dispose evicted sessions outside the dictionary.
/// </summary>
public sealed class BrowseSessionRegistry
{
private readonly ConcurrentDictionary<Guid, IBrowseSession> _sessions = new();
/// <summary>Adds (or replaces) a session in the registry keyed by its token.</summary>
public void Register(IBrowseSession session) => _sessions[session.Token] = session;
/// <summary>Looks up a session by token without removing it.</summary>
public bool TryGet(Guid token, out IBrowseSession session) =>
_sessions.TryGetValue(token, out session!);
/// <summary>Atomically removes a session from the registry, returning it for disposal.</summary>
public bool TryRemove(Guid token, out IBrowseSession session) =>
_sessions.TryRemove(token, out session!);
/// <summary>Returns a point-in-time snapshot of all currently registered sessions.</summary>
public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() =>
_sessions.Select(kv => (kv.Key, kv.Value)).ToList();
}
@@ -0,0 +1,72 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Default <see cref="IBrowserSessionService"/> implementation. Indexes injected
/// <see cref="IDriverBrowser"/>s by <see cref="IDriverBrowser.DriverType"/>
/// (case-insensitive) at construction, registers opened sessions, and wraps each
/// expand/attributes call in a 20-second linked CTS so a stuck driver cannot
/// stall the UI indefinitely.
/// </summary>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
private readonly IReadOnlyDictionary<string, IDriverBrowser> _browsersByType =
browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
if (!_browsersByType.TryGetValue(driverType, out var browser))
return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
try
{
var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false);
registry.Register(session);
return new(true, null, session.Token);
}
catch (Exception ex)
{
logger.LogInformation(ex,
"Browser open failed for driverType={DriverType}: {Message}", driverType, ex.Message);
return new(false, ex.Message, Guid.Empty);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c));
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
/// <inheritdoc />
public async Task CloseAsync(Guid token)
{
if (!registry.TryRemove(token, out var session)) return;
try { await session.DisposeAsync().ConfigureAwait(false); } catch { }
logger.LogDebug("Browse session {Token} closed reason=user-close", token);
}
private async Task<T> InvokeAsync<T>(
Guid token, CancellationToken callerCt, Func<IBrowseSession, CancellationToken, Task<T>> op)
{
if (!registry.TryGet(token, out var session))
throw new BrowseSessionNotFoundException(token);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(callerCt);
cts.CancelAfter(PerCallTimeout);
return await op(session, cts.Token).ConfigureAwait(false);
}
}
@@ -0,0 +1,48 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Outcome of <see cref="IBrowserSessionService.OpenAsync"/>. On success
/// <paramref name="Ok"/> is <see langword="true"/> and <paramref name="Token"/> is the
/// registry handle; on failure <paramref name="Message"/> carries a human-readable
/// diagnostic for the UI's error chip.
/// </summary>
/// <param name="Ok">True iff the browse session was opened and registered.</param>
/// <param name="Message">Failure diagnostic, or <see langword="null"/> on success.</param>
/// <param name="Token">Registry handle on success; <see cref="Guid.Empty"/> on failure.</param>
public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token);
/// <summary>
/// Scoped Razor-page facade over the in-process browse-session machinery. Owns
/// driver-type dispatch on open and per-call timeout enforcement on expand/attributes.
/// </summary>
public interface IBrowserSessionService
{
/// <summary>Opens a session against the named driver type using the given JSON config.
/// Never throws — all errors are surfaced via <see cref="BrowseOpenResult"/>.</summary>
Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
/// <summary>Returns the root nodes of an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct);
/// <summary>Returns the direct children of <paramref name="nodeId"/> in an open session.
/// Throws <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Returns the attributes of <paramref name="nodeId"/> in an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
Task CloseAsync(Guid token);
}
/// <summary>
/// Raised by the service layer when a caller references a token that is not
/// (or no longer) in the registry — typically because the reaper evicted it
/// between calls.
/// </summary>
public sealed class BrowseSessionNotFoundException(Guid token)
: InvalidOperationException($"Browse session {token} not found (may have been reaped).");
@@ -36,4 +36,12 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
linked.CancelAfter(AskTimeout); linked.CancelAfter(AskTimeout);
return await _proxy.Ask<StartDeploymentResult>(msg, AskTimeout, linked.Token); return await _proxy.Ask<StartDeploymentResult>(msg, AskTimeout, linked.Token);
} }
/// <summary>
/// Generic Ask — forwards any message to the AdminOperationsActor singleton proxy.
/// Uses the caller-supplied <paramref name="ct"/> for cancellation; does not impose an
/// additional internal timeout beyond what the proxy itself enforces.
/// </summary>
public Task<T> AskAsync<T>(object message, CancellationToken ct)
=> _proxy.Ask<T>(message, cancellationToken: ct);
} }
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// Thin AdminUI-side wrapper for the Test Connect operation. Dispatches a
/// <see cref="TestDriverConnect"/> through <c>IAdminOperationsClient</c>, applies a
/// 65-second outer wall (the actor itself clamps to [1,60]s; this guards against the
/// Ask never replying), and surfaces a friendly result for the Razor button to render.
/// </summary>
public sealed class AdminProbeService
{
private readonly IAdminOperationsClient _client;
/// <summary>Initializes a new instance of the <see cref="AdminProbeService"/>.</summary>
/// <param name="client">The admin operations client used to dispatch probe requests.</param>
public AdminProbeService(IAdminOperationsClient client) => _client = client;
/// <summary>
/// Dispatches a Test Connect probe for the supplied driver type and config JSON,
/// waiting up to 65 seconds for a reply before surfacing a timeout failure.
/// </summary>
/// <param name="driverType">Driver type key (must match an installed <c>IDriverProbe.DriverType</c>).</param>
/// <param name="configJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
/// <param name="timeoutSeconds">Per-probe timeout; actor clamps to [1, 60].</param>
/// <param name="ct">Optional cancellation token from the caller.</param>
public async Task<TestDriverConnectResult> TestAsync(
string driverType,
string configJson,
int timeoutSeconds,
CancellationToken ct = default)
{
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect(driverType, configJson, timeoutSeconds, correlationId);
// 65s outer guard — the actor's CTS clamps to 60s; if the Ask never returns we still want
// a deterministic failure surface for the UI.
using var outerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
outerCts.CancelAfter(TimeSpan.FromSeconds(65));
try
{
return await _client.AskAsync<TestDriverConnectResult>(msg, outerCts.Token);
}
catch (OperationCanceledException)
{
return new TestDriverConnectResult(false, "Probe request did not return within 65s.", null, correlationId);
}
catch (Exception ex)
{
return new TestDriverConnectResult(false, $"Probe dispatch failed: {ex.Message}", null, correlationId);
}
}
}
@@ -14,6 +14,7 @@ public static class ServiceCollectionExtensions
{ {
services.AddScoped<IAdminOperationsClient, AdminOperationsClient>(); services.AddScoped<IAdminOperationsClient, AdminOperationsClient>();
services.AddScoped<IFleetDiagnosticsClient, FleetDiagnosticsClient>(); services.AddScoped<IFleetDiagnosticsClient, FleetDiagnosticsClient>();
services.AddScoped<AdminProbeService>();
return services; return services;
} }
} }
@@ -4,11 +4,10 @@
and the AB CIP ALMD bridge. *@ and the AB CIP ALMD bridge. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts
@inject NavigationManager Nav @inject IInProcessBroadcaster<AlarmTransitionEvent> Alarms
@implements IAsyncDisposable @implements IDisposable
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alerts</h4> <h4 class="mb-0">Alerts</h4>
@@ -73,36 +72,26 @@ else
private const int Capacity = 200; private const int Capacity = 200;
private readonly List<AlarmTransitionEvent> _rows = new(); private readonly List<AlarmTransitionEvent> _rows = new();
private HubConnection? _hub;
private bool _connected; private bool _connected;
protected override async Task OnInitializedAsync() protected override void OnInitialized()
{ {
_hub = new HubConnectionBuilder() // Live alarm tail straight from the in-process broadcaster (fed by AlertSignalRBridge off the
.WithUrl(Nav.ToAbsoluteUri(AlertHub.Endpoint)) // 'alerts' DPS topic). A Blazor Server component can't self-connect a SignalR HubConnection
.WithAutomaticReconnect() // behind a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead.
.Build(); Alarms.Received += OnAlarm;
_connected = true;
}
_hub.On<AlarmTransitionEvent>(AlertHub.MethodName, evt => private void OnAlarm(AlarmTransitionEvent evt) =>
// Marshal both the mutation and the re-render onto the circuit sync context so this can't
// race ClearAsync (which runs there) over the shared _rows list.
InvokeAsync(() =>
{ {
_rows.Insert(0, evt); _rows.Insert(0, evt);
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
InvokeAsync(StateHasChanged); StateHasChanged();
}); });
_hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); };
_hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); };
try
{
await _hub.StartAsync();
_connected = true;
}
catch
{
// Connection failures (admin-only deployment, hub not mapped, etc.) leave the page
// showing "disconnected" — operator action: reload or talk to the host operator.
}
}
private async Task ClearAsync() private async Task ClearAsync()
{ {
@@ -119,8 +108,5 @@ else
_ => "chip-idle", _ => "chip-idle",
}; };
public async ValueTask DisposeAsync() public void Dispose() => Alarms.Received -= OnAlarm;
{
if (_hub is not null) await _hub.DisposeAsync();
}
} }
@@ -21,9 +21,8 @@ else
{ {
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope
(a folder, an equipment, a tag). Q4 of the AdminUI rebuild plan dropped per-cluster role (a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
grants in favour of fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained per-node scope.
per-node scope. Live editing lands in a Phase C.2 follow-up.
</section> </section>
<section class="panel rise mt-3" style="animation-delay:.08s"> <section class="panel rise mt-3" style="animation-delay:.08s">
@@ -19,12 +19,6 @@
} }
else else
{ {
<section class="panel notice rise" style="animation-delay:.02s">
Per Q1 of the AdminUI rebuild plan, typed driver editors (Modbus, FOCAS) are deferred.
The expanded view below shows raw JSON config. Live editing — including a generic JSON
editor and per-driver-type forms when operators ask — lands in a Phase C.2 follow-up.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s"> <section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")</div> <div class="panel-head">@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0) @if (_rows.Count == 0)
@@ -25,7 +25,7 @@ else
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
Equipment rows are scoped to a UNS line and bound to a single driver. EquipmentId is Equipment rows are scoped to a UNS line and bound to a single driver. EquipmentId is
system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag
(ERP). Live editing lands in a Phase C.2 follow-up. (ERP).
</section> </section>
<section class="panel rise mt-3" style="animation-delay:.08s"> <section class="panel rise mt-3" style="animation-delay:.08s">
@@ -21,8 +21,7 @@ else
{ {
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
Namespaces are content (decision #123) — they're served at the OPC UA endpoint and bound Namespaces are content (decision #123) — they're served at the OPC UA endpoint and bound
to driver instances. NamespaceUri must be unique fleet-wide. Live editing lands in a to driver instances. NamespaceUri must be unique fleet-wide.
Phase C.2 follow-up.
</section> </section>
<section class="panel rise mt-3" style="animation-delay:.08s"> <section class="panel rise mt-3" style="animation-delay:.08s">
@@ -21,7 +21,7 @@ else
{ {
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
Tags are bound to a driver instance and (optionally) an equipment + poll group. The view Tags are bound to a driver instance and (optionally) an equipment + poll group. The view
below shows the first @PageSize tags by Name; full pagination + search land in Phase C.2. below shows the first @PageSize tags by Name.
</section> </section>
<div class="d-flex align-items-center mb-3 gap-2 mt-3"> <div class="d-flex align-items-center mb-3 gap-2 mt-3">
@@ -20,8 +20,7 @@ else
{ {
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
UNS levels: Enterprise (cluster) → Site (cluster) → Area → Line → Equipment. Areas and UNS levels: Enterprise (cluster) → Site (cluster) → Area → Line → Equipment. Areas and
lines are cluster-scoped; equipment hangs under a single line. Live editing lands in a lines are cluster-scoped; equipment hangs under a single line.
Phase C.2 follow-up.
</section> </section>
<section class="panel rise mt-3" style="animation-delay:.08s"> <section class="panel rise mt-3" style="animation-delay:.08s">
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip @using ZB.MOM.WW.OtOpcUa.Driver.AbCip
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" /> <DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB CIP address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation timeout *@ @* Operation timeout *@
<section class="panel rise mt-3" style="animation-delay:.06s"> <section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div> <div class="panel-head">Operation settings</div>
@@ -121,27 +146,65 @@ else
</div> </div>
</section> </section>
@* Devices — read-only JSON view *@ @* Devices *@
<section class="panel rise mt-3" style="animation-delay:.12s"> <CollectionEditor TRow="AbCipDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
<div class="panel-head">Devices</div> AnimationDelay=".12s"
<div style="padding:1rem"> NewRow="@(() => new AbCipDeviceRow())" Clone="@(r => r.Clone())"
<p class="form-text mb-2"> Validate="AbCipDeviceRow.ValidateRow">
Device list (host addresses, PLC family, packing overrides) — full list-editor coming in a follow-up phase. Each entry: <code>{ "hostAddress": "ab://gateway/1,0", "plcFamily": "ControlLogix" }</code>. <HeaderTemplate>
</p> <tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_devicesJson</pre> </HeaderTemplate>
</div> <RowTemplate Context="d">
</section> <td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="ab://gateway/1,0" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbCipPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags — read-only JSON view *@ @* Tags *@
<section class="panel rise mt-3" style="animation-delay:.14s"> <CollectionEditor TRow="AbCipTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
<div class="panel-head">Tags</div> AnimationDelay=".14s"
<div style="padding:1rem"> NewRow="@(() => new AbCipTagRow())" Clone="@(r => r.Clone())"
<p class="form-text mb-2"> Validate="AbCipTagRow.ValidateRow">
Tag list — full list-editor coming in a follow-up phase. Edit via the Tag editor pages or export/import the driver config JSON. <HeaderTemplate>
</p> <tr><th>Name</th><th>Device</th><th>Tag path</th><th>Type</th><th>Writable</th><th></th></tr>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre> </HeaderTemplate>
</div> <RowTemplate Context="t">
</section> <td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.TagPath</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="ab://gateway/1,0" /></div>
<div class="col-md-6"><label class="form-label">Tag path</label>
<input class="form-control form-control-sm mono" @bind="t.TagPath"
placeholder="e.g. Program:Main.SomeTag" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<AbCipDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" /> <DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell> </DriverFormShell>
@@ -171,11 +234,15 @@ else
private bool _busy; private bool _busy;
private string? _error; private string? _error;
// Collections are preserved through round-trip and shown as read-only JSON. // Address picker state
private IReadOnlyList<AbCipDeviceOptions> _devices = []; private bool _showPicker;
private IReadOnlyList<AbCipTagDefinition> _tags = []; private string _pickedAddress = "";
private string _devicesJson = "[]";
private string _tagsJson = "[]"; private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<AbCipDeviceRow> _devices = [];
private List<AbCipTagRow> _tags = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -215,12 +282,10 @@ else
_form = FormModel.FromOptions(opts); _form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig; _form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion; _form.RowVersion = _existing.RowVersion;
_devices = opts.Devices; _devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
_tags = opts.Tags; _tags = opts.Tags.Select(AbCipTagRow.FromDefinition).ToList();
} }
} }
_devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts);
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
_loaded = true; _loaded = true;
} }
@@ -229,7 +294,11 @@ else
_busy = true; _error = null; _busy = true; _error = null;
try try
{ {
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts); var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew) if (IsNew)
{ {
@@ -299,12 +368,104 @@ else
finally { _busy = false; } finally { _busy = false; }
} }
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbCipDriverOptions? TryDeserialize(string json) private static AbCipDriverOptions? TryDeserialize(string json)
{ {
try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); } try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
catch { return null; } catch { return null; }
} }
// Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record.
public sealed class AbCipDeviceRow
{
public string HostAddress { get; set; } = "";
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (AllowPacking, ConnectionSize) across a load→save.
private AbCipDeviceOptions? _source;
public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbCipDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbCipDeviceRow row, IReadOnlyList<AbCipDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Mutable VM for the modal editor — AbCipTagDefinition is an immutable record.
public sealed class AbCipTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string TagPath { get; set; } = "";
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent, Members, SafetyTag) across a load→save.
private AbCipTagDefinition? _source;
public AbCipTagRow Clone() => (AbCipTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbCipTagRow FromDefinition(AbCipTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, TagPath = d.TagPath,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public AbCipTagDefinition ToDefinition()
{
var baseDef = _source ?? new AbCipTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), TagPath.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
TagPath = TagPath.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(AbCipTagRow row, IReadOnlyList<AbCipTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value. // Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions(). // Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel public sealed class FormModel
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy @using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
@@ -37,6 +39,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" /> <DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB Legacy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbLegacyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation settings *@ @* Operation settings *@
<section class="panel rise mt-3" style="animation-delay:.06s"> <section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div> <div class="panel-head">Operation settings</div>
@@ -87,30 +112,65 @@ else
</div> </div>
</section> </section>
@* Devices — read-only JSON view *@ @* Devices *@
<section class="panel rise mt-3" style="animation-delay:.10s"> <CollectionEditor TRow="AbLegacyDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
<div class="panel-head">Devices</div> AnimationDelay=".10s"
<div style="padding:1rem"> NewRow="@(() => new AbLegacyDeviceRow())" Clone="@(r => r.Clone())"
<p class="form-text mb-2"> Validate="AbLegacyDeviceRow.ValidateRow">
Device list (host addresses, PLC family) — full list-editor coming in a follow-up phase. <HeaderTemplate>
Each entry: <code>{ "hostAddress": "...", "plcFamily": "Slc500" }</code>. <tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
PLC families: <code>Slc500</code>, <code>MicroLogix</code>, <code>Plc5</code>, <code>LogixPccc</code>. </HeaderTemplate>
</p> <RowTemplate Context="d">
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_devicesJson</pre> <td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
</div> <td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</section> </RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="10.0.0.10" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags — read-only JSON view *@ @* Tags *@
<section class="panel rise mt-3" style="animation-delay:.12s"> <CollectionEditor TRow="AbLegacyTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
<div class="panel-head">Tags</div> AnimationDelay=".12s"
<div style="padding:1rem"> NewRow="@(() => new AbLegacyTagRow())" Clone="@(r => r.Clone())"
<p class="form-text mb-2"> Validate="AbLegacyTagRow.ValidateRow">
Tag list — full list-editor coming in a follow-up phase. Edit via the Tag editor pages or export/import the driver config JSON. <HeaderTemplate>
Each tag has a PCCC file address (e.g. <code>N7:0</code>, <code>F8:0</code>, <code>B3:0/0</code>). <tr><th>Name</th><th>Device</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</p> </HeaderTemplate>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre> <RowTemplate Context="t">
</div> <td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
</section> <td class="mono">@t.Address</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="10.0.0.10" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. N7:0, F8:0, B3:0/0" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<AbLegacyDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" /> <DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell> </DriverFormShell>
@@ -140,11 +200,15 @@ else
private bool _busy; private bool _busy;
private string? _error; private string? _error;
// Collections are preserved through round-trip and shown as read-only JSON. // Address picker state
private IReadOnlyList<AbLegacyDeviceOptions> _devices = []; private bool _showPicker;
private IReadOnlyList<AbLegacyTagDefinition> _tags = []; private string _pickedAddress = "";
private string _devicesJson = "[]";
private string _tagsJson = "[]"; private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<AbLegacyDeviceRow> _devices = [];
private List<AbLegacyTagRow> _tags = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -184,12 +248,10 @@ else
_form = FormModel.FromOptions(opts); _form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig; _form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion; _form.RowVersion = _existing.RowVersion;
_devices = opts.Devices; _devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList();
_tags = opts.Tags; _tags = opts.Tags.Select(AbLegacyTagRow.FromDefinition).ToList();
} }
} }
_devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts);
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
_loaded = true; _loaded = true;
} }
@@ -198,7 +260,11 @@ else
_busy = true; _error = null; _busy = true; _error = null;
try try
{ {
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts); var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew) if (IsNew)
{ {
@@ -268,12 +334,104 @@ else
finally { _busy = false; } finally { _busy = false; }
} }
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbLegacyDriverOptions? TryDeserialize(string json) private static AbLegacyDriverOptions? TryDeserialize(string json)
{ {
try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); } try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
catch { return null; } catch { return null; }
} }
// Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record.
public sealed class AbLegacyDeviceRow
{
public string HostAddress { get; set; } = "";
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// across a load→save.
private AbLegacyDeviceOptions? _source;
public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbLegacyDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList<AbLegacyDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Mutable VM for the modal editor — AbLegacyTagDefinition is an immutable record.
public sealed class AbLegacyTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string Address { get; set; } = "";
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private AbLegacyTagDefinition? _source;
public AbLegacyTagRow Clone() => (AbLegacyTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbLegacyTagRow FromDefinition(AbLegacyTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public AbLegacyTagDefinition ToDefinition()
{
var baseDef = _source ?? new AbLegacyTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(AbLegacyTagRow row, IReadOnlyList<AbLegacyTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value. // Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions(). // Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel public sealed class FormModel
@@ -1,8 +1,6 @@
@* Dispatch page: reads DriverInstance.DriverType and renders the matching typed editor @* Dispatch page: reads DriverInstance.DriverType and dispatches to the matching typed editor
via <DynamicComponent>. Falls back to the legacy DriverEdit for any type not yet in via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
the map. The route collides with DriverEdit.razor's identical directive — that's no registered typed page. *@
intentional. Task 3.4 removes the route from DriverEdit.razor. Blazor route conflicts
are runtime, not build-time, so the build succeeds now. *@
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}" @page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@@ -61,7 +59,7 @@ else
["TwinCat"] = typeof(TwinCATDriverPage), ["TwinCat"] = typeof(TwinCATDriverPage),
["Focas"] = typeof(FocasDriverPage), ["Focas"] = typeof(FocasDriverPage),
["OpcUaClient"] = typeof(OpcUaClientDriverPage), ["OpcUaClient"] = typeof(OpcUaClientDriverPage),
["Galaxy"] = typeof(GalaxyDriverPage), ["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage), ["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage),
}; };
@@ -1,6 +1,5 @@
@* TODO(3.3): This route collides with DriverEdit.razor's @page "/clusters/{ClusterId}/drivers/new". @* Driver type picker — presents a card grid of registered driver types and links to the
Task 3.3 removes the /drivers/new directive from DriverEdit.razor so this page takes over. per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
Blazor resolves route conflicts at runtime, not compile time, so the build succeeds now. *@
@page "/clusters/{ClusterId}/drivers/new" @page "/clusters/{ClusterId}/drivers/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Microsoft.AspNetCore.Authorization.Authorize]
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS @using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" /> <DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="FOCAS address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@ @* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s"> <section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div> <div class="panel-head">Connection</div>
@@ -164,43 +189,65 @@ else
</div> </div>
</section> </section>
@* Devices — read-only JSON view *@ @* Devices *@
<section class="panel rise mt-3" style="animation-delay:.20s"> <CollectionEditor TRow="FocasDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
<div class="panel-head">Devices</div> AnimationDelay=".20s"
<div style="padding:1rem"> NewRow="@(() => new FocasDeviceRow())" Clone="@(r => r.Clone())"
<div class="form-text mb-2"> Validate="FocasDeviceRow.ValidateRow">
Each device represents one CNC. Device list editor (with CNC series selector) coming in a follow-up phase. <HeaderTemplate>
Format: <code>[{"hostAddress":"192.168.0.10:8193","deviceName":"CNC1","series":"Thirty_i"}]</code> <tr><th>Host address</th><th>CNC series</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.Series</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.10:8193" /></div>
<div class="col-md-3"><label class="form-label">CNC series</label>
<select class="form-select form-select-sm" @bind="d.Series">
@foreach (var e in Enum.GetValues<FocasCncSeries>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div> </div>
@if (_form.DevicesJson is not null) </EditTemplate>
{ </CollectionEditor>
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.DevicesJson</pre>
}
else
{
<p class="text-muted"><em>No devices configured.</em></p>
}
</div>
</section>
@* Tags — read-only JSON view *@ @* Tags *@
<section class="panel rise mt-3" style="animation-delay:.23s"> <CollectionEditor TRow="FocasTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
<div class="panel-head">Tags</div> AnimationDelay=".23s"
<div style="padding:1rem"> NewRow="@(() => new FocasTagRow())" Clone="@(r => r.Clone())"
<div class="form-text mb-2"> Validate="FocasTagRow.ValidateRow">
Tag list editor coming in a follow-up phase. Tags reference device host addresses and FOCAS address strings <HeaderTemplate>
(e.g. <code>X0.0</code>, <code>R100</code>, <code>PARAM:1815/0</code>, <code>MACRO:500</code>). <tr><th>Name</th><th>Device</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.Address</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="192.168.0.10:8193" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. X0.0, R100, PARAM:1815/0, MACRO:500" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<FocasDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div> </div>
@if (_form.TagsJson is not null) </EditTemplate>
{ </CollectionEditor>
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
}
else
{
<p class="text-muted"><em>No tags configured.</em></p>
}
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" /> <DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell> </DriverFormShell>
@@ -229,6 +276,16 @@ else
private bool _loaded, _busy; private bool _loaded, _busy;
private string? _error; private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<FocasDeviceRow> _devices = [];
private List<FocasTagRow> _tags = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
@@ -259,6 +316,8 @@ else
_form = FormModel.FromOptions(opts); _form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig; _form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion; _form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(FocasTagRow.FromDefinition).ToList();
} }
} }
_loaded = true; _loaded = true;
@@ -269,7 +328,9 @@ else
_busy = true; _error = null; _busy = true; _error = null;
try try
{ {
var opts = _form.ToOptions(); var opts = _form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew) if (IsNew)
@@ -339,12 +400,106 @@ else
finally { _busy = false; } finally { _busy = false; }
} }
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static FocasDriverOptions? TryDeserialize(string json) private static FocasDriverOptions? TryDeserialize(string json)
{ {
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); } try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
catch { return null; } catch { return null; }
} }
// Mutable VM for the modal editor — FocasDeviceOptions is an immutable record.
public sealed class FocasDeviceRow
{
public string HostAddress { get; set; } = "";
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private FocasDeviceOptions? _source;
public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new()
{
HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName,
_source = d,
};
public FocasDeviceOptions ToDefinition()
{
var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
Series = Series,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList<FocasDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Mutable VM for the modal editor — FocasTagDefinition is an immutable record.
public sealed class FocasTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string Address { get; set; } = "";
public FocasDataType DataType { get; set; } = FocasDataType.Int32;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private FocasTagDefinition? _source;
public FocasTagRow Clone() => (FocasTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static FocasTagRow FromDefinition(FocasTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public FocasTagDefinition ToDefinition()
{
var baseDef = _source ?? new FocasTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(FocasTagRow row, IReadOnlyList<FocasTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel public sealed class FormModel
{ {
// Connection // Connection
@@ -370,52 +525,30 @@ else
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1; public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30; public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
// Collections JSON view (read-only)
public string? DevicesJson { get; set; }
public string? TagsJson { get; set; }
// Preserved originals (round-tripped unchanged)
private IReadOnlyList<FocasDeviceOptions> _devices = [];
private IReadOnlyList<FocasTagDefinition> _tags = [];
// Common // Common
public string? ResilienceConfig { get; set; } public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = []; public byte[] RowVersion { get; set; } = [];
private static readonly System.Text.Json.JsonSerializerOptions _displayOpts = new() public static FormModel FromOptions(FocasDriverOptions o) => new()
{ {
WriteIndented = true, TimeoutSeconds = (int)o.Timeout.TotalSeconds,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
HandleRecycleEnabled = o.HandleRecycle.Enabled,
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
FixedTreeEnabled = o.FixedTree.Enabled,
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
}; };
public static FormModel FromOptions(FocasDriverOptions o) public FocasDriverOptions ToOptions(
{ IReadOnlyList<FocasDeviceOptions> devices,
var m = new FormModel IReadOnlyList<FocasTagDefinition> tags) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
HandleRecycleEnabled = o.HandleRecycle.Enabled,
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
FixedTreeEnabled = o.FixedTree.Enabled,
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
_devices = o.Devices,
_tags = o.Tags,
};
m.DevicesJson = o.Devices.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Devices, _displayOpts);
m.TagsJson = o.Tags.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Tags, _displayOpts);
return m;
}
public FocasDriverOptions ToOptions() => new()
{ {
Timeout = TimeSpan.FromSeconds(TimeoutSeconds), Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new FocasProbeOptions Probe = new FocasProbeOptions
@@ -442,8 +575,8 @@ else
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds), ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds), TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
}, },
Devices = _devices, Devices = devices,
Tags = _tags, Tags = tags,
}; };
} }
} }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config @using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
@@ -36,6 +38,30 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" /> <DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Galaxy.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Galaxy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* mxaccessgw connection *@ @* mxaccessgw connection *@
<section class="panel rise mt-3" style="animation-delay:.06s"> <section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">mxaccessgw connection</div> <div class="panel-head">mxaccessgw connection</div>
@@ -182,13 +208,14 @@ else
[Parameter] public string ClusterId { get; set; } = ""; [Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; } [Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "Galaxy"; private const string DriverTypeKey = "GalaxyMxGateway";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{ {
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false, WriteIndented = false,
}; };
@@ -201,6 +228,12 @@ else
private bool _busy; private bool _busy;
private string? _error; private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
@@ -326,6 +359,9 @@ else
finally { _busy = false; } finally { _busy = false; }
} }
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
private static GalaxyDriverOptions? TryDeserialize(string json) private static GalaxyDriverOptions? TryDeserialize(string json)
{ {
try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); } try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
@@ -373,26 +409,36 @@ else
// GalaxyDriverOptions top-level // GalaxyDriverOptions top-level
public int ProbeTimeoutSeconds { get; set; } = 30; public int ProbeTimeoutSeconds { get; set; } = 30;
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r) => new() public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{ {
GatewayEndpoint = r.Gateway.Endpoint, // Null-coalesce each nested record to its default so that persisted configs
GatewayApiKeySecretRef = r.Gateway.ApiKeySecretRef, // that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
GatewayUseTls = r.Gateway.UseTls, // don't match the camelCase deserializer) don't cause a NullReferenceException.
GatewayCaCertificatePath = r.Gateway.CaCertificatePath, var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
GatewayConnectTimeoutSeconds = r.Gateway.ConnectTimeoutSeconds, var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
GatewayDefaultCallTimeoutSeconds = r.Gateway.DefaultCallTimeoutSeconds, var repo = r.Repository ?? new GalaxyRepositoryOptions();
GatewayStreamTimeoutSeconds = r.Gateway.StreamTimeoutSeconds, var rc = r.Reconnect ?? new GalaxyReconnectOptions();
MxClientName = r.MxAccess.ClientName, return new()
MxPublishingIntervalMs = r.MxAccess.PublishingIntervalMs, {
MxWriteUserId = r.MxAccess.WriteUserId, GatewayEndpoint = gw.Endpoint,
MxEventPumpChannelCapacity = r.MxAccess.EventPumpChannelCapacity, GatewayApiKeySecretRef = gw.ApiKeySecretRef,
RepositoryDiscoverPageSize = r.Repository.DiscoverPageSize, GatewayUseTls = gw.UseTls,
RepositoryWatchDeployEvents = r.Repository.WatchDeployEvents, GatewayCaCertificatePath = gw.CaCertificatePath,
ReconnectInitialBackoffMs = r.Reconnect.InitialBackoffMs, GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
ReconnectMaxBackoffMs = r.Reconnect.MaxBackoffMs, GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
ReconnectReplayOnSessionLost = r.Reconnect.ReplayOnSessionLost, GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, MxClientName = mx.ClientName,
}; MxPublishingIntervalMs = mx.PublishingIntervalMs,
MxWriteUserId = mx.WriteUserId,
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
};
}
public GalaxyDriverOptions ToRecord() => new( public GalaxyDriverOptions ToRecord() => new(
Gateway: new GalaxyGatewayOptions( Gateway: new GalaxyGatewayOptions(
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client @using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" /> <DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Historian.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Historian Wonderware address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<HistorianWonderwareAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@ @* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s"> <section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Connection</div> <div class="panel-head">Connection</div>
@@ -133,6 +158,12 @@ else
private bool _busy; private bool _busy;
private string? _error; private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
@@ -255,6 +286,9 @@ else
finally { _busy = false; } finally { _busy = false; }
} }
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.Historian.ToRecord(), _jsonOpts);
private static WonderwareHistorianClientOptions? TryDeserialize(string json) private static WonderwareHistorianClientOptions? TryDeserialize(string json)
{ {
try { return System.Text.Json.JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _jsonOpts); } try { return System.Text.Json.JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus @using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" /> <DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Modbus address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Transport *@ @* Transport *@
<section class="panel rise mt-3" style="animation-delay:.06s"> <section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Transport</div> <div class="panel-head">Transport</div>
@@ -248,16 +273,44 @@ else
</div> </div>
</section> </section>
@* Tags — read-only JSON view *@ <CollectionEditor TRow="ModbusTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
<section class="panel rise mt-3" style="animation-delay:.18s"> NewRow="@(() => new ModbusTagRow())" Clone="@(r => r.Clone())"
<div class="panel-head">Tags</div> Validate="ModbusTagRow.ValidateRow">
<div style="padding:1rem"> <HeaderTemplate>
<p class="form-text mb-2"> <tr><th>Name</th><th>Region</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
Tag list — full list-editor coming in a follow-up phase. Edit tags via the Tag editor pages or by exporting/importing the driver config JSON. </HeaderTemplate>
</p> <RowTemplate Context="t">
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre> <td class="mono">@t.Name</td><td>@t.Region</td><td class="mono">@t.Address</td>
</div> <td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</section> </RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-3"><label class="form-label">Region</label>
<select class="form-select form-select-sm" @bind="t.Region">
@foreach (var e in Enum.GetValues<ModbusRegion>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Address</label>
<input type="number" class="form-control form-control-sm" @bind="t.Address" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<ModbusDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Byte order</label>
<select class="form-select form-select-sm" @bind="t.ByteOrder">
@foreach (var e in Enum.GetValues<ModbusByteOrder>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Bit index</label>
<input type="number" class="form-control form-control-sm" @bind="t.BitIndex" /></div>
<div class="col-md-2"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" /></div>
<div class="col-md-2"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" /> <DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell> </DriverFormShell>
@@ -286,9 +339,15 @@ else
private bool _loaded; private bool _loaded;
private bool _busy; private bool _busy;
private string? _error; private string? _error;
// Held separately because Tags is a collection — rendered as read-only JSON.
private IReadOnlyList<ModbusTagDefinition> _tags = []; // Address picker state
private string _tagsJson = "[]"; private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<ModbusTagRow> _tags = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -328,10 +387,9 @@ else
_form = FormModel.FromOptions(opts); _form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig; _form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion; _form.RowVersion = _existing.RowVersion;
_tags = opts.Tags; _tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList();
} }
} }
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
_loaded = true; _loaded = true;
} }
@@ -340,7 +398,7 @@ else
_busy = true; _error = null; _busy = true; _error = null;
try try
{ {
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts); var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew) if (IsNew)
{ {
@@ -410,12 +468,69 @@ else
finally { _busy = false; } finally { _busy = false; }
} }
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
private static ModbusDriverOptions? TryDeserialize(string json) private static ModbusDriverOptions? TryDeserialize(string json)
{ {
try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); } try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
catch { return null; } catch { return null; }
} }
// Mutable VM for the modal editor — ModbusTagDefinition is an immutable record.
public sealed class ModbusTagRow
{
public string Name { get; set; } = "";
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
public int Address { get; set; }
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
public bool Writable { get; set; } = true;
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
public int BitIndex { get; set; }
public int StringLength { get; set; }
public bool WriteIdempotent { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (StringByteOrder, ArrayCount, Deadband, UnitId, CoalesceProhibited) across a load→save.
private ModbusTagDefinition? _source;
public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static ModbusTagRow FromDefinition(ModbusTagDefinition d) => new()
{
Name = d.Name, Region = d.Region, Address = d.Address, DataType = d.DataType,
Writable = d.Writable, ByteOrder = d.ByteOrder, BitIndex = d.BitIndex,
StringLength = d.StringLength, WriteIdempotent = d.WriteIdempotent,
_source = d,
};
public ModbusTagDefinition ToDefinition()
{
var baseDef = _source ?? new ModbusTagDefinition(Name.Trim(), Region, 0, DataType);
return baseDef with
{
Name = Name.Trim(),
Region = Region,
Address = (ushort)Math.Clamp(Address, 0, 65535),
DataType = DataType,
Writable = Writable,
ByteOrder = ByteOrder,
BitIndex = (byte)Math.Clamp(BitIndex, 0, 255),
StringLength = (ushort)Math.Clamp(StringLength, 0, 65535),
WriteIdempotent = WriteIdempotent,
};
}
public static string? ValidateRow(ModbusTagRow row, IReadOnlyList<ModbusTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works. // Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works.
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options. // Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
public sealed class FormModel public sealed class FormModel
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer @rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient @using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
@@ -36,6 +38,30 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" /> <DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.OpcUa.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="OPC UA address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* Endpoint *@ @* Endpoint *@
<section class="panel rise mt-3" style="animation-delay:.06s"> <section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Endpoint</div> <div class="panel-head">Endpoint</div>
@@ -105,11 +131,25 @@ else
<div class="form-text">Default 10.</div> <div class="form-text">Default 10.</div>
</div> </div>
</div> </div>
@* Endpoint URLs list — read-only JSON view (full list-editor is a follow-up) *@
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
<div class="col-12"> <div class="col-12">
<label class="form-label">Endpoint URLs (failover list — read-only; edit via raw JSON import or use Endpoint URL above)</label> <CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_endpointUrlsJson</pre> Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
Validate="EndpointUrlRow.ValidateRow">
<HeaderTemplate>
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="e">
<td class="mono">@e.Url</td>
</RowTemplate>
<EditTemplate Context="e">
<label class="form-label">Endpoint URL</label>
<input class="form-control form-control-sm mono" @bind="e.Url"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
</EditTemplate>
</CollectionEditor>
</div> </div>
</div> </div>
</div> </div>
@@ -249,8 +289,16 @@ else
private bool _busy; private bool _busy;
private string? _error; private string? _error;
// Read-only JSON snippets for collections that have no list editor yet. // Address picker state
private string _endpointUrlsJson = "[]"; private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
private List<EndpointUrlRow> _endpoints = [];
// Read-only JSON snippet for the UnsMappingTable, which has no list editor yet.
private string _unsMappingTableJson = "{}"; private string _unsMappingTableJson = "{}";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -290,7 +338,7 @@ else
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions(); var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
_form = new FormModel(); _form = new FormModel();
_form.OpcUa = OpcUaClientFormModel.FromRecord(opts); _form.OpcUa = OpcUaClientFormModel.FromRecord(opts);
_endpointUrlsJson = System.Text.Json.JsonSerializer.Serialize(opts.EndpointUrls, _jsonOpts); _endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
_unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts); _unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
_form.ResilienceConfig = _existing.ResilienceConfig; _form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion; _form.RowVersion = _existing.RowVersion;
@@ -304,7 +352,7 @@ else
_busy = true; _error = null; _busy = true; _error = null;
try try
{ {
var opts = _form.OpcUa.ToRecord(); var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew) if (IsNew)
@@ -374,6 +422,10 @@ else
finally { _busy = false; } finally { _busy = false; }
} }
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
private static OpcUaClientDriverOptions? TryDeserialize(string json) private static OpcUaClientDriverOptions? TryDeserialize(string json)
{ {
try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); } try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
@@ -387,11 +439,36 @@ else
public byte[] RowVersion { get; set; } = []; public byte[] RowVersion { get; set; } = [];
} }
/// <summary>
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
/// <c>List&lt;string&gt;</c> (a failover list) so the row is a thin wrapper the
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
/// </summary>
public sealed class EndpointUrlRow
{
public string Url { get; set; } = "";
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
public string ToUrl() => Url.Trim();
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
return "Endpoint URL must start with opc.tcp://";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
return $"Duplicate endpoint '{row.Url}'.";
return null;
}
}
/// <summary> /// <summary>
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for /// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for
/// TimeSpan fields so Blazor InputNumber can bind them. /// TimeSpan fields so Blazor InputNumber can bind them.
/// EndpointUrls and UnsMappingTable are shown as read-only JSON; they survive round-trip /// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and
/// via the original deserialized record and are re-serialized unchanged. /// threaded into <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
/// survives round-trip via the original deserialized record, re-serialized unchanged.
/// </summary> /// </summary>
public sealed class OpcUaClientFormModel public sealed class OpcUaClientFormModel
{ {
@@ -426,8 +503,7 @@ else
// Diagnostics // Diagnostics
public int ProbeTimeoutSeconds { get; set; } = 15; public int ProbeTimeoutSeconds { get; set; } = 15;
// Preserved read-only collections (round-tripped unchanged from original record) // Preserved read-only collection (round-tripped unchanged from original record)
internal IReadOnlyList<string> _endpointUrls = [];
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>(); internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new() public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
@@ -453,14 +529,13 @@ else
UserCertificatePassword = r.UserCertificatePassword, UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind, TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
_endpointUrls = r.EndpointUrls,
_unsMappingTable = r.UnsMappingTable, _unsMappingTable = r.UnsMappingTable,
}; };
public OpcUaClientDriverOptions ToRecord() => new() public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
{ {
EndpointUrl = EndpointUrl, EndpointUrl = EndpointUrl,
EndpointUrls = _endpointUrls, EndpointUrls = endpointUrls,
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot, BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
ApplicationUri = ApplicationUri, ApplicationUri = ApplicationUri,
SessionName = SessionName, SessionName = SessionName,

Some files were not shown because too many files have changed in this diff Show More