Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 695fa6408b | |||
| 61193629b6 | |||
| e3a27422a1 | |||
| 32d7fd7cc9 | |||
| de666b24c3 | |||
| a4fb97aef8 | |||
| da4634d67e | |||
| 869be660fd | |||
| a8916c3e08 | |||
| 79b2345834 | |||
| 4df5b849ac | |||
| a58151e99e | |||
| 1fd093d95d | |||
| f210f09caf | |||
| 042f3b6a65 | |||
| bc40388914 | |||
| b719194046 | |||
| 7570df76d3 | |||
| 244949caa3 | |||
| a5a0d06dbe | |||
| 6882761f4c | |||
| 15f3797f1e | |||
| 534d670b21 | |||
| b351a81c8f | |||
| f655efc570 | |||
| c4116e54c9 | |||
| c3fec1426c | |||
| a2761e4b98 | |||
| 4a469fbe06 | |||
| e2fa6754bb | |||
| b76561a780 | |||
| c49fccbe0c | |||
| 5622e51006 | |||
| 9e479ce675 | |||
| af691f3291 | |||
| 453340e71e | |||
| b64d670303 | |||
| c83e9397e6 | |||
| 74b9218a92 | |||
| 532e9933f3 | |||
| ee8add4416 | |||
| bc4fce5fbe | |||
| 7a0b8525a9 |
@@ -33,7 +33,6 @@
|
|||||||
<PackageVersion Include="libplctag" Version="1.5.2" />
|
<PackageVersion Include="libplctag" Version="1.5.2" />
|
||||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||||
<PackageVersion Include="MessagePack" Version="2.5.187" />
|
<PackageVersion Include="MessagePack" Version="2.5.187" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
|
|
||||||
admin-b:
|
admin-b:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -115,7 +115,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
|
|
||||||
driver-a:
|
driver-a:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -129,7 +129,7 @@ services:
|
|||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4840:4840"
|
- "4840:4840"
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ services:
|
|||||||
Cluster__PublicHostname: "driver-b"
|
Cluster__PublicHostname: "driver-b"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4841:4840"
|
- "4841:4840"
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4842:4840"
|
- "4842:4840"
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4843:4840"
|
- "4843:4840"
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4844:4840"
|
- "4844:4840"
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4845:4840"
|
- "4845:4840"
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ services:
|
|||||||
- --providers.file.watch=true
|
- --providers.file.watch=true
|
||||||
- --api.insecure=true
|
- --api.insecure=true
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "9200:80" # host port 9200 → traefik :80 entrypoint (80 conflicts with scadabridge-traefik)
|
||||||
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
|
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
|
||||||
volumes:
|
volumes:
|
||||||
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Design — Complete AdminUI deferred follow-ups
|
||||||
|
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Status:** Approved (design); implementation plan to follow
|
||||||
|
**Author:** Joseph Doherty (with Claude Code)
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The AdminUI carried a family of "deferred / Phase C.2 follow-up" notes. A prior
|
||||||
|
change stripped the stale *rendered roadmap banners* from the cluster list pages.
|
||||||
|
Three remaining note groups were investigated to decide what real work they hide:
|
||||||
|
|
||||||
|
- **Group 1 — driver-page inline notes** ("list-editor coming in a follow-up
|
||||||
|
phase" for tags/devices/endpoints; "typed-form-ifying Polly is a follow-up").
|
||||||
|
→ **Real pending UI work.**
|
||||||
|
- **Group 2 — RoleGrants** ("UI-driven editing of the mapping is deferred — it
|
||||||
|
implies a config-reload mechanism that doesn't exist yet"). → **Real work; half
|
||||||
|
the infra already exists.**
|
||||||
|
- **Group 3 — source comments** (F15 Razor migration, F16 FleetStatusHub bridge,
|
||||||
|
"Phase 4" identity section, `TODO(3.3/3.4)` route collision). → **~90% stale**;
|
||||||
|
the referenced work already shipped (the F16 bridge is wired; the legacy
|
||||||
|
`DriverEdit.razor` no longer exists). Only the Polly typed form is real, and it
|
||||||
|
is already counted in Group 1.
|
||||||
|
|
||||||
|
### Key facts established during exploration
|
||||||
|
|
||||||
|
- **Driver-embedded tag/device lists in `DriverConfig` JSON are the runtime source
|
||||||
|
of truth.** Driver factories deserialize them and poll exactly those rows; the
|
||||||
|
canonical `Tag` table is orthogonal (OPC UA browse-tree only, never read by
|
||||||
|
drivers). So inline editors are meaningful, not redundant — editing them changes
|
||||||
|
what the driver polls on the next publish/reinitialize.
|
||||||
|
- **Resilience** already has a strongly-typed model: `DriverResilienceOptions`
|
||||||
|
(`BulkheadMaxConcurrent`, `BulkheadMaxQueue`, `RecycleIntervalSeconds`,
|
||||||
|
`CapabilityPolicies: {DriverCapability → (TimeoutSeconds, RetryCount,
|
||||||
|
BreakerFailureThreshold)}`) with tier A/B/C defaults via `GetTierDefaults(tier)`
|
||||||
|
and a `DriverResilienceOptionsParser`. The stored JSON is an *override* shape;
|
||||||
|
null/absent keys fall back to tier defaults.
|
||||||
|
- **LDAP role map**: the `LdapGroupRoleMapping` entity + migration +
|
||||||
|
`ILdapGroupRoleMappingService` (CRUD) already exist but are **not wired** into
|
||||||
|
login. `LdapAuthService` still reads the static appsettings `GroupToRole`
|
||||||
|
(`Dictionary<string,string>`). `RoleGrants.razor` is read-only.
|
||||||
|
- **Testing**: no bUnit. Established pattern = test `FromOptions`/`ToOptions`
|
||||||
|
round-trips (xUnit + Shouldly in `AdminUI.Tests`) and services with in-memory EF
|
||||||
|
(`Configuration.Tests`).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Scope:** full build — all real follow-ups in Groups 1 & 2, plus Group 3
|
||||||
|
comment cleanup.
|
||||||
|
- **List-editor UX:** modal-per-row with a shared shell component.
|
||||||
|
- **LDAP reload semantics:** DB-backed, **live on the user's next sign-in**
|
||||||
|
(per-login DB query; no restart, no new infra). appsettings `GroupToRole` becomes
|
||||||
|
a bootstrap **fallback** layer.
|
||||||
|
- **Roles are GLOBAL.** No cluster-level permissions / no per-cluster enforcement
|
||||||
|
(explicitly chosen for simplicity, reversing an earlier cluster-scoping answer).
|
||||||
|
Every `LdapGroupRoleMapping` row is `IsSystemWide=true`, `ClusterId=null`.
|
||||||
|
|
||||||
|
## Workstreams
|
||||||
|
|
||||||
|
### WS1 — Driver collection editors (modal-per-row + shared shell)
|
||||||
|
|
||||||
|
- New generic `CollectionEditor<TRow>` component in `Components/Shared/Drivers/`:
|
||||||
|
compact read-only table + `[+ Add]` / per-row `Edit` / `Delete`, and a Bootstrap
|
||||||
|
modal editing a **working copy** of a row (commit on modal-Save, discard on
|
||||||
|
Cancel). Parameters: `List<TRow> Items` (bound), header fragment, read-only-cells
|
||||||
|
fragment, modal-body fragment, `NewRow` factory, optional `Validate` delegate.
|
||||||
|
- Each driver page swaps its read-only `<pre>` for a `CollectionEditor` supplying
|
||||||
|
its own columns + modal fields. Edits mutate the in-memory `List<T>` already in
|
||||||
|
the page's `FormModel`; the page's existing **Save** serializes it into
|
||||||
|
`DriverConfig` — no new persistence path.
|
||||||
|
- Coverage: tags (Modbus, AbCip, AbLegacy, TwinCAT, S7, FOCAS); devices (AbCip,
|
||||||
|
AbLegacy, TwinCAT, FOCAS); endpoints (OpcUaClient).
|
||||||
|
- **Errors/validation:** required fields, duplicate Name within list,
|
||||||
|
driver-specific address format; delete confirm; list mutates only on valid commit.
|
||||||
|
- **Testing:** per-driver `NewRow` factories + `Validate` methods unit-tested
|
||||||
|
directly; existing `*FormSerializationTests` extended for add/remove via the form
|
||||||
|
model. Modal interaction verified manually via `/run`.
|
||||||
|
|
||||||
|
### WS2 — Resilience typed form
|
||||||
|
|
||||||
|
- Replace the textarea in `DriverResilienceSection.razor` with a typed form bound to
|
||||||
|
a new mutable `ResilienceFormModel` (all fields nullable; null = tier default):
|
||||||
|
bulkhead concurrent/queue, recycle interval, and an 8-capability grid (Read,
|
||||||
|
Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead)
|
||||||
|
of (timeout / retry / breaker-threshold).
|
||||||
|
- `FromJson`/`ToJson` emit only non-null overrides (blank → `null`, preserving the
|
||||||
|
current "null = tier defaults" contract). The section gains a `DriverTier`
|
||||||
|
parameter; each driver page passes its known tier so `GetTierDefaults(tier)`
|
||||||
|
renders as placeholders. A collapsible "raw JSON" view remains as escape hatch.
|
||||||
|
- **Errors:** non-negative / sane-range numeric validation; emitted JSON must
|
||||||
|
re-parse cleanly through `DriverResilienceOptionsParser`.
|
||||||
|
- **Testing:** `ResilienceFormModel` round-trip tests in `AdminUI.Tests` —
|
||||||
|
blank→null, partial-override-preserved, emit→parse-back compatibility.
|
||||||
|
|
||||||
|
### WS3 — Editable LDAP→role map (DB-backed, global, live on next sign-in)
|
||||||
|
|
||||||
|
- `RoleGrants.razor` → full CRUD over `LdapGroupRoleMapping` via the existing
|
||||||
|
`ILdapGroupRoleMappingService`. **Global only**: `IsSystemWide=true`,
|
||||||
|
`ClusterId=null`; no cluster UI. Fields: LDAP group, `AdminRole`
|
||||||
|
(ConfigViewer/ConfigEditor/FleetAdmin), notes. A group may carry several roles
|
||||||
|
(multiple rows). Edit page gated to **FleetAdmin** (add a minimal FleetAdmin
|
||||||
|
authorization policy; confirm existing role-policy plumbing during plan-writing).
|
||||||
|
- Wire the service into `LdapAuthService`: at login → resolve groups →
|
||||||
|
`GetByGroupsAsync` (indexed) → map roles → **merge appsettings `GroupToRole` as a
|
||||||
|
fallback layer** (used when no DB row covers a group). Edits take effect on the
|
||||||
|
user's next sign-in. DB rows authoritative + editable; appsettings entries shown
|
||||||
|
read-only as "fallback."
|
||||||
|
- **Errors:** DB unreachable at login → catch, log, fall back to appsettings;
|
||||||
|
login never blocks. CRUD: no duplicate `(LdapGroup, Role)`; group/role required.
|
||||||
|
- **Testing:** extend `LdapGroupRoleMappingServiceTests` (in-memory EF) for CRUD +
|
||||||
|
dedupe; new `RoleMapper` overload `Map(groups, dbRows, fallbackDict)` unit-tested
|
||||||
|
for merge + fallback precedence + DB-error fallback.
|
||||||
|
|
||||||
|
### WS4 — Cleanup (runs last, after the features exist)
|
||||||
|
|
||||||
|
- **Delete stale comments:** `FleetStatusHub.cs` ("passive channel / until the
|
||||||
|
bridge lands"), `EndpointRouteBuilderExtensions.cs` (F15), `DriverIdentitySection.razor`
|
||||||
|
("Phase 4 / generic DriverEdit"), `DriverEditRouter.razor` + `DriverTypePicker.razor`
|
||||||
|
(`TODO(3.3/3.4)` + the "falls back to legacy DriverEdit" path — verify & clean,
|
||||||
|
legacy file is gone), and update `DriverResilienceSection.razor`'s comment.
|
||||||
|
- **Strip rendered notes** now true: per-driver "list-editor coming in a follow-up
|
||||||
|
phase" notes, the OpcUaClient endpoint note, the resilience "typed-form-ifying
|
||||||
|
Polly is a follow-up" note, and the RoleGrants "UI-driven editing is deferred" note.
|
||||||
|
|
||||||
|
## Cross-cutting
|
||||||
|
|
||||||
|
- **No DB schema change** — `LdapGroupRoleMapping` migration already applied;
|
||||||
|
`DriverConfig`/`ResilienceConfig` columns unchanged.
|
||||||
|
- **Definition of done:** build clean + `dotnet test` green + a `/run` pass
|
||||||
|
exercising the modal editors and role-map CRUD.
|
||||||
|
- **Suggested sequence:** WS1 shared shell + Modbus tags as proof → remaining
|
||||||
|
drivers → WS2 → WS3 → WS4.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-29-adminui-followups.md",
|
||||||
|
"branch": "feat/adminui-followups",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 11, "plan": 1, "subject": "Task 1: Generic CollectionEditor<TRow> component", "status": "pending"},
|
||||||
|
{"id": 12, "plan": 2, "subject": "Task 2: Modbus tag editor (proof) + tests", "status": "pending", "blockedBy": [11]},
|
||||||
|
{"id": 13, "plan": 3, "subject": "Task 3: AbCip device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||||
|
{"id": 14, "plan": 4, "subject": "Task 4: AbLegacy device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||||
|
{"id": 15, "plan": 5, "subject": "Task 5: TwinCAT device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||||
|
{"id": 16, "plan": 6, "subject": "Task 6: FOCAS device+tag editors + tests", "status": "pending", "blockedBy": [11]},
|
||||||
|
{"id": 17, "plan": 7, "subject": "Task 7: S7 tag editor + tests", "status": "pending", "blockedBy": [11]},
|
||||||
|
{"id": 18, "plan": 8, "subject": "Task 8: OpcUaClient endpoint-URL editor + tests", "status": "pending", "blockedBy": [11]},
|
||||||
|
{"id": 19, "plan": 9, "subject": "Task 9: ResilienceFormModel + tests", "status": "pending"},
|
||||||
|
{"id": 20, "plan": 10, "subject": "Task 10: Typed resilience form in DriverResilienceSection", "status": "pending", "blockedBy": [19]},
|
||||||
|
{"id": 21, "plan": 11, "subject": "Task 11: RoleMapper.Merge overload + tests", "status": "pending"},
|
||||||
|
{"id": 22, "plan": 12, "subject": "Task 12: Register ILdapGroupRoleMappingService in DI", "status": "pending"},
|
||||||
|
{"id": 23, "plan": 13, "subject": "Task 13: Wire DB merge into AuthEndpoints.LoginAsync", "status": "pending", "blockedBy": [21, 22]},
|
||||||
|
{"id": 24, "plan": 14, "subject": "Task 14: Add FleetAdmin authorization policy", "status": "pending"},
|
||||||
|
{"id": 25, "plan": 15, "subject": "Task 15: RoleGrants.razor global CRUD (FleetAdmin-gated)", "status": "pending", "blockedBy": [22, 24]},
|
||||||
|
{"id": 26, "plan": 16, "subject": "Task 16: LdapGroupRoleMapping service tests (global CRUD)", "status": "pending"},
|
||||||
|
{"id": 27, "plan": 17, "subject": "Task 17: Delete stale source comments", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 20, 25]},
|
||||||
|
{"id": 28, "plan": 18, "subject": "Task 18: Strip now-true rendered notes", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 25]},
|
||||||
|
{"id": 29, "plan": 19, "subject": "Task 19: Full verification (build + test + /run)", "status": "pending", "blockedBy": [20, 23, 26, 27, 28]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-29"
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Auth/login alignment with ScadaBridge — design
|
||||||
|
|
||||||
|
> **Status:** approved 2026-05-29. Implementation plan to follow via `writing-plans`.
|
||||||
|
> **Trigger:** browser hitting `http://localhost:9200/` rendered Chrome's `HTTP_RESPONSE_CODE_FAILURE` page because the cookie scheme's `OnRedirectToLogin` event was overridden to return 401 with no body, and the parallel JwtBearer scheme stamped `WWW-Authenticate: Bearer`. ScadaBridge sets `LoginPath` and lets the framework do its built-in browser-vs-AJAX heuristic; OtOpcUa diverged.
|
||||||
|
|
||||||
|
**Goal:** Restore default browser-redirect ergonomics on protected GETs, retire the unused JwtBearer server-side scheme, and externalize cookie config — bringing OtOpcUa's auth structure into parity with ScadaBridge.
|
||||||
|
|
||||||
|
**Architecture:** Single Cookie auth scheme. The JWT keeps minting (via `JwtTokenService`) and validating (in `CookieAuthenticationStateProvider`) as the **cookie payload only**; no `AddJwtBearer`, no parallel `Authorization: Bearer` validation. Cookie config (`Name`, `ExpiryMinutes`, `RequireHttpsCookie`) flows through the existing-but-unused `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step — same pattern ScadaBridge uses.
|
||||||
|
|
||||||
|
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` only (drop `Microsoft.AspNetCore.Authentication.JwtBearer` from the wiring if its only remaining transitive use disappears with this change).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture
|
||||||
|
|
||||||
|
### Schemes
|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|---|---|
|
||||||
|
| Cookie (primary) + JwtBearer (parallel) | Cookie only |
|
||||||
|
| `FallbackPolicy` lists both schemes | `FallbackPolicy` lists Cookie only |
|
||||||
|
| `OnRedirectToLogin` overridden to 401 | default behavior: 302 for browsers, 401 for AJAX |
|
||||||
|
| `OnRedirectToAccessDenied` overridden to 403 | default behavior: 302 to `/Account/AccessDenied` (404s today; matches ScadaBridge) |
|
||||||
|
|
||||||
|
### Cookie config — externalized via `OtOpcUaCookieOptions`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class OtOpcUaCookieOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Security:Cookie";
|
||||||
|
|
||||||
|
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||||
|
public int ExpiryMinutes { get; set; } = 30;
|
||||||
|
public bool RequireHttpsCookie { get; set; } = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired into `CookieAuthenticationOptions` via:
|
||||||
|
```csharp
|
||||||
|
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||||
|
{
|
||||||
|
cookieOpts.Cookie.Name = ourOpts.Value.Name;
|
||||||
|
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(ourOpts.Value.ExpiryMinutes);
|
||||||
|
cookieOpts.SlidingExpiration = true;
|
||||||
|
cookieOpts.Cookie.SecurePolicy = ourOpts.Value.RequireHttpsCookie
|
||||||
|
? CookieSecurePolicy.Always
|
||||||
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
if (!ourOpts.Value.RequireHttpsCookie)
|
||||||
|
{
|
||||||
|
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||||
|
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. " +
|
||||||
|
"Cookie travels in cleartext over plain HTTP. Dev-only.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoint surface — unchanged
|
||||||
|
|
||||||
|
| Path | Auth | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /auth/login` | AllowAnonymous | LDAP auth → SignInAsync(Cookie); JSON callers get 204 / 401 / 503, form posters get 302 + cookie |
|
||||||
|
| `POST /auth/logout` | RequireAuthorization | SignOutAsync(Cookie) |
|
||||||
|
| `GET /auth/ping` | AllowAnonymous (handler-returns 200/401) | Polled by Blazor every 60s |
|
||||||
|
| `POST /auth/token` | RequireAuthorization | Mints JWT for hypothetical external callers (matches ScadaBridge — they keep this even without JwtBearer wired) |
|
||||||
|
|
||||||
|
### Cookie rename
|
||||||
|
|
||||||
|
Old: `OtOpcUa.Auth`. New: `ZB.MOM.WW.OtOpcUa.Auth`. Effect: all sessions in flight at deploy time are invisible to the new handler → users re-prompt for login on next protected GET. No security impact (the old cookie expires per its own sliding window; nothing reads it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Components
|
||||||
|
|
||||||
|
### Files modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/Server/.../Security/CookieOptions.cs` | Add `RequireHttpsCookie`; change `Name` default to `ZB.MOM.WW.OtOpcUa.Auth` |
|
||||||
|
| `src/Server/.../Security/ServiceCollectionExtensions.cs` | Drop `using JwtBearer`; delete `ConfigureJwtBearerFromTokenService` class; drop `.AddJwtBearer` + its IPostConfigureOptions registration; drop `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides; add `LoginPath` + `LogoutPath`; add PostConfigure block consuming `OtOpcUaCookieOptions`; remove `JwtBearerDefaults.AuthenticationScheme` from `FallbackPolicy` builder |
|
||||||
|
| `tests/Server/.../Security.Tests/AuthEndpointsIntegrationTests.cs` | Update the `Set-Cookie` assertion on the login-success test from `OtOpcUa.Auth=` → `ZB.MOM.WW.OtOpcUa.Auth=` |
|
||||||
|
|
||||||
|
### Files NOT modified
|
||||||
|
|
||||||
|
| File | Why |
|
||||||
|
|---|---|
|
||||||
|
| `Endpoints/AuthEndpoints.cs` | Endpoint contracts unchanged |
|
||||||
|
| `Jwt/JwtTokenService.cs` | Still mints JWT into cookie payload |
|
||||||
|
| `Blazor/CookieAuthenticationStateProvider.cs` | Still polls `/auth/ping` |
|
||||||
|
| `Ldap/*` | Untouched |
|
||||||
|
| Razor login page | POST target unchanged |
|
||||||
|
| `appsettings*.json` | Defaults are production-safe; no required config edit |
|
||||||
|
|
||||||
|
### Tests added
|
||||||
|
|
||||||
|
Single new file or appended class in `tests/Server/.../Security.Tests/`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class AuthChallengeTests : AuthEndpointsTestBase
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||||
|
{
|
||||||
|
var client = NewClient(allowAutoRedirect: false);
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("text/html");
|
||||||
|
var resp = await client.GetAsync("/", Ct);
|
||||||
|
resp.StatusCode.ShouldBe(HttpStatusCode.Found); // 302
|
||||||
|
resp.Headers.Location!.ToString().ShouldContain("/login");
|
||||||
|
resp.Headers.Location.ToString().ShouldContain("ReturnUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_xhr_GET_returns_401()
|
||||||
|
{
|
||||||
|
var client = NewClient(allowAutoRedirect: false);
|
||||||
|
client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest");
|
||||||
|
var resp = await client.GetAsync("/", Ct);
|
||||||
|
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
// Framework still writes a Location header alongside the 401 — AJAX clients ignore it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Framework reality vs. earlier hypothesis:** The ASP.NET Core cookie handler's `IsAjaxRequest` heuristic checks ONLY the `X-Requested-With: XMLHttpRequest` header, NOT the `Accept` content type. A request with `Accept: application/json` but no XHR header is classified as a browser → 302. The third test originally proposed (`Root_anonymous_json_GET_returns_401`) was dropped because it tests behavior the framework doesn't have. ScadaBridge accepts the same framework reality (it doesn't override the heuristic either).
|
||||||
|
|
||||||
|
### Package references
|
||||||
|
|
||||||
|
`src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`: remove `<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />` if grep confirms `JwtTokenService` doesn't itself need it (it uses `Microsoft.IdentityModel.Tokens` for validation parameters, separate package).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data flow
|
||||||
|
|
||||||
|
### Anonymous browser hits `/`
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → GET /
|
||||||
|
Accept: text/html
|
||||||
|
┌──> AuthN: no cookie → unauthenticated
|
||||||
|
├──> AuthZ FallbackPolicy fails
|
||||||
|
└──> Cookie HandleChallengeAsync:
|
||||||
|
- Accept: text/html → browser
|
||||||
|
- 302 Location: /login?ReturnUrl=%2F
|
||||||
|
Browser → GET /login ← redirect followed; login page renders (AllowAnonymous)
|
||||||
|
[user submits form]
|
||||||
|
Browser → POST /auth/login Content-Type: application/x-www-form-urlencoded
|
||||||
|
─── LoginAsync:
|
||||||
|
- LDAP authenticate
|
||||||
|
- SignInAsync(Cookie)
|
||||||
|
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=...
|
||||||
|
- 302 Location: / (or ReturnUrl)
|
||||||
|
Browser → GET / cookie present → AuthZ passes → 200 + Razor render
|
||||||
|
```
|
||||||
|
|
||||||
|
### XHR / fetch hits a protected endpoint without cookie
|
||||||
|
|
||||||
|
```
|
||||||
|
fetch('/api/something') Accept: application/json
|
||||||
|
X-Requested-With: XMLHttpRequest
|
||||||
|
┌──> AuthN: no cookie → unauthenticated
|
||||||
|
├──> AuthZ FallbackPolicy fails
|
||||||
|
└──> Cookie HandleChallengeAsync:
|
||||||
|
- not text/html → API client
|
||||||
|
- 401 (no body, no Location)
|
||||||
|
```
|
||||||
|
|
||||||
|
The cookie handler's built-in `IsAjaxRequest` heuristic is what makes this work — it looks for `X-Requested-With: XMLHttpRequest`. No custom event handler needed. Note: requests with only `Accept: application/json` (no XHR header) are classified as browsers → 302; AJAX callers should set the XHR header to get 401.
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
|
||||||
|
```
|
||||||
|
fetch('/auth/logout', POST) cookie present
|
||||||
|
─── LogoutAsync (RequireAuthorization passes):
|
||||||
|
- SignOutAsync(Cookie)
|
||||||
|
- Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=; expires=...
|
||||||
|
- 204 (or browser-form: 302 /login)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Old cookie ignored
|
||||||
|
|
||||||
|
Browser holds stale `OtOpcUa.Auth` from a session that predates the deploy. Cookie scheme is now configured for `ZB.MOM.WW.OtOpcUa.Auth` — old cookie is invisible. User treated as anonymous → 302 to `/login`. Old cookie sits in jar until its own sliding window expires (max 30 min); no security risk because nothing reads it.
|
||||||
|
|
||||||
|
### Blazor `/auth/ping` polling
|
||||||
|
|
||||||
|
```
|
||||||
|
CookieAuthenticationStateProvider → GET /auth/ping every 60s
|
||||||
|
cookie present → 200
|
||||||
|
cookie expired/missing → 401
|
||||||
|
Blazor → invalidates auth state → re-render → root [Authorize] fails
|
||||||
|
→ Cookie HandleChallengeAsync → 302 /login
|
||||||
|
```
|
||||||
|
|
||||||
|
Unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Error handling
|
||||||
|
|
||||||
|
| Surface | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Unknown `Accept` (`*/*`, missing, JSON) | Framework default: treated as non-AJAX → 302 to `/login`. The cookie handler's `IsAjaxRequest` only looks at `X-Requested-With`, NOT `Accept`. CLI tools that want a 401 should set `X-Requested-With: XMLHttpRequest`. |
|
||||||
|
| `LoginAsync` bad creds | JSON: `401`. Form: `302 /login?error=…&returnUrl=…`. Handler-returned, unaffected by middleware changes. |
|
||||||
|
| `LoginAsync` LDAP throws | `503 ServiceUnavailable`. Handler-returned. |
|
||||||
|
| `LoginAsync` success | JSON: `204`. Form: `302 /` (or `ReturnUrl`). |
|
||||||
|
| Cookie expires mid-request | Treated as anonymous → 302 to `/login` (browser) or 401 (AJAX). Active users kept alive by `SlidingExpiration = true`. |
|
||||||
|
| `RequireHttpsCookie = false` over HTTPS | Cookie marked `SecurePolicy = SameAsRequest`. Misconfiguration risk; startup logs Warning every boot so it's audible. No validator-refused boot — default is `true`; dev compose explicitly opts out. |
|
||||||
|
| Missing `Security:Cookie` section in config | `.Bind()` no-ops; defaults take over (`Name = ZB.MOM.WW.OtOpcUa.Auth`, `ExpiryMinutes = 30`, `RequireHttpsCookie = true`). Production-safe. |
|
||||||
|
| `[Authorize(Policy="DriverOperator")]` denied for authenticated non-operator | Cookie handler redirects to default `AccessDeniedPath = "/Account/AccessDenied"` which 404s in OtOpcUa. Matches ScadaBridge; rare enough not to be a P0. Follow-up: add a minimal `/access-denied` Razor page. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
|
||||||
|
### Existing tests pass unchanged
|
||||||
|
|
||||||
|
- `Login_with_invalid_credentials_returns_401` — handler-returned, unaffected
|
||||||
|
- `Login_when_ldap_throws_returns_503` — handler-returned, unaffected
|
||||||
|
- `Ping_anonymous_returns_401` — handler-returned, unaffected
|
||||||
|
- `Ping_after_cookie_login_returns_200` — uses HttpClient cookie container, picks up renamed cookie automatically
|
||||||
|
- `Login_with_cookie_credentials_returns_204_and_sets_cookie` — needs one assertion update (cookie name)
|
||||||
|
|
||||||
|
### Tests added (3 new)
|
||||||
|
|
||||||
|
- `Root_anonymous_browser_GET_redirects_to_login` — asserts 302 + `Location` contains `/login` + `ReturnUrl`
|
||||||
|
- `Root_anonymous_ajax_GET_returns_401` — `X-Requested-With: XMLHttpRequest` → 401, no `Location`
|
||||||
|
(the originally planned `Root_anonymous_json_GET_returns_401` was dropped — see Section 3 framework-reality note above)
|
||||||
|
|
||||||
|
### Removed/orphaned tests
|
||||||
|
|
||||||
|
None expected. The explore phase found no test depending on `ConfigureJwtBearerFromTokenService` or the `WWW-Authenticate: Bearer` response. Grep at plan-write time to confirm.
|
||||||
|
|
||||||
|
### Manual smoke (docker-dev stack)
|
||||||
|
|
||||||
|
1. `http://localhost:9200/` anonymously → expect 302 to `/login?ReturnUrl=%2F` (was: Chrome error page)
|
||||||
|
2. Sign in via the form
|
||||||
|
3. `http://localhost:9200/` authenticated → expect Razor dashboard
|
||||||
|
4. DevTools → Application → Cookies → confirm `ZB.MOM.WW.OtOpcUa.Auth`
|
||||||
|
5. `curl -i http://localhost:9200/` → `302 Found`, Location: `/login?ReturnUrl=%2F`
|
||||||
|
6. `curl -i -H "Accept: application/json" http://localhost:9200/` → `401 Unauthorized`
|
||||||
|
|
||||||
|
### Verification gates at PR time
|
||||||
|
|
||||||
|
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero new errors (pre-existing 12 unchanged)
|
||||||
|
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green
|
||||||
|
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — all green
|
||||||
|
- Manual Chrome smoke above passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sequencing (for plan-writing)
|
||||||
|
|
||||||
|
Single-PR feature, but split into reviewable phases:
|
||||||
|
|
||||||
|
1. **Phase 1 — Options class.** Extend `OtOpcUaCookieOptions` with `RequireHttpsCookie` and new `Name` default. Tests unaffected.
|
||||||
|
2. **Phase 2 — Wiring rewrite.** Edit `ServiceCollectionExtensions.cs`: drop JwtBearer, drop event overrides, add `LoginPath`/`LogoutPath`, add PostConfigure consumption of `OtOpcUaCookieOptions`. Update the one existing test assertion. Build + existing Security.Tests green.
|
||||||
|
3. **Phase 3 — New challenge tests.** Add the 3 new redirect/401 tests.
|
||||||
|
4. **Phase 4 — Package cleanup.** Remove `Microsoft.AspNetCore.Authentication.JwtBearer` from csproj if grep confirms no remaining consumer.
|
||||||
|
5. **Phase 5 — Manual smoke + commit.** Restart admin-a/admin-b in docker-dev; verify in Chrome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions table
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Drop JwtBearer server-side scheme | No in-repo consumer; brought non-redirect 401 + `WWW-Authenticate: Bearer` to browser GETs |
|
||||||
|
| 2 | Keep `JwtTokenService` + `/auth/token` | Token-as-cookie-payload is load-bearing for Blazor; `/auth/token` matches ScadaBridge surface |
|
||||||
|
| 3 | Rename cookie `OtOpcUa.Auth` → `ZB.MOM.WW.OtOpcUa.Auth` | Naming parity with ScadaBridge; one-time forced sign-out acceptable |
|
||||||
|
| 4 | Externalize via existing `OtOpcUaCookieOptions` + PostConfigure | Mirrors ScadaBridge pattern; fixes pre-existing bug where options class was bound but ignored |
|
||||||
|
| 5 | Drop both `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides | Restores framework's browser-vs-AJAX heuristic; ScadaBridge does the same |
|
||||||
|
| 6 | Set `LoginPath = "/login"`, `LogoutPath = "/auth/logout"` | Required for the framework's default redirect to work |
|
||||||
|
| 7 | Accept 404 on `/Account/AccessDenied` for v1 | Matches ScadaBridge; rare path; follow-up to add minimal page |
|
||||||
|
| 8 | Warning-log when `RequireHttpsCookie = false` | Audible misconfig signal; same as ScadaBridge |
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
# Auth/login alignment with ScadaBridge — implementation plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` or `superpowers-extended-cc:subagent-driven-development` to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Match ScadaBridge's single-Cookie auth pattern: drop the unused JwtBearer parallel scheme, restore the framework's default browser-vs-AJAX challenge heuristic, and externalize cookie config through the existing-but-unused `OtOpcUaCookieOptions`.
|
||||||
|
|
||||||
|
**Architecture:** Cookie-only auth. `JwtTokenService` keeps minting JWTs as the cookie payload (Blazor circuit hydration depends on it). Cookie name + idle timeout + HTTPS policy flow through `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step. Endpoint surface (`/auth/login`, `/auth/logout`, `/auth/ping`, `/auth/token`) unchanged.
|
||||||
|
|
||||||
|
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` / xUnit v3 + Shouldly / `Microsoft.AspNetCore.TestHost.TestServer`.
|
||||||
|
|
||||||
|
**Design doc:** `docs/plans/2026-05-29-auth-alignment-design.md` (commit `bc4fce5`). Each task below cites the design section it implements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (Options class)
|
||||||
|
└─► Task 2 (Wiring rewrite + test assertion update)
|
||||||
|
├─► Task 3 (3 new challenge tests)
|
||||||
|
└─► Task 4 (csproj cleanup)
|
||||||
|
└─► Task 5 (manual smoke + final commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks 3 and 4 are parallelizable (disjoint files).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — Extend `OtOpcUaCookieOptions`
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** none (Task 2 depends on this)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs`
|
||||||
|
|
||||||
|
**Implements design:** Section 1 (Architecture, "Cookie config — externalized") + Section 2 (Components, file table row 1).
|
||||||
|
|
||||||
|
### Step 1: Replace file contents
|
||||||
|
|
||||||
|
Current file (12 lines):
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
public sealed class OtOpcUaCookieOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Security:Cookie";
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the cookie name.</summary>
|
||||||
|
public string Name { get; set; } = "OtOpcUa.Auth";
|
||||||
|
|
||||||
|
/// <summary>Idle sliding window, in minutes (default 30).</summary>
|
||||||
|
public int ExpiryMinutes { get; set; } = 30;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
|
||||||
|
/// <c>Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory></c> step inside
|
||||||
|
/// <c>AddOtOpcUaAuth</c> that copies the values onto <c>CookieAuthenticationOptions</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OtOpcUaCookieOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name (<c>Security:Cookie</c>).</summary>
|
||||||
|
public const string SectionName = "Security:Cookie";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auth cookie name. Default uses the <c>ZB.MOM.WW</c> convention; mirrors ScadaBridge's
|
||||||
|
/// <c>ZB.MOM.WW.ScadaBridge.Auth</c>. Changing this invalidates existing sessions on next
|
||||||
|
/// deploy.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||||
|
|
||||||
|
/// <summary>Idle sliding-window length in minutes (default 30).</summary>
|
||||||
|
public int ExpiryMinutes { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Require HTTPS for the auth cookie. Default <c>true</c>: cookie is marked
|
||||||
|
/// <c>SecurePolicy = Always</c>. Set to <c>false</c> ONLY for local dev stacks running
|
||||||
|
/// plain HTTP — emits a startup Warning when disabled so the misconfiguration is
|
||||||
|
/// audible.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireHttpsCookie { get; set; } = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Build
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
```
|
||||||
|
Expected: 0 errors, 0 warnings.
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "feat(security): extend OtOpcUaCookieOptions with RequireHttpsCookie + ZB.MOM.WW cookie name default"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- Lines before / after
|
||||||
|
- Build clean
|
||||||
|
- Commit SHA
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `Name` default is `"ZB.MOM.WW.OtOpcUa.Auth"` (NOT `"OtOpcUa.Auth"`)
|
||||||
|
- [ ] `RequireHttpsCookie` field added with default `true` and XML doc explaining the dev-only opt-out
|
||||||
|
- [ ] `ExpiryMinutes` default unchanged at 30
|
||||||
|
- [ ] `SectionName` constant unchanged
|
||||||
|
- [ ] Build clean
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — Rewrite auth wiring in `ServiceCollectionExtensions.cs`
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (Tasks 3 and 4 depend on this)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`
|
||||||
|
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs:93`
|
||||||
|
|
||||||
|
**Implements design:** Section 1 + Section 2 file table rows 2 + 3.
|
||||||
|
|
||||||
|
### Step 1: Read current file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Current shape (relevant excerpt):
|
||||||
|
- `using Microsoft.AspNetCore.Authentication.JwtBearer;` at top
|
||||||
|
- `internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService) : IPostConfigureOptions<JwtBearerOptions>` class (lines ~15-35)
|
||||||
|
- `.AddCookie(o => { ... })` with `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides
|
||||||
|
- `.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { })` chained after AddCookie
|
||||||
|
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>()` after the AddAuthentication block
|
||||||
|
- `FallbackPolicy` builder takes both Cookie + JwtBearer schemes
|
||||||
|
|
||||||
|
### Step 2: Replace the file with the new shape
|
||||||
|
|
||||||
|
The full target file:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||||
|
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||||
|
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
||||||
|
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
||||||
|
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
||||||
|
/// challenge heuristic).</summary>
|
||||||
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="configuration">The application configuration root.</param>
|
||||||
|
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||||
|
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
|
||||||
|
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<JwtTokenService>();
|
||||||
|
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||||
|
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||||
|
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
|
services.AddDataProtection()
|
||||||
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||||
|
.SetApplicationName("OtOpcUa");
|
||||||
|
|
||||||
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(o =>
|
||||||
|
{
|
||||||
|
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||||
|
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||||
|
o.LoginPath = "/login";
|
||||||
|
o.LogoutPath = "/auth/logout";
|
||||||
|
o.Cookie.HttpOnly = true;
|
||||||
|
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
|
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||||
|
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||||
|
});
|
||||||
|
|
||||||
|
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||||
|
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||||
|
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||||
|
{
|
||||||
|
var v = ourOpts.Value;
|
||||||
|
cookieOpts.Cookie.Name = v.Name;
|
||||||
|
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||||
|
cookieOpts.SlidingExpiration = true;
|
||||||
|
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||||
|
? CookieSecurePolicy.Always
|
||||||
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
|
||||||
|
if (!v.RequireHttpsCookie)
|
||||||
|
{
|
||||||
|
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||||
|
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
|
||||||
|
"SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
|
||||||
|
"Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthorization(o =>
|
||||||
|
{
|
||||||
|
o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// DriverOperator: may issue Reconnect/Restart commands against live driver instances
|
||||||
|
// from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
|
||||||
|
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
||||||
|
o.AddPolicy("DriverOperator", policy =>
|
||||||
|
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What's gone (vs. the original):
|
||||||
|
- `using Microsoft.AspNetCore.Authentication.JwtBearer;`
|
||||||
|
- `ConfigureJwtBearerFromTokenService` internal class entirely
|
||||||
|
- `.AddJwtBearer(...)` chain after `.AddCookie(...)`
|
||||||
|
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();`
|
||||||
|
- `OnRedirectToLogin` / `OnRedirectToAccessDenied` event overrides
|
||||||
|
- Hardcoded `o.Cookie.Name = "OtOpcUa.Auth"`, `o.SlidingExpiration = true`, `o.ExpireTimeSpan = TimeSpan.FromMinutes(30)`, `o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest`
|
||||||
|
- `JwtBearerDefaults.AuthenticationScheme` from the `FallbackPolicy` builder
|
||||||
|
|
||||||
|
What's added:
|
||||||
|
- `using Microsoft.Extensions.Logging;`
|
||||||
|
- `o.LoginPath = "/login"`, `o.LogoutPath = "/auth/logout"` inside `.AddCookie(...)`
|
||||||
|
- The `services.AddOptions<CookieAuthenticationOptions>(...).Configure<...>(...)` PostConfigure block
|
||||||
|
|
||||||
|
### Step 3: Update the one existing test assertion
|
||||||
|
|
||||||
|
In `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` around line 93:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// before
|
||||||
|
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
|
||||||
|
// after
|
||||||
|
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Build + run security tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build clean; all Security.Tests pass (the existing 5 AuthEndpointsIntegrationTests + JwtTokenServiceTests + LdapHelperTests + RoleMapperTests).
|
||||||
|
|
||||||
|
### Step 5: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||||
|
src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs \
|
||||||
|
tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "$(cat <<'EOF'
|
||||||
|
refactor(security): drop JwtBearer parallel scheme, externalize cookie config
|
||||||
|
|
||||||
|
Single Cookie auth scheme; framework default challenge restores 302 → /login
|
||||||
|
for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to
|
||||||
|
CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the
|
||||||
|
options class was bound but ignored). Cookie name moves to
|
||||||
|
ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- Net LOC change (additions / deletions)
|
||||||
|
- Build clean
|
||||||
|
- Test count run / passed
|
||||||
|
- Commit SHA
|
||||||
|
- Anything unexpected
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `using Microsoft.AspNetCore.Authentication.JwtBearer;` removed
|
||||||
|
- [ ] `ConfigureJwtBearerFromTokenService` class deleted
|
||||||
|
- [ ] `.AddJwtBearer(...)` call deleted
|
||||||
|
- [ ] `IPostConfigureOptions<JwtBearerOptions>` singleton registration deleted
|
||||||
|
- [ ] `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides deleted
|
||||||
|
- [ ] `LoginPath = "/login"` and `LogoutPath = "/auth/logout"` added inside `.AddCookie(...)`
|
||||||
|
- [ ] PostConfigure block added consuming `OtOpcUaCookieOptions`
|
||||||
|
- [ ] Warning log fires when `RequireHttpsCookie == false`
|
||||||
|
- [ ] `FallbackPolicy` now takes only `CookieAuthenticationDefaults.AuthenticationScheme`
|
||||||
|
- [ ] `DriverOperator` policy unchanged
|
||||||
|
- [ ] Test assertion updated to `ZB.MOM.WW.OtOpcUa.Auth=`
|
||||||
|
- [ ] `dotnet test tests/Server/.../Security.Tests/` all green
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — Add browser-vs-AJAX challenge tests
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` (append 3 new test methods + 1 helper)
|
||||||
|
|
||||||
|
**Implements design:** Section 5 "Tests added" + Section 4 "Auth challenge for unknown content type".
|
||||||
|
|
||||||
|
### Context for the implementer
|
||||||
|
|
||||||
|
`AuthEndpointsIntegrationTests` is `IAsyncLifetime`-backed and stands up a `TestServer` with `MapOtOpcUaAuth()` mounted (line 66). The `web.UseEndpoints(e => e.MapOtOpcUaAuth())` wires ONLY the four `/auth/*` endpoints — there is NO root `MapGet("/", ...)` registered. So an anonymous GET to `/` hits the routing pipeline, falls through to a 404 BEFORE auth middleware even challenges.
|
||||||
|
|
||||||
|
**The test harness needs a protected root endpoint.** Add one in `InitializeAsync` inside the `web.UseEndpoints(...)` callback. Then the 3 new tests will exercise the cookie scheme's challenge for that protected route.
|
||||||
|
|
||||||
|
### Step 1: Modify the test host setup
|
||||||
|
|
||||||
|
In `AuthEndpointsIntegrationTests.cs`, change `web.UseEndpoints(...)` (around line 66) from:
|
||||||
|
```csharp
|
||||||
|
app.UseEndpoints(e => e.MapOtOpcUaAuth());
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```csharp
|
||||||
|
app.UseEndpoints(e =>
|
||||||
|
{
|
||||||
|
e.MapOtOpcUaAuth();
|
||||||
|
// Protected root used by AuthChallengeTests below — exercises the cookie
|
||||||
|
// scheme's challenge heuristic without depending on the full Razor host.
|
||||||
|
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Add the three new test methods
|
||||||
|
|
||||||
|
Append at the bottom of the class (before the closing brace), keeping the file's existing summary style and using `TestContext.Current.CancellationToken` via the existing `Ct` property:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||||
|
{
|
||||||
|
var client = NewClientNoRedirect();
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||||
|
req.Headers.Accept.ParseAdd("text/html");
|
||||||
|
var resp = await client.SendAsync(req, Ct);
|
||||||
|
|
||||||
|
resp.StatusCode.ShouldBe(HttpStatusCode.Found);
|
||||||
|
resp.Headers.Location.ShouldNotBeNull();
|
||||||
|
resp.Headers.Location!.OriginalString.ShouldContain("/login");
|
||||||
|
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Anonymous AJAX GET of a protected route returns 401 with no Location.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_ajax_GET_returns_401()
|
||||||
|
{
|
||||||
|
var client = NewClientNoRedirect();
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||||
|
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||||
|
var resp = await client.SendAsync(req, Ct);
|
||||||
|
|
||||||
|
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
resp.Headers.Location.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Anonymous JSON GET of a protected route returns 401.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_json_GET_returns_401()
|
||||||
|
{
|
||||||
|
var client = NewClientNoRedirect();
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||||
|
req.Headers.Accept.ParseAdd("application/json");
|
||||||
|
var resp = await client.SendAsync(req, Ct);
|
||||||
|
|
||||||
|
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Add the no-redirect client helper
|
||||||
|
|
||||||
|
Right next to the existing `NewClient()` method (line 82):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
|
||||||
|
/// Used by challenge tests so we can assert on the 302 / Location directly.</summary>
|
||||||
|
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = _server.BaseAddress,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: existing 5 tests still pass + 3 new tests pass = 8+ total green.
|
||||||
|
|
||||||
|
**If `Root_anonymous_browser_GET_redirects_to_login` returns 200 instead of 302**: HttpClient is still auto-following redirects. Two fixes to try in order:
|
||||||
|
1. Confirm `NewClientNoRedirect` uses `_server.CreateHandler()` (not `CreateClient()`).
|
||||||
|
2. If still wrong, swap to: `var handler = new HttpClientHandler { AllowAutoRedirect = false };` — but TestServer doesn't expose HttpClientHandler directly. The `CreateHandler()` path SHOULD return a non-redirecting handler; if it doesn't, the implementation may need a `DelegatingHandler` wrapper.
|
||||||
|
|
||||||
|
**If `Root_anonymous_browser_GET_redirects_to_login` returns 401 instead of 302**: the cookie scheme isn't classifying `Accept: text/html` as a browser. Inspect Task 2's changes — `OnRedirectToLogin` may not have been fully removed, OR `LoginPath` was not set, OR an `Accept` parsing issue. Look at the response body — if it's empty + 401, the JwtBearer scheme or the override is still in play.
|
||||||
|
|
||||||
|
### Step 5: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "test(security): add browser-vs-AJAX challenge tests for root path"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- 3 new tests + 1 helper + modified InitializeAsync
|
||||||
|
- Build clean
|
||||||
|
- Test count: existing N + 3 new = N+3 green
|
||||||
|
- Commit SHA
|
||||||
|
- Anything unexpected (e.g. redirect-following behavior of `_server.CreateHandler()`)
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `MapGet("/", ...).RequireAuthorization()` added inside `web.UseEndpoints(...)`
|
||||||
|
- [ ] `NewClientNoRedirect()` helper added
|
||||||
|
- [ ] 3 new `[Fact]` methods added with `TestContext.Current.CancellationToken` via the `Ct` property
|
||||||
|
- [ ] Each test asserts on the exact status + Location header (or absence)
|
||||||
|
- [ ] All tests green
|
||||||
|
- [ ] Existing 5 tests still pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 — Remove `Microsoft.AspNetCore.Authentication.JwtBearer` package reference
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** Task 3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj` (delete one line)
|
||||||
|
- Verify: `Directory.Packages.props` — leave the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ... />` entry in place (other projects may consume it).
|
||||||
|
|
||||||
|
**Implements design:** Section 2 "Package references" + Section 6 phase 4.
|
||||||
|
|
||||||
|
### Step 1: Confirm no remaining consumer in the Security project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer\|JwtBearer" \
|
||||||
|
/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ \
|
||||||
|
--include="*.cs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: zero matches. (Task 2 removed all uses.) If there are matches, STOP and report — Task 2 was incomplete.
|
||||||
|
|
||||||
|
### Step 2: Remove the PackageReference
|
||||||
|
|
||||||
|
In `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`, find this line (currently around line 13):
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
||||||
|
```
|
||||||
|
Delete it. **Keep** these:
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||||
|
```
|
||||||
|
(`JwtTokenService` consumes those for `TokenValidationParameters` + JWT creation respectively — they're not from the JwtBearer authentication package.)
|
||||||
|
|
||||||
|
### Step 3: Check whether ANY other project still references the package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer" \
|
||||||
|
/Users/dohertj2/Desktop/OtOpcUa/src/ /Users/dohertj2/Desktop/OtOpcUa/tests/ \
|
||||||
|
--include="*.csproj"
|
||||||
|
```
|
||||||
|
|
||||||
|
If zero results: also remove the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ...>` line from `Directory.Packages.props` (search for it). If one or more other projects still reference it, leave `Directory.Packages.props` alone.
|
||||||
|
|
||||||
|
### Step 4: Restore + build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet restore src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 NEW errors. The known pre-existing 12 errors (OpcUaServer.Tests + Runtime.Tests + AbLegacy.Cli + S7.Cli) remain unchanged.
|
||||||
|
|
||||||
|
### Step 5: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||||
|
src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj \
|
||||||
|
Directory.Packages.props # only if you also removed it from Directory.Packages.props
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "chore(security): drop Microsoft.AspNetCore.Authentication.JwtBearer (unused)"
|
||||||
|
```
|
||||||
|
|
||||||
|
If only the csproj changed: omit `Directory.Packages.props` from the add.
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- Was Directory.Packages.props also touched? Justify based on whether other projects still reference the package.
|
||||||
|
- Build clean (0 new errors)
|
||||||
|
- Commit SHA
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] Confirmed zero `Microsoft.AspNetCore.Authentication.JwtBearer` or `JwtBearer` matches in `src/Server/ZB.MOM.WW.OtOpcUa.Security/**/*.cs` before deletion
|
||||||
|
- [ ] PackageReference removed from Security.csproj
|
||||||
|
- [ ] `Microsoft.IdentityModel.Tokens` and `System.IdentityModel.Tokens.Jwt` kept
|
||||||
|
- [ ] Directory.Packages.props touched ONLY if no other project consumes the package
|
||||||
|
- [ ] Full solution build adds zero new errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 — Manual smoke + final commit
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (verification + optional cleanup commit)
|
||||||
|
|
||||||
|
**Implements design:** Section 5 "Manual smoke" + Section 6 phase 5.
|
||||||
|
|
||||||
|
### Step 1: Restart the docker-dev cluster
|
||||||
|
|
||||||
|
The admin nodes need to pick up the new `Microsoft.AspNetCore.TestHost`-side code path AND the new cookie name. Since the in-cluster admin processes run a prior build, force a rebuild + recreate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
docker compose -f docker-dev/docker-compose.yml up -d --build admin-a admin-b
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait ~15 s for warm-up. Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-dev/docker-compose.yml ps admin-a admin-b
|
||||||
|
```
|
||||||
|
|
||||||
|
Both should show `Up` and `(healthy)` (or `Up` if no healthcheck).
|
||||||
|
|
||||||
|
### Step 2: curl smoke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Anonymous browser-shaped GET → 302 to /login with ReturnUrl
|
||||||
|
curl -i -H "Accept: text/html" http://localhost:9200/ 2>&1 | head -12
|
||||||
|
# Expected: HTTP/1.1 302 Found, Location: /login?ReturnUrl=%2F
|
||||||
|
|
||||||
|
# Anonymous AJAX GET → 401
|
||||||
|
curl -i -H "X-Requested-With: XMLHttpRequest" http://localhost:9200/ 2>&1 | head -8
|
||||||
|
# Expected: HTTP/1.1 401 Unauthorized
|
||||||
|
|
||||||
|
# Anonymous JSON GET → 401
|
||||||
|
curl -i -H "Accept: application/json" http://localhost:9200/ 2>&1 | head -8
|
||||||
|
# Expected: HTTP/1.1 401 Unauthorized
|
||||||
|
|
||||||
|
# Login form → 302 with Set-Cookie ZB.MOM.WW.OtOpcUa.Auth
|
||||||
|
curl -i -X POST -d "username=alice&password=alice" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
http://localhost:9200/auth/login 2>&1 | head -15
|
||||||
|
# Expected: HTTP/1.1 302 Found, Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=... (the test stub user may differ — check docker-compose's GLAuth seed for a valid LDAP creds pair)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Chrome smoke (via the macbook browser instance from earlier in the session)
|
||||||
|
|
||||||
|
1. Open `http://localhost:9200/` — should redirect to `/login?ReturnUrl=%2F` (not Chrome's error page)
|
||||||
|
2. Sign in via the form
|
||||||
|
3. DevTools → Application → Cookies → confirm cookie name is `ZB.MOM.WW.OtOpcUa.Auth`
|
||||||
|
4. Navigate to `http://localhost:9200/` again — should render the AdminUI dashboard
|
||||||
|
5. Click logout → confirm redirect back to `/login`
|
||||||
|
|
||||||
|
### Step 4: Optional CLAUDE.md update
|
||||||
|
|
||||||
|
If `CLAUDE.md` mentions the old `OtOpcUa.Auth` cookie name anywhere, update to the new `ZB.MOM.WW.OtOpcUa.Auth`. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "OtOpcUa\.Auth" /Users/dohertj2/Desktop/OtOpcUa/CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If matches: update them, otherwise skip.
|
||||||
|
|
||||||
|
### Step 5: Final commit (only if Step 4 changed CLAUDE.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add CLAUDE.md
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "docs: update cookie name reference in CLAUDE.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- All 4 curl smoke checks passed?
|
||||||
|
- Chrome smoke passed?
|
||||||
|
- CLAUDE.md changed?
|
||||||
|
- Final SHA on master (if any docs commit)
|
||||||
|
- Commit count since this plan started (vs `bc4fce5`)
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `docker compose up -d --build admin-a admin-b` succeeded
|
||||||
|
- [ ] All 4 curl smoke checks return expected status codes
|
||||||
|
- [ ] Chrome smoke shows redirect to `/login`, then dashboard after auth
|
||||||
|
- [ ] Cookie name in DevTools matches `ZB.MOM.WW.OtOpcUa.Auth`
|
||||||
|
- [ ] No new commits left uncommitted in the working tree
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification gates (apply at end of every task)
|
||||||
|
|
||||||
|
- `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/` — 0 errors
|
||||||
|
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green (existing + new)
|
||||||
|
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — no NEW errors beyond the 12 pre-existing
|
||||||
|
- No untracked files staged accidentally (especially `sql_login.txt`, `pki/`, doc-fix artifacts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk hot-spots for reviewers
|
||||||
|
|
||||||
|
1. **TestServer's no-redirect HttpClient.** The plan assumes `new HttpClient(_server.CreateHandler()) { BaseAddress = _server.BaseAddress }` does NOT auto-follow redirects. If it does, the `Root_anonymous_browser_GET_redirects_to_login` test fails with 200 instead of 302. Fix path documented in Task 3 Step 4.
|
||||||
|
2. **Framework default of `Accept: */*` → 302.** Curl's default Accept header is `*/*`, which the framework classifies as browser → 302. Documented behavior, mirrors ScadaBridge; reviewers should not flag the smoke step that uses `Accept: text/html` as redundant — it's the explicit "browser" assertion.
|
||||||
|
3. **Cookie rename invalidates sessions.** The deploy effectively logs every currently-signed-in user out. Document in commit body; the cluster was just restarted on the new API key anyway, so the timing is opportune.
|
||||||
|
4. **`Directory.Packages.props` change is conditional.** Don't touch it if other projects still consume the JwtBearer package. Task 4 has explicit grep guard.
|
||||||
|
5. **`/Account/AccessDenied` 404.** Authenticated users hitting a `DriverOperator`-only route now get a generic 404 page instead of a clean access-denied message. Documented design choice; follow-up to add a Razor page if UX feedback demands it.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-29-auth-alignment-plan.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 1: Extend OtOpcUaCookieOptions", "status": "pending"},
|
||||||
|
{"id": 2, "subject": "Task 2: Rewrite auth wiring + update cookie-name assertion", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 3: Add browser-vs-AJAX challenge tests", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 4, "subject": "Task 4: Remove JwtBearer package reference", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 5, "subject": "Task 5: Manual smoke + final commit", "status": "pending", "blockedBy": [3, 4]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-29T00:00:00Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -9,24 +9,41 @@
|
|||||||
> the new RPCs; the sub-attribute fallback path keeps Galaxy alarms
|
> the new RPCs; the sub-attribute fallback path keeps Galaxy alarms
|
||||||
> functional today.
|
> functional today.
|
||||||
>
|
>
|
||||||
> ⚠️ **Worker-side native alarm subscription blocked on a dev-rig
|
> ✅ **UPDATE 2026-05-29 — native alarm feed VERIFIED working; the
|
||||||
> finding (2026-04-30):** the MXAccess COM Toolkit at
|
> 2026-04-30 "blocked" finding below is superseded.** A live
|
||||||
|
> `StreamAlarms` check against the gateway at `10.100.0.48:5120`
|
||||||
|
> returned the active-alarm snapshot (20 alarms) with full native
|
||||||
|
> metadata — `severity`, `category`, `currentState`,
|
||||||
|
> `lastTransitionTimestamp`, **and `operatorComment`** (the field the
|
||||||
|
> note below called "the only v1 regression"). The lmxopcua consumer
|
||||||
|
> (`GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition` →
|
||||||
|
> `AlarmEventArgs` → `IAlarmSource`) ingests it with full fidelity and
|
||||||
|
> the OPC UA severity-bucket mapping applied — proven by the passing
|
||||||
|
> Skip-gated live test `GatewayGalaxyAlarmFeedLiveTests`. `AlarmEventArgs`
|
||||||
|
> already carries operator-comment / original-raise-time / category, so
|
||||||
|
> **E.7 is done too**. See `docs/plans/alarms-d1-smoke-artifact.md` for
|
||||||
|
> the captured evidence. The gateway delivers this via **true MxAccess
|
||||||
|
> alarm-event support** in the mxaccessgw .NET client (a real
|
||||||
|
> alarm-event subscription — **not** the sub-attribute fallback), so A.2
|
||||||
|
> is implemented as originally specified. Still open: the scripted-alarm
|
||||||
|
> → AVEVA Historian write-back live smoke (C.1's `Live_*` leg) and a full
|
||||||
|
> running-server → OPC UA A&C round-trip — both need the Windows parity rig.
|
||||||
|
>
|
||||||
|
> ⚠️ **[SUPERSEDED — kept for history] Worker-side native alarm
|
||||||
|
> subscription blocked on a dev-rig finding (2026-04-30):** the MXAccess
|
||||||
|
> COM Toolkit at
|
||||||
> `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
|
> `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
|
||||||
> exposes no alarm-event family — only `OnDataChange`,
|
> exposed no alarm-event family — only `OnDataChange`,
|
||||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange`.
|
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange` — and
|
||||||
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
|
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
|
||||||
> assemblies are x64-only and incompatible with the worker's x86
|
> assemblies are x64-only vs. the worker's x86 bitness. The operator
|
||||||
> bitness. **Operator decision needed before
|
> decision (accept the value-driven sub-attribute path, or add an x64
|
||||||
> `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries any events:** either
|
> alarm-helper sub-process) has since been resolved on the gateway side
|
||||||
> accept the value-driven sub-attribute path as the production
|
> — `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` now carries events (verified
|
||||||
> architecture (operator-comment fidelity is the only v1 regression)
|
> above). The C.1 `SdkAlarmHistorianWriteBackend` is **no longer a
|
||||||
> or add an x64 alarm-helper sub-process alongside the worker. See
|
> placeholder** — it writes through the real
|
||||||
> `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` in the
|
> `HistorianAccess.AddStreamedValue` path (only its live-rig write
|
||||||
> mxaccessgw repo for the architectural notes. Live
|
> smoke remains).
|
||||||
> `aahClientManaged` alarm-event write call site
|
|
||||||
> (`SdkAlarmHistorianWriteBackend` placeholder from PR C.1) and the
|
|
||||||
> D.1 smoke artifact ship once those decisions resolve. The
|
|
||||||
> remainder of this document is preserved as the design record.
|
|
||||||
|
|
||||||
Coordinated epic across two repos:
|
Coordinated epic across two repos:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Alarms Worker Wiring Plan
|
# Alarms Worker Wiring Plan
|
||||||
|
|
||||||
|
> ✅ **UPDATE 2026-05-29 — the blocker below is RESOLVED on the gateway side; this
|
||||||
|
> plan is largely complete.** A live `StreamAlarms` check against `10.100.0.48:5120`
|
||||||
|
> returns the active-alarm snapshot with full native metadata **including
|
||||||
|
> `operatorComment`**, and the lmxopcua consumer ingests it end-to-end (passing live
|
||||||
|
> test `GatewayGalaxyAlarmFeedLiveTests`). So **A.2 / A.3 / A.4** are functionally done
|
||||||
|
> at the gateway boundary (the worker now emits native alarm transitions and the client
|
||||||
|
> exposes `AcknowledgeAlarm` / `QueryActiveAlarms` RPCs). **C.1** ships real code
|
||||||
|
> (`SdkAlarmHistorianWriteBackend` → `HistorianAccess.AddStreamedValue`). **D.1**'s
|
||||||
|
> alarm-source leg is captured in `docs/plans/alarms-d1-smoke-artifact.md`. Only two
|
||||||
|
> things remain, both needing the Windows parity rig: C.1's live historian-write smoke
|
||||||
|
> and a full running-server → OPC UA A&C round-trip. The per-item detail below is kept
|
||||||
|
> as the historical record of the original blocked state.
|
||||||
|
>
|
||||||
> **Context**: The alarms-over-gateway epic shipped 19 PRs across the
|
> **Context**: The alarms-over-gateway epic shipped 19 PRs across the
|
||||||
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
|
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
|
||||||
> the sub-attribute fallback path keeps Galaxy alarms functional today. Four
|
> the sub-attribute fallback path keeps Galaxy alarms functional today. Four
|
||||||
@@ -16,7 +29,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dev-rig finding that blocks everything (2026-04-30)
|
## Dev-rig finding that blocks everything (2026-04-30) — [SUPERSEDED 2026-05-29]
|
||||||
|
|
||||||
During PR A.2 work the following was discovered on the dev box:
|
During PR A.2 work the following was discovered on the dev box:
|
||||||
|
|
||||||
@@ -318,16 +331,20 @@ fallback as production).
|
|||||||
|
|
||||||
## Summary of blocks
|
## Summary of blocks
|
||||||
|
|
||||||
| Item | Blocked by | Estimated effort once unblocked |
|
> **Resolved as of 2026-05-29** — see the update banner at the top and
|
||||||
|------|-----------|--------------------------------|
|
> `docs/plans/alarms-d1-smoke-artifact.md`. Original status table kept for history.
|
||||||
| A.2 | Architectural decision (x64 alarm-helper vs. sub-attribute fallback as production) | 2–3 days implementation; 1 day tests |
|
|
||||||
| A.3 | A.2 delivering WorkerEvent bodies | 1–2 days |
|
|
||||||
| A.4 | A.2 (active-alarm query needs AlarmClient session) | 1 day |
|
|
||||||
| C.1 | aahClientManaged SDK access (available on dev box); NOT blocked by A.2 | 1–2 days |
|
|
||||||
| D.1 | A.2 + A.3 + C.1 all passing on parity rig | 0.5 day (smoke + artifact capture) |
|
|
||||||
|
|
||||||
C.1 can proceed in parallel with A.2 / A.3 since the sidecar's `aahClientManaged`
|
| Item | Status (2026-05-29) | Original block |
|
||||||
is x64 and does not share the worker bitness constraint.
|
|------|--------------------|----------------|
|
||||||
|
| A.2 | ✅ **True MxAccess alarm-event support** in the gateway client (real alarm-event subscription, not the sub-attribute fallback); verified via live `StreamAlarms` with operator-comment fidelity | Architectural decision (x64 alarm-helper vs. sub-attribute fallback) |
|
||||||
|
| A.3 | ✅ Dispatch + `AcknowledgeAlarm` RPC present on the client surface | A.2 delivering WorkerEvent bodies |
|
||||||
|
| A.4 | ✅ `QueryActiveAlarms` RPC present on the client surface | A.2 (active-alarm query needs AlarmClient session) |
|
||||||
|
| C.1 | ✅ Code shipped (`AddStreamedValue` path); ⏳ live historian-write smoke needs the Windows rig | aahClientManaged SDK access |
|
||||||
|
| D.1 | ◑ Alarm-source leg captured (`alarms-d1-smoke-artifact.md`); ⏳ historian-write leg + full server→A&C round-trip need the Windows rig | A.2 + A.3 + C.1 all passing on parity rig |
|
||||||
|
|
||||||
|
The gateway delivers operator-comment fidelity through **true MxAccess alarm-event
|
||||||
|
support** in the mxaccessgw .NET client — a real alarm-event subscription, not the
|
||||||
|
value-driven sub-attribute path. The sub-attribute fallback is now legacy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Driver type key — matches the AdminUI's persisted "Galaxy" value.</summary>
|
/// <summary>Driver type key — matches the AdminUI's persisted "GalaxyMxGateway" value.</summary>
|
||||||
public string DriverType => "Galaxy";
|
// Hardcoded literal: this project references Driver.Galaxy.Contracts, not Driver.Galaxy,
|
||||||
|
// so GalaxyDriverFactoryExtensions.DriverTypeName isn't available here.
|
||||||
|
public string DriverType => "GalaxyMxGateway";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deserializes a <see cref="GalaxyDriverOptions"/> blob, opens a transient
|
/// Deserializes a <see cref="GalaxyDriverOptions"/> blob, opens a transient
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class GalaxyDriverProbe : IDriverProbe
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
// Matches DriverInstance.DriverType strings set by the AdminUI's GalaxyDriverPage.
|
// Matches DriverInstance.DriverType strings set by the AdminUI's GalaxyDriverPage.
|
||||||
public string DriverType => "Galaxy";
|
public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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">
|
||||||
|
|||||||
+1
-2
@@ -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">
|
||||||
|
|||||||
+158
-31
@@ -146,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>
|
||||||
@@ -202,11 +240,9 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
// Collections are preserved through round-trip and shown as read-only JSON.
|
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||||
private IReadOnlyList<AbCipDeviceOptions> _devices = [];
|
private List<AbCipDeviceRow> _devices = [];
|
||||||
private IReadOnlyList<AbCipTagDefinition> _tags = [];
|
private List<AbCipTagRow> _tags = [];
|
||||||
private string _devicesJson = "[]";
|
|
||||||
private string _tagsJson = "[]";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -246,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,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)
|
||||||
{
|
{
|
||||||
@@ -331,7 +369,11 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
private string SerializeCurrentConfig()
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
|
=> 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)
|
||||||
{
|
{
|
||||||
@@ -339,6 +381,91 @@ else
|
|||||||
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
|
||||||
|
|||||||
+158
-34
@@ -112,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>
|
||||||
@@ -171,11 +206,9 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
// Collections are preserved through round-trip and shown as read-only JSON.
|
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||||
private IReadOnlyList<AbLegacyDeviceOptions> _devices = [];
|
private List<AbLegacyDeviceRow> _devices = [];
|
||||||
private IReadOnlyList<AbLegacyTagDefinition> _tags = [];
|
private List<AbLegacyTagRow> _tags = [];
|
||||||
private string _devicesJson = "[]";
|
|
||||||
private string _tagsJson = "[]";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -215,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,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)
|
||||||
{
|
{
|
||||||
@@ -300,7 +335,11 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
private string SerializeCurrentConfig()
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
|
=> 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)
|
||||||
{
|
{
|
||||||
@@ -308,6 +347,91 @@ else
|
|||||||
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
|
||||||
|
|||||||
+4
-6
@@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -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]
|
||||||
|
|
||||||
|
|||||||
+176
-77
@@ -189,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>
|
||||||
@@ -260,6 +282,10 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
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();
|
||||||
@@ -290,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;
|
||||||
@@ -300,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)
|
||||||
@@ -371,7 +401,11 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
private string SerializeCurrentConfig()
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
=> 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)
|
||||||
{
|
{
|
||||||
@@ -379,6 +413,93 @@ else
|
|||||||
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
|
||||||
@@ -404,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
|
||||||
@@ -476,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-20
@@ -208,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,
|
||||||
};
|
};
|
||||||
@@ -408,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(
|
||||||
|
|||||||
+97
-17
@@ -273,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>
|
||||||
@@ -318,9 +346,8 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
// Held separately because Tags is a collection — rendered as read-only JSON.
|
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
|
||||||
private IReadOnlyList<ModbusTagDefinition> _tags = [];
|
private List<ModbusTagRow> _tags = [];
|
||||||
private string _tagsJson = "[]";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -360,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,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)
|
||||||
{
|
{
|
||||||
@@ -443,7 +469,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
private string SerializeCurrentConfig()
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts);
|
=> 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)
|
||||||
{
|
{
|
||||||
@@ -451,6 +477,60 @@ else
|
|||||||
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
|
||||||
|
|||||||
+55
-15
@@ -131,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>
|
||||||
@@ -281,8 +295,10 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
// Read-only JSON snippets for collections that have no list editor yet.
|
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
|
||||||
private string _endpointUrlsJson = "[]";
|
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()
|
||||||
@@ -322,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;
|
||||||
@@ -336,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)
|
||||||
@@ -407,7 +423,8 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
private string SerializeCurrentConfig()
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.OpcUa.ToRecord(), _jsonOpts);
|
=> 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)
|
||||||
{
|
{
|
||||||
@@ -422,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<string></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
|
||||||
{
|
{
|
||||||
@@ -461,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()
|
||||||
@@ -488,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,
|
||||||
|
|||||||
+99
-50
@@ -145,23 +145,38 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@* Tags — read-only JSON view *@
|
@* Tags *@
|
||||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
<CollectionEditor TRow="S7TagRow" Items="_tags" Title="Tags" ItemNoun="tag"
|
||||||
<div class="panel-head">Tags</div>
|
AnimationDelay=".11s"
|
||||||
<div style="padding:1rem">
|
NewRow="@(() => new S7TagRow())" Clone="@(r => r.Clone())"
|
||||||
<div class="form-text mb-2">
|
Validate="S7TagRow.ValidateRow">
|
||||||
Tag list editor coming in a follow-up phase. To add/remove tags, edit the JSON directly in the raw driver config via the generic editor, or deploy via the import tooling.
|
<HeaderTemplate>
|
||||||
|
<tr><th>Name</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.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">Address</label>
|
||||||
|
<input class="form-control form-control-sm mono" @bind="t.Address"
|
||||||
|
placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" /></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<S7DataType>()) { <option value="@e">@e</option> }
|
||||||
|
</select></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">String length</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" />
|
||||||
|
<div class="form-text">Only for String type. Max 254.</div></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>
|
||||||
@@ -196,6 +211,9 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
|
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
|
||||||
|
private List<S7TagRow> _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();
|
||||||
@@ -226,6 +244,7 @@ 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.Select(S7TagRow.FromDefinition).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
@@ -236,7 +255,7 @@ else
|
|||||||
_busy = true; _error = null;
|
_busy = true; _error = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var opts = _form.ToOptions();
|
var opts = _form.ToOptions(_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)
|
||||||
@@ -307,7 +326,8 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
private string SerializeCurrentConfig()
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
=> System.Text.Json.JsonSerializer.Serialize(
|
||||||
|
_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
|
||||||
|
|
||||||
private static S7DriverOptions? TryDeserialize(string json)
|
private static S7DriverOptions? TryDeserialize(string json)
|
||||||
{
|
{
|
||||||
@@ -315,6 +335,53 @@ else
|
|||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mutable VM for the modal editor — S7TagDefinition is an immutable record.
|
||||||
|
public sealed class S7TagRow
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Address { get; set; } = "";
|
||||||
|
public S7DataType DataType { get; set; } = S7DataType.Int16;
|
||||||
|
public bool Writable { get; set; } = true;
|
||||||
|
public int StringLength { get; set; } = 254;
|
||||||
|
|
||||||
|
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
||||||
|
// (WriteIdempotent) across a load→save.
|
||||||
|
private S7TagDefinition? _source;
|
||||||
|
|
||||||
|
public S7TagRow Clone() => (S7TagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||||
|
|
||||||
|
public static S7TagRow FromDefinition(S7TagDefinition d) => new()
|
||||||
|
{
|
||||||
|
Name = d.Name, Address = d.Address, DataType = d.DataType,
|
||||||
|
Writable = d.Writable, StringLength = d.StringLength,
|
||||||
|
_source = d,
|
||||||
|
};
|
||||||
|
|
||||||
|
public S7TagDefinition ToDefinition()
|
||||||
|
{
|
||||||
|
var baseDef = _source ?? new S7TagDefinition(Name.Trim(), Address.Trim(), DataType);
|
||||||
|
return baseDef with
|
||||||
|
{
|
||||||
|
Name = Name.Trim(),
|
||||||
|
Address = Address.Trim(),
|
||||||
|
DataType = DataType,
|
||||||
|
Writable = Writable,
|
||||||
|
StringLength = StringLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ValidateRow(S7TagRow row, IReadOnlyList<S7TagRow> 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.
|
||||||
|
// Collection (Tags) is kept on the component (_tags) and passed in on ToOptions().
|
||||||
public sealed class FormModel
|
public sealed class FormModel
|
||||||
{
|
{
|
||||||
// Connection
|
// Connection
|
||||||
@@ -331,43 +398,25 @@ else
|
|||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||||
|
|
||||||
// Tags JSON view (read-only)
|
|
||||||
public string? TagsJson { get; set; }
|
|
||||||
|
|
||||||
// Preserved originals (round-tripped unchanged from original options)
|
|
||||||
private IReadOnlyList<S7TagDefinition> _tags = [];
|
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
public string? ResilienceConfig { get; set; }
|
public string? ResilienceConfig { get; set; }
|
||||||
public byte[] RowVersion { get; set; } = [];
|
public byte[] RowVersion { get; set; } = [];
|
||||||
|
|
||||||
public static FormModel FromOptions(S7DriverOptions o)
|
public static FormModel FromOptions(S7DriverOptions o) => new()
|
||||||
{
|
{
|
||||||
string? tagsJson = o.Tags.Count == 0 ? null
|
Host = o.Host,
|
||||||
: System.Text.Json.JsonSerializer.Serialize(o.Tags,
|
Port = o.Port,
|
||||||
new System.Text.Json.JsonSerializerOptions
|
CpuType = o.CpuType,
|
||||||
{
|
Rack = o.Rack,
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
Slot = o.Slot,
|
||||||
WriteIndented = true,
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
});
|
ProbeEnabled = o.Probe.Enabled,
|
||||||
return new FormModel
|
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||||
{
|
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||||
Host = o.Host,
|
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||||
Port = o.Port,
|
};
|
||||||
CpuType = o.CpuType,
|
|
||||||
Rack = o.Rack,
|
|
||||||
Slot = o.Slot,
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
TagsJson = tagsJson,
|
|
||||||
_tags = o.Tags,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public S7DriverOptions ToOptions() => new()
|
public S7DriverOptions ToOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||||
{
|
{
|
||||||
Host = Host,
|
Host = Host,
|
||||||
Port = Port,
|
Port = Port,
|
||||||
@@ -382,7 +431,7 @@ else
|
|||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||||
},
|
},
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||||
Tags = _tags,
|
Tags = tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-72
@@ -132,42 +132,61 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@* Devices — read-only JSON view *@
|
@* Devices *@
|
||||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||||
<div class="panel-head">Devices</div>
|
AnimationDelay=".11s"
|
||||||
<div style="padding:1rem">
|
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
|
||||||
<div class="form-text mb-2">
|
Validate="TwinCATDeviceRow.ValidateRow">
|
||||||
Each device is identified by AMS Net Id + port. Device list editor coming in a follow-up phase.
|
<HeaderTemplate>
|
||||||
Format: <code>[{"hostAddress":"192.168.0.1.1.1:851","deviceName":"PLC1"}]</code>
|
<tr><th>Host address</th><th>Device name</th><th></th></tr>
|
||||||
|
</HeaderTemplate>
|
||||||
|
<RowTemplate Context="d">
|
||||||
|
<td class="mono">@d.HostAddress</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 (AMS Net Id:port)</label>
|
||||||
|
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
||||||
|
placeholder="192.168.0.1.1.1:851" /></div>
|
||||||
|
<div class="col-md-6"><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:.14s">
|
<CollectionEditor TRow="TwinCATTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
|
||||||
<div class="panel-head">Tags</div>
|
AnimationDelay=".14s"
|
||||||
<div style="padding:1rem">
|
NewRow="@(() => new TwinCATTagRow())" Clone="@(r => r.Clone())"
|
||||||
<div class="form-text mb-2">
|
Validate="TwinCATTagRow.ValidateRow">
|
||||||
Tag list editor coming in a follow-up phase. Tags reference device host addresses and TwinCAT symbol paths.
|
<HeaderTemplate>
|
||||||
|
<tr><th>Name</th><th>Device</th><th>Symbol path</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.SymbolPath</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.1.1.1:851" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Symbol path</label>
|
||||||
|
<input class="form-control form-control-sm mono" @bind="t.SymbolPath"
|
||||||
|
placeholder="e.g. MAIN.bStart, GVL.Counter" /></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<TwinCATDataType>()) { <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>
|
||||||
@@ -202,6 +221,10 @@ else
|
|||||||
|
|
||||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||||
|
|
||||||
|
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||||
|
private List<TwinCATDeviceRow> _devices = [];
|
||||||
|
private List<TwinCATTagRow> _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();
|
||||||
@@ -232,6 +255,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(TwinCATDeviceRow.FromDefinition).ToList();
|
||||||
|
_tags = opts.Tags.Select(TwinCATTagRow.FromDefinition).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
@@ -242,8 +267,11 @@ else
|
|||||||
_busy = true; _error = null;
|
_busy = true; _error = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var opts = _form.ToOptions();
|
var configJson = System.Text.Json.JsonSerializer.Serialize(
|
||||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
_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)
|
||||||
{
|
{
|
||||||
@@ -313,7 +341,11 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeCurrentConfig()
|
private string SerializeCurrentConfig()
|
||||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
=> System.Text.Json.JsonSerializer.Serialize(
|
||||||
|
_form.ToOptions(
|
||||||
|
_devices.Select(r => r.ToDefinition()).ToList(),
|
||||||
|
_tags.Select(r => r.ToDefinition()).ToList()),
|
||||||
|
_jsonOpts);
|
||||||
|
|
||||||
private static TwinCATDriverOptions? TryDeserialize(string json)
|
private static TwinCATDriverOptions? TryDeserialize(string json)
|
||||||
{
|
{
|
||||||
@@ -321,6 +353,91 @@ else
|
|||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
|
||||||
|
public sealed class TwinCATDeviceRow
|
||||||
|
{
|
||||||
|
public string HostAddress { get; set; } = "";
|
||||||
|
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 TwinCATDeviceOptions? _source;
|
||||||
|
|
||||||
|
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||||
|
|
||||||
|
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
|
||||||
|
{
|
||||||
|
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
|
||||||
|
_source = d,
|
||||||
|
};
|
||||||
|
|
||||||
|
public TwinCATDeviceOptions ToDefinition()
|
||||||
|
{
|
||||||
|
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
|
||||||
|
return baseDef with
|
||||||
|
{
|
||||||
|
HostAddress = HostAddress.Trim(),
|
||||||
|
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> 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 — TwinCATTagDefinition is an immutable record.
|
||||||
|
public sealed class TwinCATTagRow
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string DeviceHostAddress { get; set; } = "";
|
||||||
|
public string SymbolPath { get; set; } = "";
|
||||||
|
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
|
||||||
|
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 TwinCATTagDefinition? _source;
|
||||||
|
|
||||||
|
public TwinCATTagRow Clone() => (TwinCATTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||||
|
|
||||||
|
public static TwinCATTagRow FromDefinition(TwinCATTagDefinition d) => new()
|
||||||
|
{
|
||||||
|
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, SymbolPath = d.SymbolPath,
|
||||||
|
DataType = d.DataType, Writable = d.Writable,
|
||||||
|
_source = d,
|
||||||
|
};
|
||||||
|
|
||||||
|
public TwinCATTagDefinition ToDefinition()
|
||||||
|
{
|
||||||
|
var baseDef = _source ?? new TwinCATTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), SymbolPath.Trim(), DataType);
|
||||||
|
return baseDef with
|
||||||
|
{
|
||||||
|
Name = Name.Trim(),
|
||||||
|
DeviceHostAddress = DeviceHostAddress.Trim(),
|
||||||
|
SymbolPath = SymbolPath.Trim(),
|
||||||
|
DataType = DataType,
|
||||||
|
Writable = Writable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ValidateRow(TwinCATTagRow row, IReadOnlyList<TwinCATTagRow> 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
|
||||||
{
|
{
|
||||||
// Options
|
// Options
|
||||||
@@ -335,47 +452,25 @@ else
|
|||||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||||
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
public int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||||
|
|
||||||
// Collections JSON view (read-only)
|
|
||||||
public string? DevicesJson { get; set; }
|
|
||||||
public string? TagsJson { get; set; }
|
|
||||||
|
|
||||||
// Preserved originals (round-tripped unchanged)
|
|
||||||
private IReadOnlyList<TwinCATDeviceOptions> _devices = [];
|
|
||||||
private IReadOnlyList<TwinCATTagDefinition> _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(TwinCATDriverOptions o) => new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
UseNativeNotifications = o.UseNativeNotifications,
|
||||||
|
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||||
|
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
||||||
|
ProbeEnabled = o.Probe.Enabled,
|
||||||
|
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||||
|
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||||
|
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static FormModel FromOptions(TwinCATDriverOptions o)
|
public TwinCATDriverOptions ToOptions(
|
||||||
{
|
IReadOnlyList<TwinCATDeviceOptions> devices,
|
||||||
var m = new FormModel
|
IReadOnlyList<TwinCATTagDefinition> tags) => new()
|
||||||
{
|
|
||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
|
||||||
UseNativeNotifications = o.UseNativeNotifications,
|
|
||||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
|
||||||
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
|
|
||||||
ProbeEnabled = o.Probe.Enabled,
|
|
||||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
|
||||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
|
||||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
|
||||||
_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 TwinCATDriverOptions ToOptions() => new()
|
|
||||||
{
|
{
|
||||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||||
UseNativeNotifications = UseNativeNotifications,
|
UseNativeNotifications = UseNativeNotifications,
|
||||||
@@ -388,8 +483,8 @@ else
|
|||||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||||
},
|
},
|
||||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||||
Devices = _devices,
|
Devices = devices,
|
||||||
Tags = _tags,
|
Tags = tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,4 @@
|
|||||||
<PageTitle>OtOpcUa</PageTitle>
|
<PageTitle>OtOpcUa</PageTitle>
|
||||||
|
|
||||||
<h1>OtOpcUa Admin</h1>
|
<h1>OtOpcUa Admin</h1>
|
||||||
<p>v2 fused host. Use the nav above to manage deployments.</p>
|
<p>Use the nav above to configure clusters, drivers, and tags, then deploy.</p>
|
||||||
<p class="text-muted">Most v1 admin pages were removed by the live-edit migration — see follow-up F15 for the per-page restoration plan.</p>
|
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
@page "/role-grants"
|
@page "/role-grants"
|
||||||
@* Per Q4 of the AdminUI rebuild plan, v2 replaced v1's per-cluster RoleGrants table with a
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
|
||||||
fleet-wide LDAP-group → role map. This page surfaces the mapping read-only; the source of
|
|
||||||
truth is Authentication:Ldap:GroupToRole in appsettings (editable on the host filesystem, not
|
|
||||||
from the UI yet). *@
|
|
||||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
||||||
@rendermode RenderMode.InteractiveServer
|
@rendermode RenderMode.InteractiveServer
|
||||||
@using Microsoft.Extensions.Options
|
@using Microsoft.Extensions.Options
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||||||
@using ZB.MOM.WW.OtOpcUa.Security.Ldap
|
@using ZB.MOM.WW.OtOpcUa.Security.Ldap
|
||||||
@inject IOptionsSnapshot<LdapOptions> Ldap
|
@inject IOptionsSnapshot<LdapOptions> Ldap
|
||||||
|
@inject ILdapGroupRoleMappingService RoleMappings
|
||||||
|
|
||||||
<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">Role grants</h4>
|
<h4 class="mb-0">Role grants</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
@if (_options is not null)
|
||||||
LDAP group membership determines fleet roles. Edit the mapping in
|
|
||||||
<span class="mono">appsettings.json</span> under <span class="mono">Authentication:Ldap:GroupToRole</span>
|
|
||||||
and restart the admin node (or sign out + back in for cached claims to refresh). UI-driven
|
|
||||||
editing of the mapping is deferred — it implies a config-reload mechanism that doesn't exist
|
|
||||||
yet.
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if (_options is null)
|
|
||||||
{
|
{
|
||||||
<p>Loading…</p>
|
<section class="card-grid rise" style="animation-delay:.02s">
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<section class="card-grid rise mt-3" style="animation-delay:.08s">
|
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="panel-head">LDAP binding</div>
|
<div class="panel-head">LDAP binding</div>
|
||||||
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
|
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
|
||||||
@@ -40,16 +28,61 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
|
<div class="panel-head">Group → role (database)</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<input class="form-control form-control-sm mono" style="max-width:32rem"
|
||||||
|
@bind="_newGroup" placeholder="cn=fleet-admin,ou=groups,..." />
|
||||||
|
<select class="form-select form-select-sm" style="max-width:14rem" @bind="_newRole">
|
||||||
|
@foreach (var role in Enum.GetValues<AdminRole>())
|
||||||
|
{
|
||||||
|
<option value="@role">@role</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddAsync" disabled="@_busy">Add</button>
|
||||||
|
</div>
|
||||||
|
@if (_error is not null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-2">@_error</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>LDAP group</th><th>Role</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
<tr><td colspan="3" class="text-muted">No database role grants. Authentication falls back to the appsettings map below.</td></tr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var r in _rows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><span class="mono">@r.LdapGroup</span></td>
|
||||||
|
<td><span class="chip chip-idle">@r.Role</span></td>
|
||||||
|
<td><button class="btn btn-sm btn-link text-danger" @onclick="() => DeleteAsync(r.Id)" disabled="@_busy">Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (_options is not null)
|
||||||
|
{
|
||||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||||
<div class="panel-head">Group → role mapping (@(_options.GroupToRole?.Count ?? 0))</div>
|
<div class="panel-head">Fallback (appsettings) (@(_options.GroupToRole?.Count ?? 0))</div>
|
||||||
|
<div style="padding:1rem 1rem 0" class="text-muted small">
|
||||||
|
These <span class="mono">Authentication:Ldap:GroupToRole</span> entries apply when a group has no database row above.
|
||||||
|
</div>
|
||||||
@if (_options.GroupToRole is null || _options.GroupToRole.Count == 0)
|
@if (_options.GroupToRole is null || _options.GroupToRole.Count == 0)
|
||||||
{
|
{
|
||||||
<div style="padding:1rem" class="text-muted">
|
<div style="padding:1rem" class="text-muted">No appsettings fallback mapping configured.</div>
|
||||||
No mapping configured. Every authenticated user lands with zero roles —
|
|
||||||
the fallback authorization policy will refuse every request. Add a
|
|
||||||
<span class="mono">GroupToRole</span> entry before deploying.
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -73,9 +106,47 @@ else
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private LdapOptions? _options;
|
private LdapOptions? _options;
|
||||||
|
private IReadOnlyList<LdapGroupRoleMapping> _rows = [];
|
||||||
|
private string _newGroup = "";
|
||||||
|
private AdminRole _newRole = AdminRole.ConfigViewer;
|
||||||
|
private string? _error;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_options = Ldap.Value;
|
_options = Ldap.Value;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadAsync()
|
||||||
|
=> _rows = (await RoleMappings.ListAllAsync(default)).Where(r => r.IsSystemWide).ToList();
|
||||||
|
|
||||||
|
private async Task AddAsync()
|
||||||
|
{
|
||||||
|
_error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_newGroup)) { _error = "LDAP group is required."; return; }
|
||||||
|
_busy = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RoleMappings.CreateAsync(new LdapGroupRoleMapping
|
||||||
|
{
|
||||||
|
LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null,
|
||||||
|
}, default);
|
||||||
|
_newGroup = "";
|
||||||
|
_newRole = AdminRole.ConfigViewer;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Guid id)
|
||||||
|
{
|
||||||
|
_error = null; _busy = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try { await RoleMappings.DeleteAsync(id, default); await ReloadAsync(); }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
|
VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
|
||||||
@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.Logging
|
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
|
||||||
@inject NavigationManager Nav
|
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
|
||||||
@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">Script log</h4>
|
<h4 class="mb-0">Script log</h4>
|
||||||
@@ -87,7 +86,6 @@ else
|
|||||||
private const int Capacity = 500;
|
private const int Capacity = 500;
|
||||||
|
|
||||||
private readonly List<ScriptLogEntry> _rows = new();
|
private readonly List<ScriptLogEntry> _rows = new();
|
||||||
private HubConnection? _hub;
|
|
||||||
private bool _connected;
|
private bool _connected;
|
||||||
private string _levelFilter = "";
|
private string _levelFilter = "";
|
||||||
private string _scriptFilter = "";
|
private string _scriptFilter = "";
|
||||||
@@ -115,32 +113,24 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
_hub = new HubConnectionBuilder()
|
// Live tail straight from the in-process broadcaster (fed by ScriptLogSignalRBridge off the
|
||||||
.WithUrl(Nav.ToAbsoluteUri(ScriptLogHub.Endpoint))
|
// 'script-logs' DPS topic). Blazor Server can't self-connect a SignalR HubConnection behind
|
||||||
.WithAutomaticReconnect()
|
// a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead.
|
||||||
.Build();
|
ScriptLogs.Received += OnEntry;
|
||||||
|
_connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
_hub.On<ScriptLogEntry>(ScriptLogHub.MethodName, entry =>
|
private void OnEntry(ScriptLogEntry entry) =>
|
||||||
|
// 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, entry);
|
_rows.Insert(0, entry);
|
||||||
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 error — page shows "disconnected".
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ClearAsync()
|
private async Task ClearAsync()
|
||||||
{
|
{
|
||||||
@@ -156,8 +146,5 @@ else
|
|||||||
_ => "chip-idle",
|
_ => "chip-idle",
|
||||||
};
|
};
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public void Dispose() => ScriptLogs.Received -= OnEntry;
|
||||||
{
|
|
||||||
if (_hub is not null) await _hub.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ else
|
|||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
<section class="panel notice rise" style="animation-delay:.02s">
|
||||||
Virtual tags evaluate a script per equipment instance and publish the result as an OPC UA
|
Virtual tags evaluate a script per equipment instance and publish the result as an OPC UA
|
||||||
variable. ChangeTriggered = re-evaluate when any dependency changes; TimerIntervalMs
|
variable. ChangeTriggered = re-evaluate when any dependency changes; TimerIntervalMs
|
||||||
re-evaluates on a periodic timer. Live editing lands in a Phase C.2-equivalent follow-up.
|
re-evaluates on a periodic timer.
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
@* Generic modal-per-row list editor. The parent owns the List<TRow> (a MUTABLE row VM,
|
||||||
|
because driver contracts are immutable records). This renders a read-only table with
|
||||||
|
Add/Edit/Delete and a modal that edits a CLONED working copy — commit on Save, discard
|
||||||
|
on Cancel. NewRow builds a default VM; Clone copies one for the working copy; Validate
|
||||||
|
(optional) returns an error string to block commit or null to allow. *@
|
||||||
|
@typeparam TRow
|
||||||
|
|
||||||
|
<section class="panel rise mt-3" style="@_styleDelay">
|
||||||
|
<div class="panel-head d-flex align-items-center">
|
||||||
|
<span>@Title (@Items.Count)</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary ms-auto" @onclick="Add">+ Add @ItemNoun</button>
|
||||||
|
</div>
|
||||||
|
@if (Items.Count == 0)
|
||||||
|
{
|
||||||
|
<div style="padding:1rem" class="text-muted">No @ItemNoun.ToLowerInvariant() rows.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>@HeaderTemplate</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (var i = 0; i < Items.Count; i++)
|
||||||
|
{
|
||||||
|
var idx = i;
|
||||||
|
<tr @key="Items[idx]">
|
||||||
|
@RowTemplate(Items[idx])
|
||||||
|
<td class="text-end" style="white-space:nowrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-link p-0 me-2" @onclick="() => Edit(idx)">Edit</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-link p-0 text-danger" @onclick="() => Delete(idx)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (_modalOpen && _working is not null)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||||
|
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">@(_editIndex is null ? $"Add {ItemNoun}" : $"Edit {ItemNoun}")</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@EditTemplate(_working)
|
||||||
|
@if (!string.IsNullOrEmpty(_validationError))
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-2">@_validationError</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="Commit">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public List<TRow> Items { get; set; } = default!;
|
||||||
|
[Parameter] public EventCallback ItemsChanged { get; set; }
|
||||||
|
[Parameter] public string Title { get; set; } = "Items";
|
||||||
|
[Parameter] public string ItemNoun { get; set; } = "row";
|
||||||
|
[Parameter] public string AnimationDelay { get; set; } = ".18s";
|
||||||
|
[Parameter, EditorRequired] public RenderFragment HeaderTemplate { get; set; } = default!;
|
||||||
|
[Parameter, EditorRequired] public RenderFragment<TRow> RowTemplate { get; set; } = default!;
|
||||||
|
[Parameter, EditorRequired] public RenderFragment<TRow> EditTemplate { get; set; } = default!;
|
||||||
|
[Parameter, EditorRequired] public Func<TRow> NewRow { get; set; } = default!;
|
||||||
|
[Parameter, EditorRequired] public Func<TRow, TRow> Clone { get; set; } = default!;
|
||||||
|
[Parameter] public Func<TRow, IReadOnlyList<TRow>, int?, string?>? Validate { get; set; }
|
||||||
|
|
||||||
|
private string _styleDelay => $"animation-delay:{AnimationDelay}";
|
||||||
|
private bool _modalOpen;
|
||||||
|
private int? _editIndex;
|
||||||
|
private TRow? _working;
|
||||||
|
private string? _validationError;
|
||||||
|
|
||||||
|
private void Add()
|
||||||
|
{
|
||||||
|
_editIndex = null;
|
||||||
|
_working = NewRow();
|
||||||
|
_validationError = null;
|
||||||
|
_modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Edit(int index)
|
||||||
|
{
|
||||||
|
_editIndex = index;
|
||||||
|
_working = Clone(Items[index]);
|
||||||
|
_validationError = null;
|
||||||
|
_modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Delete(int index)
|
||||||
|
{
|
||||||
|
Items.RemoveAt(index);
|
||||||
|
await ItemsChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
_modalOpen = false;
|
||||||
|
_working = default;
|
||||||
|
_editIndex = null;
|
||||||
|
_validationError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Commit()
|
||||||
|
{
|
||||||
|
if (_working is null) return;
|
||||||
|
_validationError = Validate?.Invoke(_working, Items, _editIndex);
|
||||||
|
if (_validationError is not null) return;
|
||||||
|
|
||||||
|
if (_editIndex is int i) Items[i] = _working;
|
||||||
|
else Items.Add(_working);
|
||||||
|
|
||||||
|
_modalOpen = false;
|
||||||
|
_working = default;
|
||||||
|
_editIndex = null;
|
||||||
|
await ItemsChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-5
@@ -1,7 +1,5 @@
|
|||||||
@* Identity section shared across the generic DriverEdit page and the typed driver pages (Phase 4).
|
@* Identity section shared by the typed driver pages. The parent page owns the <EditForm> and all
|
||||||
The parent page owns the <EditForm> and all data loading/persistence — this component is
|
data loading/persistence — this component is purely a section of inputs. *@
|
||||||
purely a section of inputs.
|
|
||||||
Set ShowDriverType=true on the generic editor; typed pages leave it false (type is fixed). *@
|
|
||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
|
||||||
@@ -37,7 +35,7 @@
|
|||||||
<option value="TwinCat">TwinCat</option>
|
<option value="TwinCat">TwinCat</option>
|
||||||
<option value="Focas">Focas</option>
|
<option value="Focas">Focas</option>
|
||||||
<option value="OpcUaClient">OpcUaClient</option>
|
<option value="OpcUaClient">OpcUaClient</option>
|
||||||
<option value="Galaxy">Galaxy</option>
|
<option value="GalaxyMxGateway">Galaxy</option>
|
||||||
<option value="Historian.Wonderware">Historian.Wonderware</option>
|
<option value="Historian.Wonderware">Historian.Wonderware</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
||||||
|
|||||||
+53
-12
@@ -1,16 +1,42 @@
|
|||||||
@* Resilience overrides — JSON textarea. Typed-form-ifying Polly is a follow-up; for now this
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||||
matches the legacy DriverEdit.razor behaviour exactly. *@
|
|
||||||
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||||
<div class="panel-head">Resilience overrides (optional)</div>
|
<div class="panel-head">Resilience overrides (optional)</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
<InputTextArea Value="@ResilienceConfig"
|
<p class="form-text mb-3">Blank fields use the driver type's stability-tier defaults
|
||||||
ValueExpression="() => ResilienceConfig"
|
(see <span class="mono">docs/v2/driver-stability.md</span>). Set only what you need to override.</p>
|
||||||
ValueChanged="OnChangedAsync"
|
|
||||||
rows="6"
|
<div class="row g-3">
|
||||||
class="form-control form-control-sm mono"
|
<div class="col-md-4"><label class="form-label">Bulkhead max concurrent</label>
|
||||||
placeholder="Leave blank to use tier defaults" />
|
<input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxConcurrent" @bind:after="EmitAsync" placeholder="tier default" /></div>
|
||||||
<div class="form-text">Polly pipeline overrides per docs/v2/driver-stability.md — bulkhead, retry counts, breaker thresholds. Null = use the driver type's tier defaults.</div>
|
<div class="col-md-4"><label class="form-label">Bulkhead max queue</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxQueue" @bind:after="EmitAsync" placeholder="tier default" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Recycle interval (s, Tier C only)</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="_m.RecycleIntervalSeconds" @bind:after="EmitAsync" placeholder="none" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap mt-3">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Capability</th><th>Timeout (s)</th><th>Retries</th><th>Breaker threshold</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var cap in ResilienceFormModel.Capabilities)
|
||||||
|
{
|
||||||
|
var row = _m.Policies[cap];
|
||||||
|
<tr>
|
||||||
|
<td class="mono">@cap</td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" @bind="row.TimeoutSeconds" @bind:after="EmitAsync" placeholder="default" /></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" @bind="row.RetryCount" @bind:after="EmitAsync" placeholder="default" /></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" @bind="row.BreakerFailureThreshold" @bind:after="EmitAsync" placeholder="default" /></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="small text-muted">Raw JSON (advanced)</summary>
|
||||||
|
<pre class="form-control form-control-sm mono mt-2" style="white-space:pre-wrap;min-height:3rem;">@(_m.ToJson() ?? "(null — all tier defaults)")</pre>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -18,9 +44,24 @@
|
|||||||
[Parameter] public string? ResilienceConfig { get; set; }
|
[Parameter] public string? ResilienceConfig { get; set; }
|
||||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||||
|
|
||||||
private async Task OnChangedAsync(string? newValue)
|
private ResilienceFormModel _m = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
ResilienceConfig = newValue;
|
// Re-parse only when the inbound value actually changed (avoid clobbering edits on re-render).
|
||||||
await ResilienceConfigChanged.InvokeAsync(newValue);
|
if (!string.Equals(_lastParsed, ResilienceConfig, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_m = ResilienceFormModel.FromJson(ResilienceConfig);
|
||||||
|
_lastParsed = ResilienceConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = _m.ToJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
ResilienceConfig = json;
|
||||||
|
await ResilienceConfigChanged.InvokeAsync(json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-24
@@ -4,14 +4,14 @@
|
|||||||
DriverOperator-gated Reconnect/Restart buttons appear for authorised users. *@
|
DriverOperator-gated Reconnect/Restart buttons appear for authorised users. *@
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.SignalR.Client
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
||||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
|
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
|
||||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
|
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
|
||||||
@inject NavigationManager Nav
|
|
||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
@inject IAuthorizationService AuthorizationService
|
@inject IAuthorizationService AuthorizationService
|
||||||
@inject IAdminOperationsClient AdminOps
|
@inject IAdminOperationsClient AdminOps
|
||||||
|
@inject IDriverStatusSnapshotStore StatusStore
|
||||||
|
|
||||||
<section class="panel rise mt-3" style="animation-delay:.04s; @(_stale ? "opacity:0.5;" : "")">
|
<section class="panel rise mt-3" style="animation-delay:.04s; @(_stale ? "opacity:0.5;" : "")">
|
||||||
<div class="panel-head d-flex align-items-center gap-2">
|
<div class="panel-head d-flex align-items-center gap-2">
|
||||||
@@ -139,7 +139,6 @@
|
|||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
[Parameter] public bool Enabled { get; set; } = true;
|
[Parameter] public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
private HubConnection? _hub;
|
|
||||||
private DriverHealthChanged? _snapshot;
|
private DriverHealthChanged? _snapshot;
|
||||||
private DateTime _lastUpdateUtc = DateTime.MinValue;
|
private DateTime _lastUpdateUtc = DateTime.MinValue;
|
||||||
private bool _stale;
|
private bool _stale;
|
||||||
@@ -180,30 +179,44 @@
|
|||||||
InvokeAsync(StateHasChanged);
|
InvokeAsync(StateHasChanged);
|
||||||
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
_hub = new HubConnectionBuilder()
|
// Read live status straight from the in-process snapshot store rather than opening a
|
||||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/driverstatus"))
|
// self-targeted SignalR connection. This component runs server-side (Blazor
|
||||||
.WithAutomaticReconnect()
|
// InteractiveServer), so a HubConnection to the browser's public URL (e.g.
|
||||||
.Build();
|
// http://localhost:9200 behind Traefik) would dial that port from *inside* the container —
|
||||||
|
// where only Kestrel's :9000 listens — and fail with "Connection refused". The store is fed
|
||||||
_hub.On<DriverHealthChanged>("status", snap =>
|
// on every admin node by DriverStatusSignalRBridge (a per-node DistributedPubSub
|
||||||
{
|
// subscriber), so the local singleton is always current regardless of which replica serves
|
||||||
_snapshot = snap;
|
// this circuit.
|
||||||
_lastUpdateUtc = DateTime.UtcNow;
|
|
||||||
_stale = false;
|
|
||||||
InvokeAsync(StateHasChanged);
|
|
||||||
});
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _hub.StartAsync();
|
StatusStore.SnapshotChanged += OnSnapshotChanged;
|
||||||
_connecting = false;
|
if (StatusStore.TryGet(DriverInstanceId, out var snap))
|
||||||
await _hub.InvokeAsync("JoinDriver", DriverInstanceId);
|
{
|
||||||
|
_snapshot = snap;
|
||||||
|
_lastUpdateUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_connecting = false;
|
|
||||||
_error = ex.Message;
|
_error = ex.Message;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_connecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoked by the snapshot store (on the bridge actor's thread) for every driver instance;
|
||||||
|
// ignore snapshots for other instances and marshal onto the render sync context.
|
||||||
|
private void OnSnapshotChanged(DriverHealthChanged snap)
|
||||||
|
{
|
||||||
|
if (!string.Equals(snap.DriverInstanceId, DriverInstanceId, StringComparison.Ordinal))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_snapshot = snap;
|
||||||
|
_lastUpdateUtc = DateTime.UtcNow;
|
||||||
|
_stale = false;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ReconnectAsync()
|
private async Task ReconnectAsync()
|
||||||
@@ -285,12 +298,13 @@
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
// Drain BOTH timers first so an in-flight callback can't invoke StateHasChanged on
|
// Unsubscribe first so the singleton store can't invoke a handler on a disposed component.
|
||||||
// a component whose hub has already been released. System.Threading.Timer's async
|
StatusStore.SnapshotChanged -= OnSnapshotChanged;
|
||||||
// dispose awaits any in-flight callback (.NET 6+).
|
// Drain BOTH timers so an in-flight callback can't invoke StateHasChanged on a component
|
||||||
|
// that's already gone. System.Threading.Timer's async dispose awaits any in-flight
|
||||||
|
// callback (.NET 6+).
|
||||||
if (_timer is not null) await _timer.DisposeAsync();
|
if (_timer is not null) await _timer.DisposeAsync();
|
||||||
if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync();
|
if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync();
|
||||||
if (_hub is not null) await _hub.DisposeAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map DriverState string → chip CSS class using the 4 defined theme variants.
|
// Map DriverState string → chip CSS class using the 4 defined theme variants.
|
||||||
|
|||||||
+1
-1
@@ -126,7 +126,7 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = GetConfigJson() ?? "{}";
|
var json = GetConfigJson() ?? "{}";
|
||||||
var result = await BrowserService.OpenAsync("Galaxy", json, default);
|
var result = await BrowserService.OpenAsync("GalaxyMxGateway", json, default);
|
||||||
if (result.Ok) _token = result.Token;
|
if (result.Ok) _token = result.Token;
|
||||||
else _openError = result.Message;
|
else _openError = result.Message;
|
||||||
}
|
}
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutable, all-nullable form model for the driver resilience override. Binds the typed
|
||||||
|
/// fields in DriverResilienceSection; null/blank = "use the driver's tier default", so a
|
||||||
|
/// blank form serializes back to null (preserving DriverInstance.ResilienceConfig = null).
|
||||||
|
/// Emits / reads the exact override JSON shape DriverResilienceOptionsParser consumes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ResilienceFormModel
|
||||||
|
{
|
||||||
|
public static readonly string[] Capabilities =
|
||||||
|
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];
|
||||||
|
|
||||||
|
public int? BulkheadMaxConcurrent { get; set; }
|
||||||
|
public int? BulkheadMaxQueue { get; set; }
|
||||||
|
public int? RecycleIntervalSeconds { get; set; }
|
||||||
|
|
||||||
|
// capability name -> (timeout, retry, breaker), each nullable.
|
||||||
|
public Dictionary<string, CapabilityRow> Policies { get; set; } =
|
||||||
|
Capabilities.ToDictionary(c => c, _ => new CapabilityRow(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public sealed class CapabilityRow
|
||||||
|
{
|
||||||
|
public int? TimeoutSeconds { get; set; }
|
||||||
|
public int? RetryCount { get; set; }
|
||||||
|
public int? BreakerFailureThreshold { get; set; }
|
||||||
|
public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ResilienceFormModel FromJson(string? json)
|
||||||
|
{
|
||||||
|
var model = new ResilienceFormModel();
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return model;
|
||||||
|
|
||||||
|
Shape? shape;
|
||||||
|
try { shape = JsonSerializer.Deserialize<Shape>(json, ReadOpts); }
|
||||||
|
catch (JsonException) { return model; } // malformed -> empty form; raw view (next task) shows the text
|
||||||
|
if (shape is null) return model;
|
||||||
|
|
||||||
|
model.BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent;
|
||||||
|
model.BulkheadMaxQueue = shape.BulkheadMaxQueue;
|
||||||
|
model.RecycleIntervalSeconds = shape.RecycleIntervalSeconds;
|
||||||
|
if (shape.CapabilityPolicies is not null)
|
||||||
|
foreach (var (cap, p) in shape.CapabilityPolicies)
|
||||||
|
if (model.Policies.TryGetValue(cap, out var row))
|
||||||
|
{
|
||||||
|
row.TimeoutSeconds = p.TimeoutSeconds;
|
||||||
|
row.RetryCount = p.RetryCount;
|
||||||
|
row.BreakerFailureThreshold = p.BreakerFailureThreshold;
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Emit only the non-null overrides; returns null when nothing is overridden.</summary>
|
||||||
|
public string? ToJson()
|
||||||
|
{
|
||||||
|
var caps = Policies
|
||||||
|
.Where(kv => !kv.Value.IsEmpty)
|
||||||
|
.ToDictionary(kv => kv.Key, kv => new PolicyShape
|
||||||
|
{
|
||||||
|
TimeoutSeconds = kv.Value.TimeoutSeconds,
|
||||||
|
RetryCount = kv.Value.RetryCount,
|
||||||
|
BreakerFailureThreshold = kv.Value.BreakerFailureThreshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
var hasAny = BulkheadMaxConcurrent is not null || BulkheadMaxQueue is not null
|
||||||
|
|| RecycleIntervalSeconds is not null || caps.Count > 0;
|
||||||
|
if (!hasAny) return null;
|
||||||
|
|
||||||
|
var shape = new Shape
|
||||||
|
{
|
||||||
|
BulkheadMaxConcurrent = BulkheadMaxConcurrent,
|
||||||
|
BulkheadMaxQueue = BulkheadMaxQueue,
|
||||||
|
RecycleIntervalSeconds = RecycleIntervalSeconds,
|
||||||
|
CapabilityPolicies = caps.Count > 0 ? caps : null,
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(shape, WriteOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Shape
|
||||||
|
{
|
||||||
|
public int? BulkheadMaxConcurrent { get; set; }
|
||||||
|
public int? BulkheadMaxQueue { get; set; }
|
||||||
|
public int? RecycleIntervalSeconds { get; set; }
|
||||||
|
public Dictionary<string, PolicyShape>? CapabilityPolicies { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PolicyShape
|
||||||
|
{
|
||||||
|
public int? TimeoutSeconds { get; set; }
|
||||||
|
public int? RetryCount { get; set; }
|
||||||
|
public int? BreakerFailureThreshold { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,6 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mounts the AdminUI Razor components and the AdminUI static asset pipeline at the root.
|
/// Mounts the AdminUI Razor components and the AdminUI static asset pipeline at the root.
|
||||||
/// Call from the fused Host's Program.cs alongside <c>app.MapOtOpcUaAuth()</c>.
|
/// Call from the fused Host's Program.cs alongside <c>app.MapOtOpcUaAuth()</c>.
|
||||||
///
|
|
||||||
/// Razor component migration from legacy <c>OtOpcUa.Admin/Components/</c> is staged for
|
|
||||||
/// follow-up F15 — 47 .razor files plus codebehind. Until then this extension wires the
|
|
||||||
/// Blazor pipeline but the only built-in components are the v2-native ones added in this
|
|
||||||
/// library (e.g. <c>Deployments</c>, Task 52).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="TApp">The root component type for Razor pages.</typeparam>
|
/// <typeparam name="TApp">The root component type for Razor pages.</typeparam>
|
||||||
/// <param name="app">The endpoint route builder.</param>
|
/// <param name="app">The endpoint route builder.</param>
|
||||||
|
|||||||
@@ -17,22 +17,26 @@ public sealed class AlertSignalRBridge : ReceiveActor
|
|||||||
public const string TopicName = "alerts";
|
public const string TopicName = "alerts";
|
||||||
|
|
||||||
private readonly IHubContext<AlertHub> _hub;
|
private readonly IHubContext<AlertHub> _hub;
|
||||||
|
private readonly IInProcessBroadcaster<AlarmTransitionEvent> _broadcaster;
|
||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates actor props for the AlertSignalRBridge.
|
/// Creates actor props for the AlertSignalRBridge.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="hub">The SignalR hub context to send alerts to.</param>
|
/// <param name="hub">The SignalR hub context to send alerts to.</param>
|
||||||
public static Props Props(IHubContext<AlertHub> hub) =>
|
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Alerts page.</param>
|
||||||
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub));
|
public static Props Props(IHubContext<AlertHub> hub, IInProcessBroadcaster<AlarmTransitionEvent> broadcaster) =>
|
||||||
|
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub, broadcaster));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the AlertSignalRBridge actor.
|
/// Initializes a new instance of the AlertSignalRBridge actor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="hub">The SignalR hub context to send alerts to.</param>
|
/// <param name="hub">The SignalR hub context to send alerts to.</param>
|
||||||
public AlertSignalRBridge(IHubContext<AlertHub> hub)
|
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Alerts page.</param>
|
||||||
|
public AlertSignalRBridge(IHubContext<AlertHub> hub, IInProcessBroadcaster<AlarmTransitionEvent> broadcaster)
|
||||||
{
|
{
|
||||||
_hub = hub;
|
_hub = hub;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
ReceiveAsync<AlarmTransitionEvent>(ForwardAsync);
|
ReceiveAsync<AlarmTransitionEvent>(ForwardAsync);
|
||||||
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
|
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,9 @@ public sealed class AlertSignalRBridge : ReceiveActor
|
|||||||
|
|
||||||
private async Task ForwardAsync(AlarmTransitionEvent msg)
|
private async Task ForwardAsync(AlarmTransitionEvent msg)
|
||||||
{
|
{
|
||||||
|
// In-process fan-out first — this is what the Blazor Server Alerts page reads. The hub push
|
||||||
|
// is kept for any out-of-process (e.g. WASM) SignalR client.
|
||||||
|
_broadcaster.Publish(msg);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _hub.Clients.All.SendAsync(AlertHub.MethodName, msg);
|
await _hub.Clients.All.SendAsync(AlertHub.MethodName, msg);
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
|||||||
/// Browser-facing fleet-status push channel. Subscribers receive <see cref="FleetStatusChanged"/>
|
/// Browser-facing fleet-status push channel. Subscribers receive <see cref="FleetStatusChanged"/>
|
||||||
/// snapshots whenever the admin-role <c>FleetStatusBroadcaster</c> publishes a diff.
|
/// snapshots whenever the admin-role <c>FleetStatusBroadcaster</c> publishes a diff.
|
||||||
///
|
///
|
||||||
/// Server-side bridge from <c>FleetStatusBroadcaster.broadcast</c> → <c>IHubContext<FleetStatusHub></c>
|
/// Server pushes fleet-status updates to connected clients via <c>FleetStatusSignalRBridge</c>
|
||||||
/// is staged for follow-up F16. For now the hub is a passive channel; SignalR clients connect
|
/// (DistributedPubSub 'fleet-status' → <c>IHubContext<FleetStatusHub></c>).
|
||||||
/// and stay idle until the bridge lands.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FleetStatusHub : Hub
|
public sealed class FleetStatusHub : Hub
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,14 +13,21 @@ public static class HubServiceCollectionExtensions
|
|||||||
public const string DriverStatusSignalRBridgeName = "driver-status-signalr-bridge";
|
public const string DriverStatusSignalRBridgeName = "driver-status-signalr-bridge";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers services required by the driver-status hub pipeline:
|
/// Registers the in-process live-push services the AdminUI's Blazor Server panels read
|
||||||
/// <see cref="IDriverStatusSnapshotStore"/> as a singleton backed by
|
/// directly (instead of self-connecting a SignalR <c>HubConnection</c>, which fails behind a
|
||||||
/// <see cref="InMemoryDriverStatusSnapshotStore"/>.
|
/// reverse proxy — see <see cref="IInProcessBroadcaster{T}"/>):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="IDriverStatusSnapshotStore"/> — last-value snapshot per driver.</item>
|
||||||
|
/// <item><see cref="IInProcessBroadcaster{T}"/> — append-stream fan-out (alarm
|
||||||
|
/// transitions, script-log lines). Registered as an open generic so each closed type
|
||||||
|
/// resolves to its own singleton shared by the bridge actor and the consuming component.</item>
|
||||||
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
public static IServiceCollection AddOtOpcUaDriverStatusServices(this IServiceCollection services)
|
public static IServiceCollection AddOtOpcUaDriverStatusServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IDriverStatusSnapshotStore, InMemoryDriverStatusSnapshotStore>();
|
services.AddSingleton<IDriverStatusSnapshotStore, InMemoryDriverStatusSnapshotStore>();
|
||||||
|
services.AddSingleton(typeof(IInProcessBroadcaster<>), typeof(InProcessBroadcaster<>));
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +55,13 @@ public static class HubServiceCollectionExtensions
|
|||||||
registry.Register<FleetStatusSignalRBridgeKey>(fleetBridge);
|
registry.Register<FleetStatusSignalRBridgeKey>(fleetBridge);
|
||||||
|
|
||||||
var alertHub = resolver.GetService<IHubContext<AlertHub>>();
|
var alertHub = resolver.GetService<IHubContext<AlertHub>>();
|
||||||
var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub), AlertSignalRBridgeName);
|
var alertBroadcaster = resolver.GetService<IInProcessBroadcaster<Commons.Messages.Alerts.AlarmTransitionEvent>>();
|
||||||
|
var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub, alertBroadcaster), AlertSignalRBridgeName);
|
||||||
registry.Register<AlertSignalRBridgeKey>(alertBridge);
|
registry.Register<AlertSignalRBridgeKey>(alertBridge);
|
||||||
|
|
||||||
var scriptLogHub = resolver.GetService<IHubContext<ScriptLogHub>>();
|
var scriptLogHub = resolver.GetService<IHubContext<ScriptLogHub>>();
|
||||||
var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub), ScriptLogSignalRBridgeName);
|
var scriptLogBroadcaster = resolver.GetService<IInProcessBroadcaster<Commons.Messages.Logging.ScriptLogEntry>>();
|
||||||
|
var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub, scriptLogBroadcaster), ScriptLogSignalRBridgeName);
|
||||||
registry.Register<ScriptLogSignalRBridgeKey>(scriptLogBridge);
|
registry.Register<ScriptLogSignalRBridgeKey>(scriptLogBridge);
|
||||||
|
|
||||||
var driverStatusHub = resolver.GetService<IHubContext<DriverStatusHub>>();
|
var driverStatusHub = resolver.GetService<IHubContext<DriverStatusHub>>();
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
|||||||
/// Singleton last-snapshot-per-instance cache. Populated by
|
/// Singleton last-snapshot-per-instance cache. Populated by
|
||||||
/// <c>DriverStatusSignalRBridge</c> as it forwards DPS messages; read by
|
/// <c>DriverStatusSignalRBridge</c> as it forwards DPS messages; read by
|
||||||
/// <see cref="DriverStatusHub.JoinDriver"/> so newly-joined clients see current state
|
/// <see cref="DriverStatusHub.JoinDriver"/> so newly-joined clients see current state
|
||||||
/// without waiting for the next change event.
|
/// without waiting for the next change event, and subscribed to directly by the Blazor
|
||||||
|
/// Server <c>DriverStatusPanel</c> via <see cref="SnapshotChanged"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IDriverStatusSnapshotStore
|
public interface IDriverStatusSnapshotStore
|
||||||
{
|
{
|
||||||
void Upsert(DriverHealthChanged snapshot);
|
void Upsert(DriverHealthChanged snapshot);
|
||||||
bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot);
|
bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after every <see cref="Upsert"/> with the just-stored snapshot. Lets in-process
|
||||||
|
/// consumers (the Blazor Server <c>DriverStatusPanel</c>) receive live updates by reading
|
||||||
|
/// this singleton directly instead of opening a self-targeted SignalR connection — which a
|
||||||
|
/// server-side Blazor component cannot reach when the public URL (e.g. a reverse-proxy port)
|
||||||
|
/// differs from the local Kestrel bind. Handlers run on the caller's thread (the bridge
|
||||||
|
/// actor), so subscribers must marshal to their own sync context.
|
||||||
|
/// </summary>
|
||||||
|
event Action<DriverHealthChanged>? SnapshotChanged;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A singleton, in-process fan-out for live event streams (alarm transitions, script-log
|
||||||
|
/// lines). A per-node SignalR bridge actor subscribes to the cluster's DistributedPubSub topic
|
||||||
|
/// and calls <see cref="Publish"/>; Blazor Server components subscribe to <see cref="Received"/>
|
||||||
|
/// to render the live tail.
|
||||||
|
/// <para>
|
||||||
|
/// This exists because the AdminUI runs as Blazor <em>Server</em>: a component opening a
|
||||||
|
/// SignalR <c>HubConnection</c> to its own hub would dial the browser's public URL from
|
||||||
|
/// server-side code, which is unreachable behind a reverse proxy (e.g. Traefik mapping host
|
||||||
|
/// :9200 → container :9000) and so fails with "Connection refused". Reading this in-process
|
||||||
|
/// broadcaster instead avoids the network hop entirely. Mirrors the
|
||||||
|
/// <c>IDriverStatusSnapshotStore.SnapshotChanged</c> pattern for stream (vs. last-value) feeds.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The event payload type (e.g. AlarmTransitionEvent, ScriptLogEntry).</typeparam>
|
||||||
|
public interface IInProcessBroadcaster<T>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised once per <see cref="Publish"/> with the published item. Handlers run on the
|
||||||
|
/// caller's thread (the bridge actor), so subscribers must marshal to their own sync
|
||||||
|
/// context (Blazor's <c>InvokeAsync</c>).
|
||||||
|
/// </summary>
|
||||||
|
event Action<T>? Received;
|
||||||
|
|
||||||
|
/// <summary>Fan the item out to all current <see cref="Received"/> subscribers.</summary>
|
||||||
|
void Publish(T item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thread-safe singleton implementation of <see cref="IInProcessBroadcaster{T}"/>.</summary>
|
||||||
|
/// <typeparam name="T">The event payload type.</typeparam>
|
||||||
|
public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event Action<T>? Received;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
// Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise.
|
||||||
|
public void Publish(T item) => Received?.Invoke(item);
|
||||||
|
}
|
||||||
@@ -11,9 +11,16 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
|
|||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
|
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event Action<DriverHealthChanged>? SnapshotChanged;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Upsert(DriverHealthChanged snapshot)
|
public void Upsert(DriverHealthChanged snapshot)
|
||||||
=> _byInstance[snapshot.DriverInstanceId] = snapshot;
|
{
|
||||||
|
_byInstance[snapshot.DriverInstanceId] = snapshot;
|
||||||
|
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise.
|
||||||
|
SnapshotChanged?.Invoke(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot)
|
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot)
|
||||||
|
|||||||
@@ -15,18 +15,22 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor
|
|||||||
public const string TopicName = "script-logs";
|
public const string TopicName = "script-logs";
|
||||||
|
|
||||||
private readonly IHubContext<ScriptLogHub> _hub;
|
private readonly IHubContext<ScriptLogHub> _hub;
|
||||||
|
private readonly IInProcessBroadcaster<ScriptLogEntry> _broadcaster;
|
||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
|
|
||||||
/// <summary>Creates a Props instance for the ScriptLogSignalRBridge.</summary>
|
/// <summary>Creates a Props instance for the ScriptLogSignalRBridge.</summary>
|
||||||
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
|
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
|
||||||
public static Props Props(IHubContext<ScriptLogHub> hub) =>
|
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Script log page.</param>
|
||||||
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub));
|
public static Props Props(IHubContext<ScriptLogHub> hub, IInProcessBroadcaster<ScriptLogEntry> broadcaster) =>
|
||||||
|
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub, broadcaster));
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="ScriptLogSignalRBridge"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="ScriptLogSignalRBridge"/> class.</summary>
|
||||||
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
|
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
|
||||||
public ScriptLogSignalRBridge(IHubContext<ScriptLogHub> hub)
|
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Script log page.</param>
|
||||||
|
public ScriptLogSignalRBridge(IHubContext<ScriptLogHub> hub, IInProcessBroadcaster<ScriptLogEntry> broadcaster)
|
||||||
{
|
{
|
||||||
_hub = hub;
|
_hub = hub;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
ReceiveAsync<ScriptLogEntry>(ForwardAsync);
|
ReceiveAsync<ScriptLogEntry>(ForwardAsync);
|
||||||
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
|
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
|
||||||
}
|
}
|
||||||
@@ -37,6 +41,9 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor
|
|||||||
|
|
||||||
private async Task ForwardAsync(ScriptLogEntry msg)
|
private async Task ForwardAsync(ScriptLogEntry msg)
|
||||||
{
|
{
|
||||||
|
// In-process fan-out first — this is what the Blazor Server Script log page reads. The hub
|
||||||
|
// push is kept for any out-of-process (e.g. WASM) SignalR client.
|
||||||
|
_broadcaster.Publish(msg);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _hub.Clients.All.SendAsync(ScriptLogHub.MethodName, msg);
|
await _hub.Clients.All.SendAsync(ScriptLogHub.MethodName, msg);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
@@ -23,8 +24,10 @@ using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDri
|
|||||||
/// over it. Replaces the F7 seam's <c>NullDriverFactory</c> default so deploys actually
|
/// over it. Replaces the F7 seam's <c>NullDriverFactory</c> default so deploys actually
|
||||||
/// materialise real <see cref="IDriver"/> instances on driver-role nodes.
|
/// materialise real <see cref="IDriver"/> instances on driver-role nodes.
|
||||||
///
|
///
|
||||||
/// Skipped entirely on admin-only nodes — they never run drivers, so the registry doesn't
|
/// The factory registry is skipped on admin-only nodes — they never run drivers, so it doesn't
|
||||||
/// need to exist (Program.cs guards via the <c>hasDriver</c> flag).
|
/// need to exist (Program.cs guards via the <c>hasDriver</c> flag). The driver <em>probe</em>
|
||||||
|
/// set is the exception: it backs the AdminUI Test Connect button and so must also be wired on
|
||||||
|
/// admin nodes — see <see cref="AddOtOpcUaDriverProbes"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DriverFactoryBootstrap
|
public static class DriverFactoryBootstrap
|
||||||
{
|
{
|
||||||
@@ -46,16 +49,42 @@ public static class DriverFactoryBootstrap
|
|||||||
services.AddSingleton<IDriverFactory>(sp =>
|
services.AddSingleton<IDriverFactory>(sp =>
|
||||||
new DriverFactoryRegistryAdapter(sp.GetRequiredService<DriverFactoryRegistry>()));
|
new DriverFactoryRegistryAdapter(sp.GetRequiredService<DriverFactoryRegistry>()));
|
||||||
|
|
||||||
// One IDriverProbe per driver type — wired into AdminOperationsActor via DI enumeration.
|
// Driver nodes also carry the probe set so a fused admin,driver node has it; the admin-only
|
||||||
services.AddSingleton<IDriverProbe, ModbusProbe>();
|
// case is covered by Program.cs calling AddOtOpcUaDriverProbes() in the hasAdmin block.
|
||||||
services.AddSingleton<IDriverProbe, AbCipProbe>();
|
services.AddOtOpcUaDriverProbes();
|
||||||
services.AddSingleton<IDriverProbe, AbLegacyProbe>();
|
|
||||||
services.AddSingleton<IDriverProbe, S7Probe>();
|
return services;
|
||||||
services.AddSingleton<IDriverProbe, TwinCATProbe>();
|
}
|
||||||
services.AddSingleton<IDriverProbe, FocasProbe>();
|
|
||||||
services.AddSingleton<IDriverProbe, OpcUaProbe>();
|
/// <summary>
|
||||||
services.AddSingleton<IDriverProbe, GalaxyProbe>();
|
/// Register one <see cref="IDriverProbe"/> per driver type. These back the AdminUI's
|
||||||
services.AddSingleton<IDriverProbe, HistorianProbe>();
|
/// "Test Connect" button: the <c>admin-operations</c> cluster singleton resolves
|
||||||
|
/// <see cref="IEnumerable{T}"/> of <see cref="IDriverProbe"/> and dispatches by DriverType.
|
||||||
|
/// <para>
|
||||||
|
/// That singleton is role-pinned to <c>admin</c>, so this MUST be wired on admin nodes —
|
||||||
|
/// including admin-only nodes that lack the <c>driver</c> role (e.g. the MAIN cluster's
|
||||||
|
/// admin-a/admin-b). Probes are lightweight (cheap connect, no persistent state) and don't
|
||||||
|
/// need the driver-factory registry, so they register independently of
|
||||||
|
/// <see cref="AddOtOpcUaDriverFactories"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Uses <c>TryAddEnumerable</c> so a fused admin,driver node — which reaches this from both
|
||||||
|
/// the driver-factory path and the admin path — registers each probe exactly once. A
|
||||||
|
/// duplicate would make the singleton's <c>ToDictionary(p => p.DriverType)</c> throw.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to register driver probes with.</param>
|
||||||
|
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, S7Probe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, TwinCATProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, HistorianProbe>());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ if (hasAdmin)
|
|||||||
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
||||||
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
||||||
builder.Services.AddAdminUI();
|
builder.Services.AddAdminUI();
|
||||||
|
// Test Connect probes back the AdminUI driver pages. The admin-operations singleton (role-pinned
|
||||||
|
// to admin) resolves IEnumerable<IDriverProbe>, so admin-only nodes — which skip the hasDriver
|
||||||
|
// block above — must wire the probe set here too, or every Test Connect returns "No probe
|
||||||
|
// registered". Idempotent on fused admin,driver nodes (TryAddEnumerable de-dups).
|
||||||
|
builder.Services.AddOtOpcUaDriverProbes();
|
||||||
// Flow AuthenticationState through cascading parameters so <AuthorizeView/> works
|
// Flow AuthenticationState through cascading parameters so <AuthorizeView/> works
|
||||||
// inside interactive components (NavSidebar's session block).
|
// inside interactive components (NavSidebar's session block).
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
|
||||||
|
/// <c>Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory></c> step inside
|
||||||
|
/// <c>AddOtOpcUaAuth</c> that copies the values onto <c>CookieAuthenticationOptions</c>.
|
||||||
|
/// </summary>
|
||||||
public sealed class OtOpcUaCookieOptions
|
public sealed class OtOpcUaCookieOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>Configuration section name (<c>Security:Cookie</c>).</summary>
|
||||||
public const string SectionName = "Security:Cookie";
|
public const string SectionName = "Security:Cookie";
|
||||||
|
|
||||||
/// <summary>Gets or sets the cookie name.</summary>
|
/// <summary>
|
||||||
public string Name { get; set; } = "OtOpcUa.Auth";
|
/// 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, in minutes (default 30).</summary>
|
/// <summary>Idle sliding-window length in minutes (default 30).</summary>
|
||||||
public int ExpiryMinutes { get; set; } = 30;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ public static class AuthEndpoints
|
|||||||
private static async Task<IResult> LoginAsync(
|
private static async Task<IResult> LoginAsync(
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
ILdapAuthService ldap,
|
ILdapAuthService ldap,
|
||||||
|
ILdapGroupRoleMappingService roleMappings,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var isForm = http.Request.HasFormContentType;
|
var isForm = http.Request.HasFormContentType;
|
||||||
@@ -83,13 +87,27 @@ public static class AuthEndpoints
|
|||||||
return Results.Redirect("/login" + qs);
|
return Results.Redirect("/login" + qs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<string> roles = result.Roles;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
|
||||||
|
roles = RoleMapper.Merge(result.Roles, dbRows);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// A DB hiccup must never block sign-in — fall back to the appsettings baseline roles.
|
||||||
|
http.RequestServices.GetService<ILoggerFactory>()?
|
||||||
|
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||||||
|
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline roles", username);
|
||||||
|
}
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||||
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
||||||
};
|
};
|
||||||
foreach (var role in result.Roles)
|
foreach (var role in roles)
|
||||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -24,4 +26,21 @@ public static class RoleMapper
|
|||||||
}
|
}
|
||||||
return [.. roles];
|
return [.. roles];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merge the appsettings-derived baseline roles with system-wide DB grants. DB rows are
|
||||||
|
/// additive; cluster-scoped rows (IsSystemWide == false) are ignored under the global model.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="baselineRoles">Roles already resolved from appsettings (or the dev stub).</param>
|
||||||
|
/// <param name="dbRows">LdapGroupRoleMapping rows for the user's groups (from GetByGroupsAsync).</param>
|
||||||
|
public static IReadOnlyList<string> Merge(
|
||||||
|
IReadOnlyCollection<string> baselineRoles,
|
||||||
|
IReadOnlyCollection<LdapGroupRoleMapping> dbRows)
|
||||||
|
{
|
||||||
|
var roles = new HashSet<string>(baselineRoles, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var row in dbRows)
|
||||||
|
if (row.IsSystemWide)
|
||||||
|
roles.Add(row.Role.ToString());
|
||||||
|
return [.. roles];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||||
@@ -12,35 +13,20 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves <see cref="JwtTokenService"/> from the real DI container at runtime so the bearer
|
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||||
/// pipeline's <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> stay in
|
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||||
/// lock-step with <see cref="JwtTokenService.BuildValidationParameters"/>. Replaces the prior
|
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||||
/// <c>services.BuildServiceProvider()</c> antipattern (ASP0000) that built a captive provider
|
|
||||||
/// from inside <c>.AddJwtBearer</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService)
|
|
||||||
: IPostConfigureOptions<JwtBearerOptions>
|
|
||||||
{
|
|
||||||
/// <summary>Configures JWT bearer options from the token service.</summary>
|
|
||||||
/// <param name="name">The options name.</param>
|
|
||||||
/// <param name="options">The JWT bearer options to configure.</param>
|
|
||||||
public void PostConfigure(string? name, JwtBearerOptions options)
|
|
||||||
{
|
|
||||||
if (name != JwtBearerDefaults.AuthenticationScheme) return;
|
|
||||||
options.TokenValidationParameters = tokenService.BuildValidationParameters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wires cookie+JWT hybrid authentication. Cookies are the primary scheme for browser-facing
|
/// Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
||||||
/// Blazor + Razor flows; JWT bearer is layered in for external API consumers (OPC UA client
|
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
||||||
/// tools, scripts). DataProtection keys persist to the shared ConfigDb so cookies survive
|
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
||||||
/// failover between nodes.
|
/// challenge heuristic).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
/// <param name="configuration">The application configuration.</param>
|
/// <param name="configuration">The application configuration root.</param>
|
||||||
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||||
@@ -50,8 +36,6 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<JwtTokenService>();
|
services.AddSingleton<JwtTokenService>();
|
||||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||||
// The driver-branch in Host/Program.cs registers the same way; consistent lifetime
|
|
||||||
// across both paths keeps ValidateScopes-on-Build clean.
|
|
||||||
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
services.AddDataProtection()
|
services.AddDataProtection()
|
||||||
@@ -61,32 +45,42 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(o =>
|
.AddCookie(o =>
|
||||||
{
|
{
|
||||||
o.Cookie.Name = "OtOpcUa.Auth";
|
// 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.HttpOnly = true;
|
||||||
o.Cookie.SameSite = SameSiteMode.Strict;
|
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||||
o.SlidingExpiration = true;
|
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||||
o.ExpireTimeSpan = TimeSpan.FromMinutes(30);
|
});
|
||||||
o.Events.OnRedirectToLogin = ctx =>
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
o.Events.OnRedirectToAccessDenied = ctx =>
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { /* parameters set by IPostConfigureOptions below */ });
|
|
||||||
|
|
||||||
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();
|
// 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 =>
|
services.AddAuthorization(o =>
|
||||||
{
|
{
|
||||||
o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
|
o.FallbackPolicy = new AuthorizationPolicyBuilder(
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
JwtBearerDefaults.AuthenticationScheme)
|
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -95,6 +89,9 @@ public static class ServiceCollectionExtensions
|
|||||||
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
||||||
o.AddPolicy("DriverOperator", policy =>
|
o.AddPolicy("DriverOperator", policy =>
|
||||||
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
||||||
|
|
||||||
|
// FleetAdmin: full administrative access; gates fleet-wide pages such as RoleGrants.
|
||||||
|
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin"));
|
||||||
});
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
|
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
|
||||||
|
|||||||
@@ -122,15 +122,45 @@ public sealed class DraftValidatorTests
|
|||||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Galaxy driver cannot be placed in Equipment namespace.</summary>
|
/// <summary>Verifies that the canonical Galaxy driver type (GalaxyMxGateway, per PR 7.2 —
|
||||||
|
/// it was "Galaxy" pre-PR-7.2) is allowed in a SystemPlatform namespace, i.e. produces no
|
||||||
|
/// kind-mismatch error.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Galaxy_driver_in_Equipment_namespace_is_rejected()
|
public void GalaxyMxGateway_driver_in_SystemPlatform_namespace_is_allowed()
|
||||||
|
{
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.SystemPlatform }],
|
||||||
|
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the canonical Galaxy driver type cannot be placed in an Equipment namespace.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void GalaxyMxGateway_driver_in_Equipment_namespace_is_rejected()
|
||||||
{
|
{
|
||||||
var draft = new DraftSnapshot
|
var draft = new DraftSnapshot
|
||||||
{
|
{
|
||||||
GenerationId = 1, ClusterId = "c",
|
GenerationId = 1, ClusterId = "c",
|
||||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
|
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-Galaxy driver cannot be placed in a SystemPlatform namespace.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void NonGalaxy_driver_in_SystemPlatform_namespace_is_rejected()
|
||||||
|
{
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.SystemPlatform }],
|
||||||
|
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||||
@@ -145,7 +175,7 @@ public sealed class DraftValidatorTests
|
|||||||
{
|
{
|
||||||
GenerationId = 1, ClusterId = "c-A",
|
GenerationId = 1, ClusterId = "c-A",
|
||||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
|
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }],
|
||||||
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }],
|
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -146,4 +146,23 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
|||||||
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
// no exception
|
// no exception
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a system-wide row (IsSystemWide=true, ClusterId=null) appears in both ListAllAsync and GetByGroupsAsync.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task SystemWide_Row_AppearsIn_ListAll_And_GetByGroups()
|
||||||
|
{
|
||||||
|
var svc = new LdapGroupRoleMappingService(_db);
|
||||||
|
var saved = await svc.CreateAsync(
|
||||||
|
Make("cn=sysadmins,dc=x", AdminRole.FleetAdmin, clusterId: null, isSystemWide: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
saved.IsSystemWide.ShouldBeTrue();
|
||||||
|
saved.ClusterId.ShouldBeNull();
|
||||||
|
|
||||||
|
var all = await svc.ListAllAsync(CancellationToken.None);
|
||||||
|
all.ShouldContain(r => r.Id == saved.Id);
|
||||||
|
|
||||||
|
var byGroup = await svc.GetByGroupsAsync(["cn=sysadmins,dc=x"], CancellationToken.None);
|
||||||
|
byGroup.ShouldContain(r => r.Id == saved.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -2,8 +2,8 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||||
using S7NetCpuType = global::S7.Net.CpuType;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ public sealed class S7CommandBaseBuildOptionsTests
|
|||||||
{
|
{
|
||||||
Host = "10.0.0.5",
|
Host = "10.0.0.5",
|
||||||
Port = 102,
|
Port = 102,
|
||||||
CpuType = S7NetCpuType.S71500,
|
CpuType = S7CpuType.S71500,
|
||||||
Rack = 0,
|
Rack = 0,
|
||||||
Slot = 0,
|
Slot = 0,
|
||||||
TimeoutMs = 5000,
|
TimeoutMs = 5000,
|
||||||
@@ -72,7 +72,7 @@ public sealed class S7CommandBaseBuildOptionsTests
|
|||||||
{
|
{
|
||||||
Host = "plc.shop.local",
|
Host = "plc.shop.local",
|
||||||
Port = 4102,
|
Port = 4102,
|
||||||
CpuType = S7NetCpuType.S7300,
|
CpuType = S7CpuType.S7300,
|
||||||
Rack = 1,
|
Rack = 1,
|
||||||
Slot = 2,
|
Slot = 2,
|
||||||
TimeoutMs = 3000,
|
TimeoutMs = 3000,
|
||||||
@@ -82,7 +82,7 @@ public sealed class S7CommandBaseBuildOptionsTests
|
|||||||
|
|
||||||
options.Host.ShouldBe("plc.shop.local");
|
options.Host.ShouldBe("plc.shop.local");
|
||||||
options.Port.ShouldBe(4102);
|
options.Port.ShouldBe(4102);
|
||||||
options.CpuType.ShouldBe(S7NetCpuType.S7300);
|
options.CpuType.ShouldBe(S7CpuType.S7300);
|
||||||
options.Rack.ShouldBe((short)1);
|
options.Rack.ShouldBe((short)1);
|
||||||
options.Slot.ShouldBe((short)2);
|
options.Slot.ShouldBe((short)2);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -16,10 +16,10 @@ public sealed class GalaxyDriverBrowserTests
|
|||||||
{
|
{
|
||||||
private readonly GalaxyDriverBrowser _sut = new();
|
private readonly GalaxyDriverBrowser _sut = new();
|
||||||
|
|
||||||
/// <summary>The DriverType key must match the AdminUI's persisted "Galaxy" value
|
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
||||||
/// so the factory wire-up picks the right browser implementation.</summary>
|
/// so the factory wire-up picks the right browser implementation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DriverType_is_Galaxy() => _sut.DriverType.ShouldBe("Galaxy");
|
public void DriverType_is_GalaxyMxGateway() => _sut.DriverType.ShouldBe("GalaxyMxGateway");
|
||||||
|
|
||||||
/// <summary>An empty Gateway.Endpoint must fail fast with a clear, endpoint-mentioning
|
/// <summary>An empty Gateway.Endpoint must fail fast with a clear, endpoint-mentioning
|
||||||
/// message rather than surfacing a downstream gRPC URI parse error.</summary>
|
/// message rather than surfacing a downstream gRPC URI parse error.</summary>
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// D.1 smoke (alarm-source leg): drives the REAL gateway <c>StreamAlarms</c> feed through the
|
||||||
|
/// production lmxopcua consumer (<see cref="GatewayGalaxyAlarmFeed"/>) and asserts native alarm
|
||||||
|
/// transitions — with operator comment, category, original raise time, and the mapped OPC UA
|
||||||
|
/// severity bucket preserved — reach the driver-side boundary that feeds
|
||||||
|
/// <c>IAlarmSource.OnAlarmEvent</c>.
|
||||||
|
/// <para>
|
||||||
|
/// Skip-gated: runs only when <c>MXGW_ENDPOINT</c> + <c>GALAXY_MXGW_API_KEY</c> are set to a
|
||||||
|
/// reachable gateway. Captured 2026-05-29 against <c>10.100.0.48:5120</c> — see
|
||||||
|
/// <c>docs/plans/alarms-d1-smoke-artifact.md</c>. Set <c>D1_SMOKE_OUT</c> to dump the observed
|
||||||
|
/// transitions to a file for artifact capture.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class GatewayGalaxyAlarmFeedLiveTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Live_gateway_delivers_native_alarm_transitions_through_the_consumer()
|
||||||
|
{
|
||||||
|
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
|
||||||
|
var apiKey = Environment.GetEnvironmentVariable("GALAXY_MXGW_API_KEY");
|
||||||
|
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
Assert.Skip("Set MXGW_ENDPOINT + GALAXY_MXGW_API_KEY to run the live gateway alarm-feed smoke.");
|
||||||
|
|
||||||
|
var client = MxGatewayClient.Create(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(endpoint!, UriKind.Absolute),
|
||||||
|
ApiKey = apiKey!,
|
||||||
|
UseTls = false,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||||
|
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||||
|
StreamTimeout = TimeSpan.FromSeconds(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
var observed = new List<GalaxyAlarmTransition>();
|
||||||
|
var gotOne = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
// Wire the live client's StreamAlarms method group into the production consumer seam.
|
||||||
|
await using var feed = new GatewayGalaxyAlarmFeed(client.StreamAlarmsAsync, clientName: "D1Smoke");
|
||||||
|
feed.OnAlarmTransition += (_, t) =>
|
||||||
|
{
|
||||||
|
lock (observed) { observed.Add(t); }
|
||||||
|
gotOne.TrySetResult(true);
|
||||||
|
};
|
||||||
|
feed.Start();
|
||||||
|
|
||||||
|
// The stream opens with the active-alarm snapshot, so we expect ≥1 transition promptly.
|
||||||
|
await Task.WhenAny(gotOne.Task, Task.Delay(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
List<GalaxyAlarmTransition> snapshot;
|
||||||
|
lock (observed) snapshot = observed.ToList();
|
||||||
|
|
||||||
|
snapshot.ShouldNotBeEmpty(
|
||||||
|
"Live gateway should deliver at least the active-alarm snapshot through the lmxopcua consumer.");
|
||||||
|
var first = snapshot[0];
|
||||||
|
first.AlarmFullReference.ShouldNotBeNullOrWhiteSpace();
|
||||||
|
first.OpcUaSeverity.ShouldBeGreaterThan(0); // severity bucket mapping applied by the consumer
|
||||||
|
|
||||||
|
foreach (var t in snapshot.Take(8))
|
||||||
|
TestContext.Current.SendDiagnosticMessage(
|
||||||
|
$"{t.TransitionKind,-11} {t.AlarmFullReference} sev={t.OpcUaSeverity}({t.SeverityBucket}) cat={t.Category} comment='{t.OperatorComment}'");
|
||||||
|
TestContext.Current.SendDiagnosticMessage($"TOTAL consumer transitions observed: {snapshot.Count}");
|
||||||
|
|
||||||
|
// Deterministic artifact capture (only when D1_SMOKE_OUT is set).
|
||||||
|
var outPath = Environment.GetEnvironmentVariable("D1_SMOKE_OUT");
|
||||||
|
if (!string.IsNullOrWhiteSpace(outPath))
|
||||||
|
{
|
||||||
|
var lines = snapshot.Take(50).Select(t =>
|
||||||
|
$"{t.TransitionKind,-11} {t.AlarmFullReference} | sev={t.OpcUaSeverity}({t.SeverityBucket}) raw={t.RawMxAccessSeverity} | cat={t.Category} | comment='{t.OperatorComment}' | xitionUtc={t.TransitionTimestampUtc:o}");
|
||||||
|
await File.WriteAllLinesAsync(outPath!,
|
||||||
|
new[] { $"# consumer transitions observed: {snapshot.Count}" }.Concat(lines),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -25,6 +25,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
|
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
|
||||||
|
<!-- Client package: only the Skip-gated live alarm-feed smoke (GatewayGalaxyAlarmFeedLiveTests)
|
||||||
|
constructs a real MxGatewayClient. Unit tests use the fake stream-factory seam. -->
|
||||||
|
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+113
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
@@ -14,6 +15,12 @@ public sealed class AbCipDriverPageFormSerializationTests
|
|||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions TestJsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
@@ -78,4 +85,110 @@ public sealed class AbCipDriverPageFormSerializationTests
|
|||||||
back.ShouldNotBeNull();
|
back.ShouldNotBeNull();
|
||||||
back.ProbeTimeoutSeconds.ShouldBe(10);
|
back.ProbeTimeoutSeconds.ShouldBe(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_round_trips_through_definition()
|
||||||
|
{
|
||||||
|
var row = new AbCipDriverPage.AbCipDeviceRow
|
||||||
|
{
|
||||||
|
HostAddress = "ab://10.0.0.1/1,0", PlcFamily = AbCipPlcFamily.CompactLogix, DeviceName = "PLC-A",
|
||||||
|
};
|
||||||
|
var def = row.ToDefinition();
|
||||||
|
var back = AbCipDriverPage.AbCipDeviceRow.FromDefinition(def);
|
||||||
|
|
||||||
|
back.HostAddress.ShouldBe("ab://10.0.0.1/1,0");
|
||||||
|
back.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
|
||||||
|
back.DeviceName.ShouldBe("PLC-A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_preserves_unedited_fields()
|
||||||
|
{
|
||||||
|
var original = new AbCipDeviceOptions(
|
||||||
|
"ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-A",
|
||||||
|
AllowPacking: true, ConnectionSize: 4002);
|
||||||
|
var row = AbCipDriverPage.AbCipDeviceRow.FromDefinition(original);
|
||||||
|
row.HostAddress = "ab://10.0.0.2/1,0";
|
||||||
|
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
back.HostAddress.ShouldBe("ab://10.0.0.2/1,0");
|
||||||
|
back.AllowPacking.ShouldBe(true);
|
||||||
|
back.ConnectionSize.ShouldBe(4002);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_round_trips_through_definition()
|
||||||
|
{
|
||||||
|
var row = new AbCipDriverPage.AbCipTagRow
|
||||||
|
{
|
||||||
|
Name = "Speed", DeviceHostAddress = "ab://10.0.0.1/1,0", TagPath = "Motor1.Speed",
|
||||||
|
DataType = AbCipDataType.Real, Writable = true,
|
||||||
|
};
|
||||||
|
var def = row.ToDefinition();
|
||||||
|
var back = AbCipDriverPage.AbCipTagRow.FromDefinition(def);
|
||||||
|
|
||||||
|
back.Name.ShouldBe("Speed");
|
||||||
|
back.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
|
||||||
|
back.TagPath.ShouldBe("Motor1.Speed");
|
||||||
|
back.DataType.ShouldBe(AbCipDataType.Real);
|
||||||
|
back.Writable.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_preserves_unedited_fields()
|
||||||
|
{
|
||||||
|
var original = new AbCipTagDefinition(
|
||||||
|
"Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Structure,
|
||||||
|
Writable: true, WriteIdempotent: true,
|
||||||
|
Members: [new AbCipStructureMember("Sub", AbCipDataType.DInt)],
|
||||||
|
SafetyTag: true);
|
||||||
|
var row = AbCipDriverPage.AbCipTagRow.FromDefinition(original);
|
||||||
|
row.Name = "Renamed";
|
||||||
|
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
back.Name.ShouldBe("Renamed");
|
||||||
|
back.WriteIdempotent.ShouldBeTrue();
|
||||||
|
back.SafetyTag.ShouldBeTrue();
|
||||||
|
back.Members.ShouldNotBeNull();
|
||||||
|
back.Members!.Count.ShouldBe(1);
|
||||||
|
back.Members[0].Name.ShouldBe("Sub");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||||
|
{
|
||||||
|
var rows = new List<AbCipDriverPage.AbCipDeviceRow> { new() { HostAddress = "ab://10.0.0.1/1,0" } };
|
||||||
|
AbCipDriverPage.AbCipDeviceRow.ValidateRow(new() { HostAddress = "ab://10.0.0.1/1,0" }, rows, null)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTagRow_rejects_duplicate_name()
|
||||||
|
{
|
||||||
|
var rows = new List<AbCipDriverPage.AbCipTagRow> { new() { Name = "Speed" } };
|
||||||
|
AbCipDriverPage.AbCipTagRow.ValidateRow(new() { Name = "Speed" }, rows, null)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Device_and_tag_lists_survive_options_serialize_round_trip()
|
||||||
|
{
|
||||||
|
var devices = new List<AbCipDeviceOptions>
|
||||||
|
{
|
||||||
|
new("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"),
|
||||||
|
new("ab://10.0.0.2/1,0", AbCipPlcFamily.CompactLogix, "PLC-2"),
|
||||||
|
};
|
||||||
|
var tags = new List<AbCipTagDefinition>
|
||||||
|
{
|
||||||
|
new("Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Real),
|
||||||
|
new("Run", "ab://10.0.0.2/1,0", "Motor2.Run", AbCipDataType.Bool),
|
||||||
|
};
|
||||||
|
var opts = new AbCipDriverPage.FormModel().ToOptions(devices, tags);
|
||||||
|
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||||
|
var back = JsonSerializer.Deserialize<AbCipDriverOptions>(json, TestJsonOpts)!;
|
||||||
|
back.Devices.Count.ShouldBe(2);
|
||||||
|
back.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0");
|
||||||
|
back.Tags.Count.ShouldBe(2);
|
||||||
|
back.Tags[0].Name.ShouldBe("Speed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
@@ -15,6 +16,12 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
|||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions TestJsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
@@ -78,4 +85,102 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
|||||||
back.ShouldNotBeNull();
|
back.ShouldNotBeNull();
|
||||||
back.ProbeTimeoutSeconds.ShouldBe(10);
|
back.ProbeTimeoutSeconds.ShouldBe(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_round_trips_through_definition()
|
||||||
|
{
|
||||||
|
var row = new AbLegacyDriverPage.AbLegacyDeviceRow
|
||||||
|
{
|
||||||
|
HostAddress = "10.0.0.10", PlcFamily = AbLegacyPlcFamily.MicroLogix, DeviceName = "PLC-A",
|
||||||
|
};
|
||||||
|
var def = row.ToDefinition();
|
||||||
|
var back = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(def);
|
||||||
|
|
||||||
|
back.HostAddress.ShouldBe("10.0.0.10");
|
||||||
|
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
|
||||||
|
back.DeviceName.ShouldBe("PLC-A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_preserves_unedited_fields()
|
||||||
|
{
|
||||||
|
var original = new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Plc5, "PLC-A");
|
||||||
|
var row = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(original);
|
||||||
|
row.HostAddress = "10.0.0.20";
|
||||||
|
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
back.HostAddress.ShouldBe("10.0.0.20");
|
||||||
|
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
|
||||||
|
back.DeviceName.ShouldBe("PLC-A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_round_trips_through_definition()
|
||||||
|
{
|
||||||
|
var row = new AbLegacyDriverPage.AbLegacyTagRow
|
||||||
|
{
|
||||||
|
Name = "Level", DeviceHostAddress = "10.0.0.10", Address = "N7:5",
|
||||||
|
DataType = AbLegacyDataType.Int, Writable = true,
|
||||||
|
};
|
||||||
|
var def = row.ToDefinition();
|
||||||
|
var back = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(def);
|
||||||
|
|
||||||
|
back.Name.ShouldBe("Level");
|
||||||
|
back.DeviceHostAddress.ShouldBe("10.0.0.10");
|
||||||
|
back.Address.ShouldBe("N7:5");
|
||||||
|
back.DataType.ShouldBe(AbLegacyDataType.Int);
|
||||||
|
back.Writable.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_preserves_unedited_fields()
|
||||||
|
{
|
||||||
|
var original = new AbLegacyTagDefinition(
|
||||||
|
"Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int,
|
||||||
|
Writable: true, WriteIdempotent: true);
|
||||||
|
var row = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(original);
|
||||||
|
row.Name = "Renamed";
|
||||||
|
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
back.Name.ShouldBe("Renamed");
|
||||||
|
back.WriteIdempotent.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||||
|
{
|
||||||
|
var rows = new List<AbLegacyDriverPage.AbLegacyDeviceRow> { new() { HostAddress = "10.0.0.10" } };
|
||||||
|
AbLegacyDriverPage.AbLegacyDeviceRow.ValidateRow(new() { HostAddress = "10.0.0.10" }, rows, null)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTagRow_rejects_duplicate_name()
|
||||||
|
{
|
||||||
|
var rows = new List<AbLegacyDriverPage.AbLegacyTagRow> { new() { Name = "Level" } };
|
||||||
|
AbLegacyDriverPage.AbLegacyTagRow.ValidateRow(new() { Name = "Level" }, rows, null)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Device_and_tag_lists_survive_options_serialize_round_trip()
|
||||||
|
{
|
||||||
|
var devices = new List<AbLegacyDeviceOptions>
|
||||||
|
{
|
||||||
|
new("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-1"),
|
||||||
|
new("10.0.0.11", AbLegacyPlcFamily.MicroLogix, "PLC-2"),
|
||||||
|
};
|
||||||
|
var tags = new List<AbLegacyTagDefinition>
|
||||||
|
{
|
||||||
|
new("Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int),
|
||||||
|
new("Pump", "10.0.0.11", "B3:0/0", AbLegacyDataType.Bit),
|
||||||
|
};
|
||||||
|
var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices, tags);
|
||||||
|
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||||
|
var back = JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, TestJsonOpts)!;
|
||||||
|
back.Devices.Count.ShouldBe(2);
|
||||||
|
back.Devices[0].HostAddress.ShouldBe("10.0.0.10");
|
||||||
|
back.Tags.Count.ShouldBe(2);
|
||||||
|
back.Tags[0].Name.ShouldBe("Level");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the in-process push contract the Blazor Server <c>DriverStatusPanel</c> relies on:
|
||||||
|
/// <see cref="IDriverStatusSnapshotStore.SnapshotChanged"/> fires on every
|
||||||
|
/// <see cref="IDriverStatusSnapshotStore.Upsert"/>, and <c>TryGet</c> returns the latest.
|
||||||
|
/// The panel subscribes to this store directly instead of opening a self-targeted SignalR
|
||||||
|
/// connection (which a server-side component can't reach behind a reverse proxy).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DriverStatusSnapshotStoreTests
|
||||||
|
{
|
||||||
|
private static DriverHealthChanged Snap(string instance, string state = "Healthy") =>
|
||||||
|
new("MAIN", instance, state, null, null, 0, new DateTime(2026, 5, 29, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Upsert_raises_SnapshotChanged_with_the_stored_snapshot()
|
||||||
|
{
|
||||||
|
var store = new InMemoryDriverStatusSnapshotStore();
|
||||||
|
var received = new List<DriverHealthChanged>();
|
||||||
|
store.SnapshotChanged += received.Add;
|
||||||
|
|
||||||
|
var snap = Snap("drv-1", "Faulted");
|
||||||
|
store.Upsert(snap);
|
||||||
|
|
||||||
|
received.Count.ShouldBe(1);
|
||||||
|
received[0].ShouldBeSameAs(snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Upsert_then_TryGet_returns_the_latest_snapshot()
|
||||||
|
{
|
||||||
|
var store = new InMemoryDriverStatusSnapshotStore();
|
||||||
|
store.Upsert(Snap("drv-1", "Healthy"));
|
||||||
|
store.Upsert(Snap("drv-1", "Degraded"));
|
||||||
|
|
||||||
|
store.TryGet("drv-1", out var latest).ShouldBeTrue();
|
||||||
|
latest.State.ShouldBe("Degraded");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unsubscribed_handler_stops_receiving_after_removal()
|
||||||
|
{
|
||||||
|
var store = new InMemoryDriverStatusSnapshotStore();
|
||||||
|
var count = 0;
|
||||||
|
void Handler(DriverHealthChanged _) => count++;
|
||||||
|
|
||||||
|
store.SnapshotChanged += Handler;
|
||||||
|
store.Upsert(Snap("drv-1"));
|
||||||
|
store.SnapshotChanged -= Handler;
|
||||||
|
store.Upsert(Snap("drv-1"));
|
||||||
|
|
||||||
|
count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-1
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
@@ -14,6 +15,12 @@ public sealed class FocasDriverPageFormSerializationTests
|
|||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions TestJsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
@@ -116,7 +123,7 @@ public sealed class FocasDriverPageFormSerializationTests
|
|||||||
|
|
||||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
.FocasDriverPage.FormModel.FromOptions(opts);
|
.FocasDriverPage.FormModel.FromOptions(opts);
|
||||||
var roundTripped = form.ToOptions();
|
var roundTripped = form.ToOptions([], []);
|
||||||
|
|
||||||
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
||||||
roundTripped.Probe.Enabled.ShouldBeTrue();
|
roundTripped.Probe.Enabled.ShouldBeTrue();
|
||||||
@@ -132,4 +139,104 @@ public sealed class FocasDriverPageFormSerializationTests
|
|||||||
roundTripped.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
roundTripped.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
||||||
roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_round_trips_through_definition()
|
||||||
|
{
|
||||||
|
var row = new FocasDriverPage.FocasDeviceRow
|
||||||
|
{
|
||||||
|
HostAddress = "192.168.0.10:8193", Series = FocasCncSeries.Thirty_i, DeviceName = "CNC1",
|
||||||
|
};
|
||||||
|
var def = row.ToDefinition();
|
||||||
|
var back = FocasDriverPage.FocasDeviceRow.FromDefinition(def);
|
||||||
|
|
||||||
|
back.HostAddress.ShouldBe("192.168.0.10:8193");
|
||||||
|
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||||
|
back.DeviceName.ShouldBe("CNC1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_preserves_unedited_fields()
|
||||||
|
{
|
||||||
|
var original = new FocasDeviceOptions("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i);
|
||||||
|
var row = FocasDriverPage.FocasDeviceRow.FromDefinition(original);
|
||||||
|
row.HostAddress = "192.168.0.20:8193";
|
||||||
|
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
back.HostAddress.ShouldBe("192.168.0.20:8193");
|
||||||
|
back.DeviceName.ShouldBe("CNC1");
|
||||||
|
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_round_trips_through_definition()
|
||||||
|
{
|
||||||
|
var row = new FocasDriverPage.FocasTagRow
|
||||||
|
{
|
||||||
|
Name = "MacroVar", DeviceHostAddress = "192.168.0.10:8193", Address = "MACRO:500",
|
||||||
|
DataType = FocasDataType.Float64, Writable = true,
|
||||||
|
};
|
||||||
|
var def = row.ToDefinition();
|
||||||
|
var back = FocasDriverPage.FocasTagRow.FromDefinition(def);
|
||||||
|
|
||||||
|
back.Name.ShouldBe("MacroVar");
|
||||||
|
back.DeviceHostAddress.ShouldBe("192.168.0.10:8193");
|
||||||
|
back.Address.ShouldBe("MACRO:500");
|
||||||
|
back.DataType.ShouldBe(FocasDataType.Float64);
|
||||||
|
back.Writable.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_preserves_unedited_fields()
|
||||||
|
{
|
||||||
|
var original = new FocasTagDefinition(
|
||||||
|
"MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64,
|
||||||
|
Writable: true, WriteIdempotent: true);
|
||||||
|
var row = FocasDriverPage.FocasTagRow.FromDefinition(original);
|
||||||
|
row.Name = "Renamed";
|
||||||
|
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
back.Name.ShouldBe("Renamed");
|
||||||
|
back.WriteIdempotent.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||||
|
{
|
||||||
|
var rows = new List<FocasDriverPage.FocasDeviceRow> { new() { HostAddress = "192.168.0.10:8193" } };
|
||||||
|
FocasDriverPage.FocasDeviceRow.ValidateRow(new() { HostAddress = "192.168.0.10:8193" }, rows, null)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateTagRow_rejects_duplicate_name()
|
||||||
|
{
|
||||||
|
var rows = new List<FocasDriverPage.FocasTagRow> { new() { Name = "MacroVar" } };
|
||||||
|
FocasDriverPage.FocasTagRow.ValidateRow(new() { Name = "MacroVar" }, rows, null)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Device_and_tag_lists_survive_options_serialize_round_trip()
|
||||||
|
{
|
||||||
|
var devices = new List<FocasDeviceOptions>
|
||||||
|
{
|
||||||
|
new("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i),
|
||||||
|
new("192.168.0.20:8193", "CNC2", FocasCncSeries.Zero_i_F),
|
||||||
|
};
|
||||||
|
var tags = new List<FocasTagDefinition>
|
||||||
|
{
|
||||||
|
new("MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64),
|
||||||
|
new("Flag", "192.168.0.20:8193", "X0.0", FocasDataType.Bit),
|
||||||
|
};
|
||||||
|
var opts = new FocasDriverPage.FormModel().ToOptions(devices, tags);
|
||||||
|
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||||
|
var back = JsonSerializer.Deserialize<FocasDriverOptions>(json, TestJsonOpts)!;
|
||||||
|
back.Devices.Count.ShouldBe(2);
|
||||||
|
back.Devices[0].HostAddress.ShouldBe("192.168.0.10:8193");
|
||||||
|
back.Devices[0].Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||||
|
back.Tags.Count.ShouldBe(2);
|
||||||
|
back.Tags[0].Name.ShouldBe("MacroVar");
|
||||||
|
back.Tags[0].Address.ShouldBe("MACRO:500");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+142
@@ -2,18 +2,29 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
|
|
||||||
public sealed class GalaxyDriverPageFormSerializationTests
|
public sealed class GalaxyDriverPageFormSerializationTests
|
||||||
{
|
{
|
||||||
|
// Matches GalaxyDriverPage._jsonOpts (camelCase, no PropertyNameCaseInsensitive).
|
||||||
private static readonly JsonSerializerOptions _opts = new()
|
private static readonly JsonSerializerOptions _opts = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Matches the page's _jsonOpts exactly: camelCase + case-insensitive read + UnmappedMemberHandling.Skip.
|
||||||
|
private static readonly JsonSerializerOptions _pageOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
WriteIndented = false,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
@@ -92,4 +103,135 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
|||||||
back.ProbeTimeoutSeconds.ShouldBe(20);
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
back.Gateway.Endpoint.ShouldBe("https://localhost:5001");
|
back.Gateway.Endpoint.ShouldBe("https://localhost:5001");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression test: the seed SQL stores PascalCase JSON. With
|
||||||
|
/// <c>PropertyNameCaseInsensitive = true</c> the page must read the real values, not
|
||||||
|
/// fall back to defaults. FAILS against case-sensitive opts; PASSES with the fix.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Seeded_pascalcase_config_loads_real_values()
|
||||||
|
{
|
||||||
|
// Exact JSON from docker-dev/seed/seed-clusters.sql (lines 130-151).
|
||||||
|
var seededJson = """
|
||||||
|
{
|
||||||
|
"Gateway": {
|
||||||
|
"Endpoint": "http://10.100.0.48:5120",
|
||||||
|
"ApiKeySecretRef": "env:GALAXY_MXGW_API_KEY",
|
||||||
|
"UseTls": false,
|
||||||
|
"ConnectTimeoutSeconds": 10,
|
||||||
|
"DefaultCallTimeoutSeconds": 30
|
||||||
|
},
|
||||||
|
"MxAccess": {
|
||||||
|
"ClientName": "OtOpcUa-MAIN-docker-dev",
|
||||||
|
"PublishingIntervalMs": 1000
|
||||||
|
},
|
||||||
|
"Repository": {
|
||||||
|
"DiscoverPageSize": 5000,
|
||||||
|
"WatchDeployEvents": true
|
||||||
|
},
|
||||||
|
"Reconnect": {
|
||||||
|
"InitialBackoffMs": 500,
|
||||||
|
"MaxBackoffMs": 30000,
|
||||||
|
"ReplayOnSessionLost": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
// Deserialize with page-mirrored opts (camelCase + case-insensitive, as fixed).
|
||||||
|
var driverOpts = JsonSerializer.Deserialize<GalaxyDriverOptions>(seededJson, _pageOpts);
|
||||||
|
driverOpts.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!);
|
||||||
|
|
||||||
|
// Assert REAL seeded values — not defaults.
|
||||||
|
form.GatewayEndpoint.ShouldBe("http://10.100.0.48:5120");
|
||||||
|
form.GatewayApiKeySecretRef.ShouldBe("env:GALAXY_MXGW_API_KEY");
|
||||||
|
form.GatewayUseTls.ShouldBeFalse();
|
||||||
|
form.MxClientName.ShouldBe("OtOpcUa-MAIN-docker-dev");
|
||||||
|
form.RepositoryDiscoverPageSize.ShouldBe(5000);
|
||||||
|
form.ReconnectInitialBackoffMs.ShouldBe(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all)
|
||||||
|
/// must not throw — <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> must
|
||||||
|
/// null-coalesce the missing section to its default value.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FromRecord_with_omitted_section_uses_defaults()
|
||||||
|
{
|
||||||
|
// Only gateway section present — Reconnect intentionally absent.
|
||||||
|
var partialJson = """
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"endpoint": "opc://x",
|
||||||
|
"apiKeySecretRef": "env:K"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var driverOpts = JsonSerializer.Deserialize<GalaxyDriverOptions>(partialJson, _pageOpts);
|
||||||
|
driverOpts.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// FromRecord must not throw even though Reconnect (and other sections) is null.
|
||||||
|
var form = Should.NotThrow(() => GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!));
|
||||||
|
|
||||||
|
// Omitted Reconnect section falls back to GalaxyReconnectOptions() defaults.
|
||||||
|
var defaultRc = new GalaxyReconnectOptions();
|
||||||
|
form.ReconnectInitialBackoffMs.ShouldBe(defaultRc.InitialBackoffMs);
|
||||||
|
form.ReconnectMaxBackoffMs.ShouldBe(defaultRc.MaxBackoffMs);
|
||||||
|
form.ReconnectReplayOnSessionLost.ShouldBe(defaultRc.ReplayOnSessionLost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms that <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> still
|
||||||
|
/// round-trips correctly when all nested records are populated (non-regressed path).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FromRecord_with_fully_populated_options_round_trips()
|
||||||
|
{
|
||||||
|
var original = new GalaxyDriverOptions(
|
||||||
|
Gateway: new GalaxyGatewayOptions(
|
||||||
|
Endpoint: "https://gw.example.com:5001",
|
||||||
|
ApiKeySecretRef: "env:MY_KEY",
|
||||||
|
UseTls: true,
|
||||||
|
CaCertificatePath: null,
|
||||||
|
ConnectTimeoutSeconds: 12,
|
||||||
|
DefaultCallTimeoutSeconds: 40,
|
||||||
|
StreamTimeoutSeconds: 0),
|
||||||
|
MxAccess: new GalaxyMxAccessOptions(
|
||||||
|
ClientName: "OtOpcUa-Test",
|
||||||
|
PublishingIntervalMs: 750,
|
||||||
|
WriteUserId: 2,
|
||||||
|
EventPumpChannelCapacity: 25_000),
|
||||||
|
Repository: new GalaxyRepositoryOptions(
|
||||||
|
DiscoverPageSize: 3000,
|
||||||
|
WatchDeployEvents: false),
|
||||||
|
Reconnect: new GalaxyReconnectOptions(
|
||||||
|
InitialBackoffMs: 800,
|
||||||
|
MaxBackoffMs: 45_000,
|
||||||
|
ReplayOnSessionLost: false))
|
||||||
|
{
|
||||||
|
ProbeTimeoutSeconds = 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(original);
|
||||||
|
|
||||||
|
form.GatewayEndpoint.ShouldBe("https://gw.example.com:5001");
|
||||||
|
form.GatewayApiKeySecretRef.ShouldBe("env:MY_KEY");
|
||||||
|
form.GatewayUseTls.ShouldBeTrue();
|
||||||
|
form.GatewayConnectTimeoutSeconds.ShouldBe(12);
|
||||||
|
form.GatewayDefaultCallTimeoutSeconds.ShouldBe(40);
|
||||||
|
form.MxClientName.ShouldBe("OtOpcUa-Test");
|
||||||
|
form.MxPublishingIntervalMs.ShouldBe(750);
|
||||||
|
form.MxWriteUserId.ShouldBe(2);
|
||||||
|
form.MxEventPumpChannelCapacity.ShouldBe(25_000);
|
||||||
|
form.RepositoryDiscoverPageSize.ShouldBe(3000);
|
||||||
|
form.RepositoryWatchDeployEvents.ShouldBeFalse();
|
||||||
|
form.ReconnectInitialBackoffMs.ShouldBe(800);
|
||||||
|
form.ReconnectMaxBackoffMs.ShouldBe(45_000);
|
||||||
|
form.ReconnectReplayOnSessionLost.ShouldBeFalse();
|
||||||
|
form.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the in-process fan-out the Blazor Server Alerts / Script log pages rely on:
|
||||||
|
/// <see cref="IInProcessBroadcaster{T}.Publish"/> raises <c>Received</c> for every current
|
||||||
|
/// subscriber, and unsubscribing stops delivery. These pages read this broadcaster directly
|
||||||
|
/// instead of opening a self-targeted SignalR connection (unreachable behind a reverse proxy).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InProcessBroadcasterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Publish_raises_Received_for_all_current_subscribers()
|
||||||
|
{
|
||||||
|
var broadcaster = new InProcessBroadcaster<string>();
|
||||||
|
var a = new List<string>();
|
||||||
|
var b = new List<string>();
|
||||||
|
broadcaster.Received += a.Add;
|
||||||
|
broadcaster.Received += b.Add;
|
||||||
|
|
||||||
|
broadcaster.Publish("evt-1");
|
||||||
|
|
||||||
|
a.ShouldBe(["evt-1"]);
|
||||||
|
b.ShouldBe(["evt-1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unsubscribed_handler_stops_receiving()
|
||||||
|
{
|
||||||
|
var broadcaster = new InProcessBroadcaster<string>();
|
||||||
|
var received = new List<string>();
|
||||||
|
void Handler(string s) => received.Add(s);
|
||||||
|
|
||||||
|
broadcaster.Received += Handler;
|
||||||
|
broadcaster.Publish("first");
|
||||||
|
broadcaster.Received -= Handler;
|
||||||
|
broadcaster.Publish("second");
|
||||||
|
|
||||||
|
received.ShouldBe(["first"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Publish_with_no_subscribers_does_not_throw()
|
||||||
|
{
|
||||||
|
var broadcaster = new InProcessBroadcaster<int>();
|
||||||
|
Should.NotThrow(() => broadcaster.Publish(42));
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
@@ -14,6 +15,12 @@ public sealed class ModbusDriverPageFormSerializationTests
|
|||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions TestJsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
@@ -104,4 +111,63 @@ public sealed class ModbusDriverPageFormSerializationTests
|
|||||||
back.ShouldNotBeNull();
|
back.ShouldNotBeNull();
|
||||||
back.ProbeTimeoutSeconds.ShouldBe(10);
|
back.ProbeTimeoutSeconds.ShouldBe(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_round_trips_through_definition()
|
||||||
|
{
|
||||||
|
var row = new ModbusDriverPage.ModbusTagRow
|
||||||
|
{
|
||||||
|
Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegisters, Address = 40001,
|
||||||
|
DataType = ModbusDataType.Int16, Writable = true,
|
||||||
|
};
|
||||||
|
var def = row.ToDefinition();
|
||||||
|
var back = ModbusDriverPage.ModbusTagRow.FromDefinition(def);
|
||||||
|
|
||||||
|
back.Name.ShouldBe("Pump1_Speed");
|
||||||
|
back.Address.ShouldBe(40001);
|
||||||
|
back.DataType.ShouldBe(ModbusDataType.Int16);
|
||||||
|
back.Writable.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tag_list_survives_options_serialize_round_trip()
|
||||||
|
{
|
||||||
|
var tags = new List<ModbusTagDefinition>
|
||||||
|
{
|
||||||
|
new("A", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16),
|
||||||
|
new("B", ModbusRegion.Coils, 2, ModbusDataType.Bool),
|
||||||
|
};
|
||||||
|
var opts = new ModbusDriverPage.FormModel().ToOptions(tags);
|
||||||
|
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||||
|
var back = JsonSerializer.Deserialize<ModbusDriverOptions>(json, TestJsonOpts)!;
|
||||||
|
back.Tags.Count.ShouldBe(2);
|
||||||
|
back.Tags[0].Name.ShouldBe("A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateRow_rejects_duplicate_name()
|
||||||
|
{
|
||||||
|
var rows = new List<ModbusDriverPage.ModbusTagRow> { new() { Name = "A" } };
|
||||||
|
ModbusDriverPage.ModbusTagRow.ValidateRow(new() { Name = "A" }, rows, null)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDefinition_preserves_unedited_fields()
|
||||||
|
{
|
||||||
|
var original = new ModbusTagDefinition(
|
||||||
|
"T", ModbusRegion.HoldingRegisters, 5, ModbusDataType.Int16,
|
||||||
|
StringByteOrder: ModbusStringByteOrder.LowByteFirst,
|
||||||
|
ArrayCount: 10, Deadband: 0.5, UnitId: 3, CoalesceProhibited: true);
|
||||||
|
var row = ModbusDriverPage.ModbusTagRow.FromDefinition(original);
|
||||||
|
row.Name = "Renamed";
|
||||||
|
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
back.Name.ShouldBe("Renamed");
|
||||||
|
back.UnitId.ShouldBe((byte)3);
|
||||||
|
back.ArrayCount.ShouldBe(10);
|
||||||
|
back.Deadband.ShouldBe(0.5);
|
||||||
|
back.StringByteOrder.ShouldBe(ModbusStringByteOrder.LowByteFirst);
|
||||||
|
back.CoalesceProhibited.ShouldBeTrue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-4
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -85,9 +86,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void FormModel_RoundTrip_PreservesAllFields()
|
public void FormModel_RoundTrip_PreservesAllFields()
|
||||||
{
|
{
|
||||||
// Construct options with non-default values for every editable property plus
|
// Construct options with non-default values for every editable property plus a
|
||||||
// non-empty EndpointUrls and UnsMappingTable — both are "read-only" in the form
|
// non-empty UnsMappingTable (read-only in the form, round-tripped via the original
|
||||||
// but must survive the FormModel translation unchanged.
|
// record). EndpointUrls is now edited via the CollectionEditor on the page and is
|
||||||
|
// threaded into ToRecord explicitly; see EndpointUrls_ListRoundTrip_PreservesOrder.
|
||||||
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://backup:4840" };
|
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://backup:4840" };
|
||||||
var unsMappingTable = new Dictionary<string, string>
|
var unsMappingTable = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -123,7 +125,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original);
|
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original);
|
||||||
var result = form.ToRecord();
|
var result = form.ToRecord(endpointUrls);
|
||||||
|
|
||||||
result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840");
|
result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840");
|
||||||
result.EndpointUrls.Count.ShouldBe(2);
|
result.EndpointUrls.Count.ShouldBe(2);
|
||||||
@@ -153,4 +155,92 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
|||||||
result.UnsMappingTable["Line2/"].ShouldBe("Site/Area1/Line2");
|
result.UnsMappingTable["Line2/"].ShouldBe("Site/Area1/Line2");
|
||||||
result.ProbeTimeoutSeconds.ShouldBe(25);
|
result.ProbeTimeoutSeconds.ShouldBe(25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
|
||||||
|
{
|
||||||
|
var row = OpcUaClientDriverPage.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 ");
|
||||||
|
|
||||||
|
row.Url.ShouldBe(" opc.tcp://plc:4840 ");
|
||||||
|
row.ToUrl().ShouldBe("opc.tcp://plc:4840");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndpointUrlRow_ValidateRow_RejectsBlank()
|
||||||
|
{
|
||||||
|
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
|
||||||
|
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " };
|
||||||
|
|
||||||
|
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
|
||||||
|
|
||||||
|
error.ShouldBe("URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme()
|
||||||
|
{
|
||||||
|
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
|
||||||
|
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" };
|
||||||
|
|
||||||
|
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
|
||||||
|
|
||||||
|
error.ShouldBe("Endpoint URL must start with opc.tcp://");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndpointUrlRow_ValidateRow_RejectsDuplicate()
|
||||||
|
{
|
||||||
|
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
|
||||||
|
{
|
||||||
|
new() { Url = "opc.tcp://primary:4840" },
|
||||||
|
new() { Url = "opc.tcp://backup:4840" },
|
||||||
|
};
|
||||||
|
// Adding a new row (editIndex null) duplicating the first — case-insensitive, whitespace-insensitive.
|
||||||
|
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " };
|
||||||
|
|
||||||
|
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
|
||||||
|
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
error.ShouldContain("Duplicate endpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace()
|
||||||
|
{
|
||||||
|
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
|
||||||
|
{
|
||||||
|
new() { Url = "opc.tcp://primary:4840" },
|
||||||
|
new() { Url = "opc.tcp://backup:4840" },
|
||||||
|
};
|
||||||
|
// Editing index 0 and keeping the same URL must not flag itself as a duplicate.
|
||||||
|
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "opc.tcp://primary:4840" };
|
||||||
|
|
||||||
|
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, 0);
|
||||||
|
|
||||||
|
error.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndpointUrls_ListRoundTrip_PreservesOrder()
|
||||||
|
{
|
||||||
|
// The page holds endpoints as a List<EndpointUrlRow>; loading from EndpointUrls and
|
||||||
|
// converting back must preserve order (the failover list is ordered, primary first).
|
||||||
|
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" };
|
||||||
|
|
||||||
|
var rows = endpointUrls
|
||||||
|
.Select(OpcUaClientDriverPage.EndpointUrlRow.FromUrl)
|
||||||
|
.ToList();
|
||||||
|
var roundTripped = rows.Select(r => r.ToUrl()).ToList();
|
||||||
|
|
||||||
|
roundTripped.Count.ShouldBe(3);
|
||||||
|
roundTripped[0].ShouldBe("opc.tcp://primary:4840");
|
||||||
|
roundTripped[1].ShouldBe("opc.tcp://secondary:4840");
|
||||||
|
roundTripped[2].ShouldBe("opc.tcp://tertiary:4840");
|
||||||
|
|
||||||
|
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions());
|
||||||
|
var result = form.ToRecord(roundTripped);
|
||||||
|
result.EndpointUrls.Count.ShouldBe(3);
|
||||||
|
result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840");
|
||||||
|
result.EndpointUrls[2].ShouldBe("opc.tcp://tertiary:4840");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
|
||||||
|
public class ResilienceFormModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Blank_form_serializes_to_null()
|
||||||
|
=> new ResilienceFormModel().ToJson().ShouldBeNull();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Partial_override_round_trips()
|
||||||
|
{
|
||||||
|
var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
|
||||||
|
m.Policies["Read"].TimeoutSeconds = 5;
|
||||||
|
m.Policies["Read"].RetryCount = 5;
|
||||||
|
|
||||||
|
var json = m.ToJson();
|
||||||
|
json.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var back = ResilienceFormModel.FromJson(json);
|
||||||
|
back.BulkheadMaxConcurrent.ShouldBe(16);
|
||||||
|
back.Policies["Read"].TimeoutSeconds.ShouldBe(5);
|
||||||
|
back.Policies["Write"].IsEmpty.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Malformed_json_yields_empty_model()
|
||||||
|
{
|
||||||
|
var m = ResilienceFormModel.FromJson("{ not valid json");
|
||||||
|
m.BulkheadMaxConcurrent.ShouldBeNull();
|
||||||
|
m.Policies["Read"].IsEmpty.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Emitted_json_is_consumable_by_the_runtime_parser()
|
||||||
|
{
|
||||||
|
var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
|
||||||
|
m.Policies["Read"].TimeoutSeconds = 7;
|
||||||
|
|
||||||
|
var opts = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, m.ToJson(), out var diag);
|
||||||
|
diag.ShouldBeNull();
|
||||||
|
opts.BulkheadMaxConcurrent.ShouldBe(16);
|
||||||
|
opts.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
|
||||||
|
opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
@@ -95,7 +96,10 @@ public sealed class S7DriverPageFormSerializationTests
|
|||||||
|
|
||||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
.S7DriverPage.FormModel.FromOptions(opts);
|
.S7DriverPage.FormModel.FromOptions(opts);
|
||||||
var roundTripped = form.ToOptions();
|
var tagRows = opts.Tags
|
||||||
|
.Select(ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers.S7DriverPage.S7TagRow.FromDefinition)
|
||||||
|
.ToList();
|
||||||
|
var roundTripped = form.ToOptions(tagRows.Select(r => r.ToDefinition()).ToList());
|
||||||
|
|
||||||
roundTripped.Host.ShouldBe("192.168.1.50");
|
roundTripped.Host.ShouldBe("192.168.1.50");
|
||||||
roundTripped.Port.ShouldBe(102);
|
roundTripped.Port.ShouldBe(102);
|
||||||
@@ -117,4 +121,94 @@ public sealed class S7DriverPageFormSerializationTests
|
|||||||
roundTripped.Tags[1].Name.ShouldBe("Status");
|
roundTripped.Tags[1].Name.ShouldBe("Status");
|
||||||
roundTripped.Tags[1].Writable.ShouldBeFalse();
|
roundTripped.Tags[1].Writable.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void S7TagRow_RoundTrip_PreservesEditableFields()
|
||||||
|
{
|
||||||
|
var def = new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true, StringLength: 80);
|
||||||
|
|
||||||
|
var row = S7DriverPage.S7TagRow.FromDefinition(def);
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
|
||||||
|
back.Name.ShouldBe("Speed");
|
||||||
|
back.Address.ShouldBe("DB1.DBD0");
|
||||||
|
back.DataType.ShouldBe(S7DataType.Float32);
|
||||||
|
back.Writable.ShouldBeTrue();
|
||||||
|
back.StringLength.ShouldBe(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void S7TagRow_CarriesThroughUneditedFields()
|
||||||
|
{
|
||||||
|
// WriteIdempotent is not exposed by the editor; it must survive FromDefinition→edit→ToDefinition.
|
||||||
|
var def = new S7TagDefinition("Setpoint", "DB10.DBD0", S7DataType.Float32, Writable: true, WriteIdempotent: true);
|
||||||
|
|
||||||
|
var row = S7DriverPage.S7TagRow.FromDefinition(def);
|
||||||
|
row.Name = "SetpointRenamed";
|
||||||
|
row.Writable = false;
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
|
||||||
|
back.Name.ShouldBe("SetpointRenamed");
|
||||||
|
back.Writable.ShouldBeFalse();
|
||||||
|
// Un-edited field carried through via _source.
|
||||||
|
back.WriteIdempotent.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void S7TagRow_ValidateRow_RejectsDuplicateNames()
|
||||||
|
{
|
||||||
|
var all = new List<S7DriverPage.S7TagRow>
|
||||||
|
{
|
||||||
|
S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32)),
|
||||||
|
S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Status", "DB1.DBW4", S7DataType.Int16)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Editing index 1 to a name that case-insensitively collides with index 0.
|
||||||
|
var edited = all[1].Clone();
|
||||||
|
edited.Name = "speed";
|
||||||
|
S7DriverPage.S7TagRow.ValidateRow(edited, all, editIndex: 1)
|
||||||
|
.ShouldBe("Duplicate tag name 'speed'.");
|
||||||
|
|
||||||
|
// Required-name guard.
|
||||||
|
var blank = new S7DriverPage.S7TagRow();
|
||||||
|
S7DriverPage.S7TagRow.ValidateRow(blank, all, editIndex: null)
|
||||||
|
.ShouldBe("Name is required.");
|
||||||
|
|
||||||
|
// Unique name passes.
|
||||||
|
var ok = all[1].Clone();
|
||||||
|
ok.Name = "Torque";
|
||||||
|
S7DriverPage.S7TagRow.ValidateRow(ok, all, editIndex: 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagList_SerializeRoundTrip_PreservesTags()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "10.1.1.1",
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true),
|
||||||
|
new S7TagDefinition("Name", "DB2.DBB0", S7DataType.String, Writable: false, StringLength: 32),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var optsSkip = new JsonSerializerOptions(_opts)
|
||||||
|
{
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(opts, optsSkip);
|
||||||
|
var back = JsonSerializer.Deserialize<S7DriverOptions>(json, optsSkip);
|
||||||
|
|
||||||
|
back.ShouldNotBeNull();
|
||||||
|
back.Tags.Count.ShouldBe(2);
|
||||||
|
back.Tags[0].Name.ShouldBe("Speed");
|
||||||
|
back.Tags[0].Address.ShouldBe("DB1.DBD0");
|
||||||
|
back.Tags[0].DataType.ShouldBe(S7DataType.Float32);
|
||||||
|
back.Tags[0].Writable.ShouldBeTrue();
|
||||||
|
back.Tags[1].Name.ShouldBe("Name");
|
||||||
|
back.Tags[1].DataType.ShouldBe(S7DataType.String);
|
||||||
|
back.Tags[1].StringLength.ShouldBe(32);
|
||||||
|
back.Tags[1].Writable.ShouldBeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-1
@@ -84,7 +84,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
|||||||
|
|
||||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
.TwinCATDriverPage.FormModel.FromOptions(opts);
|
.TwinCATDriverPage.FormModel.FromOptions(opts);
|
||||||
var roundTripped = form.ToOptions();
|
var roundTripped = form.ToOptions([], []);
|
||||||
|
|
||||||
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||||
roundTripped.UseNativeNotifications.ShouldBeTrue();
|
roundTripped.UseNativeNotifications.ShouldBeTrue();
|
||||||
@@ -95,4 +95,127 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
|||||||
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2));
|
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||||
roundTripped.ProbeTimeoutSeconds.ShouldBe(15);
|
roundTripped.ProbeTimeoutSeconds.ShouldBe(15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_RoundTrip_PreservesEditableFields()
|
||||||
|
{
|
||||||
|
var def = new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1");
|
||||||
|
|
||||||
|
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
|
||||||
|
back.HostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||||
|
back.DeviceName.ShouldBe("PLC1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_CarriesThroughUneditedSourceFields()
|
||||||
|
{
|
||||||
|
// Edit only DeviceName; HostAddress on the source must survive the round-trip via _source.
|
||||||
|
var def = new TwinCATDeviceOptions("10.0.0.5.1.1:851", "Original");
|
||||||
|
|
||||||
|
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
|
||||||
|
row.DeviceName = "Renamed";
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
|
||||||
|
back.HostAddress.ShouldBe("10.0.0.5.1.1:851");
|
||||||
|
back.DeviceName.ShouldBe("Renamed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress()
|
||||||
|
{
|
||||||
|
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(new TwinCATDeviceOptions("192.168.0.1.1.1:851"));
|
||||||
|
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATDeviceRow { HostAddress = "192.168.0.1.1.1:851" };
|
||||||
|
|
||||||
|
var all = new[] { existing, dup };
|
||||||
|
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATDeviceRow.ValidateRow(dup, all, editIndex: 1);
|
||||||
|
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
error.ShouldContain("Duplicate");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_RoundTrip_PreservesEditableFields()
|
||||||
|
{
|
||||||
|
var def = new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real, Writable: false);
|
||||||
|
|
||||||
|
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
|
||||||
|
back.Name.ShouldBe("Speed");
|
||||||
|
back.DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||||
|
back.SymbolPath.ShouldBe("MAIN.rSpeed");
|
||||||
|
back.DataType.ShouldBe(TwinCATDataType.Real);
|
||||||
|
back.Writable.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_CarriesThroughUneditedWriteIdempotent()
|
||||||
|
{
|
||||||
|
// WriteIdempotent is not exposed by the editor; it must survive a load→edit→save via _source.
|
||||||
|
var def = new TwinCATTagDefinition("Cmd", "192.168.0.1.1.1:851", "GVL.Start", TwinCATDataType.Bool,
|
||||||
|
Writable: true, WriteIdempotent: true);
|
||||||
|
|
||||||
|
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
|
||||||
|
row.Name = "CmdRenamed"; // touch an edited field
|
||||||
|
var back = row.ToDefinition();
|
||||||
|
|
||||||
|
back.Name.ShouldBe("CmdRenamed");
|
||||||
|
back.WriteIdempotent.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TagRow_ValidateRow_RejectsDuplicateName()
|
||||||
|
{
|
||||||
|
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(
|
||||||
|
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real));
|
||||||
|
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATTagRow { Name = "SPEED" }; // case-insensitive collision
|
||||||
|
|
||||||
|
var all = new[] { existing, dup };
|
||||||
|
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.TwinCATTagRow.ValidateRow(dup, all, editIndex: 1);
|
||||||
|
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
error.ShouldContain("Duplicate");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormModel_ToOptions_SerializesDeviceAndTagLists()
|
||||||
|
{
|
||||||
|
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||||
|
.TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions());
|
||||||
|
|
||||||
|
var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") };
|
||||||
|
var tags = new[]
|
||||||
|
{
|
||||||
|
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real,
|
||||||
|
Writable: true, WriteIdempotent: true),
|
||||||
|
};
|
||||||
|
|
||||||
|
var opts = form.ToOptions(devices, tags);
|
||||||
|
var json = JsonSerializer.Serialize(opts, _opts);
|
||||||
|
var back = JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _opts);
|
||||||
|
|
||||||
|
back.ShouldNotBeNull();
|
||||||
|
back.Devices.Count.ShouldBe(1);
|
||||||
|
back.Devices[0].HostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||||
|
back.Devices[0].DeviceName.ShouldBe("PLC1");
|
||||||
|
back.Tags.Count.ShouldBe(1);
|
||||||
|
back.Tags[0].Name.ShouldBe("Speed");
|
||||||
|
back.Tags[0].DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||||
|
back.Tags[0].SymbolPath.ShouldBe("MAIN.rSpeed");
|
||||||
|
back.Tags[0].DataType.ShouldBe(TwinCATDataType.Real);
|
||||||
|
back.Tags[0].Writable.ShouldBeTrue();
|
||||||
|
back.Tags[0].WriteIdempotent.ShouldBeTrue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj"/>
|
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the Test Connect wiring contract: every driver type editable in the AdminUI must have
|
||||||
|
/// a registered <see cref="IDriverProbe"/>, resolvable from the same DI container that hosts the
|
||||||
|
/// <c>admin-operations</c> cluster singleton. The singleton is role-pinned to <c>admin</c>, so on
|
||||||
|
/// a split-role deployment (the MAIN cluster's admin-only nodes) the probes must be wired by the
|
||||||
|
/// admin path — not only the driver path — or every Test Connect button returns
|
||||||
|
/// "No probe registered for driver type X".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DriverProbeRegistrationTests
|
||||||
|
{
|
||||||
|
// The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey.
|
||||||
|
// Keep in sync with the DriverTypeKey constants in
|
||||||
|
// src/Server/.../Components/Pages/Clusters/Drivers/*DriverPage.razor.
|
||||||
|
private static readonly string[] AdminUiDriverTypeKeys =
|
||||||
|
[
|
||||||
|
"ModbusTcp",
|
||||||
|
"AbCip",
|
||||||
|
"AbLegacy",
|
||||||
|
"S7",
|
||||||
|
"TwinCat", // page key; probe reports "TwinCAT" — must resolve case-insensitively
|
||||||
|
"Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
|
||||||
|
"OpcUaClient",
|
||||||
|
"GalaxyMxGateway",
|
||||||
|
"Historian.Wonderware",
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddOtOpcUaDriverProbes_registers_a_probe_for_every_AdminUI_driver_type()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddOtOpcUaDriverProbes();
|
||||||
|
|
||||||
|
using var sp = services.BuildServiceProvider();
|
||||||
|
var probes = sp.GetServices<IDriverProbe>().ToList();
|
||||||
|
|
||||||
|
// No duplicate DriverType — AdminOperationsActor builds a dictionary keyed by DriverType
|
||||||
|
// (case-insensitive) and would throw on a duplicate key, crashing the singleton.
|
||||||
|
var byType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var key in AdminUiDriverTypeKeys)
|
||||||
|
byType.ContainsKey(key).ShouldBeTrue($"No IDriverProbe registered for AdminUI driver type '{key}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddOtOpcUaDriverProbes_is_idempotent()
|
||||||
|
{
|
||||||
|
// A fused admin,driver node calls the registration from both the driver-factory path and the
|
||||||
|
// admin path. TryAddEnumerable must de-dup so the probe set stays unique (else the actor's
|
||||||
|
// ToDictionary throws).
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddOtOpcUaDriverProbes();
|
||||||
|
services.AddOtOpcUaDriverProbes();
|
||||||
|
|
||||||
|
using var sp = services.BuildServiceProvider();
|
||||||
|
var probes = sp.GetServices<IDriverProbe>().ToList();
|
||||||
|
|
||||||
|
var distinctTypes = probes.Select(p => p.DriverType).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||||
|
probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup.");
|
||||||
|
distinctTypes.ShouldBe(AdminUiDriverTypeKeys.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,9 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||||
|
=> CallQueue.Enqueue($"EV:{variableNodeId}");
|
||||||
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
|
||||||
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
||||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
GalaxyTags: Array.Empty<GalaxyTagPlan>());
|
||||||
|
|
||||||
applier.MaterialiseHierarchy(composition);
|
applier.MaterialiseHierarchy(composition);
|
||||||
|
|
||||||
@@ -57,7 +58,8 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||||
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
|
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
|
||||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
GalaxyTags: Array.Empty<GalaxyTagPlan>());
|
||||||
|
|
||||||
applier.MaterialiseHierarchy(composition);
|
applier.MaterialiseHierarchy(composition);
|
||||||
|
|
||||||
@@ -91,7 +93,8 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
||||||
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
||||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
GalaxyTags: Array.Empty<GalaxyTagPlan>()));
|
||||||
|
|
||||||
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
|
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
|
||||||
|
|
||||||
@@ -101,7 +104,8 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
||||||
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
||||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
GalaxyTags: Array.Empty<GalaxyTagPlan>()));
|
||||||
|
|
||||||
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
|
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
|
||||||
}
|
}
|
||||||
@@ -149,6 +153,12 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
/// <param name="displayName">The display name of the folder.</param>
|
/// <param name="displayName">The display name of the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
|
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
|
/// <summary>Ensures a variable exists (stub implementation for testing).</summary>
|
||||||
|
/// <param name="variableNodeId">The node ID of the variable.</param>
|
||||||
|
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
||||||
|
/// <param name="displayName">The display name of the variable.</param>
|
||||||
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||||
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ public sealed class Phase7ApplierTests
|
|||||||
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
||||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
||||||
|
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
||||||
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
||||||
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
||||||
|
|
||||||
var outcome = applier.Apply(plan);
|
var outcome = applier.Apply(plan);
|
||||||
|
|
||||||
@@ -89,7 +92,10 @@ public sealed class Phase7ApplierTests
|
|||||||
},
|
},
|
||||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
||||||
|
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
||||||
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
||||||
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
||||||
|
|
||||||
var outcome = applier.Apply(plan);
|
var outcome = applier.Apply(plan);
|
||||||
|
|
||||||
@@ -111,10 +117,102 @@ public sealed class Phase7ApplierTests
|
|||||||
outcome.RebuildCalled.ShouldBeTrue();
|
outcome.RebuildCalled.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies MaterialiseGalaxyTags creates one folder per distinct FolderPath and one
|
||||||
|
/// variable per tag, with root-level tags hung directly under the namespace root.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseGalaxyTags_creates_folder_per_distinct_path_and_variable_per_tag()
|
||||||
|
{
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
var composition = new Phase7CompositionResult(
|
||||||
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||||
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||||
|
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
||||||
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
GalaxyTags: new[]
|
||||||
|
{
|
||||||
|
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
|
||||||
|
new GalaxyTagPlan("t2", "drv", "", "Pressure", "Int32", "Pressure"),
|
||||||
|
});
|
||||||
|
|
||||||
|
applier.MaterialiseGalaxyTags(composition);
|
||||||
|
|
||||||
|
// One folder for the single distinct non-empty FolderPath; the root-level tag adds none.
|
||||||
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("section.area", null, "section.area"));
|
||||||
|
|
||||||
|
// Foldered tag → NodeId is its MxAccessRef under the FolderPath parent.
|
||||||
|
// Root-level tag → NodeId is its DisplayName under the root (null parent).
|
||||||
|
sink.VariableCalls.ShouldContain(("section.area.Temperature", "section.area", "Temperature", "Float"));
|
||||||
|
sink.VariableCalls.ShouldContain(("Pressure", (string?)null, "Pressure", "Int32"));
|
||||||
|
sink.VariableCalls.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that two tags sharing a FolderPath produce a single EnsureFolder call
|
||||||
|
/// (deduped) but one EnsureVariable per tag.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseGalaxyTags_dedupes_folders_for_tags_sharing_a_path()
|
||||||
|
{
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
var composition = new Phase7CompositionResult(
|
||||||
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||||
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||||
|
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
||||||
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
GalaxyTags: new[]
|
||||||
|
{
|
||||||
|
new GalaxyTagPlan("t1", "drv", "line.cell", "Speed", "Float", "line.cell.Speed"),
|
||||||
|
new GalaxyTagPlan("t2", "drv", "line.cell", "Torque", "Float", "line.cell.Torque"),
|
||||||
|
});
|
||||||
|
|
||||||
|
applier.MaterialiseGalaxyTags(composition);
|
||||||
|
|
||||||
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("line.cell", null, "line.cell"));
|
||||||
|
sink.VariableCalls.Count.ShouldBe(2);
|
||||||
|
sink.VariableCalls.ShouldContain(("line.cell.Speed", "line.cell", "Speed", "Float"));
|
||||||
|
sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Added_galaxy_tags_trigger_rebuild()
|
||||||
|
{
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
var plan = new Phase7Plan(
|
||||||
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
|
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
||||||
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||||
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
||||||
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
||||||
|
AddedGalaxyTags: new[]
|
||||||
|
{
|
||||||
|
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
|
||||||
|
},
|
||||||
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
||||||
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
||||||
|
|
||||||
|
var outcome = applier.Apply(plan);
|
||||||
|
|
||||||
|
outcome.RebuildCalled.ShouldBeTrue();
|
||||||
|
outcome.AddedNodes.ShouldBe(1);
|
||||||
|
sink.RebuildCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
private static Phase7Plan EmptyPlan => new(
|
private static Phase7Plan EmptyPlan => new(
|
||||||
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
|
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
|
||||||
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
|
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
|
||||||
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>());
|
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>(),
|
||||||
|
Array.Empty<GalaxyTagPlan>(), Array.Empty<GalaxyTagPlan>(), Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
||||||
|
|
||||||
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
|
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
|
||||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
@@ -125,7 +223,10 @@ public sealed class Phase7ApplierTests
|
|||||||
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
||||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
|
||||||
|
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
||||||
|
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
|
||||||
|
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
|
||||||
|
|
||||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||||
{
|
{
|
||||||
@@ -133,6 +234,8 @@ public sealed class Phase7ApplierTests
|
|||||||
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
||||||
/// <summary>Gets the queue of folder creation calls.</summary>
|
/// <summary>Gets the queue of folder creation calls.</summary>
|
||||||
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
||||||
|
/// <summary>Gets the queue of variable creation calls.</summary>
|
||||||
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
|
||||||
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
||||||
public int RebuildCalls;
|
public int RebuildCalls;
|
||||||
|
|
||||||
@@ -140,6 +243,8 @@ public sealed class Phase7ApplierTests
|
|||||||
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
||||||
/// <summary>Gets the list of recorded folder creation calls.</summary>
|
/// <summary>Gets the list of recorded folder creation calls.</summary>
|
||||||
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
||||||
|
/// <summary>Gets the list of recorded variable creation calls.</summary>
|
||||||
|
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
|
||||||
|
|
||||||
/// <summary>Records a value write (no-op in this recording sink).</summary>
|
/// <summary>Records a value write (no-op in this recording sink).</summary>
|
||||||
/// <param name="nodeId">The node ID.</param>
|
/// <param name="nodeId">The node ID.</param>
|
||||||
@@ -160,6 +265,13 @@ public sealed class Phase7ApplierTests
|
|||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
|
/// <summary>Records a variable creation call.</summary>
|
||||||
|
/// <param name="variableNodeId">The variable node ID.</param>
|
||||||
|
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
||||||
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||||
|
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
|
||||||
/// <summary>Records a rebuild address space call.</summary>
|
/// <summary>Records a rebuild address space call.</summary>
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
@@ -192,6 +304,12 @@ public sealed class Phase7ApplierTests
|
|||||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
/// <summary>No-op variable creation call.</summary>
|
||||||
|
/// <param name="variableNodeId">The variable node ID.</param>
|
||||||
|
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
||||||
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||||
/// <summary>No-op rebuild address space call.</summary>
|
/// <summary>No-op rebuild address space call.</summary>
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -206,6 +206,12 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
|||||||
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
/// <summary>Ensures variable exists (stub implementation).</summary>
|
||||||
|
/// <param name="variableNodeId">The variable node identifier.</param>
|
||||||
|
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
|
||||||
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||||
/// <summary>Rebuilds address space (recorded via span).</summary>
|
/// <summary>Rebuilds address space (recorded via span).</summary>
|
||||||
public void RebuildAddressSpace() { /* recorded via span */ }
|
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
/// <param name="displayName">The display name of the folder.</param>
|
/// <param name="displayName">The display name of the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> Calls.Enqueue($"EF:{folderNodeId}");
|
=> Calls.Enqueue($"EF:{folderNodeId}");
|
||||||
|
/// <summary>Records a variable ensure call.</summary>
|
||||||
|
/// <param name="variableNodeId">The variable node ID.</param>
|
||||||
|
/// <param name="parentFolderNodeId">The parent folder node ID, or null if this is a root variable.</param>
|
||||||
|
/// <param name="displayName">The display name of the variable.</param>
|
||||||
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||||
|
=> Calls.Enqueue($"EV:{variableNodeId}");
|
||||||
/// <summary>Records a rebuild address space call.</summary>
|
/// <summary>Records a rebuild address space call.</summary>
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
/// <param name="displayName">The display name of the folder.</param>
|
/// <param name="displayName">The display name of the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
|
||||||
|
/// <summary>Ensures a variable exists (no-op in test).</summary>
|
||||||
|
/// <param name="variableNodeId">The OPC UA variable node identifier.</param>
|
||||||
|
/// <param name="parentFolderNodeId">The parent folder node identifier, or null for root.</param>
|
||||||
|
/// <param name="displayName">The display name of the variable.</param>
|
||||||
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||||
|
|
||||||
/// <summary>Records a rebuild call.</summary>
|
/// <summary>Records a rebuild call.</summary>
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -11,6 +12,9 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
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.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
private IHost _host = null!;
|
private IHost _host = null!;
|
||||||
private TestServer _server = null!;
|
private TestServer _server = null!;
|
||||||
|
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
|
||||||
|
|
||||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
@@ -57,13 +62,23 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
}).Build();
|
}).Build();
|
||||||
services.AddOtOpcUaAuth(configuration);
|
services.AddOtOpcUaAuth(configuration);
|
||||||
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
|
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
|
||||||
|
// The login handler now resolves the DB role-map service via DI to merge
|
||||||
|
// DB-backed grants on top of the appsettings baseline. Register the stub so
|
||||||
|
// the minimal-API handler can be constructed; tests drive its rows.
|
||||||
|
services.AddSingleton<ILdapGroupRoleMappingService>(_roleMappings);
|
||||||
});
|
});
|
||||||
web.Configure(app =>
|
web.Configure(app =>
|
||||||
{
|
{
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseEndpoints(e => e.MapOtOpcUaAuth());
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
@@ -81,6 +96,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
|
|
||||||
private HttpClient NewClient() => _server.CreateClient();
|
private HttpClient NewClient() => _server.CreateClient();
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
|
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
|
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
|
||||||
@@ -90,7 +112,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
|
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tests that login with invalid credentials returns 401.</summary>
|
/// <summary>Tests that login with invalid credentials returns 401.</summary>
|
||||||
@@ -160,6 +182,79 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
token!.Split('.').Length.ShouldBe(3);
|
token!.Split('.').Length.ShouldBe(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A system-wide DB row for a group the user holds grants an extra role on top of
|
||||||
|
/// the appsettings baseline; the merged role surfaces in the issued JWT's Role claims.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_merges_db_role_grant_into_claims()
|
||||||
|
{
|
||||||
|
// StubLdapAuthService returns Groups ["ReadOnly"], baseline Roles ["ConfigViewer"].
|
||||||
|
// A system-wide row maps "ReadOnly" → FleetAdmin, so the merged set is both.
|
||||||
|
_roleMappings.Rows.Add(new LdapGroupRoleMapping
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
LdapGroup = "ReadOnly",
|
||||||
|
Role = AdminRole.FleetAdmin,
|
||||||
|
IsSystemWide = true,
|
||||||
|
ClusterId = null,
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = NewClient();
|
||||||
|
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
||||||
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||||
|
loginResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||||
|
AttachCookies(tokenReq, loginResponse);
|
||||||
|
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
||||||
|
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||||
|
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
|
||||||
|
roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved
|
||||||
|
roles.ShouldContain("FleetAdmin"); // DB grant merged in
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>When the DB role-map lookup throws, sign-in still succeeds with the appsettings
|
||||||
|
/// baseline roles — a DB hiccup must never block login.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_when_db_role_map_throws_falls_back_to_baseline_roles()
|
||||||
|
{
|
||||||
|
_roleMappings.Throws = true;
|
||||||
|
|
||||||
|
var client = NewClient();
|
||||||
|
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
||||||
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||||
|
|
||||||
|
// Login proceeds despite the simulated DB outage.
|
||||||
|
loginResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||||
|
|
||||||
|
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||||
|
AttachCookies(tokenReq, loginResponse);
|
||||||
|
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
||||||
|
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||||
|
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
|
||||||
|
roles.ShouldContain("ConfigViewer"); // baseline still present
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extracts the "Role" claim values from a JWT's payload segment.</summary>
|
||||||
|
private static IReadOnlyList<string> JwtRoleClaims(string jwt)
|
||||||
|
{
|
||||||
|
var payloadSegment = jwt.Split('.')[1];
|
||||||
|
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
|
||||||
|
switch (padded.Length % 4)
|
||||||
|
{
|
||||||
|
case 2: padded += "=="; break;
|
||||||
|
case 3: padded += "="; break;
|
||||||
|
}
|
||||||
|
var json = JsonDocument.Parse(Convert.FromBase64String(padded));
|
||||||
|
if (!json.RootElement.TryGetProperty("Role", out var roleProp)) return [];
|
||||||
|
return roleProp.ValueKind == JsonValueKind.Array
|
||||||
|
? [.. roleProp.EnumerateArray().Select(e => e.GetString()!)]
|
||||||
|
: [roleProp.GetString()!];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Tests that logout clears the cookie.</summary>
|
/// <summary>Tests that logout clears the cookie.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Logout_clears_the_cookie()
|
public async Task Logout_clears_the_cookie()
|
||||||
@@ -170,12 +265,43 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
loginResponse.EnsureSuccessStatusCode();
|
loginResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var logoutReq = new HttpRequestMessage(HttpMethod.Post, "/auth/logout");
|
var logoutReq = new HttpRequestMessage(HttpMethod.Post, "/auth/logout");
|
||||||
|
logoutReq.Headers.Accept.ParseAdd("application/json");
|
||||||
AttachCookies(logoutReq, loginResponse);
|
AttachCookies(logoutReq, loginResponse);
|
||||||
var response = await client.SendAsync(logoutReq, Ct);
|
var response = await client.SendAsync(logoutReq, Ct);
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||||
|
|
||||||
response.Headers.GetValues("Set-Cookie")
|
response.Headers.GetValues("Set-Cookie")
|
||||||
.ShouldContain(c => c.StartsWith("OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
|
.ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 XHR GET of a protected route returns 401 (caller signaled non-browser
|
||||||
|
/// via the <c>X-Requested-With</c> header — the ASP.NET cookie handler's IsAjaxRequest
|
||||||
|
/// heuristic). The framework still writes a <c>Location</c> header alongside the 401;
|
||||||
|
/// AJAX clients ignore it.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_xhr_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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior)
|
private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior)
|
||||||
@@ -215,4 +341,38 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
Error: "Invalid username or password"));
|
Error: "Invalid username or password"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory stub for the DB-backed group→role mapping service. Tests seed <see cref="Rows"/>
|
||||||
|
/// and the login handler merges any system-wide row whose group the user holds. Set
|
||||||
|
/// <see cref="Throws"/> to simulate a DB outage and exercise the baseline-roles fallback.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService
|
||||||
|
{
|
||||||
|
public List<LdapGroupRoleMapping> Rows { get; } = [];
|
||||||
|
public bool Throws { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Returns seeded rows whose group matches one of <paramref name="ldapGroups"/>.</summary>
|
||||||
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||||
|
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Throws) throw new InvalidOperationException("simulated DB outage");
|
||||||
|
var groups = new HashSet<string>(ldapGroups, StringComparer.OrdinalIgnoreCase);
|
||||||
|
IReadOnlyList<LdapGroupRoleMapping> matched =
|
||||||
|
[.. Rows.Where(r => groups.Contains(r.LdapGroup))];
|
||||||
|
return Task.FromResult(matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Not exercised by these tests.</summary>
|
||||||
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <summary>Not exercised by these tests.</summary>
|
||||||
|
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <summary>Not exercised by these tests.</summary>
|
||||||
|
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||||
@@ -59,4 +61,22 @@ public sealed class RoleMapperTests
|
|||||||
|
|
||||||
roles.ShouldBe(new[] { "FleetAdmin" });
|
roles.ShouldBe(new[] { "FleetAdmin" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_unions_baseline_and_systemwide_db_roles()
|
||||||
|
{
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true },
|
||||||
|
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||||
|
};
|
||||||
|
var result = RoleMapper.Merge(["ConfigViewer"], rows);
|
||||||
|
result.ShouldContain("ConfigViewer");
|
||||||
|
result.ShouldContain("FleetAdmin");
|
||||||
|
result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_with_no_db_rows_returns_baseline()
|
||||||
|
=> RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user