Compare commits
54 Commits
6d262f7d7c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c2d16d4af | |||
| a08ddab9dd | |||
| 744eb090ac | |||
| 94512acf1f | |||
| 2c6c764d3c | |||
| a30f8551e9 | |||
| afd0287f54 | |||
| 1041f87b59 | |||
| 5572edda85 | |||
| aff7264df8 | |||
| 510b0010d6 | |||
| 42ad31aded | |||
| e3c0503a4f | |||
| a0527f9b5a | |||
| 5f7d7e1b58 | |||
| 78418346df | |||
| 4920b89666 | |||
| 989db9317d | |||
| 81bf7322f0 | |||
| 8033a7f12d | |||
| 63cddfb65b | |||
| 965f5006f2 | |||
| 294da8b2db | |||
| bbb7942788 | |||
| d5b134b117 | |||
| eb8b44c29d | |||
| a6fa36043a | |||
| 05a4a547f4 | |||
| 4d57e34ff3 | |||
| b3d8990a0f | |||
| 5655b75fe6 | |||
| dce6f83488 | |||
| fd34e25cb1 | |||
| eb26bf3248 | |||
| e5a609be83 | |||
| f1efe6e081 | |||
| 0e41e7c2e4 | |||
| 5f97c9d1ed | |||
| 9d373efbe0 | |||
| 4c0f1eaaf7 | |||
| 0f2b2b8351 | |||
| 5be0cec601 | |||
| 106fb8b149 | |||
| b0fe7b15ca | |||
| 3070169e5d | |||
| ea4116cc5b | |||
| ca21615090 | |||
| a474eb6bd6 | |||
| 9e4dedc987 | |||
| 6aa2ee8095 | |||
| e2749b7d69 | |||
| edd49765d6 | |||
| 7e11f9aac8 | |||
| e6e9dbfedb |
@@ -6,12 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
`scadaproj` is primarily an umbrella/index workspace that aggregates a family of
|
`scadaproj` is primarily an umbrella/index workspace that aggregates a family of
|
||||||
related SCADA / OT / Wonderware / OPC UA "sister projects" that live as **sibling
|
related SCADA / OT / Wonderware / OPC UA "sister projects" that live as **sibling
|
||||||
directories under `~/Desktop/`**. It now also **hosts five pieces of source itself** —
|
directories under `~/Desktop/`**. It now also **hosts six pieces of source itself** —
|
||||||
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library, the shared
|
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library, the shared
|
||||||
[`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) UI kit, the shared
|
[`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) UI kit, the shared
|
||||||
[`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) health-check library, the shared
|
[`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) health-check library, the shared
|
||||||
[`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) observability library, and the shared
|
[`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) observability library, the shared
|
||||||
[`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) config-validation library — all the realized output of their
|
[`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) config-validation library, and the new
|
||||||
|
[`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) Galaxy browse library — all the realized output of their
|
||||||
respective component normalizations (see [Component normalization](#component-normalization)).
|
respective component normalizations (see [Component normalization](#component-normalization)).
|
||||||
The point of this file is to give a high-level scan of each sister project — its purpose,
|
The point of this file is to give a high-level scan of each sister project — its purpose,
|
||||||
location, stack, and primary commands — so a fresh Claude Code session can orient across
|
location, stack, and primary commands — so a fresh Claude Code session can orient across
|
||||||
@@ -30,9 +31,10 @@ own `CLAUDE.md` for the full picture. See [Refreshing this index](#refreshing-th
|
|||||||
|
|
||||||
| Project | Location | Stack | Repo | Summary |
|
| Project | Location | Stack | Repo | Summary |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes AVEVA System Platform (Wonderware) Galaxy tags as an OPC UA address space. Galaxy access flows through an in-process `GalaxyDriver` → gRPC → the **mxaccessgw** gateway. |
|
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **unified Equipment-based address space** — native-protocol drivers (Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient) **and AVEVA System Platform (Wonderware) Galaxy, now a standard Equipment-kind driver** (the old SystemPlatform mirror / alias-tag model was retired ~2026-06-12). Galaxy access flows through the in-process `GalaxyDriver` → gRPC → the **mxaccessgw** gateway. Surfaces live read + authorized write, native OPC UA Part 9 alarms, and server-side HistoryRead. |
|
||||||
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** |
|
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** |
|
||||||
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | _git_ | Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. |
|
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | _git_ | Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. |
|
||||||
|
| **HistorianGateway** | `~/Desktop/HistorianGateway` | .NET 10 x64, gRPC, Blazor | `gitea.dohertylan.com/dohertj2/historiangw` | Single-process gRPC sidecar exposing (1) full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared `ZB.MOM.WW.GalaxyRepository` lib (consumed as a Gitea-feed package). No COM, no x86 worker. Dashboard on `:5220` (HTTP/1.1); gRPC h2c on `:5221`. Vendors `AVEVA.Historian.Client` from `histsdk`. 590 tests total — 584 green on macOS; the env-gated live historian + Galaxy integration suite (6 tests) skips without a live server. |
|
||||||
|
|
||||||
## Cross-project relationships
|
## Cross-project relationships
|
||||||
|
|
||||||
@@ -84,8 +86,10 @@ the gateway uses `MxGateway.*`). The common subject is **AVEVA System Platform (
|
|||||||
`GalaxyRepositoryClient` for the static hierarchy, and an MXAccess session
|
`GalaxyRepositoryClient` for the static hierarchy, and an MXAccess session
|
||||||
(`MxCommand`/`MxEvent` protos) for live read/write/subscribe. A `DeployWatcher` polls the
|
(`MxCommand`/`MxEvent` protos) for live read/write/subscribe. A `DeployWatcher` polls the
|
||||||
gateway's deploy-event signal to rebuild the OPC UA address space on Galaxy redeploy.
|
gateway's deploy-event signal to rebuild the OPC UA address space on Galaxy redeploy.
|
||||||
OtOpcUa's job is purely a **protocol bridge**: it republishes Galaxy as an OPC UA address
|
OtOpcUa's job is a **protocol bridge**: it republishes Galaxy — now bound as a *standard
|
||||||
space for *any* OPC UA client.
|
Equipment-kind driver* alongside its native-protocol drivers, not a special SystemPlatform
|
||||||
|
mirror — as an OPC UA address space (live values, Part 9 alarms, HistoryRead) for *any* OPC
|
||||||
|
UA client.
|
||||||
- **ScadaBridge → OPC UA** (OPC UA client). ScadaBridge's DCL has an OPC UA adapter that
|
- **ScadaBridge → OPC UA** (OPC UA client). ScadaBridge's DCL has an OPC UA adapter that
|
||||||
collects data and mirrors native OPC UA Alarms & Conditions. OtOpcUa is exactly such a
|
collects data and mirrors native OPC UA Alarms & Conditions. OtOpcUa is exactly such a
|
||||||
server, so ScadaBridge can ingest Wonderware data **indirectly via OtOpcUa**.
|
server, so ScadaBridge can ingest Wonderware data **indirectly via OtOpcUa**.
|
||||||
@@ -101,15 +105,21 @@ the gateway uses `MxGateway.*`). The common subject is **AVEVA System Platform (
|
|||||||
- ScadaBridge has **two paths** to the same Wonderware data: (1) OPC UA → OtOpcUa →
|
- ScadaBridge has **two paths** to the same Wonderware data: (1) OPC UA → OtOpcUa →
|
||||||
gateway, or (2) MxGateway adapter → gateway directly. Path 1 gives standards-based OPC UA
|
gateway, or (2) MxGateway adapter → gateway directly. Path 1 gives standards-based OPC UA
|
||||||
decoupling; path 2 gives a more direct/native feed.
|
decoupling; path 2 gives a more direct/native feed.
|
||||||
|
- **HistorianGateway is a new, independent sidecar** (no runtime coupling to the three above).
|
||||||
|
It reaches the Historian via its vendored gRPC client and the Galaxy Repository SQL DB directly,
|
||||||
|
not through `mxaccessgw`. It consumes the shared `ZB.MOM.WW.GalaxyRepository` lib
|
||||||
|
(cross-repo `ProjectReference`). Any client that needs Historian data or Galaxy browse can
|
||||||
|
target HistorianGateway independently; it is not a dependency of OtOpcUa or ScadaBridge today.
|
||||||
- Coupling is loose: each repo references the others only as **sibling context** (the
|
- Coupling is loose: each repo references the others only as **sibling context** (the
|
||||||
`## Sister Projects` note in ScadaBridge's own `CLAUDE.md` lists `MxAccessGateway` and
|
`## Sister Projects` note in ScadaBridge's own `CLAUDE.md` lists `MxAccessGateway` and
|
||||||
`OtOpcUa` with their Gitea URLs but states they are *not part of its solution*).
|
`OtOpcUa` with their Gitea URLs but states they are *not part of its solution*).
|
||||||
- **The break surface is the wire contracts, not code.** Because coupling is by network
|
- **The break surface is the wire contracts, not code.** Because coupling is by network
|
||||||
protocol, the things that break across repo boundaries are: the gateway's `.proto` files
|
protocol, the things that break across repo boundaries are: the gateway's `.proto` files
|
||||||
(`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`), and the
|
(`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`), the
|
||||||
OPC UA address-space shape OtOpcUa publishes (browse paths, node IDs, A&C alarm model).
|
`historian_gateway.v1` proto (HistorianGateway's own contract), and the OPC UA address-space
|
||||||
Changes to any of these must be coordinated across the affected repos — a green build in
|
shape OtOpcUa publishes (browse paths, node IDs, A&C alarm model). Changes to any of these
|
||||||
one repo does not prove the others still interoperate.
|
must be coordinated across the affected repos — a green build in one repo does not prove the
|
||||||
|
others still interoperate.
|
||||||
|
|
||||||
## Component normalization
|
## Component normalization
|
||||||
|
|
||||||
@@ -121,11 +131,12 @@ each project's **code-verified current state**, and the **gaps** between. See
|
|||||||
| Component | Status | Goal | Design | Implementation |
|
| Component | Status | Goal | Design | Implementation |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||||
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
| UI Theme (layout / tokens / components) | Adopted (lib `0.2.0`; all 3 apps, merged to **local default** + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
||||||
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
|
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
|
||||||
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
|
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
|
||||||
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
|
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
|
||||||
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||||
|
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | Built (lib `0.1.0`, **published to the Gitea feed**; consumed by HistorianGateway as a feed `PackageReference`) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) |
|
||||||
|
|
||||||
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
||||||
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
|
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
|
||||||
@@ -156,10 +167,18 @@ backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + wi
|
|||||||
per-project = each app's `site.css` page layout, route content, scoped `.razor.css`.
|
per-project = each app's `site.css` page layout, route content, scoped `.razor.css`.
|
||||||
|
|
||||||
The shared RCL is **built and lives in this repo** at [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/)
|
The shared RCL is **built and lives in this repo** at [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/)
|
||||||
(.NET 10 Razor Class Library; single package; 32 bUnit tests; `dotnet pack` → 1 nupkg @ 0.1.0).
|
(.NET 10 Razor Class Library; single package; 44 bUnit tests; `dotnet pack` → 1 nupkg @ 0.2.0,
|
||||||
The implementation plan is at
|
**published to the Gitea feed**). The build plan is at
|
||||||
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md).
|
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md);
|
||||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
|
the adoption plan at [`docs/plans/2026-06-03-ui-theme-adoption.md`](docs/plans/2026-06-03-ui-theme-adoption.md).
|
||||||
|
**Adopted across all three apps on 2026-06-03** (full canonical cutover, SPEC §7) on each repo's
|
||||||
|
`feat/adopt-zb-theme` branch — committed + spec/code-reviewed, then **fast-forward-merged into each repo's local
|
||||||
|
default (master/main) and PUSHED to origin (gitea)** (in sync; `feat/*` kept locally as history): OtOpcUa
|
||||||
|
→`lmxopcua` `master`@`11de14d`, ScadaBridge `main`@`58352a6`, MxGateway→`mxaccessgw` `main`@`73e54e2`. The `0.1.0 → 0.2.0` bump first promoted nav-expand persistence
|
||||||
|
into the kit (`NavRailSection.Key`/`data-nav-key` + a localStorage `nav-state.js` enhancer emitted by a new
|
||||||
|
`<ThemeScripts/>`), so all three apps share one persistence mechanism (OtOpcUa's bespoke cookie/JS-interop nav
|
||||||
|
island retired); MxGateway additionally gained a net-new Blazor `<LoginCard>` `/login` page over its existing
|
||||||
|
hardened endpoint. Per-app result in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
|
||||||
Build/test from `ZB.MOM.WW.Theme/`: `dotnet test`. Consumer matrix: all three apps consume
|
Build/test from `ZB.MOM.WW.Theme/`: `dotnet test`. Consumer matrix: all three apps consume
|
||||||
the single `ZB.MOM.WW.Theme` package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
|
the single `ZB.MOM.WW.Theme` package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
|
||||||
|
|
||||||
@@ -253,6 +272,25 @@ migration, MSSQL-verified). Phase 3 wires `Actor` from the Auth principal at aut
|
|||||||
Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
|
Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
|
||||||
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
|
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
|
||||||
|
|
||||||
|
The Galaxy Repository component normalizes the **Galaxy object-hierarchy SQL browse + reusable gRPC service**
|
||||||
|
that was previously embedded in `mxaccessgw`. Shared = canonical `galaxy_repository.v1` proto (wire-compatible
|
||||||
|
with `mxaccessgw`'s existing contract so OtOpcUa's `GalaxyRepositoryClient` is unaffected), the SQL browse
|
||||||
|
provider (`HierarchySql` / `AttributesSql` validated reverse-engineered queries), in-memory hierarchy cache +
|
||||||
|
snapshot + deploy-poll refresh `BackgroundService`, `GalaxyHierarchyProjector`, and `AddZbGalaxyRepository` /
|
||||||
|
`MapZbGalaxyRepository` DI extension. Left per-consumer = section path, subtree auth filtering, and any
|
||||||
|
app-specific paging defaults.
|
||||||
|
|
||||||
|
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/)
|
||||||
|
(.NET 10; single package `ZB.MOM.WW.GalaxyRepository`; `dotnet pack` → 1 nupkg @ 0.1.0, **published to
|
||||||
|
the Gitea NuGet feed** `gitea.dohertylan.com/api/packages/dohertj2/nuget`). The design doc is at
|
||||||
|
[`docs/plans/2026-06-23-historian-gateway-design.md`](docs/plans/2026-06-23-historian-gateway-design.md) (§10, component 1).
|
||||||
|
**Consumed by HistorianGateway** as a `PackageReference` from that Gitea feed, pinned at `0.1.0` (originally a
|
||||||
|
cross-repo `ProjectReference` to this scadaproj tree; switched to the feed package 2026-06-24).
|
||||||
|
**mxaccessgw adoption is a tracked follow-on** — once adopted, mxaccessgw's inline Galaxy browse code is replaced
|
||||||
|
by the shared lib (the `galaxy_repository.v1` wire contract is already identical, so OtOpcUa and ScadaBridge
|
||||||
|
clients are unaffected). Build/test from `ZB.MOM.WW.GalaxyRepository/`: `dotnet test`.
|
||||||
|
Consumer matrix: HistorianGateway (initial); mxaccessgw (follow-on adoption).
|
||||||
|
|
||||||
## Per-project primary commands
|
## Per-project primary commands
|
||||||
|
|
||||||
Run these from inside each project directory (not from `scadaproj`).
|
Run these from inside each project directory (not from `scadaproj`).
|
||||||
@@ -273,9 +311,22 @@ dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
|
|||||||
# ScadaBridge (~/Desktop/ScadaBridge)
|
# ScadaBridge (~/Desktop/ScadaBridge)
|
||||||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||||||
bash docker/deploy.sh # rebuild + redeploy the 8-node cluster
|
bash docker/deploy.sh # rebuild + redeploy the 8-node cluster
|
||||||
cd infra && docker compose up -d # local test services (LDAP, SQL, OPC UA, SMTP, REST, Traefik)
|
cd infra && docker compose up -d # local test services (SQL, OPC UA, SMTP, REST, Traefik) — LDAP is NOT here
|
||||||
|
|
||||||
|
# HistorianGateway (~/Desktop/HistorianGateway)
|
||||||
|
dotnet build ZB.MOM.WW.HistorianGateway.slnx
|
||||||
|
dotnet test ZB.MOM.WW.HistorianGateway.slnx # unit + golden; live integration tests skip without env vars
|
||||||
|
dotnet run --project src/ZB.MOM.WW.HistorianGateway.Server/ZB.MOM.WW.HistorianGateway.Server.csproj
|
||||||
|
# dashboard on :5220, gRPC h2c on :5221
|
||||||
|
# Live integration (need HISTORIAN_GRPC_HOST + HISTORIAN_GRPC_WRITE_SANDBOX_TAG + GALAXY_SQL_CONNSTR set)
|
||||||
|
dotnet test ZB.MOM.WW.HistorianGateway.slnx --filter "Category=LiveIntegration"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Shared GLAuth (all three apps + HistorianGateway):** LDAP auth for every local dev/test stack is provided by a
|
||||||
|
> single `zb-shared-glauth` container on the Linux fixture host **`10.100.0.35:3893`**
|
||||||
|
> (`baseDN dc=zb,dc=local`, Transport=None). Source of truth and deploy runbook:
|
||||||
|
> [`scadaproj/infra/glauth/`](infra/glauth/) (`config.toml` + `docker-compose.yml` + `README.md`).
|
||||||
|
|
||||||
## Refreshing this index
|
## Refreshing this index
|
||||||
|
|
||||||
This file is meant to be re-scanned when `scadaproj` is opened in Claude Code:
|
This file is meant to be re-scanned when `scadaproj` is opened in Claude Code:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Library -->
|
||||||
|
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||||
|
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||||
|
<!-- Google.Protobuf and Grpc.Tools must be >= the minimums Grpc.AspNetCore 2.76.0 requires -->
|
||||||
|
<PackageVersion Include="Google.Protobuf" Version="3.31.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/ZB.MOM.WW.GalaxyRepository/ZB.MOM.WW.GalaxyRepository.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/ZB.MOM.WW.GalaxyRepository.Tests/ZB.MOM.WW.GalaxyRepository.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dependency-injection and endpoint-routing extensions that register the reusable
|
||||||
|
/// Galaxy Repository services and map the canonical gRPC service. A consuming gateway
|
||||||
|
/// calls <see cref="AddZbGalaxyRepository"/> during service registration and
|
||||||
|
/// <see cref="MapZbGalaxyRepository"/> while building its endpoint pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public static class GalaxyRepositoryServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the Galaxy Repository SQL provider, shared hierarchy cache, deploy
|
||||||
|
/// notifier, on-disk snapshot store, and the background refresh service, binding
|
||||||
|
/// <see cref="GalaxyRepositoryOptions"/> from the supplied configuration section.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to add registrations to.</param>
|
||||||
|
/// <param name="configuration">The application configuration root.</param>
|
||||||
|
/// <param name="sectionPath">
|
||||||
|
/// The configuration section path to bind <see cref="GalaxyRepositoryOptions"/> from
|
||||||
|
/// (for example <c>MxGateway:Galaxy</c> or <c>HistorianGateway:Galaxy</c>).
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The service collection for chaining.</returns>
|
||||||
|
public static IServiceCollection AddZbGalaxyRepository(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration,
|
||||||
|
string sectionPath)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||||
|
|
||||||
|
// Bind only — this shared lib ships no validator, so a .ValidateOnStart() here
|
||||||
|
// would be a silent no-op. The consuming application owns option validation
|
||||||
|
// (e.g. the sidecar's ConfigPreflight / validated-options layer).
|
||||||
|
services
|
||||||
|
.AddOptions<GalaxyRepositoryOptions>()
|
||||||
|
.Bind(configuration.GetSection(sectionPath));
|
||||||
|
|
||||||
|
services.AddSingleton(sp =>
|
||||||
|
new GalaxyRepository(sp.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value));
|
||||||
|
services.AddSingleton<IGalaxyRepository>(sp => sp.GetRequiredService<GalaxyRepository>());
|
||||||
|
|
||||||
|
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
|
||||||
|
services.AddSingleton<IGalaxyHierarchySnapshotStore, GalaxyHierarchySnapshotStore>();
|
||||||
|
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
|
||||||
|
services.AddHostedService<GalaxyHierarchyRefreshService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the canonical <see cref="GalaxyRepositoryGrpcService"/> onto the consuming
|
||||||
|
/// application's endpoint pipeline. Call after <see cref="AddZbGalaxyRepository"/> and
|
||||||
|
/// after gRPC has been added to the application's services.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="endpoints">The endpoint route builder to map the gRPC service onto.</param>
|
||||||
|
/// <returns>The endpoint route builder for chaining.</returns>
|
||||||
|
public static IEndpointRouteBuilder MapZbGalaxyRepository(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(endpoints);
|
||||||
|
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
|
||||||
|
public sealed class GalaxyAttributeRow
|
||||||
|
{
|
||||||
|
/// <summary>Gets the Galaxy object identifier.</summary>
|
||||||
|
public int GobjectId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the tag name.</summary>
|
||||||
|
public string TagName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets the attribute name.</summary>
|
||||||
|
public string AttributeName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets the full tag reference.</summary>
|
||||||
|
public string FullTagReference { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets the MXAccess data type code.</summary>
|
||||||
|
public int MxDataType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the data type name.</summary>
|
||||||
|
public string? DataTypeName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether this is an array.</summary>
|
||||||
|
public bool IsArray { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the array dimension, if applicable.</summary>
|
||||||
|
public int? ArrayDimension { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the MXAccess attribute category code.</summary>
|
||||||
|
public int MxAttributeCategory { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the security classification code.</summary>
|
||||||
|
public int SecurityClassification { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether this is historized.</summary>
|
||||||
|
public bool IsHistorized { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether this is an alarm.</summary>
|
||||||
|
public bool IsAlarm { get; init; }
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of one <see cref="GalaxyBrowseProjector.ProjectChildren"/> call. Holds a
|
||||||
|
/// materialized page of direct children for the requested parent, along with a
|
||||||
|
/// parallel-indexed <see cref="ChildHasChildren"/> hint and the total post-filter
|
||||||
|
/// sibling count for paging.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Children">The page of direct children, sorted areas-first then by display name.</param>
|
||||||
|
/// <param name="ChildHasChildren">Parallel array indicating whether each child has at least one matching descendant under the same filter set.</param>
|
||||||
|
/// <param name="TotalChildCount">Total matching direct children of the parent (post-filter).</param>
|
||||||
|
/// <param name="FilterSignature">Stable signature of the filter and parent selector, used to bind page tokens.</param>
|
||||||
|
public sealed record GalaxyBrowseChildrenResult(
|
||||||
|
IReadOnlyList<GalaxyObject> Children,
|
||||||
|
IReadOnlyList<bool> ChildHasChildren,
|
||||||
|
int TotalChildCount,
|
||||||
|
string FilterSignature);
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Grpc.Core;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects one level of children of a parent object out of an immutable
|
||||||
|
/// <see cref="GalaxyHierarchyCacheEntry"/>. Pure and side-effect free. Memoizes the
|
||||||
|
/// filtered child list per cache-entry instance so repeated paging is an O(pageSize)
|
||||||
|
/// slice rather than an O(siblings) filter scan per page. The memo is keyed on the
|
||||||
|
/// immutable cache entry, so when the cache publishes a new entry the stale memo
|
||||||
|
/// becomes unreachable and is reclaimed with it.
|
||||||
|
/// </summary>
|
||||||
|
public static class GalaxyBrowseProjector
|
||||||
|
{
|
||||||
|
private static readonly ConditionalWeakTable<
|
||||||
|
GalaxyHierarchyCacheEntry,
|
||||||
|
ConcurrentDictionary<string, FilteredChildren>> FilteredChildrenCache = new();
|
||||||
|
|
||||||
|
/// <summary>Projects one page of direct children of the resolved parent.</summary>
|
||||||
|
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||||
|
/// <param name="request">The browse-children request.</param>
|
||||||
|
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||||
|
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
||||||
|
/// <param name="pageSize">Maximum number of children to return.</param>
|
||||||
|
public static GalaxyBrowseChildrenResult ProjectChildren(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
int offset,
|
||||||
|
int pageSize)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (offset < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
|
||||||
|
}
|
||||||
|
if (pageSize <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int parentId = ResolveParentId(entry, request);
|
||||||
|
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId);
|
||||||
|
FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature);
|
||||||
|
|
||||||
|
bool includeAttributes = IncludeAttributes(request);
|
||||||
|
int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count);
|
||||||
|
List<GalaxyObject> page = new(Math.Max(0, end - offset));
|
||||||
|
List<bool> hasChildren = new(Math.Max(0, end - offset));
|
||||||
|
for (int index = offset; index < end; index++)
|
||||||
|
{
|
||||||
|
page.Add(CloneObject(filtered.Children[index].Object, includeAttributes));
|
||||||
|
hasChildren.Add(filtered.HasMatchingDescendant[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the request's parent oneof to a gobject id, throwing
|
||||||
|
/// <see cref="RpcException"/> with <see cref="StatusCode.NotFound"/> when the
|
||||||
|
/// parent does not exist. Public so the gRPC handler can compute the same
|
||||||
|
/// parent id (needed for the page-token signature) without reimplementing the
|
||||||
|
/// resolution rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||||
|
/// <param name="request">The browse-children request.</param>
|
||||||
|
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||||
|
{
|
||||||
|
switch (request.ParentCase)
|
||||||
|
{
|
||||||
|
case BrowseChildrenRequest.ParentOneofCase.None:
|
||||||
|
return 0;
|
||||||
|
case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId:
|
||||||
|
if (request.ParentGobjectId == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||||
|
}
|
||||||
|
return request.ParentGobjectId;
|
||||||
|
case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
|
||||||
|
{
|
||||||
|
if (!entry.Index.ObjectViewsByTagName.TryGetValue(request.ParentTagName, out GalaxyObjectView? match))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||||
|
}
|
||||||
|
return match.Object.GobjectId;
|
||||||
|
}
|
||||||
|
case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
|
||||||
|
{
|
||||||
|
if (!entry.Index.ObjectViewsByContainedPath.TryGetValue(request.ParentContainedPath, out GalaxyObjectView? match))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||||
|
}
|
||||||
|
return match.Object.GobjectId;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FilteredChildren GetFilteredChildren(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
int parentId,
|
||||||
|
string filterSignature)
|
||||||
|
{
|
||||||
|
ConcurrentDictionary<string, FilteredChildren> memo =
|
||||||
|
FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary<string, FilteredChildren>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
return memo.GetOrAdd(
|
||||||
|
filterSignature,
|
||||||
|
static (_, state) =>
|
||||||
|
{
|
||||||
|
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> map = state.Entry.Index.ChildrenByParent;
|
||||||
|
IReadOnlyList<GalaxyObjectView> directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList<GalaxyObjectView>? list)
|
||||||
|
? list
|
||||||
|
: Array.Empty<GalaxyObjectView>();
|
||||||
|
|
||||||
|
List<GalaxyObjectView> matched = [];
|
||||||
|
List<bool> hasMatching = [];
|
||||||
|
foreach (GalaxyObjectView view in directChildren)
|
||||||
|
{
|
||||||
|
if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!MatchesFilters(view.Object, state.Request))
|
||||||
|
{
|
||||||
|
// Even if the direct child itself fails the filter, a matching
|
||||||
|
// descendant should still surface its ancestor — but only when
|
||||||
|
// there is one. Mirror the dashboard browse-tree semantics: if a
|
||||||
|
// descendant matches, include the parent with has-children true.
|
||||||
|
if (HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs))
|
||||||
|
{
|
||||||
|
matched.Add(view);
|
||||||
|
hasMatching.Add(true);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matched.Add(view);
|
||||||
|
hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FilteredChildren(matched, hasMatching);
|
||||||
|
},
|
||||||
|
(Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasMatchingDescendant(
|
||||||
|
GalaxyObjectView parent,
|
||||||
|
GalaxyHierarchyIndex index,
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
|
{
|
||||||
|
if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? children))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defend against pathological cycles in Galaxy data (e.g. a corrupt A→B→A chain).
|
||||||
|
// BuildContainedPath uses the same visited-id pattern; mirror it so this walk
|
||||||
|
// terminates even when ChildrenByParent forms a cycle.
|
||||||
|
HashSet<int> visited = new() { parent.Object.GobjectId };
|
||||||
|
Stack<GalaxyObjectView> stack = new();
|
||||||
|
foreach (GalaxyObjectView child in children)
|
||||||
|
{
|
||||||
|
if (visited.Add(child.Object.GobjectId))
|
||||||
|
{
|
||||||
|
stack.Push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
GalaxyObjectView candidate = stack.Pop();
|
||||||
|
if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs)
|
||||||
|
&& MatchesFilters(candidate.Object, request))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? grandchildren))
|
||||||
|
{
|
||||||
|
foreach (GalaxyObjectView grandchild in grandchildren)
|
||||||
|
{
|
||||||
|
if (visited.Add(grandchild.Object.GobjectId))
|
||||||
|
{
|
||||||
|
stack.Push(grandchild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
|
{
|
||||||
|
return browseSubtreeGlobs is null
|
||||||
|
|| browseSubtreeGlobs.Count == 0
|
||||||
|
|| browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request)
|
||||||
|
{
|
||||||
|
if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (string templateFilter in request.TemplateChainContains)
|
||||||
|
{
|
||||||
|
if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
|
||||||
|
&& !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IncludeAttributes(BrowseChildrenRequest request)
|
||||||
|
{
|
||||||
|
return !request.HasIncludeAttributes || request.IncludeAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
|
||||||
|
{
|
||||||
|
GalaxyObject clone = source.Clone();
|
||||||
|
if (!includeAttributes)
|
||||||
|
{
|
||||||
|
clone.Attributes.Clear();
|
||||||
|
}
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
||||||
|
/// <param name="request">The browse-children request.</param>
|
||||||
|
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||||
|
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
||||||
|
public static string ComputeFilterSignature(
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
int parentId)
|
||||||
|
{
|
||||||
|
StringBuilder builder = new();
|
||||||
|
builder.Append("parent=").Append(parentId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
|
||||||
|
builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
|
||||||
|
builder.Append("|glob=").Append(request.TagNameGlob);
|
||||||
|
builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
|
||||||
|
builder.Append("|alarm=").Append(request.AlarmBearingOnly);
|
||||||
|
builder.Append("|hist=").Append(request.HistorizedOnly);
|
||||||
|
builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
||||||
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||||
|
return Convert.ToHexString(hash, 0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FilteredChildren(
|
||||||
|
IReadOnlyList<GalaxyObjectView> Children,
|
||||||
|
IReadOnlyList<bool> HasMatchingDescendant);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>Freshness state of the shared Galaxy hierarchy cache entry.</summary>
|
||||||
|
public enum GalaxyCacheStatus
|
||||||
|
{
|
||||||
|
/// <summary>Cache has never completed a refresh.</summary>
|
||||||
|
Unknown = 0,
|
||||||
|
|
||||||
|
/// <summary>Cache holds data from a recent successful refresh.</summary>
|
||||||
|
Healthy = 1,
|
||||||
|
|
||||||
|
/// <summary>Cache holds data, but the most recent refresh attempt failed
|
||||||
|
/// or no successful refresh has happened within the staleness threshold.</summary>
|
||||||
|
Stale = 2,
|
||||||
|
|
||||||
|
/// <summary>Latest refresh failed and no prior data is available.</summary>
|
||||||
|
Unavailable = 3,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single Galaxy deploy notification. Published by <see cref="GalaxyHierarchyCache"/>
|
||||||
|
/// whenever a refresh detects that <c>galaxy.time_of_last_deploy</c> has changed (or on
|
||||||
|
/// the first successful refresh). Consumed by <see cref="IGalaxyDeployNotifier"/>
|
||||||
|
/// subscribers (the streaming gRPC RPC).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Sequence">Monotonically increasing per process start; gaps indicate dropped events.</param>
|
||||||
|
/// <param name="ObservedAt">Server wall-clock when the cache observed the deploy.</param>
|
||||||
|
/// <param name="TimeOfLastDeploy">The <c>galaxy.time_of_last_deploy</c> value, or <see langword="null"/> when the Galaxy table reports none.</param>
|
||||||
|
/// <param name="ObjectCount">Number of objects in the hierarchy at the time of the event.</param>
|
||||||
|
/// <param name="AttributeCount">Number of attributes in the hierarchy at the time of the event.</param>
|
||||||
|
public sealed record GalaxyDeployEventInfo(
|
||||||
|
long Sequence,
|
||||||
|
DateTimeOffset ObservedAt,
|
||||||
|
DateTimeOffset? TimeOfLastDeploy,
|
||||||
|
int ObjectCount,
|
||||||
|
int AttributeCount);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channel-based fan-out of Galaxy deploy events to streaming gRPC subscribers. Each
|
||||||
|
/// subscriber gets a private bounded channel so a slow client cannot back-pressure
|
||||||
|
/// other subscribers or the publisher. When a subscriber's channel is full the oldest
|
||||||
|
/// event is dropped — clients use the sequence field to detect gaps.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
|
||||||
|
{
|
||||||
|
private const int SubscriberQueueCapacity = 16;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
||||||
|
private GalaxyDeployEventInfo? _latest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The most recent deploy event, or null if none has been published.
|
||||||
|
/// </summary>
|
||||||
|
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Publish(GalaxyDeployEventInfo info)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(info);
|
||||||
|
|
||||||
|
Volatile.Write(ref _latest, info);
|
||||||
|
|
||||||
|
foreach (Channel<GalaxyDeployEventInfo> channel in _subscribers.Values)
|
||||||
|
{
|
||||||
|
// BoundedChannelFullMode.DropOldest -> writes never wait; we only fail if the
|
||||||
|
// channel was completed by the subscriber side, which we ignore.
|
||||||
|
channel.Writer.TryWrite(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Guid subscriberId = Guid.NewGuid();
|
||||||
|
Channel<GalaxyDeployEventInfo> channel = Channel.CreateBounded<GalaxyDeployEventInfo>(
|
||||||
|
new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.DropOldest,
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
_subscribers[subscriberId] = channel;
|
||||||
|
|
||||||
|
// Bootstrap: emit the latest known event so subscribers don't need to wait for
|
||||||
|
// the next deploy to know current state.
|
||||||
|
GalaxyDeployEventInfo? bootstrap = Volatile.Read(ref _latest);
|
||||||
|
if (bootstrap is not null)
|
||||||
|
{
|
||||||
|
channel.Writer.TryWrite(bootstrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
while (channel.Reader.TryRead(out GalaxyDeployEventInfo? next))
|
||||||
|
{
|
||||||
|
yield return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_subscribers.TryRemove(subscriberId, out _);
|
||||||
|
channel.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anchored, case-insensitive glob matcher (<c>*</c> and <c>?</c> wildcards) used by the
|
||||||
|
/// hierarchy and browse projectors to filter object tag names and browse subtrees.
|
||||||
|
/// Compiled regexes are cached and the cache is bounded so an unbounded stream of distinct
|
||||||
|
/// client-supplied globs cannot grow memory without limit.
|
||||||
|
/// </summary>
|
||||||
|
public static class GalaxyGlobMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of compiled-regex entries retained in <see cref="RegexCache"/>.
|
||||||
|
/// The cache is keyed by glob pattern and patterns flow in from two sources:
|
||||||
|
/// admin-controlled API-key constraints (naturally bounded) and the
|
||||||
|
/// client-supplied <c>DiscoverHierarchyRequest.TagNameGlob</c> (unbounded — a
|
||||||
|
/// client can iterate through generated names and create millions of distinct
|
||||||
|
/// globs over the process lifetime). Capping the cache bounds memory while
|
||||||
|
/// keeping the hot working set hit-cached.
|
||||||
|
/// </summary>
|
||||||
|
internal const int RegexCacheCapacity = 256;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounded compiled-regex cache keyed by glob pattern. <c>IsMatch</c> is called
|
||||||
|
/// once per object per <c>DiscoverHierarchy</c>/<c>WatchDeployEvents</c>
|
||||||
|
/// evaluation, so the same handful of glob patterns are translated
|
||||||
|
/// repeatedly; caching avoids rebuilding and recompiling the regex on every
|
||||||
|
/// call. Beyond <see cref="RegexCacheCapacity"/> entries the oldest insertion
|
||||||
|
/// is evicted so a client cannot grow the cache without bound by submitting
|
||||||
|
/// unique patterns. Eviction is approximate (FIFO over insertion order, not
|
||||||
|
/// true LRU) because we only need the bound, not exact recency tracking.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Insertion-order queue used to evict the oldest cache entry when the cache
|
||||||
|
/// exceeds <see cref="RegexCacheCapacity"/>. A separate queue keeps the
|
||||||
|
/// <see cref="RegexCache"/> reads lock-free; the lock below only guards the
|
||||||
|
/// eviction path.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly ConcurrentQueue<string> InsertionOrder = new();
|
||||||
|
private static readonly object EvictionLock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current cache size, exposed for tests asserting the cap is honoured.
|
||||||
|
/// </summary>
|
||||||
|
internal static int CurrentCacheSize => RegexCache.Count;
|
||||||
|
|
||||||
|
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
|
||||||
|
/// <param name="value">The value to test against the glob pattern.</param>
|
||||||
|
/// <param name="glob">The glob pattern with * and ? wildcards.</param>
|
||||||
|
public static bool IsMatch(string value, string glob)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(glob))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetOrCreateRegex(glob).IsMatch(value ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Regex GetOrCreateRegex(string glob)
|
||||||
|
{
|
||||||
|
if (RegexCache.TryGetValue(glob, out Regex? existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Regex compiled = new(
|
||||||
|
BuildRegex(glob),
|
||||||
|
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||||
|
TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
// GetOrAdd atomically returns whichever instance is in the cache after the
|
||||||
|
// call — either the locally-compiled regex (we won the race) or the regex
|
||||||
|
// another thread inserted (we lost). It also avoids the TryAdd-then-indexer
|
||||||
|
// pattern where the key could be evicted between the failed TryAdd and the
|
||||||
|
// indexer read, producing a KeyNotFoundException under contention near the cap.
|
||||||
|
Regex result = RegexCache.GetOrAdd(glob, compiled);
|
||||||
|
if (ReferenceEquals(result, compiled))
|
||||||
|
{
|
||||||
|
// We were the inserter — track for FIFO eviction and bound the cache.
|
||||||
|
InsertionOrder.Enqueue(glob);
|
||||||
|
EvictIfOverCapacity();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EvictIfOverCapacity()
|
||||||
|
{
|
||||||
|
if (RegexCache.Count <= RegexCacheCapacity)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize eviction so two threads do not race past the cap together.
|
||||||
|
lock (EvictionLock)
|
||||||
|
{
|
||||||
|
while (RegexCache.Count > RegexCacheCapacity && InsertionOrder.TryDequeue(out string? oldest))
|
||||||
|
{
|
||||||
|
RegexCache.TryRemove(oldest, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildRegex(string glob)
|
||||||
|
{
|
||||||
|
StringBuilder builder = new("^", glob.Length + 2);
|
||||||
|
foreach (char character in glob)
|
||||||
|
{
|
||||||
|
switch (character)
|
||||||
|
{
|
||||||
|
case '*':
|
||||||
|
builder.Append(".*");
|
||||||
|
break;
|
||||||
|
case '?':
|
||||||
|
builder.Append('.');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
builder.Append(Regex.Escape(character.ToString()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('$');
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
|
||||||
|
/// entry — the materialized object list is produced once per refresh and reused across
|
||||||
|
/// requests. Refreshes are deploy-time gated: every tick queries
|
||||||
|
/// <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy + attributes rowsets
|
||||||
|
/// are pulled only when that timestamp has advanced.
|
||||||
|
/// Each successful heavy refresh is persisted to disk through
|
||||||
|
/// <see cref="IGalaxyHierarchySnapshotStore"/>; the first refresh restores that
|
||||||
|
/// snapshot (as <see cref="GalaxyCacheStatus.Stale"/>) so clients can browse
|
||||||
|
/// last-known data when the Galaxy database is unreachable on a cold start.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private readonly IGalaxyRepository _repository;
|
||||||
|
private readonly IGalaxyDeployNotifier _notifier;
|
||||||
|
private readonly IGalaxyHierarchySnapshotStore? _snapshotStore;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<GalaxyHierarchyCache>? _logger;
|
||||||
|
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||||
|
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
||||||
|
private bool _restoreAttempted;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
||||||
|
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
||||||
|
/// <param name="notifier">Galaxy deploy event notifier.</param>
|
||||||
|
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
||||||
|
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||||
|
/// <param name="snapshotStore">
|
||||||
|
/// Optional on-disk snapshot store. When supplied, the cache persists each
|
||||||
|
/// successful refresh and restores the last snapshot on first load.
|
||||||
|
/// </param>
|
||||||
|
public GalaxyHierarchyCache(
|
||||||
|
IGalaxyRepository repository,
|
||||||
|
IGalaxyDeployNotifier notifier,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
ILogger<GalaxyHierarchyCache>? logger = null,
|
||||||
|
IGalaxyHierarchySnapshotStore? snapshotStore = null)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_notifier = notifier;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_logger = logger;
|
||||||
|
_snapshotStore = snapshotStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
||||||
|
public GalaxyHierarchyCacheEntry Current
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
|
||||||
|
GalaxyCacheStatus projected = ProjectStatus(snapshot);
|
||||||
|
return projected == snapshot.Status
|
||||||
|
? snapshot
|
||||||
|
: snapshot with { Status = projected };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
||||||
|
public async Task RefreshAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>Asynchronous task representing the wait operation.</returns>
|
||||||
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _firstLoad.Task.WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes the refresh gate. As a DI singleton the cache is disposed once at host
|
||||||
|
/// shutdown, after the refresh <see cref="GalaxyHierarchyRefreshService"/> has stopped,
|
||||||
|
/// so no in-flight refresh can be holding the gate.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_refreshGate.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// First refresh only: seed the cache from the on-disk snapshot before
|
||||||
|
// querying SQL, so a cold start with an unreachable Galaxy database can
|
||||||
|
// still serve last-known browse data. Runs under the refresh gate.
|
||||||
|
if (!_restoreAttempted)
|
||||||
|
{
|
||||||
|
_restoreAttempted = true;
|
||||||
|
await TryRestoreFromDiskAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
|
||||||
|
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DateTime? deployRaw = await _repository.GetLastDeployTimeAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
DateTimeOffset? deployTime = deployRaw.HasValue
|
||||||
|
? new DateTimeOffset(DateTime.SpecifyKind(deployRaw.Value, DateTimeKind.Utc))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
bool hasPriorData = previous.HasData;
|
||||||
|
bool deployChanged = !hasPriorData || deployTime != previous.LastDeployTime;
|
||||||
|
|
||||||
|
if (!deployChanged)
|
||||||
|
{
|
||||||
|
// No deploy change — skip heavy queries; just bump LastSuccessAt.
|
||||||
|
GalaxyHierarchyCacheEntry refreshed = previous with
|
||||||
|
{
|
||||||
|
Status = GalaxyCacheStatus.Healthy,
|
||||||
|
LastQueriedAt = queriedAt,
|
||||||
|
LastSuccessAt = queriedAt,
|
||||||
|
LastError = null,
|
||||||
|
};
|
||||||
|
Volatile.Write(ref _current, refreshed);
|
||||||
|
_firstLoad.TrySetResult();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<List<GalaxyHierarchyRow>> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken);
|
||||||
|
Task<List<GalaxyAttributeRow>> attributesTask = _repository.GetAttributesAsync(cancellationToken);
|
||||||
|
await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false);
|
||||||
|
|
||||||
|
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
||||||
|
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
||||||
|
|
||||||
|
long nextSequence = previous.Sequence + 1;
|
||||||
|
GalaxyHierarchyCacheEntry next = BuildEntry(
|
||||||
|
status: GalaxyCacheStatus.Healthy,
|
||||||
|
sequence: nextSequence,
|
||||||
|
lastQueriedAt: queriedAt,
|
||||||
|
lastSuccessAt: queriedAt,
|
||||||
|
lastDeployTime: deployTime,
|
||||||
|
lastError: null,
|
||||||
|
hierarchy: hierarchy,
|
||||||
|
attributes: attributes);
|
||||||
|
|
||||||
|
Volatile.Write(ref _current, next);
|
||||||
|
_firstLoad.TrySetResult();
|
||||||
|
|
||||||
|
_notifier.Publish(new GalaxyDeployEventInfo(
|
||||||
|
Sequence: nextSequence,
|
||||||
|
ObservedAt: queriedAt,
|
||||||
|
TimeOfLastDeploy: deployTime,
|
||||||
|
ObjectCount: hierarchy.Count,
|
||||||
|
AttributeCount: attributes.Count));
|
||||||
|
|
||||||
|
await PersistSnapshotAsync(deployTime, queriedAt, hierarchy, attributes, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// Catch every non-cancellation failure — not just SqlException /
|
||||||
|
// InvalidOperationException. A TimeoutException or Win32Exception
|
||||||
|
// from connection establishment, or another DbException subtype,
|
||||||
|
// must still degrade gracefully to Stale/Unavailable and complete
|
||||||
|
// _firstLoad rather than escape and fault the refresh BackgroundService.
|
||||||
|
_logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed.");
|
||||||
|
GalaxyHierarchyCacheEntry failed = previous with
|
||||||
|
{
|
||||||
|
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
|
||||||
|
LastQueriedAt = queriedAt,
|
||||||
|
LastError = exception.Message,
|
||||||
|
};
|
||||||
|
Volatile.Write(ref _current, failed);
|
||||||
|
_firstLoad.TrySetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes a complete <see cref="GalaxyHierarchyCacheEntry"/> from raw
|
||||||
|
/// hierarchy and attribute rowsets. Shared by the live refresh path and the
|
||||||
|
/// on-disk restore path so both produce an identical object list and index.
|
||||||
|
/// </summary>
|
||||||
|
private static GalaxyHierarchyCacheEntry BuildEntry(
|
||||||
|
GalaxyCacheStatus status,
|
||||||
|
long sequence,
|
||||||
|
DateTimeOffset? lastQueriedAt,
|
||||||
|
DateTimeOffset? lastSuccessAt,
|
||||||
|
DateTimeOffset? lastDeployTime,
|
||||||
|
string? lastError,
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
|
{
|
||||||
|
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
||||||
|
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
||||||
|
|
||||||
|
int areaCount = hierarchy.Count(row => row.IsArea);
|
||||||
|
int historized = attributes.Count(row => row.IsHistorized);
|
||||||
|
int alarms = attributes.Count(row => row.IsAlarm);
|
||||||
|
|
||||||
|
return new GalaxyHierarchyCacheEntry(
|
||||||
|
Status: status,
|
||||||
|
Sequence: sequence,
|
||||||
|
LastQueriedAt: lastQueriedAt,
|
||||||
|
LastSuccessAt: lastSuccessAt,
|
||||||
|
LastDeployTime: lastDeployTime,
|
||||||
|
LastError: lastError,
|
||||||
|
Objects: objects,
|
||||||
|
Index: index,
|
||||||
|
ObjectCount: hierarchy.Count,
|
||||||
|
AreaCount: areaCount,
|
||||||
|
AttributeCount: attributes.Count,
|
||||||
|
HistorizedAttributeCount: historized,
|
||||||
|
AlarmAttributeCount: alarms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the cache from the on-disk snapshot when no live data has loaded yet.
|
||||||
|
/// The restored entry is marked <see cref="GalaxyCacheStatus.Stale"/> — it is
|
||||||
|
/// last-known data, not live. A later refresh that observes the same deploy
|
||||||
|
/// time promotes it to healthy; one that observes a newer deploy replaces it.
|
||||||
|
/// </summary>
|
||||||
|
private async Task TryRestoreFromDiskAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_snapshotStore is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Volatile.Read(ref _current).HasData)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GalaxyHierarchySnapshot? snapshot;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
snapshot = await _snapshotStore.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(exception, "Failed to restore the Galaxy hierarchy from the on-disk snapshot.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long sequence = Volatile.Read(ref _current).Sequence + 1;
|
||||||
|
GalaxyHierarchyCacheEntry restored = BuildEntry(
|
||||||
|
status: GalaxyCacheStatus.Stale,
|
||||||
|
sequence: sequence,
|
||||||
|
lastQueriedAt: snapshot.SavedAt,
|
||||||
|
lastSuccessAt: snapshot.SavedAt,
|
||||||
|
lastDeployTime: snapshot.LastDeployTime,
|
||||||
|
lastError: null,
|
||||||
|
hierarchy: snapshot.Hierarchy,
|
||||||
|
attributes: snapshot.Attributes);
|
||||||
|
Volatile.Write(ref _current, restored);
|
||||||
|
|
||||||
|
// Restored data is a valid completed first load: unblock callers waiting on
|
||||||
|
// the bootstrap gate immediately, rather than making them wait out the full
|
||||||
|
// wait budget for a live query that — when the database is unreachable, the
|
||||||
|
// scenario this restore exists for — may not return for seconds.
|
||||||
|
_firstLoad.TrySetResult();
|
||||||
|
|
||||||
|
_notifier.Publish(new GalaxyDeployEventInfo(
|
||||||
|
Sequence: sequence,
|
||||||
|
ObservedAt: _timeProvider.GetUtcNow(),
|
||||||
|
TimeOfLastDeploy: snapshot.LastDeployTime,
|
||||||
|
ObjectCount: snapshot.Hierarchy.Count,
|
||||||
|
AttributeCount: snapshot.Attributes.Count));
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"Restored Galaxy hierarchy from on-disk snapshot saved {SavedAt:o}: {ObjectCount} objects, {AttributeCount} attributes (status Stale until the Galaxy database confirms).",
|
||||||
|
snapshot.SavedAt,
|
||||||
|
snapshot.Hierarchy.Count,
|
||||||
|
snapshot.Attributes.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists a successful refresh to disk. Persistence failures are logged and
|
||||||
|
/// swallowed — a cache that cannot write its backup is still fully usable.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PersistSnapshotAsync(
|
||||||
|
DateTimeOffset? deployTime,
|
||||||
|
DateTimeOffset savedAt,
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_snapshotStore is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _snapshotStore.SaveAsync(
|
||||||
|
new GalaxyHierarchySnapshot(deployTime, savedAt, hierarchy, attributes),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// The refresh was cancelled (service shutdown) before the write finished.
|
||||||
|
// That is not a persistence failure — do not log it as a warning.
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(exception, "Failed to persist the Galaxy hierarchy snapshot to disk.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
|
{
|
||||||
|
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
||||||
|
.GroupBy(a => a.GobjectId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
List<GalaxyObject> objects = new(hierarchy.Count);
|
||||||
|
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||||
|
{
|
||||||
|
objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
||||||
|
}
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
|
||||||
|
{
|
||||||
|
return snapshot.Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.LastSuccessAt is { } success
|
||||||
|
&& _timeProvider.GetUtcNow() - success > StaleThreshold)
|
||||||
|
{
|
||||||
|
return GalaxyCacheStatus.Stale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.Status;
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable snapshot of the Galaxy Repository browse data held by
|
||||||
|
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
|
||||||
|
/// materialized object list and precomputed hierarchy index.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Status">The cache freshness state at the time the entry was produced.</param>
|
||||||
|
/// <param name="Sequence">Monotonically increasing per process start; bumped on each heavy refresh.</param>
|
||||||
|
/// <param name="LastQueriedAt">UTC wall-clock of the most recent refresh attempt.</param>
|
||||||
|
/// <param name="LastSuccessAt">UTC wall-clock of the most recent successful refresh.</param>
|
||||||
|
/// <param name="LastDeployTime">The <c>galaxy.time_of_last_deploy</c> the data was pulled at.</param>
|
||||||
|
/// <param name="LastError">The most recent refresh error message, or <see langword="null"/>.</param>
|
||||||
|
/// <param name="Objects">The materialized Galaxy object list.</param>
|
||||||
|
/// <param name="Index">Precomputed lookup structures over <paramref name="Objects"/>.</param>
|
||||||
|
/// <param name="ObjectCount">Number of objects in the hierarchy.</param>
|
||||||
|
/// <param name="AreaCount">Number of area objects in the hierarchy.</param>
|
||||||
|
/// <param name="AttributeCount">Number of attributes across all objects.</param>
|
||||||
|
/// <param name="HistorizedAttributeCount">Number of historized attributes.</param>
|
||||||
|
/// <param name="AlarmAttributeCount">Number of alarm-bearing attributes.</param>
|
||||||
|
public sealed record GalaxyHierarchyCacheEntry(
|
||||||
|
GalaxyCacheStatus Status,
|
||||||
|
long Sequence,
|
||||||
|
DateTimeOffset? LastQueriedAt,
|
||||||
|
DateTimeOffset? LastSuccessAt,
|
||||||
|
DateTimeOffset? LastDeployTime,
|
||||||
|
string? LastError,
|
||||||
|
IReadOnlyList<GalaxyObject> Objects,
|
||||||
|
GalaxyHierarchyIndex Index,
|
||||||
|
int ObjectCount,
|
||||||
|
int AreaCount,
|
||||||
|
int AttributeCount,
|
||||||
|
int HistorizedAttributeCount,
|
||||||
|
int AlarmAttributeCount)
|
||||||
|
{
|
||||||
|
/// <summary>Gets an empty Galaxy hierarchy cache entry.</summary>
|
||||||
|
public static GalaxyHierarchyCacheEntry Empty { get; } = new(
|
||||||
|
Status: GalaxyCacheStatus.Unknown,
|
||||||
|
Sequence: 0,
|
||||||
|
LastQueriedAt: null,
|
||||||
|
LastSuccessAt: null,
|
||||||
|
LastDeployTime: null,
|
||||||
|
LastError: null,
|
||||||
|
Objects: Array.Empty<GalaxyObject>(),
|
||||||
|
Index: GalaxyHierarchyIndex.Empty,
|
||||||
|
ObjectCount: 0,
|
||||||
|
AreaCount: 0,
|
||||||
|
AttributeCount: 0,
|
||||||
|
HistorizedAttributeCount: 0,
|
||||||
|
AlarmAttributeCount: 0);
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether the cache entry contains usable data.</summary>
|
||||||
|
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Precomputed lookup structures over a materialized Galaxy object list. Built once per
|
||||||
|
/// cache entry so browse/discover handlers can resolve roots/parents by id, tag name, or
|
||||||
|
/// contained path in O(1), enumerate direct children, and resolve tag addresses to objects
|
||||||
|
/// or attributes without rescanning the full object list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchyIndex
|
||||||
|
{
|
||||||
|
private GalaxyHierarchyIndex(
|
||||||
|
IReadOnlyList<GalaxyObjectView> objectViews,
|
||||||
|
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
|
||||||
|
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress,
|
||||||
|
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent,
|
||||||
|
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByTagName,
|
||||||
|
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByContainedPath)
|
||||||
|
{
|
||||||
|
ObjectViews = objectViews;
|
||||||
|
ObjectViewsById = objectViewsById;
|
||||||
|
TagsByAddress = tagsByAddress;
|
||||||
|
ChildrenByParent = childrenByParent;
|
||||||
|
ObjectViewsByTagName = objectViewsByTagName;
|
||||||
|
ObjectViewsByContainedPath = objectViewsByContainedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
|
||||||
|
public static GalaxyHierarchyIndex Empty { get; } = new(
|
||||||
|
Array.Empty<GalaxyObjectView>(),
|
||||||
|
new Dictionary<int, GalaxyObjectView>(),
|
||||||
|
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase),
|
||||||
|
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>(),
|
||||||
|
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase),
|
||||||
|
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>Gets the object views.</summary>
|
||||||
|
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the object views indexed by gobject id.</summary>
|
||||||
|
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets tags indexed by address.</summary>
|
||||||
|
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets direct children grouped by parent gobject id. Root objects (no parent, or self-parented) live under key 0. Each list is sorted areas-first, then by display name (OrdinalIgnoreCase).</summary>
|
||||||
|
public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets object views indexed by <see cref="GalaxyObject.TagName"/> (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by tag name in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||||
|
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByTagName { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets object views indexed by contained path (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by path in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||||
|
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByContainedPath { get; }
|
||||||
|
|
||||||
|
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
|
||||||
|
/// <param name="objects">The Galaxy objects to index.</param>
|
||||||
|
/// <returns>A new Galaxy hierarchy index.</returns>
|
||||||
|
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
|
||||||
|
{
|
||||||
|
if (objects.Count == 0)
|
||||||
|
{
|
||||||
|
return Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, GalaxyObject> objectsById = new();
|
||||||
|
foreach (GalaxyObject obj in objects)
|
||||||
|
{
|
||||||
|
objectsById.TryAdd(obj.GobjectId, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GalaxyObjectView> views = new(objects.Count);
|
||||||
|
Dictionary<int, GalaxyObjectView> viewsById = new();
|
||||||
|
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
Dictionary<string, GalaxyObjectView> viewsByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
Dictionary<string, GalaxyObjectView> viewsByContainedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (GalaxyObject obj in objects)
|
||||||
|
{
|
||||||
|
string path = BuildContainedPath(obj, objectsById);
|
||||||
|
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
|
||||||
|
GalaxyObjectView view = new(obj, path, depth);
|
||||||
|
views.Add(view);
|
||||||
|
viewsById.TryAdd(obj.GobjectId, view);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.TagName))
|
||||||
|
{
|
||||||
|
tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path));
|
||||||
|
viewsByTagName.TryAdd(obj.TagName, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
viewsByContainedPath.TryAdd(path, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (GalaxyAttribute attribute in obj.Attributes)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(attribute.FullTagReference))
|
||||||
|
{
|
||||||
|
tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, List<GalaxyObjectView>> childrenByParent = new();
|
||||||
|
foreach (GalaxyObjectView view in views)
|
||||||
|
{
|
||||||
|
int parentKey = view.Object.ParentGobjectId;
|
||||||
|
// Treat self-parented (corrupt) rows as roots.
|
||||||
|
if (parentKey == view.Object.GobjectId)
|
||||||
|
{
|
||||||
|
parentKey = 0;
|
||||||
|
}
|
||||||
|
// Re-root orphans whose parent object is absent from the set (e.g. a deleted or
|
||||||
|
// never-loaded container area). Otherwise they bucket under a phantom parent id
|
||||||
|
// that is never reached from the root, so they vanish from browse entirely.
|
||||||
|
else if (parentKey != 0 && !objectsById.ContainsKey(parentKey))
|
||||||
|
{
|
||||||
|
parentKey = 0;
|
||||||
|
}
|
||||||
|
if (!childrenByParent.TryGetValue(parentKey, out List<GalaxyObjectView>? bucket))
|
||||||
|
{
|
||||||
|
bucket = [];
|
||||||
|
childrenByParent[parentKey] = bucket;
|
||||||
|
}
|
||||||
|
bucket.Add(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (List<GalaxyObjectView> bucket in childrenByParent.Values)
|
||||||
|
{
|
||||||
|
bucket.Sort(CompareByAreaThenDisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, IReadOnlyList<GalaxyObjectView>> readOnlyChildren = new(childrenByParent.Count);
|
||||||
|
foreach (KeyValuePair<int, List<GalaxyObjectView>> kvp in childrenByParent)
|
||||||
|
{
|
||||||
|
readOnlyChildren[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GalaxyHierarchyIndex(
|
||||||
|
views,
|
||||||
|
viewsById,
|
||||||
|
tagsByAddress,
|
||||||
|
readOnlyChildren,
|
||||||
|
viewsByTagName,
|
||||||
|
viewsByContainedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildContainedPath(
|
||||||
|
GalaxyObject obj,
|
||||||
|
IReadOnlyDictionary<int, GalaxyObject> objectsById)
|
||||||
|
{
|
||||||
|
Stack<string> names = new();
|
||||||
|
HashSet<int> seen = [];
|
||||||
|
GalaxyObject? current = obj;
|
||||||
|
while (current is not null && seen.Add(current.GobjectId))
|
||||||
|
{
|
||||||
|
names.Push(ResolvePathSegment(current));
|
||||||
|
current = current.ParentGobjectId != 0
|
||||||
|
&& objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent)
|
||||||
|
? parent
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePathSegment(GalaxyObject obj)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
|
||||||
|
{
|
||||||
|
return obj.ContainedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
|
||||||
|
{
|
||||||
|
return obj.BrowseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.TagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareByAreaThenDisplayName(GalaxyObjectView left, GalaxyObjectView right)
|
||||||
|
{
|
||||||
|
if (left.Object.IsArea != right.Object.IsArea)
|
||||||
|
{
|
||||||
|
return left.Object.IsArea ? -1 : 1;
|
||||||
|
}
|
||||||
|
return string.Compare(DisplayNameOf(left), DisplayNameOf(right), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DisplayNameOf(GalaxyObjectView view)
|
||||||
|
{
|
||||||
|
GalaxyObject obj = view.Object;
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
|
||||||
|
{
|
||||||
|
return obj.BrowseName;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
|
||||||
|
{
|
||||||
|
return obj.ContainedName;
|
||||||
|
}
|
||||||
|
return obj.TagName;
|
||||||
|
}
|
||||||
|
}
|
||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Grpc.Core;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects a <c>DiscoverHierarchy</c> request against an immutable
|
||||||
|
/// <see cref="GalaxyHierarchyCacheEntry"/>: applies the root/depth/category/template/glob
|
||||||
|
/// filters, pages the result, and memoizes the filtered list per cache-entry instance so
|
||||||
|
/// paging is O(pageSize) rather than O(total) per page. Pure and side-effect free.
|
||||||
|
/// </summary>
|
||||||
|
public static class GalaxyHierarchyProjector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-cache-entry memo of filtered, ordered <see cref="GalaxyObjectView"/> lists
|
||||||
|
/// keyed by filter signature. Without it, paging through a large hierarchy
|
||||||
|
/// re-applies every filter and re-scans the full <see cref="GalaxyHierarchyIndex.ObjectViews"/>
|
||||||
|
/// collection on every page — O(total) per page, O(total²/pageSize) end-to-end.
|
||||||
|
/// With it, the first page builds the filtered list and each subsequent page is an
|
||||||
|
/// O(pageSize) slice. The table is keyed on the immutable cache-entry instance, so
|
||||||
|
/// when the cache publishes a new entry the stale memo becomes unreachable and is
|
||||||
|
/// reclaimed with it — no explicit invalidation needed.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new();
|
||||||
|
|
||||||
|
/// <summary>Projects a discovery request against a cache entry and returns all matching objects.</summary>
|
||||||
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
|
/// <param name="request">The discovery hierarchy request.</param>
|
||||||
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
|
public static GalaxyHierarchyQueryResult Project(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs = null)
|
||||||
|
{
|
||||||
|
return Project(
|
||||||
|
entry,
|
||||||
|
request,
|
||||||
|
browseSubtreeGlobs,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: int.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a discovery request with paging against a cache entry and returns a page of matching objects.</summary>
|
||||||
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
|
/// <param name="request">The discovery hierarchy request.</param>
|
||||||
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
|
/// <param name="offset">The zero-based offset into the result set.</param>
|
||||||
|
/// <param name="pageSize">The maximum number of results to return.</param>
|
||||||
|
public static GalaxyHierarchyQueryResult Project(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
int offset,
|
||||||
|
int pageSize)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (offset < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageSize <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int? maxDepth = request.MaxDepth;
|
||||||
|
if (maxDepth < 0)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
|
||||||
|
}
|
||||||
|
|
||||||
|
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs);
|
||||||
|
IReadOnlyList<GalaxyObjectView> matchedViews = GetFilteredViews(
|
||||||
|
entry,
|
||||||
|
request,
|
||||||
|
browseSubtreeGlobs,
|
||||||
|
maxDepth,
|
||||||
|
filterSignature);
|
||||||
|
|
||||||
|
bool includeAttributes = IncludeAttributes(request);
|
||||||
|
List<GalaxyObject> page = new(Math.Min(pageSize, Math.Max(0, matchedViews.Count - offset)));
|
||||||
|
int end = (int)Math.Min((long)offset + pageSize, matchedViews.Count);
|
||||||
|
for (int index = offset; index < end; index++)
|
||||||
|
{
|
||||||
|
page.Add(CloneObject(matchedViews[index].Object, includeAttributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GalaxyHierarchyQueryResult(
|
||||||
|
page,
|
||||||
|
matchedViews.Count,
|
||||||
|
filterSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<GalaxyObjectView> GetFilteredViews(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
int? maxDepth,
|
||||||
|
string filterSignature)
|
||||||
|
{
|
||||||
|
// ResolveRoot can throw RpcException(NotFound); run it before consulting the
|
||||||
|
// memo so a bad root surfaces consistently regardless of cache state.
|
||||||
|
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||||
|
GalaxyObjectView? root = ResolveRoot(request, entry.Index);
|
||||||
|
|
||||||
|
ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>> memo =
|
||||||
|
FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
return memo.GetOrAdd(
|
||||||
|
filterSignature,
|
||||||
|
static (_, state) =>
|
||||||
|
{
|
||||||
|
List<GalaxyObjectView> matched = [];
|
||||||
|
foreach (GalaxyObjectView view in state.Views)
|
||||||
|
{
|
||||||
|
if (MatchesRoot(view, state.Root, state.MaxDepth)
|
||||||
|
&& MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)
|
||||||
|
&& MatchesFilters(view.Object, state.Request))
|
||||||
|
{
|
||||||
|
matched.Add(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched;
|
||||||
|
},
|
||||||
|
(Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
|
||||||
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
|
/// <param name="tagAddress">The tag address to search for.</param>
|
||||||
|
public static GalaxyObject? FindObjectForTag(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
string tagAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||||
|
? lookup.Object
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
|
||||||
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
|
/// <param name="tagAddress">The tag address to search for.</param>
|
||||||
|
public static GalaxyAttribute? FindAttributeForTag(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
string tagAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||||
|
? lookup.Attribute
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
|
||||||
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
|
/// <param name="gobjectId">The Galaxy object ID.</param>
|
||||||
|
public static string GetContainedPath(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
int gobjectId)
|
||||||
|
{
|
||||||
|
return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view)
|
||||||
|
? view.ContainedPath
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyObjectView? ResolveRoot(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
GalaxyHierarchyIndex index)
|
||||||
|
{
|
||||||
|
GalaxyObjectView? root = request.RootCase switch
|
||||||
|
{
|
||||||
|
DiscoverHierarchyRequest.RootOneofCase.None => null,
|
||||||
|
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId =>
|
||||||
|
index.ObjectViewsById.TryGetValue(request.RootGobjectId, out GalaxyObjectView? byId) ? byId : null,
|
||||||
|
DiscoverHierarchyRequest.RootOneofCase.RootTagName =>
|
||||||
|
index.ObjectViewsByTagName.TryGetValue(request.RootTagName, out GalaxyObjectView? byTag) ? byTag : null,
|
||||||
|
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath =>
|
||||||
|
index.ObjectViewsByContainedPath.TryGetValue(request.RootContainedPath, out GalaxyObjectView? byPath) ? byPath : null,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.RootCase != DiscoverHierarchyRequest.RootOneofCase.None && root is null)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "DiscoverHierarchy root was not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesRoot(
|
||||||
|
GalaxyObjectView view,
|
||||||
|
GalaxyObjectView? root,
|
||||||
|
int? maxDepth)
|
||||||
|
{
|
||||||
|
if (root is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isRoot = view.Object.GobjectId == root.Object.GobjectId;
|
||||||
|
bool isDescendant = view.ContainedPath.StartsWith(root.ContainedPath + "/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!isRoot && !isDescendant)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxDepth is null || view.Depth - root.Depth <= maxDepth.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesBrowseSubtrees(
|
||||||
|
GalaxyObjectView view,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
|
{
|
||||||
|
return browseSubtreeGlobs is null
|
||||||
|
|| browseSubtreeGlobs.Count == 0
|
||||||
|
|| browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesFilters(
|
||||||
|
GalaxyObject obj,
|
||||||
|
DiscoverHierarchyRequest request)
|
||||||
|
{
|
||||||
|
if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string templateFilter in request.TemplateChainContains)
|
||||||
|
{
|
||||||
|
if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
|
||||||
|
&& !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IncludeAttributes(DiscoverHierarchyRequest request)
|
||||||
|
{
|
||||||
|
return !request.HasIncludeAttributes || request.IncludeAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
|
||||||
|
{
|
||||||
|
GalaxyObject clone = source.Clone();
|
||||||
|
if (!includeAttributes)
|
||||||
|
{
|
||||||
|
clone.Attributes.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
||||||
|
/// <param name="request">The discovery hierarchy request.</param>
|
||||||
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
|
public static string ComputeFilterSignature(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
|
{
|
||||||
|
StringBuilder builder = new();
|
||||||
|
builder.Append("root=").Append(request.RootCase).Append('|');
|
||||||
|
builder.Append(request.RootCase switch
|
||||||
|
{
|
||||||
|
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => request.RootGobjectId.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
DiscoverHierarchyRequest.RootOneofCase.RootTagName => request.RootTagName,
|
||||||
|
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => request.RootContainedPath,
|
||||||
|
_ => string.Empty,
|
||||||
|
});
|
||||||
|
builder.Append("|max=").Append(request.MaxDepth?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "");
|
||||||
|
builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
|
||||||
|
builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
|
||||||
|
builder.Append("|glob=").Append(request.TagNameGlob);
|
||||||
|
builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
|
||||||
|
builder.Append("|alarm=").Append(request.AlarmBearingOnly);
|
||||||
|
builder.Append("|hist=").Append(request.HistorizedOnly);
|
||||||
|
builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||||
|
return Convert.ToHexString(hash, 0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of one <see cref="GalaxyHierarchyProjector.Project(GalaxyHierarchyCacheEntry, DiscoverHierarchyRequest, System.Collections.Generic.IReadOnlyList{string}, int, int)"/>
|
||||||
|
/// call: a materialized page of matching objects, the total post-filter object count, and
|
||||||
|
/// the stable filter signature used to bind page tokens.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Objects">The page of matching objects.</param>
|
||||||
|
/// <param name="TotalObjectCount">Total matching objects across the whole hierarchy (post-filter).</param>
|
||||||
|
/// <param name="FilterSignature">Stable signature of the filter set, used to bind page tokens.</param>
|
||||||
|
public sealed record GalaxyHierarchyQueryResult(
|
||||||
|
IReadOnlyList<GalaxyObject> Objects,
|
||||||
|
int TotalObjectCount,
|
||||||
|
string FilterSignature);
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path.</summary>
|
||||||
|
public sealed class GalaxyHierarchyRefreshService(
|
||||||
|
IGalaxyHierarchyCache cache,
|
||||||
|
IOptions<GalaxyRepositoryOptions> options,
|
||||||
|
ILogger<GalaxyHierarchyRefreshService> logger,
|
||||||
|
TimeProvider? timeProvider = null) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// A transient first-load failure (e.g. a TimeoutException or
|
||||||
|
// Win32Exception from connection establishment, or a DbException
|
||||||
|
// subtype the cache does not catch) must not fault this
|
||||||
|
// BackgroundService and stop the whole host. The cache records
|
||||||
|
// its own Unavailable/Stale status; the periodic tick below retries.
|
||||||
|
logger.LogWarning(exception, "Initial Galaxy hierarchy cache load failed; will retry on the refresh interval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using PeriodicTimer timer = new(interval, _timeProvider);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Galaxy hierarchy cache refresh tick failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row from <see cref="GalaxyRepository.GetHierarchyAsync"/>: a deployed Galaxy
|
||||||
|
/// <c>gobject</c> with its hierarchy parent and template-derivation chain.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchyRow
|
||||||
|
{
|
||||||
|
/// <summary>Gets the Galaxy object identifier.</summary>
|
||||||
|
public int GobjectId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the tag name.</summary>
|
||||||
|
public string TagName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets the contained name.</summary>
|
||||||
|
public string ContainedName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets the browse name.</summary>
|
||||||
|
public string BrowseName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets the parent Galaxy object identifier.</summary>
|
||||||
|
public int ParentGobjectId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether this is an area.</summary>
|
||||||
|
public bool IsArea { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the category identifier.</summary>
|
||||||
|
public int CategoryId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the Galaxy object identifier of the host.</summary>
|
||||||
|
public int HostedByGobjectId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the template derivation chain.</summary>
|
||||||
|
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A serializable point-in-time copy of the Galaxy Repository browse data.
|
||||||
|
/// Holds the raw hierarchy and attribute rowsets — not the materialized
|
||||||
|
/// protobuf objects — so the restore path runs the exact same
|
||||||
|
/// materialization as a live refresh. Persisted by
|
||||||
|
/// <see cref="IGalaxyHierarchySnapshotStore"/> after a successful refresh
|
||||||
|
/// and reloaded at startup when the Galaxy database is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="LastDeployTime">
|
||||||
|
/// The <c>galaxy.time_of_last_deploy</c> the rowsets were pulled at, or
|
||||||
|
/// <see langword="null"/> when the Galaxy table reported no deploy. A later
|
||||||
|
/// live refresh that observes this same timestamp can promote the restored
|
||||||
|
/// entry to healthy without re-running the heavy queries.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SavedAt">UTC wall-clock when the snapshot was written to disk.</param>
|
||||||
|
/// <param name="Hierarchy">The persisted object-hierarchy rowset.</param>
|
||||||
|
/// <param name="Attributes">The persisted attribute rowset.</param>
|
||||||
|
public sealed record GalaxyHierarchySnapshot(
|
||||||
|
DateTimeOffset? LastDeployTime,
|
||||||
|
DateTimeOffset SavedAt,
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> Attributes);
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON-file implementation of <see cref="IGalaxyHierarchySnapshotStore"/>.
|
||||||
|
/// Writes the on-disk snapshot atomically (temp file + rename) so a crash
|
||||||
|
/// mid-write can never leave a torn file, and ignores files whose schema
|
||||||
|
/// version it does not recognize. When
|
||||||
|
/// <see cref="GalaxyRepositoryOptions.PersistSnapshot"/> is <see langword="false"/>
|
||||||
|
/// — or <see cref="GalaxyRepositoryOptions.SnapshotCachePath"/> is empty —
|
||||||
|
/// both operations are no-ops. The snapshot path is fully consumer-supplied;
|
||||||
|
/// this store imposes no platform-specific default, so it is cross-platform.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// On-disk format version. Bump this whenever the persisted shape changes
|
||||||
|
/// in a way an older or newer consumer cannot read; a mismatched file is
|
||||||
|
/// ignored rather than misparsed.
|
||||||
|
/// </summary>
|
||||||
|
private const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string? _path;
|
||||||
|
private readonly TimeSpan _writeTimeout;
|
||||||
|
private readonly ILogger<GalaxyHierarchySnapshotStore>? _logger;
|
||||||
|
private readonly SemaphoreSlim _ioGate = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchySnapshotStore"/> class.</summary>
|
||||||
|
/// <param name="options">Galaxy repository options carrying the snapshot path and enable flag.</param>
|
||||||
|
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||||
|
public GalaxyHierarchySnapshotStore(
|
||||||
|
IOptions<GalaxyRepositoryOptions> options,
|
||||||
|
ILogger<GalaxyHierarchySnapshotStore>? logger = null)
|
||||||
|
{
|
||||||
|
GalaxyRepositoryOptions value = options.Value;
|
||||||
|
_path = value.PersistSnapshot && !string.IsNullOrWhiteSpace(value.SnapshotCachePath)
|
||||||
|
? value.SnapshotCachePath
|
||||||
|
: null;
|
||||||
|
_writeTimeout = TimeSpan.FromSeconds(Math.Max(1, value.CommandTimeoutSeconds));
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
if (_path is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistedFile file = new(CurrentSchemaVersion, snapshot);
|
||||||
|
|
||||||
|
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Bound the write so a stuck disk — e.g. a SnapshotCachePath on an
|
||||||
|
// unresponsive network share — cannot stall the caller. On the cache
|
||||||
|
// refresh path that would otherwise pin the whole refresh loop.
|
||||||
|
using CancellationTokenSource writeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
writeCts.CancelAfter(_writeTimeout);
|
||||||
|
|
||||||
|
string? directory = Path.GetDirectoryName(_path);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
string tempPath = _path + ".tmp";
|
||||||
|
await using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, file, SerializerOptions, writeCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, _path, overwrite: true);
|
||||||
|
_logger?.LogDebug(
|
||||||
|
"Persisted Galaxy hierarchy snapshot to {Path} ({ObjectCount} objects, {AttributeCount} attributes).",
|
||||||
|
_path,
|
||||||
|
snapshot.Hierarchy.Count,
|
||||||
|
snapshot.Attributes.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ioGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_path is null || !File.Exists(_path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PersistedFile? file;
|
||||||
|
await using (FileStream stream = new(_path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||||
|
{
|
||||||
|
file = await JsonSerializer.DeserializeAsync<PersistedFile>(
|
||||||
|
stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file is null || file.SchemaVersion != CurrentSchemaVersion || file.Snapshot is null)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(
|
||||||
|
"Ignoring Galaxy hierarchy snapshot at {Path}: unrecognized or empty schema version.",
|
||||||
|
_path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.Snapshot;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// A corrupt, truncated, locked, or access-denied snapshot file is an
|
||||||
|
// expected failure mode for a disk cache — honor the Try contract and
|
||||||
|
// return null rather than throwing.
|
||||||
|
_logger?.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Ignoring Galaxy hierarchy snapshot at {Path}: the file is unreadable or not valid JSON.",
|
||||||
|
_path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ioGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes the I/O gate. As a DI singleton the store is disposed once at host
|
||||||
|
/// shutdown, by which point no save/load is in flight.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ioGate.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>On-disk envelope: a schema version plus the snapshot payload.</summary>
|
||||||
|
private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="GalaxyObject"/> paired with its computed contained path and hierarchy
|
||||||
|
/// depth. Materialized once per cache entry by <see cref="GalaxyHierarchyIndex"/> so
|
||||||
|
/// browse/discover projection can filter and page without recomputing paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Object">The projected Galaxy object.</param>
|
||||||
|
/// <param name="ContainedPath">The slash-delimited contained path from the hierarchy root.</param>
|
||||||
|
/// <param name="Depth">The number of path segments from the root (zero for top-level objects).</param>
|
||||||
|
public sealed record GalaxyObjectView(
|
||||||
|
GalaxyObject Object,
|
||||||
|
string ContainedPath,
|
||||||
|
int Depth);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps <see cref="GalaxyHierarchyRow"/> + <see cref="GalaxyAttributeRow"/> rows produced
|
||||||
|
/// by <see cref="GalaxyRepository"/> into <c>galaxy_repository.v1</c> proto messages.
|
||||||
|
/// Pure function, separated so it can be unit-tested without a SQL connection.
|
||||||
|
/// </summary>
|
||||||
|
public static class GalaxyProtoMapper
|
||||||
|
{
|
||||||
|
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
|
||||||
|
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
|
||||||
|
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
|
||||||
|
public static IEnumerable<GalaxyObject> MapHierarchy(
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
|
{
|
||||||
|
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
||||||
|
.GroupBy(a => a.GobjectId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||||
|
{
|
||||||
|
yield return MapObject(row, attributesByGobjectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
|
||||||
|
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
|
||||||
|
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
|
||||||
|
public static GalaxyObject MapObject(
|
||||||
|
GalaxyHierarchyRow row,
|
||||||
|
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
|
||||||
|
{
|
||||||
|
GalaxyObject obj = new()
|
||||||
|
{
|
||||||
|
GobjectId = row.GobjectId,
|
||||||
|
TagName = row.TagName,
|
||||||
|
ContainedName = row.ContainedName,
|
||||||
|
BrowseName = row.BrowseName,
|
||||||
|
ParentGobjectId = row.ParentGobjectId,
|
||||||
|
IsArea = row.IsArea,
|
||||||
|
CategoryId = row.CategoryId,
|
||||||
|
HostedByGobjectId = row.HostedByGobjectId,
|
||||||
|
};
|
||||||
|
obj.TemplateChain.AddRange(row.TemplateChain);
|
||||||
|
|
||||||
|
if (attributesByGobjectId.TryGetValue(row.GobjectId, out List<GalaxyAttributeRow>? attrs))
|
||||||
|
{
|
||||||
|
foreach (GalaxyAttributeRow attr in attrs)
|
||||||
|
{
|
||||||
|
obj.Attributes.Add(MapAttribute(attr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
|
||||||
|
/// <param name="row">Attribute row from Galaxy Repository.</param>
|
||||||
|
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
|
||||||
|
{
|
||||||
|
AttributeName = row.AttributeName,
|
||||||
|
FullTagReference = row.FullTagReference,
|
||||||
|
MxDataType = row.MxDataType,
|
||||||
|
DataTypeName = row.DataTypeName ?? string.Empty,
|
||||||
|
IsArray = row.IsArray,
|
||||||
|
ArrayDimension = row.ArrayDimension ?? 0,
|
||||||
|
ArrayDimensionPresent = row.ArrayDimension.HasValue,
|
||||||
|
MxAttributeCategory = row.MxAttributeCategory,
|
||||||
|
SecurityClassification = row.SecurityClassification,
|
||||||
|
IsHistorized = row.IsHistorized,
|
||||||
|
IsAlarm = row.IsAlarm,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL access to the AVEVA System Platform Galaxy Repository database.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="HierarchySql" /> is the query originally ported from the OtOpcUa
|
||||||
|
/// project. <see cref="AttributesSql" /> has diverged: it additionally enumerates the
|
||||||
|
/// built-in attributes contributed by each object's primitives (from
|
||||||
|
/// <c>attribute_definition</c> via <c>primitive_instance</c>), so engine/platform objects
|
||||||
|
/// and extension sub-attributes (e.g. <c>TestAlarm001.Acked</c>) are surfaced. The
|
||||||
|
/// OtOpcUa query is not kept in sync.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
||||||
|
{
|
||||||
|
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SqlConnection conn = new(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||||
|
return result is int i && i == 1;
|
||||||
|
}
|
||||||
|
catch (SqlException) { return false; }
|
||||||
|
catch (InvalidOperationException) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using SqlConnection conn = new(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn)
|
||||||
|
{ CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||||
|
return result is DateTime dt ? dt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
List<GalaxyHierarchyRow> rows = new();
|
||||||
|
|
||||||
|
using SqlConnection conn = new(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
|
||||||
|
string[] templateChain = templateChainRaw.Length == 0
|
||||||
|
? Array.Empty<string>()
|
||||||
|
: templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(s => s.Trim())
|
||||||
|
.Where(s => s.Length > 0)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
rows.Add(new GalaxyHierarchyRow
|
||||||
|
{
|
||||||
|
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||||
|
TagName = reader.GetString(1),
|
||||||
|
ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||||
|
BrowseName = reader.GetString(3),
|
||||||
|
ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
|
||||||
|
IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
|
||||||
|
CategoryId = Convert.ToInt32(reader.GetValue(6)),
|
||||||
|
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
|
||||||
|
TemplateChain = templateChain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
List<GalaxyAttributeRow> rows = new();
|
||||||
|
|
||||||
|
using SqlConnection conn = new(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
rows.Add(new GalaxyAttributeRow
|
||||||
|
{
|
||||||
|
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||||
|
TagName = reader.GetString(1),
|
||||||
|
AttributeName = reader.GetString(2),
|
||||||
|
FullTagReference = reader.GetString(3),
|
||||||
|
MxDataType = Convert.ToInt32(reader.GetValue(4)),
|
||||||
|
DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
|
||||||
|
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
|
||||||
|
MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
|
||||||
|
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
|
||||||
|
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
|
||||||
|
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Area objects (category 13) are returned even when undeployed (deployed_package_id = 0):
|
||||||
|
// they are organizational/model nodes that group deployed objects, so excluding them
|
||||||
|
// orphans every area whose containing area is not itself deployed. All non-area objects
|
||||||
|
// still require deployment. Orphans left by a missing/deleted parent area are re-rooted
|
||||||
|
// by GalaxyHierarchyIndex.Build so nothing disappears from browse.
|
||||||
|
private const string HierarchySql = @"
|
||||||
|
;WITH template_chain AS (
|
||||||
|
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
||||||
|
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
|
||||||
|
FROM gobject g
|
||||||
|
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
|
||||||
|
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
|
||||||
|
UNION ALL
|
||||||
|
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
|
||||||
|
FROM template_chain tc
|
||||||
|
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
|
||||||
|
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
|
||||||
|
)
|
||||||
|
SELECT DISTINCT
|
||||||
|
g.gobject_id,
|
||||||
|
g.tag_name,
|
||||||
|
g.contained_name,
|
||||||
|
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
|
||||||
|
THEN g.tag_name
|
||||||
|
ELSE g.contained_name
|
||||||
|
END AS browse_name,
|
||||||
|
CASE WHEN g.contained_by_gobject_id = 0
|
||||||
|
THEN g.area_gobject_id
|
||||||
|
ELSE g.contained_by_gobject_id
|
||||||
|
END AS parent_gobject_id,
|
||||||
|
CASE WHEN td.category_id = 13
|
||||||
|
THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS is_area,
|
||||||
|
td.category_id AS category_id,
|
||||||
|
g.hosted_by_gobject_id AS hosted_by_gobject_id,
|
||||||
|
ISNULL(
|
||||||
|
STUFF((
|
||||||
|
SELECT '|' + tc.template_tag_name
|
||||||
|
FROM template_chain tc
|
||||||
|
WHERE tc.instance_gobject_id = g.gobject_id
|
||||||
|
ORDER BY tc.depth
|
||||||
|
FOR XML PATH('')
|
||||||
|
), 1, 1, ''),
|
||||||
|
''
|
||||||
|
) AS template_chain
|
||||||
|
FROM gobject g
|
||||||
|
INNER JOIN template_definition td
|
||||||
|
ON g.template_definition_id = td.template_definition_id
|
||||||
|
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
|
AND g.is_template = 0
|
||||||
|
AND (g.deployed_package_id <> 0 OR td.category_id = 13)
|
||||||
|
ORDER BY parent_gobject_id, g.tag_name";
|
||||||
|
|
||||||
|
// Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two
|
||||||
|
// kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute`
|
||||||
|
// body, src_pri 0) and the built-in attributes every object inherits from its primitives
|
||||||
|
// (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in
|
||||||
|
// attributes are why engine/platform objects and extension sub-attributes such as
|
||||||
|
// `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the
|
||||||
|
// `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the
|
||||||
|
// `_`-prefix and `.Description` name exclusions apply) and are never flagged
|
||||||
|
// `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an
|
||||||
|
// extension, not the extension's machinery leaves.
|
||||||
|
private const string AttributesSql = @"
|
||||||
|
;WITH deployed_package_chain AS (
|
||||||
|
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||||
|
FROM gobject g
|
||||||
|
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||||
|
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||||
|
UNION ALL
|
||||||
|
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||||
|
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||||
|
),
|
||||||
|
candidate AS (
|
||||||
|
SELECT
|
||||||
|
dpc.gobject_id, g.tag_name, da.attribute_name, da.mx_data_type, da.is_array,
|
||||||
|
CASE WHEN da.is_array = 1
|
||||||
|
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||||
|
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
||||||
|
ELSE NULL END AS array_dimension,
|
||||||
|
da.mx_attribute_category, da.security_classification, dpc.depth, 0 AS src_pri
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
|
||||||
|
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||||
|
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||||
|
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
|
AND da.attribute_name NOT LIKE '[_]%'
|
||||||
|
AND da.attribute_name NOT LIKE '%.Description'
|
||||||
|
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
dpc.gobject_id, g.tag_name,
|
||||||
|
CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
|
||||||
|
THEN ad.attribute_name
|
||||||
|
ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
|
||||||
|
ad.mx_data_type, ad.is_array,
|
||||||
|
CASE WHEN ad.is_array = 1
|
||||||
|
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||||
|
SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
|
||||||
|
ELSE NULL END AS array_dimension,
|
||||||
|
ad.mx_attribute_category, ad.security_classification, dpc.depth, 1 AS src_pri
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
|
||||||
|
INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||||
|
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||||
|
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||||
|
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
|
AND ad.attribute_name NOT LIKE '[_]%'
|
||||||
|
AND ad.attribute_name NOT LIKE '%.Description'
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT c.*, ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
|
||||||
|
FROM candidate c
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.gobject_id, r.tag_name, r.attribute_name,
|
||||||
|
r.tag_name + '.' + r.attribute_name
|
||||||
|
+ CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference,
|
||||||
|
r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension,
|
||||||
|
r.mx_attribute_category, r.security_classification,
|
||||||
|
CASE WHEN r.src_pri = 0 AND EXISTS (
|
||||||
|
SELECT 1 FROM deployed_package_chain dpc2
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||||
|
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
||||||
|
WHERE dpc2.gobject_id = r.gobject_id
|
||||||
|
) THEN 1 ELSE 0 END AS is_historized,
|
||||||
|
CASE WHEN r.src_pri = 0 AND EXISTS (
|
||||||
|
SELECT 1 FROM deployed_package_chain dpc2
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||||
|
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||||
|
WHERE dpc2.gobject_id = r.gobject_id
|
||||||
|
) THEN 1 ELSE 0 END AS is_alarm
|
||||||
|
FROM ranked r
|
||||||
|
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
||||||
|
WHERE r.rn = 1
|
||||||
|
ORDER BY r.tag_name, r.attribute_name";
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connection settings for the AVEVA System Platform Galaxy Repository database.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="SectionName"/> is a generic default; the DI extension accepts an explicit
|
||||||
|
/// configuration section path so a consumer can bind from its own section (e.g.
|
||||||
|
/// <c>HistorianGateway:Galaxy</c>).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyRepositoryOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generic default configuration section name. The DI extension accepts an explicit
|
||||||
|
/// section path, so a consumer may bind from a different section (e.g.
|
||||||
|
/// <c>HistorianGateway:Galaxy</c>).
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "GalaxyRepository";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default SQL Server connection string for the Galaxy Repository database.
|
||||||
|
/// Single source of truth shared with the integration-test fallback so the
|
||||||
|
/// production default and the live-test default cannot drift.
|
||||||
|
/// </summary>
|
||||||
|
public const string DefaultConnectionString =
|
||||||
|
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
|
||||||
|
|
||||||
|
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
|
||||||
|
public string ConnectionString { get; init; } = DefaultConnectionString;
|
||||||
|
|
||||||
|
/// <summary>The timeout in seconds for SQL commands executed against the Galaxy Repository.</summary>
|
||||||
|
public int CommandTimeoutSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interval (seconds) between background refreshes of the dashboard Galaxy summary
|
||||||
|
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
|
||||||
|
/// </summary>
|
||||||
|
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the latest successful Galaxy browse dataset is persisted to disk. When
|
||||||
|
/// enabled, the cache reloads that snapshot at startup so clients can still browse
|
||||||
|
/// last-known data while the Galaxy database is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
public bool PersistSnapshot { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File path for the persisted Galaxy browse snapshot. Ignored when
|
||||||
|
/// <see cref="PersistSnapshot"/> is <see langword="false"/>. There is no built-in
|
||||||
|
/// default path — the consumer supplies a cross-platform-friendly path appropriate to
|
||||||
|
/// its host. When left empty and <see cref="PersistSnapshot"/> is enabled, the
|
||||||
|
/// snapshot store (a later task) decides where to write.
|
||||||
|
/// </summary>
|
||||||
|
public string SnapshotCachePath { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolution result for a tag address: the owning <see cref="GalaxyObject"/>, the
|
||||||
|
/// specific <see cref="GalaxyAttribute"/> when the address names an attribute (otherwise
|
||||||
|
/// <see langword="null"/>), and the object's contained path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Object">The Galaxy object that owns the looked-up address.</param>
|
||||||
|
/// <param name="Attribute">The matched attribute, or <see langword="null"/> when the address names an object.</param>
|
||||||
|
/// <param name="ContainedPath">The owning object's contained path.</param>
|
||||||
|
public sealed record GalaxyTagLookup(
|
||||||
|
GalaxyObject Object,
|
||||||
|
GalaxyAttribute? Attribute,
|
||||||
|
string ContainedPath);
|
||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using ProtoGalaxyRepository = ZB.MOM.WW.GalaxyRepository.Grpc.GalaxyRepository;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reusable gRPC surface that exposes the Galaxy Repository to clients. Hosted by any
|
||||||
|
/// consuming gateway (e.g. MxAccessGateway or the HistorianGateway sidecar) via
|
||||||
|
/// <see cref="DependencyInjection.GalaxyRepositoryServiceCollectionExtensions.MapZbGalaxyRepository"/>.
|
||||||
|
/// <para>
|
||||||
|
/// <c>DiscoverHierarchy</c> and <c>GetLastDeployTime</c> serve from
|
||||||
|
/// <see cref="IGalaxyHierarchyCache"/> so many clients share a single SQL pull.
|
||||||
|
/// <c>WatchDeployEvents</c> streams events from <see cref="IGalaxyDeployNotifier"/>.
|
||||||
|
/// <c>TestConnection</c> remains a direct SQL probe since callers use it as a health check.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This service applies <b>no</b> per-identity browse-subtree filtering — the full
|
||||||
|
/// hierarchy is projected (<c>null</c> subtree globs). Authorization (including any
|
||||||
|
/// subtree scoping) is the responsibility of the hosting gateway's interceptor layer.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Direct SQL surface used by <c>TestConnection</c>.</param>
|
||||||
|
/// <param name="cache">Shared hierarchy cache that <c>DiscoverHierarchy</c>/<c>BrowseChildren</c>/<c>GetLastDeployTime</c> serve from.</param>
|
||||||
|
/// <param name="notifier">Deploy-event source streamed by <c>WatchDeployEvents</c>.</param>
|
||||||
|
public sealed class GalaxyRepositoryGrpcService(
|
||||||
|
IGalaxyRepository repository,
|
||||||
|
IGalaxyHierarchyCache cache,
|
||||||
|
IGalaxyDeployNotifier notifier) : ProtoGalaxyRepository.GalaxyRepositoryBase
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
||||||
|
private const int DefaultDiscoverPageSize = 1000;
|
||||||
|
private const int MaxDiscoverPageSize = 5000;
|
||||||
|
private const int DefaultBrowsePageSize = 500;
|
||||||
|
// MaxBrowsePageSize reuses MaxDiscoverPageSize (5000) — same cap.
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task<TestConnectionReply> TestConnection(
|
||||||
|
TestConnectionRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false);
|
||||||
|
return new TestConnectionReply { Ok = ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task<GetLastDeployTimeReply> GetLastDeployTime(
|
||||||
|
GetLastDeployTimeRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||||
|
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||||
|
|
||||||
|
if (!entry.HasData)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.Unavailable,
|
||||||
|
ResolveUnavailableMessage(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLastDeployTimeReply reply = new() { Present = entry.LastDeployTime.HasValue };
|
||||||
|
if (entry.LastDeployTime.HasValue)
|
||||||
|
{
|
||||||
|
reply.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(entry.LastDeployTime.Value);
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||||
|
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||||
|
|
||||||
|
if (!entry.HasData)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.Unavailable,
|
||||||
|
ResolveUnavailableMessage(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
int pageSize = ResolvePageSize(request.PageSize);
|
||||||
|
// The shared library applies no per-identity subtree scoping; the hosting
|
||||||
|
// gateway enforces authorization at its interceptor layer.
|
||||||
|
string filterSignature = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtreeGlobs: null);
|
||||||
|
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||||
|
GalaxyHierarchyQueryResult query = GalaxyHierarchyProjector.Project(
|
||||||
|
entry,
|
||||||
|
request,
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
pageToken.Offset,
|
||||||
|
pageSize);
|
||||||
|
int offset = pageToken.Offset;
|
||||||
|
if (offset > query.TotalObjectCount)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"DiscoverHierarchy page_token is outside the current hierarchy."));
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverHierarchyReply reply = new()
|
||||||
|
{
|
||||||
|
TotalObjectCount = query.TotalObjectCount,
|
||||||
|
};
|
||||||
|
reply.Objects.Add(query.Objects);
|
||||||
|
|
||||||
|
int nextOffset = offset + query.Objects.Count;
|
||||||
|
if (nextOffset < query.TotalObjectCount)
|
||||||
|
{
|
||||||
|
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task<BrowseChildrenReply> BrowseChildren(
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||||
|
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||||
|
|
||||||
|
if (!entry.HasData)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.Unavailable,
|
||||||
|
ResolveUnavailableMessage(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
int pageSize = ResolveBrowsePageSize(request.PageSize);
|
||||||
|
|
||||||
|
// Resolve the parent id once so the page-token signature can include it
|
||||||
|
// and the projector sees the same resolved id when memoizing. The projector
|
||||||
|
// re-resolves internally; with the by-name/by-path indexes on
|
||||||
|
// GalaxyHierarchyIndex that second call is O(1), so the redundancy is cheap
|
||||||
|
// and keeps the projector self-contained.
|
||||||
|
int parentId = GalaxyBrowseProjector.ResolveParentId(entry, request);
|
||||||
|
string filterSignature = GalaxyBrowseProjector.ComputeFilterSignature(
|
||||||
|
request, browseSubtreeGlobs: null, parentId);
|
||||||
|
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
|
entry,
|
||||||
|
request,
|
||||||
|
browseSubtreeGlobs: null,
|
||||||
|
pageToken.Offset,
|
||||||
|
pageSize);
|
||||||
|
|
||||||
|
if (pageToken.Offset > result.TotalChildCount)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"BrowseChildren page_token is outside the current children set."));
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseChildrenReply reply = new()
|
||||||
|
{
|
||||||
|
TotalChildCount = result.TotalChildCount,
|
||||||
|
CacheSequence = (ulong)entry.Sequence,
|
||||||
|
};
|
||||||
|
reply.Children.Add(result.Children);
|
||||||
|
reply.ChildHasChildren.Add(result.ChildHasChildren);
|
||||||
|
|
||||||
|
int nextOffset = pageToken.Offset + result.Children.Count;
|
||||||
|
if (nextOffset < result.TotalChildCount)
|
||||||
|
{
|
||||||
|
reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task WatchDeployEvents(
|
||||||
|
WatchDeployEventsRequest request,
|
||||||
|
IServerStreamWriter<DeployEvent> responseStream,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
|
||||||
|
|
||||||
|
await foreach (GalaxyDeployEventInfo info in notifier
|
||||||
|
.SubscribeAsync(context.CancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
// Suppress the initial bootstrap event when the client already knows about
|
||||||
|
// this deploy time. We only suppress the first one — subsequent events fire
|
||||||
|
// on actual changes, so they always pass.
|
||||||
|
if (lastSeen is { } seen && info.TimeOfLastDeploy == seen)
|
||||||
|
{
|
||||||
|
lastSeen = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastSeen = null;
|
||||||
|
|
||||||
|
await responseStream.WriteAsync(MapDeployEvent(info), context.CancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitForCacheBootstrap(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cache.Current.HasData || cache.Current.Status == GalaxyCacheStatus.Unavailable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
budget.CancelAfter(FirstLoadWaitBudget);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cache.WaitForFirstLoadAsync(budget.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Budget elapsed; fall through and let the caller see the current
|
||||||
|
// (possibly Unknown/Unavailable) entry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeployEvent MapDeployEvent(GalaxyDeployEventInfo info)
|
||||||
|
{
|
||||||
|
DeployEvent ev = new()
|
||||||
|
{
|
||||||
|
Sequence = (ulong)info.Sequence,
|
||||||
|
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
|
||||||
|
ObjectCount = info.ObjectCount,
|
||||||
|
AttributeCount = info.AttributeCount,
|
||||||
|
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
|
||||||
|
};
|
||||||
|
if (info.TimeOfLastDeploy.HasValue)
|
||||||
|
{
|
||||||
|
ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(info.TimeOfLastDeploy.Value);
|
||||||
|
}
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveUnavailableMessage(GalaxyHierarchyCacheEntry entry) => entry.Status switch
|
||||||
|
{
|
||||||
|
GalaxyCacheStatus.Unknown => "Galaxy cache has not completed its initial load yet.",
|
||||||
|
GalaxyCacheStatus.Unavailable => "Galaxy repository is unavailable.",
|
||||||
|
_ => "Galaxy cache has no data available.",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int ResolvePageSize(int requestedPageSize)
|
||||||
|
{
|
||||||
|
if (requestedPageSize < 0)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"DiscoverHierarchy page_size must be greater than zero when provided."));
|
||||||
|
}
|
||||||
|
|
||||||
|
int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize;
|
||||||
|
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveBrowsePageSize(int requested)
|
||||||
|
{
|
||||||
|
if (requested < 0)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"BrowseChildren page_size must be greater than zero when provided."));
|
||||||
|
}
|
||||||
|
int pageSize = requested == 0 ? DefaultBrowsePageSize : requested;
|
||||||
|
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatPageToken(long sequence, string filterSignature, int offset)
|
||||||
|
{
|
||||||
|
return string.Concat(
|
||||||
|
sequence.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
":",
|
||||||
|
filterSignature,
|
||||||
|
":",
|
||||||
|
offset.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PageToken ParsePageToken(string pageToken, long currentSequence, string currentFilterSignature)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pageToken))
|
||||||
|
{
|
||||||
|
return new PageToken(currentSequence, currentFilterSignature, Offset: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] parts = pageToken.Split(':', count: 3);
|
||||||
|
if (parts.Length != 3
|
||||||
|
|| !long.TryParse(
|
||||||
|
parts[0],
|
||||||
|
System.Globalization.NumberStyles.None,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out long sequence)
|
||||||
|
|| !int.TryParse(
|
||||||
|
parts[2],
|
||||||
|
System.Globalization.NumberStyles.None,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out int offset)
|
||||||
|
|| offset < 0)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"page_token is invalid."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sequence != currentSequence)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"page_token is stale."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"page_token does not match the current filters."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PageToken(sequence, parts[1], offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PageToken(long Sequence, string FilterSignature, int Offset);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>Publishes Galaxy repository deploy events to subscribers.</summary>
|
||||||
|
public interface IGalaxyDeployNotifier
|
||||||
|
{
|
||||||
|
/// <summary>The most recently published event, or null if no event has fired yet.</summary>
|
||||||
|
GalaxyDeployEventInfo? Latest { get; }
|
||||||
|
|
||||||
|
/// <summary>Publishes a deploy event to all current subscribers and stores it as Latest.</summary>
|
||||||
|
/// <param name="info">The deploy event to publish.</param>
|
||||||
|
void Publish(GalaxyDeployEventInfo info);
|
||||||
|
|
||||||
|
/// <summary>Subscribes to deploy events. The sequence yields the latest event first (if available) then streams new events as they fire.</summary>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>Async enumerable of deploy events.</returns>
|
||||||
|
IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>Cache for Galaxy Repository hierarchy data.</summary>
|
||||||
|
public interface IGalaxyHierarchyCache
|
||||||
|
{
|
||||||
|
/// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary>
|
||||||
|
GalaxyHierarchyCacheEntry Current { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forces a refresh against the Galaxy Repository. Performs a cheap
|
||||||
|
/// <c>time_of_last_deploy</c> probe first and only re-queries the heavy hierarchy +
|
||||||
|
/// attributes rowsets when the deploy time has changed since the last successful
|
||||||
|
/// refresh.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task RefreshAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Awaits the first completed refresh attempt (success or failure). Useful for
|
||||||
|
/// gRPC handlers that want to serve from cache without returning Unavailable on the
|
||||||
|
/// very first request after the service starts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the latest Galaxy Repository browse dataset to disk and reloads
|
||||||
|
/// it at startup. Lets <see cref="GalaxyHierarchyCache"/> serve last-known
|
||||||
|
/// browse data when the Galaxy database is unreachable on a cold start.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGalaxyHierarchySnapshotStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes <paramref name="snapshot"/> to disk, replacing any previous
|
||||||
|
/// snapshot atomically. A no-op when snapshot persistence is disabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">The browse dataset to persist.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the persisted Galaxy browse dataset.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The persisted snapshot, or <see langword="null"/> when none exists,
|
||||||
|
/// persistence is disabled, or the on-disk file uses an unrecognized
|
||||||
|
/// schema version.
|
||||||
|
/// </returns>
|
||||||
|
Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over <see cref="GalaxyRepository"/>: the read-only SQL surface over the
|
||||||
|
/// AVEVA System Platform Galaxy Repository database. Exists so consumers (and the cache
|
||||||
|
/// layer, a later task) can be unit-tested against an in-memory fake without standing up a
|
||||||
|
/// real <c>Microsoft.Data.SqlClient</c> <c>SqlConnection</c> against a bogus host/port.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGalaxyRepository
|
||||||
|
{
|
||||||
|
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package galaxy_repository.v1;
|
||||||
|
|
||||||
|
option csharp_namespace = "ZB.MOM.WW.GalaxyRepository.Grpc";
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
import "google/protobuf/wrappers.proto";
|
||||||
|
|
||||||
|
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||||
|
// additively only. Never renumber or repurpose an existing field number or
|
||||||
|
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||||
|
// (and `reserved` name) covering it in the same change so a future editor
|
||||||
|
// cannot accidentally reuse the retired tag. There are no `reserved`
|
||||||
|
// declarations today because no field or enum value has ever been removed.
|
||||||
|
|
||||||
|
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||||
|
// database). Lets clients enumerate the deployed object hierarchy and each
|
||||||
|
// object's dynamic attributes so they know what tag references to subscribe
|
||||||
|
// to via the MxAccessGateway service.
|
||||||
|
service GalaxyRepository {
|
||||||
|
rpc TestConnection(TestConnectionRequest) returns (TestConnectionReply);
|
||||||
|
rpc GetLastDeployTime(GetLastDeployTimeRequest) returns (GetLastDeployTimeReply);
|
||||||
|
rpc DiscoverHierarchy(DiscoverHierarchyRequest) returns (DiscoverHierarchyReply);
|
||||||
|
|
||||||
|
// Server-stream of deploy events. The server emits the current state immediately
|
||||||
|
// on subscribe (so clients can bootstrap their cache without waiting for the next
|
||||||
|
// deploy), then emits one event each time the gateway's hierarchy cache observes
|
||||||
|
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
|
||||||
|
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||||
|
// older events because the client was too slow.
|
||||||
|
rpc WatchDeployEvents(WatchDeployEventsRequest) returns (stream DeployEvent);
|
||||||
|
|
||||||
|
// Returns the direct children of a parent object (or the root objects when
|
||||||
|
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
// one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
rpc BrowseChildren(BrowseChildrenRequest) returns (BrowseChildrenReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
message TestConnectionRequest {}
|
||||||
|
|
||||||
|
message TestConnectionReply {
|
||||||
|
bool ok = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetLastDeployTimeRequest {}
|
||||||
|
|
||||||
|
message GetLastDeployTimeReply {
|
||||||
|
bool present = 1;
|
||||||
|
google.protobuf.Timestamp time_of_last_deploy = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DiscoverHierarchyRequest {
|
||||||
|
// Maximum number of objects to return. The server applies its default when
|
||||||
|
// unset and rejects non-positive values.
|
||||||
|
int32 page_size = 1;
|
||||||
|
// Opaque token returned by a previous DiscoverHierarchy response.
|
||||||
|
string page_token = 2;
|
||||||
|
// Optional. When set, return only this object and its descendants.
|
||||||
|
// Empty = full hierarchy.
|
||||||
|
oneof root {
|
||||||
|
int32 root_gobject_id = 3;
|
||||||
|
string root_tag_name = 4;
|
||||||
|
string root_contained_path = 5;
|
||||||
|
}
|
||||||
|
// Optional. Cap on descendant depth from root. Zero returns only the root.
|
||||||
|
// Unset means unlimited depth.
|
||||||
|
google.protobuf.Int32Value max_depth = 6;
|
||||||
|
// Optional object category id filters.
|
||||||
|
repeated int32 category_ids = 7;
|
||||||
|
// Optional case-insensitive substring filters against template names.
|
||||||
|
repeated string template_chain_contains = 8;
|
||||||
|
// Optional anchored, case-insensitive glob over object tag_name.
|
||||||
|
string tag_name_glob = 9;
|
||||||
|
// Optional. Unset or true includes attributes. False returns object skeletons.
|
||||||
|
optional bool include_attributes = 10;
|
||||||
|
// Optional. Return only objects with at least one alarm-bearing attribute.
|
||||||
|
bool alarm_bearing_only = 11;
|
||||||
|
// Optional. Return only objects with at least one historized attribute.
|
||||||
|
bool historized_only = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DiscoverHierarchyReply {
|
||||||
|
repeated GalaxyObject objects = 1;
|
||||||
|
// Non-empty when another page is available.
|
||||||
|
string next_page_token = 2;
|
||||||
|
// Total number of objects in the cached hierarchy at the time of the call.
|
||||||
|
int32 total_object_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WatchDeployEventsRequest {
|
||||||
|
// Optional. When set, the bootstrap event is suppressed if the cached deploy
|
||||||
|
// time matches this value. Future events are still emitted normally.
|
||||||
|
google.protobuf.Timestamp last_seen_deploy_time = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeployEvent {
|
||||||
|
// Monotonically increasing per server start. Gaps indicate dropped events.
|
||||||
|
uint64 sequence = 1;
|
||||||
|
// Server wall-clock when the cache observed the deploy.
|
||||||
|
google.protobuf.Timestamp observed_at = 2;
|
||||||
|
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
|
||||||
|
google.protobuf.Timestamp time_of_last_deploy = 3;
|
||||||
|
bool time_of_last_deploy_present = 4;
|
||||||
|
int32 object_count = 5;
|
||||||
|
int32 attribute_count = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GalaxyObject {
|
||||||
|
int32 gobject_id = 1;
|
||||||
|
string tag_name = 2;
|
||||||
|
string contained_name = 3;
|
||||||
|
string browse_name = 4;
|
||||||
|
int32 parent_gobject_id = 5;
|
||||||
|
bool is_area = 6;
|
||||||
|
int32 category_id = 7;
|
||||||
|
int32 hosted_by_gobject_id = 8;
|
||||||
|
repeated string template_chain = 9;
|
||||||
|
repeated GalaxyAttribute attributes = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GalaxyAttribute {
|
||||||
|
string attribute_name = 1;
|
||||||
|
string full_tag_reference = 2;
|
||||||
|
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||||
|
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||||
|
// type enumeration is distinct from MXAccess's wire data-type enum and
|
||||||
|
// the two must not be cast or compared. The GalaxyRepository service is
|
||||||
|
// metadata-only and deliberately does not share types with
|
||||||
|
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||||
|
int32 mx_data_type = 3;
|
||||||
|
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||||
|
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||||
|
string data_type_name = 4;
|
||||||
|
bool is_array = 5;
|
||||||
|
int32 array_dimension = 6;
|
||||||
|
bool array_dimension_present = 7;
|
||||||
|
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||||
|
// Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
|
int32 mx_attribute_category = 8;
|
||||||
|
// Raw Galaxy SQL security-classification identifier, passed through
|
||||||
|
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
|
int32 security_classification = 9;
|
||||||
|
bool is_historized = 10;
|
||||||
|
bool is_alarm = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BrowseChildrenRequest {
|
||||||
|
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
|
||||||
|
oneof parent {
|
||||||
|
int32 parent_gobject_id = 1;
|
||||||
|
string parent_tag_name = 2;
|
||||||
|
string parent_contained_path = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximum number of direct children to return. Server default 500; cap 5000.
|
||||||
|
int32 page_size = 4;
|
||||||
|
// Opaque token returned by a previous BrowseChildren response. Bound to the
|
||||||
|
// cache sequence, parent selector, and the filter set; a mismatch returns
|
||||||
|
// InvalidArgument.
|
||||||
|
string page_token = 5;
|
||||||
|
|
||||||
|
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
|
||||||
|
repeated int32 category_ids = 6;
|
||||||
|
repeated string template_chain_contains = 7;
|
||||||
|
string tag_name_glob = 8;
|
||||||
|
optional bool include_attributes = 9;
|
||||||
|
bool alarm_bearing_only = 10;
|
||||||
|
bool historized_only = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BrowseChildrenReply {
|
||||||
|
// Direct children matching the filter, sorted areas-first then by
|
||||||
|
// case-insensitive display name (same order as the dashboard tree).
|
||||||
|
repeated GalaxyObject children = 1;
|
||||||
|
// Non-empty when another page of siblings is available.
|
||||||
|
string next_page_token = 2;
|
||||||
|
// Total matching direct children of the parent (post-filter).
|
||||||
|
int32 total_child_count = 3;
|
||||||
|
// Parallel array, indexed with `children`. True when the child has at least
|
||||||
|
// one matching descendant under the same filter set. Lets a UI choose
|
||||||
|
// whether to draw an expand triangle without an extra round trip.
|
||||||
|
repeated bool child_has_children = 4;
|
||||||
|
// Cache sequence this reply was projected from. Clients may pass it back as
|
||||||
|
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
|
||||||
|
uint64 cache_sequence = 5;
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageId>ZB.MOM.WW.GalaxyRepository</PackageId>
|
||||||
|
<Authors>ZB.MOM.WW</Authors>
|
||||||
|
<Description>Read-only Galaxy object-hierarchy browse library for the ZB.MOM.WW SCADA family. Provides a SQL provider for the Galaxy Repository database and a canonical gRPC service for exposing the hierarchy to modern .NET 10 clients — extracted from MxAccessGateway so any consumer can browse the Galaxy without loading 32-bit COM.</Description>
|
||||||
|
<PackageTags>galaxy;repository;browse;aveva;wonderware;system-platform;scada;grpc;sql;zb-mom-ww</PackageTags>
|
||||||
|
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-galaxyrepository</PackageProjectUrl>
|
||||||
|
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-galaxyrepository</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||||
|
<PackageReference Include="Grpc.AspNetCore" />
|
||||||
|
<PackageReference Include="Google.Protobuf" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
|
<PackageReference Include="Grpc.Tools">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Proto files are added in Task 2; the empty glob is intentional and builds cleanly. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Protobuf Include="Protos\*.proto" GrpcServices="Server" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="IGalaxyRepository"/> returning canned rowsets. Counts the heavy
|
||||||
|
/// hierarchy/attribute reads so tests can assert deploy-gated skips, and can be flipped to
|
||||||
|
/// throw so the failure path is exercisable.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeGalaxyRepository : IGalaxyRepository
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<GalaxyHierarchyRow> _hierarchy;
|
||||||
|
private readonly IReadOnlyList<GalaxyAttributeRow> _attributes;
|
||||||
|
|
||||||
|
public FakeGalaxyRepository(
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes,
|
||||||
|
DateTime? deployTime)
|
||||||
|
{
|
||||||
|
_hierarchy = hierarchy;
|
||||||
|
_attributes = attributes;
|
||||||
|
DeployTime = deployTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The deploy time returned by <see cref="GetLastDeployTimeAsync"/>; mutate to simulate a redeploy.</summary>
|
||||||
|
public DateTime? DeployTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, every query throws this exception (simulates an unreachable database).</summary>
|
||||||
|
public Exception? ThrowOnQuery { get; set; }
|
||||||
|
|
||||||
|
public int HierarchyReadCount { get; private set; }
|
||||||
|
|
||||||
|
public int AttributeReadCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
|
||||||
|
ThrowOnQuery is null ? Task.FromResult(true) : throw ThrowOnQuery;
|
||||||
|
|
||||||
|
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ThrowOnQuery is not null)
|
||||||
|
{
|
||||||
|
throw ThrowOnQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(DeployTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ThrowOnQuery is not null)
|
||||||
|
{
|
||||||
|
throw ThrowOnQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
HierarchyReadCount++;
|
||||||
|
return Task.FromResult(_hierarchy.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (ThrowOnQuery is not null)
|
||||||
|
{
|
||||||
|
throw ThrowOnQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttributeReadCount++;
|
||||||
|
return Task.FromResult(_attributes.ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records published deploy events so tests can assert publication.</summary>
|
||||||
|
internal sealed class RecordingDeployNotifier : IGalaxyDeployNotifier
|
||||||
|
{
|
||||||
|
public List<GalaxyDeployEventInfo> Published { get; } = [];
|
||||||
|
|
||||||
|
public GalaxyDeployEventInfo? Latest { get; private set; }
|
||||||
|
|
||||||
|
public void Publish(GalaxyDeployEventInfo info)
|
||||||
|
{
|
||||||
|
Published.Add(info);
|
||||||
|
Latest = info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Latest is { } latest)
|
||||||
|
{
|
||||||
|
yield return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="IGalaxyHierarchySnapshotStore"/>. Pre-seed <see cref="Snapshot"/>
|
||||||
|
/// to exercise the restore path; reads <see cref="SaveAsync"/> back to assert persistence.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeSnapshotStore : IGalaxyHierarchySnapshotStore
|
||||||
|
{
|
||||||
|
public GalaxyHierarchySnapshot? Snapshot { get; set; }
|
||||||
|
|
||||||
|
public int SaveCount { get; private set; }
|
||||||
|
|
||||||
|
public int LoadCount { get; private set; }
|
||||||
|
|
||||||
|
public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SaveCount++;
|
||||||
|
Snapshot = snapshot;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LoadCount++;
|
||||||
|
return Task.FromResult(Snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="TimeProvider"/> whose UTC clock is fixed (and advanceable) so the cache's
|
||||||
|
/// staleness projection (which fires after a 5-minute threshold) is deterministic.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StubTimeProvider(DateTimeOffset start) : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _now = start;
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => _now;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan delta) => _now += delta;
|
||||||
|
}
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="GalaxyHierarchyCache"/> first-load, deploy-gating, snapshot
|
||||||
|
/// restore, persistence, and status-transition behavior. Uses an in-memory
|
||||||
|
/// <see cref="IGalaxyRepository"/> and snapshot store plus a fixed
|
||||||
|
/// <see cref="StubTimeProvider"/> so no SQL is touched and no asserts are time-sensitive.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchyCacheTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
private static readonly DateTime DeployTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static List<GalaxyHierarchyRow> SampleHierarchy() =>
|
||||||
|
[
|
||||||
|
new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", IsArea = true },
|
||||||
|
new() { GobjectId = 2, TagName = "Pump01", ContainedName = "Pump01", BrowseName = "Pump01", ParentGobjectId = 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
private static List<GalaxyAttributeRow> SampleAttributes() =>
|
||||||
|
[
|
||||||
|
new() { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true, IsAlarm = true },
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_FirstLoad_PopulatesCurrentWithDataAndUnblocksWaitForFirstLoad()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
RecordingDeployNotifier notifier = new();
|
||||||
|
using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));
|
||||||
|
|
||||||
|
// Before refresh, the gate is unset and there is no data.
|
||||||
|
Assert.False(cache.Current.HasData);
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Unknown, cache.Current.Status);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// First load completes (does not hang) and Current now holds usable data.
|
||||||
|
await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
|
||||||
|
GalaxyHierarchyCacheEntry current = cache.Current;
|
||||||
|
Assert.True(current.HasData);
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Healthy, current.Status);
|
||||||
|
Assert.Equal(2, current.ObjectCount);
|
||||||
|
Assert.Equal(1, current.AreaCount);
|
||||||
|
Assert.Equal(1, current.AttributeCount);
|
||||||
|
Assert.Equal(1, current.HistorizedAttributeCount);
|
||||||
|
Assert.Equal(1, current.AlarmAttributeCount);
|
||||||
|
|
||||||
|
// The heavy queries ran exactly once and a deploy event was published.
|
||||||
|
Assert.Equal(1, repository.HierarchyReadCount);
|
||||||
|
Assert.Equal(1, repository.AttributeReadCount);
|
||||||
|
GalaxyDeployEventInfo published = Assert.Single(notifier.Published);
|
||||||
|
Assert.Equal(2, published.ObjectCount);
|
||||||
|
Assert.Equal(1, published.AttributeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_NoDeployChange_SkipsHeavyQueriesOnSecondRefresh()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Deploy time unchanged => the heavy hierarchy/attribute reads happened only once.
|
||||||
|
Assert.Equal(1, repository.HierarchyReadCount);
|
||||||
|
Assert.Equal(1, repository.AttributeReadCount);
|
||||||
|
Assert.True(cache.Current.HasData);
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_DeployAdvances_RebuildsAndBumpsSequence()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
RecordingDeployNotifier notifier = new();
|
||||||
|
using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
long firstSequence = cache.Current.Sequence;
|
||||||
|
|
||||||
|
repository.DeployTime = DeployTime.AddHours(1);
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, repository.HierarchyReadCount);
|
||||||
|
Assert.Equal(firstSequence + 1, cache.Current.Sequence);
|
||||||
|
Assert.Equal(2, notifier.Published.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_FirstQueryFailsNoPriorData_StatusUnavailableButFirstLoadStillCompletes()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
|
||||||
|
{
|
||||||
|
ThrowOnQuery = new TimeoutException("galaxy db unreachable"),
|
||||||
|
};
|
||||||
|
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// First load must complete so callers do not hang, even though the query failed.
|
||||||
|
await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
|
||||||
|
Assert.False(cache.Current.HasData);
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
|
||||||
|
Assert.Contains("unreachable", cache.Current.LastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_QueryFailsAfterPriorData_DegradesToStaleAndKeepsData()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
Assert.True(cache.Current.HasData);
|
||||||
|
|
||||||
|
// A later refresh fails: data is retained but flagged Stale.
|
||||||
|
repository.DeployTime = DeployTime.AddHours(1);
|
||||||
|
repository.ThrowOnQuery = new InvalidOperationException("transient");
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(cache.Current.HasData);
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||||
|
Assert.Equal(2, cache.Current.ObjectCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Current_AfterStalenessThreshold_ProjectsHealthyToStale()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
StubTimeProvider clock = new(FixedNow);
|
||||||
|
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), clock);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||||
|
|
||||||
|
// Advance past the 5-minute staleness threshold with no successful refresh.
|
||||||
|
clock.Advance(TimeSpan.FromMinutes(6));
|
||||||
|
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||||
|
// Data is still present — Stale means "old", not "gone".
|
||||||
|
Assert.True(cache.Current.HasData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_PersistsSnapshotAfterSuccessfulHeavyRefresh()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
FakeSnapshotStore store = new();
|
||||||
|
using GalaxyHierarchyCache cache = new(
|
||||||
|
repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, store.SaveCount);
|
||||||
|
Assert.NotNull(store.Snapshot);
|
||||||
|
Assert.Equal(2, store.Snapshot!.Hierarchy.Count);
|
||||||
|
Assert.Single(store.Snapshot.Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_SnapshotRestore_ServesLastKnownDataAsStaleWhenDatabaseUnreachable()
|
||||||
|
{
|
||||||
|
// The snapshot store already holds a persisted dataset (last-known browse data).
|
||||||
|
FakeSnapshotStore store = new()
|
||||||
|
{
|
||||||
|
Snapshot = new GalaxyHierarchySnapshot(
|
||||||
|
LastDeployTime: DeployTime,
|
||||||
|
SavedAt: FixedNow.AddMinutes(-1),
|
||||||
|
Hierarchy: SampleHierarchy(),
|
||||||
|
Attributes: SampleAttributes()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The Galaxy database is unreachable on this cold start.
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
|
||||||
|
{
|
||||||
|
ThrowOnQuery = new TimeoutException("cold start, db down"),
|
||||||
|
};
|
||||||
|
RecordingDeployNotifier notifier = new();
|
||||||
|
using GalaxyHierarchyCache cache = new(
|
||||||
|
repository, notifier, new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// First load is satisfied by the restored snapshot, not by SQL.
|
||||||
|
await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
|
||||||
|
Assert.Equal(1, store.LoadCount);
|
||||||
|
GalaxyHierarchyCacheEntry current = cache.Current;
|
||||||
|
Assert.True(current.HasData);
|
||||||
|
// Restored data is "last-known", surfaced as Stale until the live DB confirms.
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Stale, current.Status);
|
||||||
|
Assert.Equal(2, current.ObjectCount);
|
||||||
|
Assert.Equal(DeployTime, current.LastDeployTime!.Value.UtcDateTime);
|
||||||
|
|
||||||
|
// A deploy event was published for the restored data.
|
||||||
|
Assert.Single(notifier.Published);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_SnapshotRestoreThenLiveQuery_PromotesRestoredDataToHealthy()
|
||||||
|
{
|
||||||
|
FakeSnapshotStore store = new()
|
||||||
|
{
|
||||||
|
Snapshot = new GalaxyHierarchySnapshot(
|
||||||
|
LastDeployTime: DeployTime,
|
||||||
|
SavedAt: FixedNow.AddMinutes(-1),
|
||||||
|
Hierarchy: SampleHierarchy(),
|
||||||
|
Attributes: SampleAttributes()),
|
||||||
|
};
|
||||||
|
// DB is reachable and reports the SAME deploy time the snapshot was pulled at.
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
using GalaxyHierarchyCache cache = new(
|
||||||
|
repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
|
||||||
|
|
||||||
|
await cache.RefreshAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Restore seeds Stale data; the same-deploy live query promotes it to Healthy
|
||||||
|
// without re-running the heavy hierarchy/attribute reads.
|
||||||
|
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||||
|
Assert.Equal(0, repository.HierarchyReadCount);
|
||||||
|
Assert.True(cache.Current.HasData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_CanBeCalledWithoutHavingRefreshed()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||||
|
GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||||
|
|
||||||
|
// Dispose must be safe even when no refresh ever ran (semaphore never entered).
|
||||||
|
cache.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
+458
@@ -0,0 +1,458 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-logic tests for <see cref="GalaxyHierarchyProjector"/> and
|
||||||
|
/// <see cref="GalaxyBrowseProjector"/>. No SQL: the cache entry under test is built
|
||||||
|
/// from a small hand-made hierarchy through the same materialization the live cache
|
||||||
|
/// uses (a fake <see cref="IGalaxyRepository"/> driven through
|
||||||
|
/// <see cref="GalaxyHierarchyCache.RefreshAsync"/>), so the projectors are exercised
|
||||||
|
/// against a real <see cref="GalaxyHierarchyIndex"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchyProjectorTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a realistic cache entry by driving a fake repository through the cache's
|
||||||
|
/// own refresh path. This goes through <c>BuildEntry</c> + <see cref="GalaxyHierarchyIndex.Build"/>
|
||||||
|
/// exactly as production does, rather than reaching for an internal factory.
|
||||||
|
/// </summary>
|
||||||
|
private static GalaxyHierarchyCacheEntry BuildEntry(
|
||||||
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
|
{
|
||||||
|
FakeGalaxyRepository repository = new(hierarchy, attributes, deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier());
|
||||||
|
cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||||
|
Assert.True(entry.HasData);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small but representative galaxy:
|
||||||
|
// PlantArea (area, id 1)
|
||||||
|
// ├─ LineA (area, id 2)
|
||||||
|
// │ ├─ Pump01 (id 10, template "Pump", historized+alarm attr)
|
||||||
|
// │ └─ Valve01 (id 11, template "Valve", plain attr)
|
||||||
|
// └─ Mixer01 (id 12, template "Mixer", alarm attr only)
|
||||||
|
// StandaloneTank (id 20, no parent — a root object)
|
||||||
|
private static GalaxyHierarchyCacheEntry BuildSampleEntry()
|
||||||
|
{
|
||||||
|
List<GalaxyHierarchyRow> hierarchy =
|
||||||
|
[
|
||||||
|
Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100),
|
||||||
|
Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100),
|
||||||
|
Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump", "$UserDefined"]),
|
||||||
|
Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]),
|
||||||
|
Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]),
|
||||||
|
Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<GalaxyAttributeRow> attributes =
|
||||||
|
[
|
||||||
|
// Pump01: historized AND alarm-bearing.
|
||||||
|
Attribute(10, "Pump01.PV", historized: true, alarm: true),
|
||||||
|
Attribute(10, "Pump01.SP", historized: false, alarm: false),
|
||||||
|
// Valve01: plain.
|
||||||
|
Attribute(11, "Valve01.Cmd", historized: false, alarm: false),
|
||||||
|
// Mixer01: alarm only.
|
||||||
|
Attribute(12, "Mixer01.Fault", historized: false, alarm: true),
|
||||||
|
// StandaloneTank: historized only.
|
||||||
|
Attribute(20, "StandaloneTank.Level", historized: true, alarm: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
return BuildEntry(hierarchy, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyHierarchyRow Hierarchy(
|
||||||
|
int id,
|
||||||
|
string tagName,
|
||||||
|
int parent,
|
||||||
|
bool isArea = false,
|
||||||
|
int category = 0,
|
||||||
|
IReadOnlyList<string>? templates = null) => new()
|
||||||
|
{
|
||||||
|
GobjectId = id,
|
||||||
|
TagName = tagName,
|
||||||
|
ContainedName = tagName,
|
||||||
|
BrowseName = tagName,
|
||||||
|
ParentGobjectId = parent,
|
||||||
|
IsArea = isArea,
|
||||||
|
CategoryId = category,
|
||||||
|
TemplateChain = templates ?? Array.Empty<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GalaxyAttributeRow Attribute(
|
||||||
|
int gobjectId,
|
||||||
|
string fullTagReference,
|
||||||
|
bool historized,
|
||||||
|
bool alarm) => new()
|
||||||
|
{
|
||||||
|
GobjectId = gobjectId,
|
||||||
|
AttributeName = fullTagReference.Split('.')[^1],
|
||||||
|
FullTagReference = fullTagReference,
|
||||||
|
IsHistorized = historized,
|
||||||
|
IsAlarm = alarm,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_NoFilters_ReturnsEveryObject()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest());
|
||||||
|
|
||||||
|
Assert.Equal(6, result.TotalObjectCount);
|
||||||
|
Assert.Equal(6, result.Objects.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_PageSizeAndOffset_SlicesTheOrderedResult()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new();
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult full = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: int.MaxValue);
|
||||||
|
GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 2);
|
||||||
|
GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 2, pageSize: 2);
|
||||||
|
GalaxyHierarchyQueryResult page3 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 4, pageSize: 2);
|
||||||
|
|
||||||
|
// Total is unaffected by paging.
|
||||||
|
Assert.Equal(6, page1.TotalObjectCount);
|
||||||
|
Assert.Equal(2, page1.Objects.Count);
|
||||||
|
Assert.Equal(2, page2.Objects.Count);
|
||||||
|
Assert.Equal(2, page3.Objects.Count);
|
||||||
|
|
||||||
|
// The three pages reconstruct the full ordered result with no gaps/dupes.
|
||||||
|
List<int> paged =
|
||||||
|
[
|
||||||
|
.. page1.Objects.Select(o => o.GobjectId),
|
||||||
|
.. page2.Objects.Select(o => o.GobjectId),
|
||||||
|
.. page3.Objects.Select(o => o.GobjectId),
|
||||||
|
];
|
||||||
|
Assert.Equal(full.Objects.Select(o => o.GobjectId), paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_OffsetPastEnd_ReturnsEmptyPageButRealTotal()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
|
||||||
|
entry, new DiscoverHierarchyRequest(), browseSubtreeGlobs: null, offset: 999, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Empty(result.Objects);
|
||||||
|
Assert.Equal(6, result.TotalObjectCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_PageSignature_IsStableAcrossPagesAndMatchesComputeFilterSignature()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" };
|
||||||
|
|
||||||
|
string expected = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtreeGlobs: null);
|
||||||
|
GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 1);
|
||||||
|
GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 1, pageSize: 1);
|
||||||
|
|
||||||
|
// The signature a caller computes to mint a page token round-trips: the projector
|
||||||
|
// reports the same signature on every page of the same filter set.
|
||||||
|
Assert.Equal(expected, page1.FilterSignature);
|
||||||
|
Assert.Equal(expected, page2.FilterSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeFilterSignature_DiffersWhenAnyFilterChanges()
|
||||||
|
{
|
||||||
|
DiscoverHierarchyRequest baseRequest = new() { TagNameGlob = "Pump*" };
|
||||||
|
DiscoverHierarchyRequest differentGlob = new() { TagNameGlob = "Valve*" };
|
||||||
|
DiscoverHierarchyRequest differentAlarm = new() { TagNameGlob = "Pump*", AlarmBearingOnly = true };
|
||||||
|
|
||||||
|
string baseSig = GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, null);
|
||||||
|
|
||||||
|
Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentGlob, null));
|
||||||
|
Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentAlarm, null));
|
||||||
|
Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, browseSubtreeGlobs: ["PlantArea/*"]));
|
||||||
|
// Same inputs => same signature (deterministic).
|
||||||
|
Assert.Equal(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(new DiscoverHierarchyRequest { TagNameGlob = "Pump*" }, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_MaxDepthZero_FromRoot_ReturnsOnlyTheRoot()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 0 };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
GalaxyObject only = Assert.Single(result.Objects);
|
||||||
|
Assert.Equal(1, only.GobjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_MaxDepthOne_FromRoot_ReturnsRootAndDirectChildrenOnly()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
// PlantArea(1) depth 0; LineA(2) and Mixer01(12) depth 1; Pump01/Valve01 depth 2.
|
||||||
|
DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 1 };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
Assert.Equal([1, 2, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_NegativeMaxDepth_ThrowsInvalidArgument()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { MaxDepth = -1 };
|
||||||
|
|
||||||
|
RpcException ex = Assert.Throws<RpcException>(() => GalaxyHierarchyProjector.Project(entry, request));
|
||||||
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_UnknownRoot_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { RootGobjectId = 99999 };
|
||||||
|
|
||||||
|
RpcException ex = Assert.Throws<RpcException>(() => GalaxyHierarchyProjector.Project(entry, request));
|
||||||
|
Assert.Equal(StatusCode.NotFound, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_HistorizedOnly_ReturnsOnlyObjectsWithAHistorizedAttribute()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { HistorizedOnly = true };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
// Pump01(10) and StandaloneTank(20) carry historized attributes.
|
||||||
|
Assert.Equal([10, 20], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_AlarmBearingOnly_ReturnsOnlyObjectsWithAnAlarmAttribute()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
// Pump01(10) and Mixer01(12) carry alarm attributes.
|
||||||
|
Assert.Equal([10, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_AlarmAndHistorizedTogether_RequiresBoth()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true, HistorizedOnly = true };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
// Only Pump01(10) carries an attribute set that is both historized and alarm-bearing.
|
||||||
|
GalaxyObject only = Assert.Single(result.Objects);
|
||||||
|
Assert.Equal(10, only.GobjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_TagNameGlob_MatchesAnchoredCaseInsensitive()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult prefix = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" });
|
||||||
|
Assert.Equal([10], prefix.Objects.Select(o => o.GobjectId));
|
||||||
|
|
||||||
|
// Case-insensitive.
|
||||||
|
GalaxyHierarchyQueryResult lower = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "pump01" });
|
||||||
|
Assert.Equal([10], lower.Objects.Select(o => o.GobjectId));
|
||||||
|
|
||||||
|
// '?' single-char wildcard: "Pump0?" matches "Pump01".
|
||||||
|
GalaxyHierarchyQueryResult single = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump0?" });
|
||||||
|
Assert.Equal([10], single.Objects.Select(o => o.GobjectId));
|
||||||
|
|
||||||
|
// Anchored: a bare substring that is not a prefix matches nothing.
|
||||||
|
GalaxyHierarchyQueryResult anchored = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "ump01" });
|
||||||
|
Assert.Empty(anchored.Objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_CategoryIds_FilterByObjectCategory()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { CategoryIds = { 200, 201 } };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
// category 200 = Pump01(10), category 201 = Valve01(11).
|
||||||
|
Assert.Equal([10, 11], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_TemplateChainContains_IsSubstringAndCaseInsensitive()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { TemplateChainContains = { "pump" } };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
GalaxyObject only = Assert.Single(result.Objects);
|
||||||
|
Assert.Equal(10, only.GobjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_IncludeAttributesDefault_CarriesAttributes()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
GalaxyObject pump = Assert.Single(result.Objects);
|
||||||
|
Assert.Equal(2, pump.Attributes.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_IncludeAttributesFalse_ReturnsSkeletons()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*", IncludeAttributes = false };
|
||||||
|
|
||||||
|
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||||
|
|
||||||
|
GalaxyObject pump = Assert.Single(result.Objects);
|
||||||
|
Assert.Empty(pump.Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_IncludeAttributesFalse_DoesNotMutateTheCachedEntry()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
|
||||||
|
// Project with attributes stripped, then again with attributes included.
|
||||||
|
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*", IncludeAttributes = false });
|
||||||
|
GalaxyHierarchyQueryResult included = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" });
|
||||||
|
|
||||||
|
// The earlier strip cloned the object — the cached entry still holds the attributes.
|
||||||
|
GalaxyObject pump = Assert.Single(included.Objects);
|
||||||
|
Assert.Equal(2, pump.Attributes.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Project_InvalidOffsetOrPageSize_Throws()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: -1, pageSize: 10));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: 0, pageSize: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GalaxyBrowseProjector ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProjectChildren_OfPlantArea_ReturnsDirectChildrenAreasFirst()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
BrowseChildrenRequest request = new() { ParentGobjectId = 1 };
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||||
|
|
||||||
|
// Direct children of PlantArea(1) are LineA(2, area) and Mixer01(12, non-area);
|
||||||
|
// areas sort first.
|
||||||
|
Assert.Equal([2, 12], result.Children.Select(c => c.GobjectId));
|
||||||
|
Assert.Equal(2, result.TotalChildCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProjectChildren_ChildHasChildrenFlag_ReflectsPresenceOfChildren()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
BrowseChildrenRequest request = new() { ParentGobjectId = 1 };
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||||
|
|
||||||
|
Dictionary<int, bool> hasChildren = result.Children
|
||||||
|
.Select((child, index) => (child.GobjectId, result.ChildHasChildren[index]))
|
||||||
|
.ToDictionary(t => t.GobjectId, t => t.Item2);
|
||||||
|
|
||||||
|
// LineA(2) contains Pump01/Valve01 -> true; Mixer01(12) is a leaf -> false.
|
||||||
|
Assert.True(hasChildren[2]);
|
||||||
|
Assert.False(hasChildren[12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProjectChildren_OfRoot_ReturnsTopLevelObjects()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
// Empty parent oneof => roots (parent id 0).
|
||||||
|
BrowseChildrenRequest request = new();
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||||
|
|
||||||
|
// Roots: PlantArea(1, area) and StandaloneTank(20, non-area); areas first.
|
||||||
|
Assert.Equal([1, 20], result.Children.Select(c => c.GobjectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProjectChildren_FilterMatchingDescendant_SurfacesNonMatchingAncestor()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
// Pump01 lives two levels under PlantArea. Browsing PlantArea's children with a
|
||||||
|
// Pump glob should still surface LineA (which itself does not match) because it
|
||||||
|
// contains a matching descendant.
|
||||||
|
BrowseChildrenRequest request = new() { ParentGobjectId = 1, TagNameGlob = "Pump*" };
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||||
|
|
||||||
|
GalaxyObject surfaced = Assert.Single(result.Children);
|
||||||
|
Assert.Equal(2, surfaced.GobjectId);
|
||||||
|
Assert.True(result.ChildHasChildren[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProjectChildren_UnknownParent_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
BrowseChildrenRequest request = new() { ParentGobjectId = 99999 };
|
||||||
|
|
||||||
|
RpcException ex = Assert.Throws<RpcException>(() =>
|
||||||
|
GalaxyBrowseProjector.ProjectChildren(entry, request, null, 0, 100));
|
||||||
|
Assert.Equal(StatusCode.NotFound, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProjectChildren_Paging_SlicesAndPreservesTotal()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
// LineA(2) has two direct children: Pump01, Valve01.
|
||||||
|
BrowseChildrenRequest request = new() { ParentGobjectId = 2 };
|
||||||
|
|
||||||
|
GalaxyBrowseChildrenResult page1 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 0, pageSize: 1);
|
||||||
|
GalaxyBrowseChildrenResult page2 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 1, pageSize: 1);
|
||||||
|
|
||||||
|
Assert.Equal(2, page1.TotalChildCount);
|
||||||
|
Assert.Single(page1.Children);
|
||||||
|
Assert.Single(page2.Children);
|
||||||
|
Assert.NotEqual(page1.Children[0].GobjectId, page2.Children[0].GobjectId);
|
||||||
|
// Same filter+parent => same signature on both pages.
|
||||||
|
Assert.Equal(page1.FilterSignature, page2.FilterSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveParentId_ByTagName_ResolvesToGobjectId()
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||||
|
BrowseChildrenRequest request = new() { ParentTagName = "LineA" };
|
||||||
|
|
||||||
|
int id = GalaxyBrowseProjector.ResolveParentId(entry, request);
|
||||||
|
|
||||||
|
Assert.Equal(2, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip tests for the real <see cref="GalaxyHierarchySnapshotStore"/> over a temp
|
||||||
|
/// file path: save then load, no-op when persistence is disabled, and clean disposal.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _path = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"galaxyrepo-snap-{Guid.NewGuid():N}.json");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_path))
|
||||||
|
{
|
||||||
|
File.Delete(_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyHierarchySnapshot SampleSnapshot() => new(
|
||||||
|
LastDeployTime: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||||
|
SavedAt: new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero),
|
||||||
|
Hierarchy:
|
||||||
|
[
|
||||||
|
new GalaxyHierarchyRow { GobjectId = 1, TagName = "Area1", IsArea = true },
|
||||||
|
new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump01", ParentGobjectId = 1 },
|
||||||
|
],
|
||||||
|
Attributes:
|
||||||
|
[
|
||||||
|
new GalaxyAttributeRow { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveThenLoad_RoundTripsTheSnapshot()
|
||||||
|
{
|
||||||
|
using GalaxyHierarchySnapshotStore store = new(
|
||||||
|
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
|
||||||
|
|
||||||
|
await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
|
||||||
|
GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(2, loaded!.Hierarchy.Count);
|
||||||
|
Assert.Single(loaded.Attributes);
|
||||||
|
Assert.Equal("Pump01.PV", loaded.Attributes[0].FullTagReference);
|
||||||
|
Assert.True(loaded.Attributes[0].IsHistorized);
|
||||||
|
Assert.Equal(SampleSnapshot().LastDeployTime, loaded.LastDeployTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAndLoad_AreNoOps_WhenPersistenceDisabled()
|
||||||
|
{
|
||||||
|
using GalaxyHierarchySnapshotStore store = new(
|
||||||
|
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = false, SnapshotCachePath = _path }));
|
||||||
|
|
||||||
|
await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(_path));
|
||||||
|
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryLoad_ReturnsNull_WhenNoFileExists()
|
||||||
|
{
|
||||||
|
using GalaxyHierarchySnapshotStore store = new(
|
||||||
|
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
|
||||||
|
|
||||||
|
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryLoad_ReturnsNull_WhenFileIsNotValidJson()
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(_path, "{ this is not valid json");
|
||||||
|
using GalaxyHierarchySnapshotStore store = new(
|
||||||
|
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
|
||||||
|
|
||||||
|
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<!-- Test project does not ship; no XML docs required (overrides Directory.Build.props). -->
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.GalaxyRepository\ZB.MOM.WW.GalaxyRepository.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.3.1</Version>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,7 +8,17 @@
|
|||||||
<div class="login-wrap rise">
|
<div class="login-wrap rise">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="login-body">
|
<div class="login-body">
|
||||||
<h1 class="login-title">@Product — sign in</h1>
|
@* The product token is wrapped in its own span so consumers can restyle
|
||||||
|
it and tests can assert the product in isolation (kit issue #5). Set
|
||||||
|
Heading to replace the whole heading copy (e.g. for localization). *@
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Heading))
|
||||||
|
{
|
||||||
|
<h1 class="login-title">@Heading</h1>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h1 class="login-title"><span class="login-product">@Product</span> — sign in</h1>
|
||||||
|
}
|
||||||
<form method="post" action="@Action" data-enhance="false">
|
<form method="post" action="@Action" data-enhance="false">
|
||||||
@if (!string.IsNullOrEmpty(ReturnUrl))
|
@if (!string.IsNullOrEmpty(ReturnUrl))
|
||||||
{
|
{
|
||||||
@@ -36,9 +46,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
/// <summary>Product name shown in the card heading. Required.</summary>
|
/// <summary>
|
||||||
|
/// Product name shown in the card heading (rendered inside a
|
||||||
|
/// <c><span class="login-product"></c>, followed by the "— sign in"
|
||||||
|
/// suffix). Required. Ignored when <see cref="Heading"/> is set.
|
||||||
|
/// </summary>
|
||||||
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
|
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional full heading override. When set (non-whitespace), it replaces the
|
||||||
|
/// default <c><Product> — sign in</c> heading entirely — use it to
|
||||||
|
/// localize or fully customize the heading copy. When unset, the default heading
|
||||||
|
/// (with <see cref="Product"/> in a <c>.login-product</c> span) is rendered.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? Heading { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Form <c>action</c> URL the sign-in POST targets. Defaults to <c>/auth/login</c>.
|
/// Form <c>action</c> URL the sign-in POST targets. Defaults to <c>/auth/login</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
@* Components/NavRailSection.razor — CSS-only collapsible (no JS, works in static SSR).
|
@* Components/NavRailSection.razor — CSS-only collapsible (no JS, works in static SSR).
|
||||||
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
|
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
|
||||||
@namespace ZB.MOM.WW.Theme
|
@namespace ZB.MOM.WW.Theme
|
||||||
<details class="rail-section" open="@Expanded">
|
<details class="rail-section" open="@Expanded" data-nav-key="@ResolvedKey">
|
||||||
<summary class="rail-eyebrow-toggle">@Title</summary>
|
@* aria-expanded mirrors the native <details open> state so tests and assistive
|
||||||
|
tech have a stable, queryable attribute (kit issue #1). It is rendered from
|
||||||
|
Expanded at SSR time and kept in sync by nav-state.js on restore and toggle. *@
|
||||||
|
<summary class="rail-eyebrow-toggle" aria-expanded="@(Expanded ? "true" : "false")">@Title</summary>
|
||||||
<div class="rail-section-body">@ChildContent</div>
|
<div class="rail-section-body">@ChildContent</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -18,8 +21,24 @@
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public bool Expanded { get; set; } = true;
|
[Parameter] public bool Expanded { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable identifier used to persist this section's open/closed state in
|
||||||
|
/// localStorage (via the kit's nav-state.js). Defaults to a slug of <see cref="Title"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? Key { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Section content — typically <see cref="NavRailItem"/> children.
|
/// Section content — typically <see cref="NavRailItem"/> children.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
private string ResolvedKey => string.IsNullOrWhiteSpace(Key) ? Slug(Title) : Key!;
|
||||||
|
|
||||||
|
private static string Slug(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return string.Empty;
|
||||||
|
var chars = s.Trim().ToLowerInvariant()
|
||||||
|
.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray();
|
||||||
|
return string.Join('-', new string(chars).Split('-', StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@namespace ZB.MOM.WW.Theme
|
||||||
|
@* Components/ThemeScripts.razor — drop before </body>. Emits the kit's nav-state
|
||||||
|
enhancer that persists NavRailSection open/closed state in localStorage. *@
|
||||||
|
<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer></script>
|
||||||
@@ -47,6 +47,20 @@
|
|||||||
and force it shown regardless of the <details> open state (the hamburger
|
and force it shown regardless of the <details> open state (the hamburger
|
||||||
toggle is hidden at this width). */
|
toggle is hidden at this width). */
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
|
/* Chromium >=121 wraps a <details>'s content in a generated ::details-content
|
||||||
|
box that carries content-visibility:hidden while the <details> is closed.
|
||||||
|
Because our app-shell ships closed by default (SSR, no JS) and the toggle
|
||||||
|
is d-lg-none here, that wrapper would (a) hide the rail+page entirely on
|
||||||
|
lg+ and (b) sit between .app-shell and its rail/page children, collapsing
|
||||||
|
the flex-lg-row layout into a vertical stack. Dissolving the wrapper with
|
||||||
|
display:contents removes its box (so content-visibility no longer hides the
|
||||||
|
content AND rail/page become direct flex children of .app-shell again).
|
||||||
|
Browsers that don't support ::details-content treat this as an invalid
|
||||||
|
selector and drop the rule, falling back to the legacy force-show below. */
|
||||||
|
.app-shell::details-content {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
#theme-rail {
|
#theme-rail {
|
||||||
display: block;
|
display: block;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -196,6 +210,13 @@
|
|||||||
.rail-section > summary::-webkit-details-marker { display: none; }
|
.rail-section > summary::-webkit-details-marker { display: none; }
|
||||||
.rail-section > summary::before { content: '\25B6'; font-size: 0.55rem; color: var(--ink-faint); margin-right: 0.4rem; }
|
.rail-section > summary::before { content: '\25B6'; font-size: 0.55rem; color: var(--ink-faint); margin-right: 0.4rem; }
|
||||||
.rail-section[open] > summary::before { content: '\25BC'; }
|
.rail-section[open] > summary::before { content: '\25BC'; }
|
||||||
|
/* Hide a collapsed section's items explicitly. The browser's built-in
|
||||||
|
<details> content-hiding (::details-content content-visibility:hidden) is
|
||||||
|
unreliable once an interactive framework (e.g. Blazor InteractiveServer)
|
||||||
|
owns/re-renders the native <details> — a closed section can otherwise keep
|
||||||
|
showing its items under a "collapsed" chevron. An explicit display:none makes
|
||||||
|
the visual collapse work across all render modes (kit issue #6). */
|
||||||
|
.rail-section:not([open]) > .rail-section-body { display: none; }
|
||||||
|
|
||||||
/* StatusPill: info variant (on-palette, reuses the info blue wash) */
|
/* StatusPill: info variant (on-palette, reuses the info blue wash) */
|
||||||
.chip-info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
|
.chip-info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// ZB.MOM.WW.Theme nav-state.js — persists <details data-nav-key> open/closed
|
||||||
|
// state in localStorage so NavRailSection expand state survives navigation and
|
||||||
|
// reloads. Pure client-side; works with static Blazor SSR. Keyed per section.
|
||||||
|
// localStorage keys are prefixed with "zbnav:" to avoid collisions.
|
||||||
|
(function () {
|
||||||
|
var PREFIX = "zbnav:";
|
||||||
|
var INIT_ATTR = "data-zbnav-initialized";
|
||||||
|
var TRANSIENT_ATTR = "data-zbnav-transient";
|
||||||
|
|
||||||
|
// Mirror a section's native <details open> onto its <summary aria-expanded>
|
||||||
|
// so tests and assistive tech have a stable, queryable attribute (issue #1).
|
||||||
|
function syncAria(el) {
|
||||||
|
var summary = el.querySelector("summary.rail-eyebrow-toggle");
|
||||||
|
if (summary) summary.setAttribute("aria-expanded", el.open ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(el) {
|
||||||
|
el.setAttribute(INIT_ATTR, "");
|
||||||
|
var key = PREFIX + el.getAttribute("data-nav-key");
|
||||||
|
var saved = null;
|
||||||
|
try { saved = window.localStorage.getItem(key); } catch (e) { saved = null; }
|
||||||
|
if (saved === "1") el.open = true;
|
||||||
|
else if (saved === "0") el.open = false;
|
||||||
|
el.addEventListener("toggle", function () {
|
||||||
|
syncAria(el);
|
||||||
|
// An active-link reveal (issue #2) is a transient open that must NOT
|
||||||
|
// overwrite the user's saved preference. The reveal flags the element
|
||||||
|
// before flipping open; consume the flag here and skip persistence.
|
||||||
|
if (el.getAttribute(TRANSIENT_ATTR) !== null) {
|
||||||
|
el.removeAttribute(TRANSIENT_ATTR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { window.localStorage.setItem(key, el.open ? "1" : "0"); } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
document.querySelectorAll("details.rail-section[data-nav-key]").forEach(function (el) {
|
||||||
|
if (!el.hasAttribute(INIT_ATTR)) wire(el); // wire once — avoid duplicate listeners
|
||||||
|
syncAria(el); // re-sync aria on every pass
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reveal the section that holds the active link even if the user (or the
|
||||||
|
// app) left it collapsed, so the nav always shows where the user is
|
||||||
|
// (issue #2). Transient: flagged so the toggle handler does not persist it.
|
||||||
|
document.querySelectorAll("details.rail-section a.rail-link.active").forEach(function (link) {
|
||||||
|
var sec = link.closest("details.rail-section");
|
||||||
|
if (sec && !sec.open) {
|
||||||
|
sec.setAttribute(TRANSIENT_ATTR, "");
|
||||||
|
sec.open = true;
|
||||||
|
syncAria(sec);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading")
|
||||||
|
document.addEventListener("DOMContentLoaded", apply);
|
||||||
|
else
|
||||||
|
apply();
|
||||||
|
|
||||||
|
// Re-run after Blazor static-SSR enhanced navigation (or any re-render that
|
||||||
|
// replaces the rail nodes) so freshly inserted sections are wired, restored,
|
||||||
|
// and active-revealed (issue #3). The per-element INIT_ATTR guard keeps this
|
||||||
|
// idempotent for nodes that survived the navigation.
|
||||||
|
if (window.Blazor && typeof window.Blazor.addEventListener === "function") {
|
||||||
|
window.Blazor.addEventListener("enhancedload", apply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run whenever rail sections are (re)inserted into the DOM. Under an
|
||||||
|
// interactive render mode (Blazor InteractiveServer/WebAssembly/Auto) the
|
||||||
|
// prerendered <details> wired on DOMContentLoaded are replaced when the
|
||||||
|
// runtime adopts the page, and `enhancedload` does NOT fire — so without this
|
||||||
|
// the live sections are never wired (no persistence, no aria sync, no
|
||||||
|
// active-reveal). A MutationObserver is the render-mode-agnostic backstop;
|
||||||
|
// the per-element INIT_ATTR guard keeps re-applies idempotent, and the
|
||||||
|
// childList-only filter (plus the active-reveal's `if (!sec.open)` guard)
|
||||||
|
// avoids any observe→mutate→observe loop (issue #6).
|
||||||
|
if (typeof MutationObserver === "function") {
|
||||||
|
var observer = new MutationObserver(function (mutations) {
|
||||||
|
for (var i = 0; i < mutations.length; i++) {
|
||||||
|
var added = mutations[i].addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) {
|
||||||
|
var node = added[j];
|
||||||
|
if (node.nodeType !== 1) continue;
|
||||||
|
if ((node.matches && node.matches("details.rail-section")) ||
|
||||||
|
(node.querySelector && node.querySelector("details.rail-section"))) {
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -41,4 +41,28 @@ public class LoginCardTests : TestContext
|
|||||||
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||||
Assert.Empty(cut.FindAll("input[name=returnUrl]"));
|
Assert.Empty(cut.FindAll("input[name=returnUrl]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme issue #5: the product token is isolated in a .login-product span so it
|
||||||
|
// can be styled/asserted apart from the "— sign in" suffix.
|
||||||
|
[Fact]
|
||||||
|
public void Product_is_wrapped_in_login_product_span()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||||
|
var product = cut.Find(".login-title .login-product");
|
||||||
|
Assert.Equal("OtOpcUa", product.TextContent);
|
||||||
|
Assert.Contains("sign in", cut.Find(".login-title").TextContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme issue #5: Heading replaces the whole heading copy when set.
|
||||||
|
[Fact]
|
||||||
|
public void Heading_overrides_default_heading_when_set()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<LoginCard>(p => p
|
||||||
|
.Add(x => x.Product, "OtOpcUa")
|
||||||
|
.Add(x => x.Heading, "Welcome back"));
|
||||||
|
var title = cut.Find(".login-title");
|
||||||
|
Assert.Equal("Welcome back", title.TextContent);
|
||||||
|
Assert.Empty(cut.FindAll(".login-title .login-product"));
|
||||||
|
Assert.DoesNotContain("sign in", title.TextContent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ public class NavRailTests : TestContext
|
|||||||
Assert.NotNull(cut.Find(".rail-section-body .rail-link"));
|
Assert.NotNull(cut.Find(".rail-section-body .rail-link"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme issue #1: the <summary> mirrors the <details open> state via
|
||||||
|
// aria-expanded so tests and assistive tech have a stable, queryable attribute.
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_summary_aria_expanded_true_when_open()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, "Navigation")
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NavRailSection_collapsed_when_not_expanded()
|
public void NavRailSection_collapsed_when_not_expanded()
|
||||||
{
|
{
|
||||||
@@ -55,4 +66,50 @@ public class NavRailTests : TestContext
|
|||||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
|
Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme issue #1: aria-expanded reflects the collapsed SSR state too.
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_summary_aria_expanded_false_when_collapsed()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, "Site Calls")
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_emits_explicit_key_when_supplied()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, " ")
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_slug_preserves_unicode_letters()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, "Café")
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public class StaticAssetsTests
|
|||||||
public void Fonts_are_vendored(string file) =>
|
public void Fonts_are_vendored(string file) =>
|
||||||
Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file)));
|
Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavStateScript_ships() =>
|
||||||
|
Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js")));
|
||||||
|
|
||||||
// Theme-002: .chip-idle pairs the idle background with the matching --idle
|
// Theme-002: .chip-idle pairs the idle background with the matching --idle
|
||||||
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
|
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ZB.MOM.WW.Theme.Tests;
|
||||||
|
|
||||||
|
public class ThemeScriptsTests : TestContext
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ThemeScripts_emits_nav_state_script_tag()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<ThemeScripts>();
|
||||||
|
var script = cut.Find("script");
|
||||||
|
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
|
||||||
|
Assert.True(script.HasAttribute("defer"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# ZB.MOM.WW.Theme — Known Issues
|
||||||
|
|
||||||
|
Issues found in the `ZB.MOM.WW.Theme` kit that are best fixed **once in the kit and
|
||||||
|
re-distributed** to every consuming app, rather than worked around per-app. Found while
|
||||||
|
debugging the ScadaBridge Central UI Playwright suite against kit version **0.2.1** (the
|
||||||
|
version ScadaBridge consumed at the time).
|
||||||
|
|
||||||
|
All file references below point at the kit source under `src/ZB.MOM.WW.Theme/`.
|
||||||
|
|
||||||
|
> **RESOLVED in kit 0.3.0 (2026-06-05).** Issues 1, 2, 3, and 5 are fixed in the kit and
|
||||||
|
> redistributed; Issue 4 is an accepted, documented tradeoff (no code change). See
|
||||||
|
> [Resolution](#resolution-kit-030) below for what changed and why.
|
||||||
|
>
|
||||||
|
> **RESOLVED in kit 0.3.1 (2026-06-05).** Issue 6 (collapsible nav non-functional under
|
||||||
|
> interactive Blazor render) is fixed — CSS `display:none`-when-closed backstop +
|
||||||
|
> `MutationObserver` re-wire in `nav-state.js`. See the [Issue 6](#issue-6--collapsible-nav-is-non-functional-under-interactive-blazor-render-mode)
|
||||||
|
> resolution note. All six issues are now resolved (5 fixed, 1 accepted tradeoff).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| # | Severity | Component | Issue | Status (0.3.0) |
|
||||||
|
|---|----------|-----------|-------|----------------|
|
||||||
|
| 1 | Medium | `NavRailSection` / `nav-state.js` | No programmatic expanded-state hook (`aria-expanded` / `data-*`) on the section toggle. | ✅ Fixed |
|
||||||
|
| 2 | Medium | `nav-state.js` | The section containing the active link is not auto-expanded on navigation. | ✅ Fixed |
|
||||||
|
| 3 | Medium | `nav-state.js` | Persistence wires once on `DOMContentLoaded`; not re-applied after Blazor enhanced navigation / dynamic re-render. | ✅ Fixed |
|
||||||
|
| 4 | Low | `NavRailSection` | Always-expanded SSR default causes a flash / layout shift of collapsed sections on load. | 📄 Accepted tradeoff (documented) |
|
||||||
|
| 5 | Low (optional) | `LoginCard` | Heading bakes the localizable `— sign in` suffix into the product title with no separate hook. | ✅ Fixed |
|
||||||
|
| 6 | High | `NavRailSection` / `nav-state.js` | Under **interactive** Blazor render mode the whole collapsible nav is non-functional: clicking a header doesn't hide items, and `nav-state.js` never wires (no aria sync, no persistence, no active-reveal). | ✅ Fixed (0.3.1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolution (kit 0.3.0)
|
||||||
|
|
||||||
|
Shipped in `ZB.MOM.WW.Theme` **0.3.0** (2026-06-05) and adopted across all three apps.
|
||||||
|
|
||||||
|
- **Issue 1 — `aria-expanded` hook.** `NavRailSection.razor` now renders
|
||||||
|
`<summary class="rail-eyebrow-toggle" aria-expanded="…">`, computed from `Expanded` at SSR
|
||||||
|
time, and `nav-state.js` keeps it in sync with the native `<details open>` state on restore
|
||||||
|
and on every `toggle`. Tests/AT can now await a stable attribute instead of inferring from
|
||||||
|
child-link visibility. (bUnit:
|
||||||
|
`NavRailSection_summary_aria_expanded_true_when_open` / `…_false_when_collapsed`.)
|
||||||
|
- **Issue 2 — auto-expand the active section.** After restoring saved state, `nav-state.js`
|
||||||
|
force-opens any `details.rail-section` that contains an `a.rail-link.active`. The reveal is
|
||||||
|
**transient** — it is flagged with `data-zbnav-transient` before the open flip so the
|
||||||
|
`toggle` handler skips persistence and the user's saved collapse preference is preserved.
|
||||||
|
- **Issue 3 — re-apply after enhanced navigation.** `apply()` is now also bound to Blazor's
|
||||||
|
`enhancedload` event (`Blazor.addEventListener('enhancedload', apply)`); the per-element
|
||||||
|
`data-zbnav-initialized` guard keeps re-runs idempotent. Static-SSR consumers keep
|
||||||
|
persistence + active-reveal after enhanced navigations; interactive Server consumers (e.g.
|
||||||
|
ScadaBridge Central UI) are unaffected as before.
|
||||||
|
- **Issue 4 — SSR flash / CLS: accepted tradeoff (no code change).** The kit deliberately
|
||||||
|
keeps **client-only** persistence to stay render-mode-agnostic, so the server renders every
|
||||||
|
section `open` and JS collapses the saved-collapsed ones after first paint. The alternative —
|
||||||
|
an inline pre-paint `<head>` snippet that mutates not-yet-parsed `<details>` from
|
||||||
|
`localStorage` — adds a FOUC-script that runs against DOM that does not yet exist, for a
|
||||||
|
Low-severity cosmetic flash. We chose **not** to take on that complexity/risk. Consumers who
|
||||||
|
care about the flash for a specific layout can add their own pre-paint restore; the kit will
|
||||||
|
not ship one by default. (This paragraph is the documented decision the issue asks for.)
|
||||||
|
- **Issue 5 — `LoginCard` heading hook.** The product token is now wrapped in
|
||||||
|
`<span class="login-product">@Product</span> — sign in`, and an optional `Heading` parameter
|
||||||
|
fully replaces the heading copy when set (for localization / custom wording). Existing
|
||||||
|
`"<Product> — sign in"` assertions still pass. (bUnit:
|
||||||
|
`Product_is_wrapped_in_login_product_span` / `Heading_overrides_default_heading_when_set`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 1 — No programmatic expanded-state hook on `NavRailSection`
|
||||||
|
|
||||||
|
**Severity:** Medium · **Files:** `Components/NavRailSection.razor`, `wwwroot/js/nav-state.js`
|
||||||
|
|
||||||
|
**Symptom.** A section's open/closed state is exposed only through the native
|
||||||
|
`<details open>` boolean attribute on the `<details class="rail-section">` element. The
|
||||||
|
`<summary class="rail-eyebrow-toggle">` toggle carries no `aria-expanded` and there is no
|
||||||
|
`data-*` state attribute. E2E tests (and some older assistive tech) cannot reliably query
|
||||||
|
or await the expanded state — they must infer it from child-link visibility.
|
||||||
|
|
||||||
|
**Root cause.** `NavRailSection.razor` renders:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<details class="rail-section" open="@Expanded" data-nav-key="@ResolvedKey">
|
||||||
|
<summary class="rail-eyebrow-toggle">@Title</summary>
|
||||||
|
<div class="rail-section-body">@ChildContent</div>
|
||||||
|
</details>
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no attribute that mirrors `open` in a test- or AT-stable way.
|
||||||
|
|
||||||
|
**Impact on consumers.** Every consumer's UI tests must assert collapse state indirectly
|
||||||
|
(e.g. waiting on a child link to become visible/hidden) instead of awaiting a stable
|
||||||
|
attribute. This was the proximate cause of several stale ScadaBridge nav tests. Native
|
||||||
|
`<details>`/`<summary>` is keyboard- and screen-reader-accessible by default, so this is
|
||||||
|
primarily a **testability** gap (with a modest a11y upside).
|
||||||
|
|
||||||
|
**Recommended fix.** Mirror `open` onto `aria-expanded` on the `<summary>`, kept in sync by
|
||||||
|
`nav-state.js` (set it during `apply()` from `el.open`, and update it inside the existing
|
||||||
|
`toggle` listener). This gives both tests and AT a stable, queryable attribute without
|
||||||
|
changing the CSS-only collapse mechanism.
|
||||||
|
|
||||||
|
**Verify.** After the fix, `summary.rail-eyebrow-toggle` exposes `aria-expanded="true|false"`
|
||||||
|
that flips when the section is toggled and after a reload restores saved state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 2 — Active section is not auto-expanded on navigation
|
||||||
|
|
||||||
|
**Severity:** Medium · **File:** `wwwroot/js/nav-state.js`
|
||||||
|
|
||||||
|
**Symptom.** When a section is collapsed (either because the user previously collapsed it —
|
||||||
|
`localStorage` `zbnav:<key>` = `"0"` — or because a consumer sets `Expanded="false"`) and
|
||||||
|
the user navigates to a route whose link lives inside that section, the section **stays
|
||||||
|
collapsed**. The active link (`a.rail-link.active`) is present in the DOM but hidden by the
|
||||||
|
closed `<details>`, so the nav no longer shows the user where they are.
|
||||||
|
|
||||||
|
**Root cause.** `nav-state.js` only *restores saved open/closed state*; it has no concept of
|
||||||
|
the active link. Grep of the kit confirms the only "active" handling is the
|
||||||
|
`.rail-link.active` CSS rule in `wwwroot/css/layout.css` — there is no JS that opens the
|
||||||
|
section containing the active link.
|
||||||
|
|
||||||
|
**Impact on consumers.** Loss of the common "navigating into a section reveals it" behavior.
|
||||||
|
A user who collapses a section and then deep-links (or is redirected) into one of its pages
|
||||||
|
lands with the relevant nav group collapsed and the current page's link hidden. (ScadaBridge
|
||||||
|
previously had app-owned auto-expand-on-navigate; the kit cutover dropped it, and the
|
||||||
|
`NavigatingIntoCollapsedSection_AutoExpandsIt` test now fails because nothing re-expands.)
|
||||||
|
|
||||||
|
**Recommended fix.** In `nav-state.js`, after restoring saved state, force-open any section
|
||||||
|
that contains the active link:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// after the saved-state restore loop, before wiring is "done":
|
||||||
|
document.querySelectorAll("details.rail-section a.rail-link.active").forEach(function (link) {
|
||||||
|
var sec = link.closest("details.rail-section");
|
||||||
|
if (sec && !sec.open) sec.open = true; // reveal the section the user is in
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this both on initial load and after Blazor navigation (see Issue 3). Decide whether the
|
||||||
|
forced-open should also persist to `localStorage` or be a transient reveal (recommended:
|
||||||
|
transient — don't overwrite the user's saved preference).
|
||||||
|
|
||||||
|
**Verify.** Collapse a section, navigate to one of its pages (or reload directly on it):
|
||||||
|
the section opens and the active link is visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 3 — Persistence wires once and is not re-applied after navigation
|
||||||
|
|
||||||
|
**Severity:** Medium · **File:** `wwwroot/js/nav-state.js`
|
||||||
|
|
||||||
|
**Symptom.** `apply()` runs only on the initial `DOMContentLoaded` (or first script eval)
|
||||||
|
and guards each element with `data-zbnav-initialized`. Under Blazor **static SSR enhanced
|
||||||
|
navigation** — or any dynamic re-render that replaces the `<details>` nodes — newly inserted
|
||||||
|
sections are never wired: their saved state is not restored and their toggles are not
|
||||||
|
persisted. The active-section logic from Issue 2 would likewise not re-run.
|
||||||
|
|
||||||
|
**Root cause.** The script self-invokes once:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (document.readyState === "loading")
|
||||||
|
document.addEventListener("DOMContentLoaded", apply);
|
||||||
|
else
|
||||||
|
apply();
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no hook for Blazor's enhanced-navigation lifecycle and no `MutationObserver`.
|
||||||
|
|
||||||
|
**Impact on consumers.** Static-SSR consumers (the kit explicitly targets "works in static
|
||||||
|
SSR") lose nav persistence after the first enhanced navigation. **Interactive Blazor Server
|
||||||
|
consumers (such as ScadaBridge Central UI) are largely unaffected**, because the rail is
|
||||||
|
prerendered once and then patched in place over the SignalR circuit, so the original
|
||||||
|
`<details>` elements and their listeners survive — which is why ScadaBridge's persistence
|
||||||
|
appears to work today. The kit should still be correct for its static-SSR audience.
|
||||||
|
|
||||||
|
**Recommended fix.** Also re-run `apply()` on Blazor's enhanced-load event (and keep the
|
||||||
|
per-element init guard so it stays idempotent):
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (window.Blazor && Blazor.addEventListener) {
|
||||||
|
Blazor.addEventListener('enhancedload', apply);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally add a `MutationObserver` on the rail container as a framework-agnostic backstop.
|
||||||
|
|
||||||
|
**Verify.** On a static-SSR host, expand/collapse a section, perform an enhanced navigation
|
||||||
|
to another page and back, and confirm the saved state is still restored and toggles still
|
||||||
|
persist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 4 — Always-expanded SSR default flashes / shifts layout on load
|
||||||
|
|
||||||
|
**Severity:** Low · **File:** `Components/NavRailSection.razor`
|
||||||
|
|
||||||
|
**Symptom.** `NavRailSection.Expanded` defaults to `true`, so every section renders `open`
|
||||||
|
in the server HTML. `nav-state.js` only collapses the saved-collapsed sections *after* the
|
||||||
|
script runs, producing a brief flash of fully-expanded nav and a layout shift (CLS) on each
|
||||||
|
load for users who keep sections collapsed.
|
||||||
|
|
||||||
|
**Root cause.** State lives in `localStorage` and is applied by JS post-render, while the
|
||||||
|
server-rendered default is unconditionally expanded. The server has no knowledge of the
|
||||||
|
saved state at render time.
|
||||||
|
|
||||||
|
**Impact on consumers.** Cosmetic flash / minor CLS on initial load; more noticeable with
|
||||||
|
many sections collapsed.
|
||||||
|
|
||||||
|
**Recommended fix (pick one).**
|
||||||
|
- Inline a tiny restore snippet in `<head>` (via `ThemeHead`) that sets each `<details>`'s
|
||||||
|
`open` from `localStorage` before first paint; or
|
||||||
|
- Accept the tradeoff and document it (the kit deliberately chose client-only persistence to
|
||||||
|
stay render-mode-agnostic).
|
||||||
|
|
||||||
|
**Verify.** With several sections saved-collapsed, reload and confirm no expanded-then-collapse
|
||||||
|
flash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 5 — `LoginCard` heading couples the product name and "— sign in" (optional)
|
||||||
|
|
||||||
|
**Severity:** Low (optional) · **File:** `Components/LoginCard.razor`
|
||||||
|
|
||||||
|
**Symptom.** The card heading is `<h1 class="login-title">@Product — sign in</h1>`.
|
||||||
|
The (localizable) `— sign in` suffix is baked into the product title with no separate hook,
|
||||||
|
so consumers can't restyle/override the heading copy or assert the product token in isolation
|
||||||
|
without string-matching the whole heading.
|
||||||
|
|
||||||
|
**Impact on consumers.** Minor: per-app heading customization and exact-text test assertions
|
||||||
|
are awkward (must match `"<Product> — sign in"` rather than the product alone).
|
||||||
|
|
||||||
|
**Recommended fix (optional).** Wrap the product in a span and/or expose an override:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<h1 class="login-title"><span class="login-product">@Product</span> — sign in</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
or add an optional `Heading` parameter that, when set, replaces the default heading entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 6 — Collapsible nav is non-functional under interactive Blazor render mode
|
||||||
|
|
||||||
|
**Severity:** High · **Files:** `Components/NavRailSection.razor`, `wwwroot/js/nav-state.js`,
|
||||||
|
`wwwroot/css/layout.css` · **Status:** ✅ Fixed in 0.3.1
|
||||||
|
|
||||||
|
> **Resolution (kit 0.3.1, 2026-06-05).** Both recommended parts shipped, so the collapsible
|
||||||
|
> nav now works under interactive render modes as well as static SSR:
|
||||||
|
> 1. **CSS robust collapse** — `layout.css` hides the body explicitly when closed instead of
|
||||||
|
> relying on the native `::details-content` content-hiding (which an interactive framework
|
||||||
|
> desyncs): `.rail-section:not([open]) > .rail-section-body { display: none; }`.
|
||||||
|
> 2. **Render-mode-agnostic re-wire** — `nav-state.js` adds a `MutationObserver` on
|
||||||
|
> `document.documentElement` (childList + subtree) that re-runs `apply()` whenever
|
||||||
|
> `details.rail-section` nodes are added/replaced, so the interactive runtime's re-render
|
||||||
|
> gets wired (aria sync, `data-zbnav-initialized`, localStorage persistence, active-reveal).
|
||||||
|
> The existing `enhancedload` hook (Issue 3) is kept for static-SSR enhanced navigation.
|
||||||
|
>
|
||||||
|
> Verified live in ScadaBridge Central UI (global `@rendermode InteractiveServer`): the
|
||||||
|
> Playwright `NavCollapseTests` (toggle-hides-items, persistence-survives-reload,
|
||||||
|
> deep-link-auto-reveal) now pass against 0.3.1.
|
||||||
|
|
||||||
|
> **This corrects Issue 3's note**, which claimed interactive Blazor Server consumers are
|
||||||
|
> "largely unaffected because the rail is patched in place." Direct observation of the live
|
||||||
|
> ScadaBridge Central UI (global `@rendermode InteractiveServer`) shows that is **false** —
|
||||||
|
> the kit's `<details>`/JS nav does not work under interactive render modes at all.
|
||||||
|
|
||||||
|
**Symptom.** In an app that renders the rail under an interactive render mode
|
||||||
|
(`InteractiveServer`, `InteractiveWebAssembly`, or `InteractiveAuto`), the collapsible nav is
|
||||||
|
visually and functionally dead:
|
||||||
|
|
||||||
|
1. Clicking a section header toggles the chevron (▶/▼) but **does not hide the section's
|
||||||
|
items** — the links stay fully visible under a "collapsed" chevron.
|
||||||
|
2. `aria-expanded` never changes, `localStorage` is never written, the saved state is not
|
||||||
|
restored on reload, and the active-section auto-reveal (Issue 2) does not fire.
|
||||||
|
|
||||||
|
**Root cause.** The kit nav is a **static-SSR / CSS-only** design (NavRailSection's own
|
||||||
|
comment: *"works in static SSR"*). Under an interactive render mode, Blazor's runtime
|
||||||
|
**owns and re-renders the `<details>`/`<summary>` DOM** after it adopts the prerendered
|
||||||
|
markup. Two independent consequences, both observed live:
|
||||||
|
|
||||||
|
- **Native collapse is defeated.** On the live page a closed section has `details.open === false`
|
||||||
|
and its `::details-content` computes `content-visibility: hidden`, yet the
|
||||||
|
`.rail-section-body` and its links remain laid out and visible (measured non-zero height /
|
||||||
|
non-null `offsetParent`). Blazor's management of the native `<details>` desyncs the browser's
|
||||||
|
built-in content-hiding. The body's `display` value (flex/block/grid/inline) makes no
|
||||||
|
difference — only an explicit `display: none` actually hides it.
|
||||||
|
- **`nav-state.js` never wires the live DOM.** The interactive `<details>` elements have **no
|
||||||
|
`data-zbnav-initialized` attribute**, i.e. `wire()` never ran on them: `apply()` runs on
|
||||||
|
`DOMContentLoaded` against the *prerendered* nodes, which Blazor then replaces, and the only
|
||||||
|
re-run hook (`enhancedload`, added for Issue 3) does not fire under interactive render modes.
|
||||||
|
So aria sync, localStorage persistence, and active-reveal are all inert.
|
||||||
|
|
||||||
|
This matters for the kit's stated goal: per the normalization notes, nav-expand persistence was
|
||||||
|
promoted into the kit at 0.2.0 *"so all three apps share one persistence mechanism."* One of the
|
||||||
|
three consumers (ScadaBridge Central UI) is interactive Blazor Server, where that mechanism
|
||||||
|
silently does nothing.
|
||||||
|
|
||||||
|
**Verified evidence (live, global InteractiveServer).** On a logged-in dashboard:
|
||||||
|
`data-zbnav-initialized` absent on every `details.rail-section`; after clicking a header,
|
||||||
|
`details.open === false` but the section's link still reports `clientHeight: 33` and a non-null
|
||||||
|
`offsetParent`; setting `.rail-section-body { display:none }` is the only thing that hides it;
|
||||||
|
`localStorage` has no `zbnav:*` keys before or after toggling.
|
||||||
|
|
||||||
|
**Recommended fix (two parts — both belong in the kit).**
|
||||||
|
|
||||||
|
1. **Make the collapse render-mode-robust (CSS).** Don't rely solely on the native
|
||||||
|
`<details>` content-hiding; hide the body explicitly when closed:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* layout.css — robust across render modes; native ::details-content hiding
|
||||||
|
is unreliable once an interactive framework manages the <details>. */
|
||||||
|
.rail-section:not([open]) > .rail-section-body { display: none; }
|
||||||
|
```
|
||||||
|
|
||||||
|
(Verified live: this is exactly what hides the items.)
|
||||||
|
|
||||||
|
2. **Make persistence/aria/reveal work under interactive render.** `enhancedload` is
|
||||||
|
static-SSR-only; also wire after the interactive runtime has (re)rendered. Options, in
|
||||||
|
preference order:
|
||||||
|
- Re-run `apply()` from Blazor's post-render hooks — `Blazor.addEventListener('afterStarted', …)`
|
||||||
|
(interactive WASM/Server boot) and re-apply on circuit/render updates; and/or
|
||||||
|
- Add a `MutationObserver` on the rail container that calls `apply()` when
|
||||||
|
`details.rail-section` nodes are added/replaced (framework-agnostic backstop — covers
|
||||||
|
interactive re-renders, enhanced nav, and dynamic nav alike);
|
||||||
|
- **Or** ship an explicitly **interactive** `NavRailSection` variant (a small Blazor
|
||||||
|
component with an `@onclick` toggle and `[Parameter] bool Expanded` two-way state) for
|
||||||
|
consumers that render interactively — which is what NavRailSection's own comment already
|
||||||
|
gestures at (*"Apps that want cookie-persisted expand state keep their own interactive
|
||||||
|
NavSection"*). If the kit's intent is that interactive apps bring their own section
|
||||||
|
component, say so loudly in the docs and have the CSS-only one degrade gracefully (part 1
|
||||||
|
still applies so at least the visual collapse works).
|
||||||
|
|
||||||
|
**Verify.** In an interactive-render host: clicking a header hides the section's items; the
|
||||||
|
summary's `aria-expanded` flips; `localStorage` gets a `zbnav:<key>` entry; the state survives
|
||||||
|
a reload; and deep-linking into a collapsed section reveals it.
|
||||||
|
|
||||||
|
**Consumer note (ScadaBridge).** Resolved on 0.3.1: ScadaBridge's Central UI consumes
|
||||||
|
`ZB.MOM.WW.Theme` 0.3.1, and the Playwright `NavCollapseTests` (toggling, persistence,
|
||||||
|
auto-reveal) now pass — the `NavCollapseWiredAsync` gate (which waits for
|
||||||
|
`data-zbnav-initialized` on every `details.rail-section`) is satisfied under interactive
|
||||||
|
render, so those tests run unskipped and green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not kit bugs — expected consumer adaptations
|
||||||
|
|
||||||
|
For the avoidance of doubt, the following are **not** theme issues; they are the normal cost
|
||||||
|
of adopting the kit and belong in each consumer's own tests/markup:
|
||||||
|
|
||||||
|
- Login markup moved from a hand-rolled `<h4>ScadaBridge</h4>` + `Sign In` button to the
|
||||||
|
kit's `LoginCard` (`h1.login-title` reading `"<Product> — sign in"`, button labelled
|
||||||
|
`Sign in`). Consumers must update selectors/text accordingly.
|
||||||
|
- Nav moved from app-owned `button.nav-section-toggle` + `aria-expanded` + a
|
||||||
|
`scadabridge_nav` cookie to the kit's `<details.rail-section>` + `<summary>` + `localStorage`
|
||||||
|
(`zbnav:<key>`). Collapsed sections now **keep their children in the DOM** (hidden), and
|
||||||
|
sections default to **expanded**, not collapsed — so DOM-presence-based "hidden" assertions
|
||||||
|
and "collapsed by default" assumptions must be rewritten around visibility and the
|
||||||
|
`<details open>` state.
|
||||||
|
|
||||||
|
These are being handled in the ScadaBridge Playwright suite separately.
|
||||||
+19317
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
Northwind Consumer Products — Unified Namespace
|
||||||
|
(generated from Galaxy DESKTOP-6JL3KKO\DEV; 40 machines, 1036 signals)
|
||||||
|
|
||||||
|
northwind
|
||||||
|
└─ birmingham
|
||||||
|
├─ filling/ (Filling & Capping; from Galaxy TestArea)
|
||||||
|
│ ├─ line-1/
|
||||||
|
│ │ ├─ rinser-01 [krones Hydra Srs3] ← TestMachine_001 (28 signals)
|
||||||
|
│ │ ├─ filler-02 [sidel SF300 Srs5] ← TestMachine_002 (28 signals)
|
||||||
|
│ │ ├─ capper-03 [khs Innofill Srs4] ← TestMachine_003 (28 signals)
|
||||||
|
│ │ ├─ labeler-04 [krones Contiroll Srs3] ← TestMachine_004 (28 signals)
|
||||||
|
│ │ ├─ inspector-05 [antares-vision Vmax Srs2] ← TestMachine_005 (28 signals)
|
||||||
|
│ │ ├─ coder-06 [videojet 1580 Srs4] ← TestMachine_006 (28 signals)
|
||||||
|
│ │ └─ rinser-07 [krones Hydra Srs3] ← TestMachine_007 (28 signals)
|
||||||
|
│ ├─ line-2/
|
||||||
|
│ │ ├─ rinser-08 [krones Hydra Srs3] ← TestMachine_008 (28 signals)
|
||||||
|
│ │ ├─ filler-09 [sidel SF300 Srs2] ← TestMachine_009 (28 signals)
|
||||||
|
│ │ ├─ capper-10 [khs Innofill Srs3] ← TestMachine_010 (28 signals)
|
||||||
|
│ │ ├─ labeler-11 [krones Contiroll Srs4] ← TestMachine_011 (28 signals)
|
||||||
|
│ │ ├─ inspector-12 [antares-vision Vmax Srs5] ← TestMachine_012 (28 signals)
|
||||||
|
│ │ └─ coder-13 [videojet 1580 Srs4] ← TestMachine_013 (28 signals)
|
||||||
|
│ └─ line-3/
|
||||||
|
│ ├─ rinser-14 [krones Hydra Srs4] ← TestMachine_014 (28 signals)
|
||||||
|
│ ├─ filler-15 [sidel SF300 Srs4] ← TestMachine_015 (28 signals)
|
||||||
|
│ ├─ capper-16 [khs Innofill Srs4] ← TestMachine_016 (28 signals)
|
||||||
|
│ ├─ labeler-17 [krones Contiroll Srs4] ← TestMachine_017 (28 signals)
|
||||||
|
│ ├─ inspector-18 [antares-vision Vmax Srs4] ← TestMachine_018 (28 signals)
|
||||||
|
│ └─ coder-19 [videojet 1580 Srs5] ← TestMachine_019 (28 signals)
|
||||||
|
├─ blending/ (Blending & CIP; from Galaxy TestArea2)
|
||||||
|
│ └─ cip-1/
|
||||||
|
│ └─ blender-20 [spx-flow APV-R5 Srs4] ← TestMachine_020 (24 signals)
|
||||||
|
└─ packaging/ (Packaging & Palletizing; from Galaxy TestArea3)
|
||||||
|
├─ pack-1/
|
||||||
|
│ ├─ cartoner-21 [marchesini MC820 Srs2] ← TestMachine_021 (24 signals)
|
||||||
|
│ ├─ case-packer-22 [bosch Elematic Srs4] ← TestMachine_022 (24 signals)
|
||||||
|
│ ├─ palletizer-23 [fanuc M410 Srs5] ← TestMachine_023 (24 signals)
|
||||||
|
│ ├─ stretch-wrapper-24 [lantech Q300 Srs4] ← TestMachine_024 (24 signals)
|
||||||
|
│ └─ checkweigher-25 [mettler-toledo C3570 Srs2] ← TestMachine_025 (24 signals)
|
||||||
|
├─ pack-2/
|
||||||
|
│ ├─ cartoner-26 [marchesini MC820 Srs2] ← TestMachine_026 (24 signals)
|
||||||
|
│ ├─ case-packer-27 [bosch Elematic Srs5] ← TestMachine_027 (24 signals)
|
||||||
|
│ ├─ palletizer-28 [fanuc M410 Srs5] ← TestMachine_028 (24 signals)
|
||||||
|
│ ├─ stretch-wrapper-29 [lantech Q300 Srs4] ← TestMachine_029 (24 signals)
|
||||||
|
│ └─ checkweigher-30 [mettler-toledo C3570 Srs5] ← TestMachine_030 (24 signals)
|
||||||
|
├─ pack-3/
|
||||||
|
│ ├─ cartoner-31 [marchesini MC820 Srs5] ← TestMachine_031 (24 signals)
|
||||||
|
│ ├─ case-packer-32 [bosch Elematic Srs5] ← TestMachine_032 (24 signals)
|
||||||
|
│ ├─ palletizer-33 [fanuc M410 Srs5] ← TestMachine_033 (24 signals)
|
||||||
|
│ ├─ stretch-wrapper-34 [lantech Q300 Srs4] ← TestMachine_034 (24 signals)
|
||||||
|
│ └─ checkweigher-35 [mettler-toledo C3570 Srs2] ← TestMachine_035 (24 signals)
|
||||||
|
└─ pack-4/
|
||||||
|
├─ cartoner-36 [marchesini MC820 Srs4] ← TestMachine_036 (24 signals)
|
||||||
|
├─ case-packer-37 [bosch Elematic Srs3] ← TestMachine_037 (24 signals)
|
||||||
|
├─ palletizer-38 [fanuc M410 Srs3] ← TestMachine_038 (24 signals)
|
||||||
|
├─ stretch-wrapper-39 [lantech Q300 Srs2] ← TestMachine_039 (24 signals)
|
||||||
|
└─ checkweigher-40 [mettler-toledo C3570 Srs5] ← TestMachine_040 (24 signals)
|
||||||
@@ -3,6 +3,38 @@
|
|||||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||||
reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||||
|
|
||||||
|
> **✅ ADOPTED 2026-06-03 (local-only).** Backlog #2–#4 implemented across all three apps on each repo's
|
||||||
|
> **`feat/adopt-zb-theme`** branch — full canonical cutover (SPEC §7): `<ThemeHead/>`/`<ThemeScripts/>`,
|
||||||
|
> thin `MainLayout` → `<ThemeShell>` + `NavRailItem`/`NavRailSection`, per-app `theme.css`/IBM-Plex fonts/
|
||||||
|
> `nav-state.js` deleted, `<LoginCard>` sign-in, and `StatusPill` (OtOpcUa's dead `StatusBadge` deleted;
|
||||||
|
> MxGateway's `StatusBadge` redirected to a thin `StatusPill` adapter; inline domain `.chip-*` kept as page
|
||||||
|
> content per §6). **Library first enhanced to `0.2.0`** — nav-expand persistence promoted INTO the kit
|
||||||
|
> (`NavRailSection.Key` → `data-nav-key` + a localStorage `nav-state.js` enhancer emitted by a new
|
||||||
|
> `<ThemeScripts/>`), so all three apps get uniform persistence from one source (OtOpcUa's bespoke
|
||||||
|
> cookie/JS-interop nav island retired). 0.2.0 published to the Gitea feed; 44 bUnit tests. **MxGateway
|
||||||
|
> additionally gained a net-new Blazor `<LoginCard>` `/login` page** reusing its existing hardened
|
||||||
|
> `POST /login` endpoint (antiforgery + `SanitizeReturnUrl` + `SignInAsync` preserved). Every task spec+code
|
||||||
|
> reviewed (high-risk via serial spec→code; the MxGateway login via an Opus security review), then
|
||||||
|
> **fast-forward-merged into each repo's local default and PUSHED to origin (gitea) 2026-06-03** (in sync;
|
||||||
|
> `feat/*` kept locally): OtOpcUa `master`@`11de14d`, ScadaBridge `main`@`58352a6`, MxGateway `main`@`73e54e2`.
|
||||||
|
> Plan: `docs/plans/2026-06-03-ui-theme-adoption*.md`. The ⛔/🟡 cells below describe the PRE-adoption
|
||||||
|
> divergence (kept for history).
|
||||||
|
>
|
||||||
|
> **Post-adoption CSS prune (2026-06-03, branch `chore/theme-css-prune` per app).** An audit found each app's
|
||||||
|
> kept `site.css` still carried the old shell CSS the kit now owns — broader than first logged. Pruned:
|
||||||
|
> **OtOpcUa** shed a near-verbatim copy of the kit's `layout.css` (`.app-shell`/`.side-rail`/`.rail-link`/
|
||||||
|
> `.rail-foot`/`.login-*`) plus dead `#sidebar-collapse` (kit emits `#theme-rail`) and `.rail-eyebrow-chevron`
|
||||||
|
> (−167 lines), keeping only app-only `.rail-eyebrow` + `.chip-alert`/`.chip-caution`; **ScadaBridge** shed the
|
||||||
|
> dead `.sidebar`/`.nav-link`/`.nav-section-toggle` block (−95), keeping `#reconnect-modal`/`.script-editor-modal`;
|
||||||
|
> **MxGateway** shed the dead `.sidebar` block + orphaned `.dashboard-login`/`.login-card` (−106), keeping
|
||||||
|
> `.app-bar` (still used by `/denied`) + the `.chip` override. Each verified unreferenced before removal; all
|
||||||
|
> three build clean (0 warn/0 err). OtOpcUa's copy was the notable one — it *overrode* the kit, not just dead code.
|
||||||
|
> **Still deferred:** a kit-side `layout.css` `calc(100vh - 3.3rem)` review; and ScadaBridge's `Host` consumes the
|
||||||
|
> kit only **transitively via `CentralUI`** (no direct `PackageReference`) — builds green, but an implicit dependency.
|
||||||
|
>
|
||||||
|
> _Feed note: the same audit re-confirmed `ZB.MOM.WW.Theme 0.2.0` **is** genuinely on the Gitea feed (registration
|
||||||
|
> `count:1`, package base `versions:["0.2.0"]`, search `totalHits:1`) — the publish was real, not optimism._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Divergence vs spec
|
## Divergence vs spec
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# Shared library: `ZB.MOM.WW.Theme`
|
# Shared library: `ZB.MOM.WW.Theme`
|
||||||
|
|
||||||
**Status: Built (`0.1.0`).** The RCL lives at
|
**Status: Built + Published + Adopted (`0.2.0`).** The RCL lives at
|
||||||
[`scadaproj/ZB.MOM.WW.Theme/`](../../../ZB.MOM.WW.Theme/) — built and tested. Adoption
|
[`scadaproj/ZB.MOM.WW.Theme/`](../../../ZB.MOM.WW.Theme/) — built, tested (44 bUnit tests), and
|
||||||
by the three apps is follow-on, tracked in [`../GAPS.md`](../GAPS.md). Realizes
|
**published to the Gitea NuGet feed**. **Adopted across all three apps on 2026-06-03** — merged to each repo's
|
||||||
[`../spec/SPEC.md`](../spec/SPEC.md).
|
local default and **pushed to origin (gitea)**, in sync (see [`../GAPS.md`](../GAPS.md)).
|
||||||
|
Realizes [`../spec/SPEC.md`](../spec/SPEC.md).
|
||||||
|
|
||||||
|
`0.2.0` adds **shared nav-expand persistence**: `NavRailSection` gained a `Key` parameter (emitted as
|
||||||
|
`data-nav-key`, defaulting to a slug of `Title`), a vendored `wwwroot/js/nav-state.js` localStorage enhancer
|
||||||
|
(keyed by `data-nav-key`, prefix `zbnav:`, idempotent), and a new **`ThemeScripts`** component (sibling to
|
||||||
|
`ThemeHead`) that emits the enhancer `<script defer>` before `</body>`. This lets every app persist nav
|
||||||
|
expand-state from one shared, static-SSR-friendly mechanism (no per-app cookie/JS-interop island).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,12 +23,14 @@ tokens-only or components-only consumers; all three apps consume the full kit.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ZB.MOM.WW.Theme` | `net10.0` Razor Class Library | Tokens + fonts + layout CSS + all components |
|
| `ZB.MOM.WW.Theme` | `net10.0` Razor Class Library | Tokens + fonts + layout CSS + all components |
|
||||||
|
|
||||||
Published to the Gitea NuGet feed; `Version 0.1.0`. SemVer — token changes are
|
Published to the Gitea NuGet feed; `Version 0.2.0`. SemVer — token changes are
|
||||||
breaking (major bump). Build from `scadaproj/ZB.MOM.WW.Theme/`:
|
breaking (major bump); the `0.1.0 → 0.2.0` bump added nav persistence (`NavRailSection.Key` +
|
||||||
|
`ThemeScripts` + `nav-state.js`) additively. Build from `scadaproj/ZB.MOM.WW.Theme/`:
|
||||||
```bash
|
```bash
|
||||||
dotnet build -c Release # 0 warnings (TreatWarningsAsErrors)
|
dotnet build -c Release # 0 warnings (TreatWarningsAsErrors)
|
||||||
dotnet test # 32 bUnit tests
|
dotnet test # 44 bUnit tests
|
||||||
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
|
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.2.0.nupkg
|
||||||
|
GITEA_NUGET_SOURCE=… GITEA_NUGET_KEY=… ./build/push.sh # publish to the Gitea feed
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -72,6 +81,18 @@ Place in `App.razor` `<head>` **after** the app's Bootstrap link.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `ThemeScripts`
|
||||||
|
|
||||||
|
Emits the nav-state localStorage enhancer `<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer>`.
|
||||||
|
No parameters. Place in `App.razor` **before `</body>`**. Persists each `NavRailSection`'s open/closed
|
||||||
|
state (keyed by its `data-nav-key`) across navigation and reloads; pure client-side, works in static SSR.
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<ThemeScripts />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `ThemeShell`
|
### `ThemeShell`
|
||||||
|
|
||||||
Canonical side-rail chassis. **Not a `LayoutComponentBase`** — delegated to from the app's
|
Canonical side-rail chassis. **Not a `LayoutComponentBase`** — delegated to from the app's
|
||||||
@@ -134,14 +155,15 @@ One rail navigation link. Wraps Blazor `<NavLink class="rail-link">`.
|
|||||||
|
|
||||||
### `NavRailSection`
|
### `NavRailSection`
|
||||||
|
|
||||||
Collapsible nav section group using CSS-only `<details open>` — no JavaScript, works in
|
Collapsible nav section group using CSS-only `<details open>` — no JavaScript required. Open/closed
|
||||||
static Blazor SSR. Apps that need interactive cookie-persisted expand state may keep a
|
state is persisted in localStorage by `<ThemeScripts/>` (keyed by `Key` → `data-nav-key`); works in
|
||||||
bespoke interactive `NavSection` alongside this.
|
static Blazor SSR.
|
||||||
|
|
||||||
| Parameter | Type | Required | Default | Notes |
|
| Parameter | Type | Required | Default | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `Title` | `string` | Yes | — | Eyebrow label |
|
| `Title` | `string` | Yes | — | Eyebrow label |
|
||||||
| `Expanded` | `bool` | No | `true` | Initial open state |
|
| `Key` | `string?` | No | slug of `Title` | Stable persistence key, emitted as `data-nav-key` |
|
||||||
|
| `Expanded` | `bool` | No | `true` | Initial open state (before localStorage restore) |
|
||||||
| `ChildContent` | `RenderFragment?` | No | `null` | `NavRailItem` children |
|
| `ChildContent` | `RenderFragment?` | No | `null` | `NavRailItem` children |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+341
@@ -0,0 +1,341 @@
|
|||||||
|
# Deployment & Environments — SCADA/OT family
|
||||||
|
|
||||||
|
> How the sister projects are deployed: environments, hosts, SSH access, Docker/Traefik
|
||||||
|
> topology, databases, and the full service/port map. Compiled **2026-06-03** by reading the
|
||||||
|
> actual compose/Traefik/SSH files (not docs alone). For the per-service **environment
|
||||||
|
> variables** see the companion [`env_vars.md`](env_vars.md).
|
||||||
|
>
|
||||||
|
> **Source confidence:** container/port/Traefik/DB facts below are read straight from the
|
||||||
|
> compose + `traefik/*.yml` files. SSH facts are from `~/.ssh/config` + `~/.ssh/known_hosts`.
|
||||||
|
> Where a fact is referenced in repo docs but not pinned in a config/script on this machine,
|
||||||
|
> it's marked _(referenced, not scripted in-repo)_ — don't treat those as automated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Environment inventory
|
||||||
|
|
||||||
|
| Environment | Where it runs | What it is | Entry point |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **ScadaBridge `docker`** | This Mac (Docker Desktop/OrbStack) | Full hub-and-spoke: 2 Central + 3 sites ×2 nodes | `http://localhost:9000` |
|
||||||
|
| **ScadaBridge `docker-env2`** | This Mac | Second isolated cluster: 2 Central + 1 site ×2 nodes | `http://localhost:9100` |
|
||||||
|
| **ScadaBridge `infra`** | This Mac | Shared backing services (MSSQL, OPC-UA sims, SMTP, REST, Playwright) — **not** LDAP (see shared GLAuth below) | n/a (deps) |
|
||||||
|
| **OtOpcUa `otopcua-dev`** | This Mac | 3 independent Akka clusters (MAIN + SITE-A + SITE-B) sharing one ConfigDb | `http://localhost:9200` |
|
||||||
|
| **MxAccessGateway** | `windev` (10.100.0.48), Windows | Windows-native gRPC gateway + per-session x86 worker (no Docker) | `http://10.100.0.48:5120` (gRPC) |
|
||||||
|
| **Production (VD03)** | `wonder-app-vd03.zmr.zimmer.com` | Single-node ScadaBridge + MxGateway prod host | see `docs/operations/` runbooks |
|
||||||
|
|
||||||
|
The three ScadaBridge stacks share one external Docker network **`scadabridge-net`**; the
|
||||||
|
OtOpcUa `otopcua-dev` stack runs on its **own** default network (`otopcua-dev_default`) and is
|
||||||
|
network-isolated from ScadaBridge. All local stacks can run simultaneously — host ports do not
|
||||||
|
collide (see [§7](#7-consolidated-host-port-map)).
|
||||||
|
|
||||||
|
> On this Apple-Silicon Mac, MSSQL runs under amd64 emulation (slow first-ready; the "platform
|
||||||
|
> does not match" warning is expected/benign). See [[scadabridge-local-deploy-gotchas]].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hosts & SSH connectivity
|
||||||
|
|
||||||
|
### 2.1 Host inventory
|
||||||
|
|
||||||
|
| Host | Address | OS | Role | SSH port |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **This Mac** | local | macOS (darwin) | Dev workstation — runs all local Docker stacks | n/a |
|
||||||
|
| **windev** | `10.100.0.48` | Windows | OtOpcUa Windows-service host **+** MxAccessGateway (gRPC `5120` / dashboard `5130`) | 22 |
|
||||||
|
| **fixture host** | `10.100.0.35` | Debian/Linux + Docker | OtOpcUa driver **integration-test fixtures** + a test SQL Server | 22 |
|
||||||
|
| **VD03 (prod)** | `wonder-app-vd03.zmr.zimmer.com` | Windows | Production single-node ScadaBridge + MxGateway | **2222** |
|
||||||
|
| **gitea** | `gitea.dohertylan.com` (`10.100.0.228`) | Linux | Git remotes + NuGet feed (`/api/packages/dohertj2/nuget`) | 22 |
|
||||||
|
|
||||||
|
All are on the private `10.x` lab network — a LAN/VPN connection is required.
|
||||||
|
|
||||||
|
### 2.2 How to connect (passwordless SSH)
|
||||||
|
|
||||||
|
Auth is **key-based (passwordless)** with `~/.ssh/id_ed25519` (a legacy `~/.ssh/id_rsa` exists
|
||||||
|
as fallback). Only **one** host alias is defined in `~/.ssh/config`:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
# ~/.ssh/config (verified)
|
||||||
|
Include ~/.orbstack/ssh/config # OrbStack local Linux VMs — use `ssh orb` / `orb` CLI
|
||||||
|
|
||||||
|
Host windev
|
||||||
|
HostName 10.100.0.48
|
||||||
|
User dohertj2
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
# Port 22 (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Target | Command | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **windev** (Win host) | `ssh windev` | Configured alias; user `dohertj2`, key `id_ed25519`, port 22 |
|
||||||
|
| **fixture host** | `ssh dohertj2@10.100.0.35` | In `known_hosts`; **no** config alias — pass user explicitly; port 22, key-based |
|
||||||
|
| **VD03 (prod)** | `ssh dohertj2@wonder-app-vd03.zmr.zimmer.com -p 2222` | In `known_hosts` on **port 2222** (the only non-standard SSH port); user/key not pinned in config — confirm before use |
|
||||||
|
| **local Linux VMs** | `ssh orb` / `orb` | OrbStack-managed |
|
||||||
|
|
||||||
|
> ⚠️ `~/bin` is **empty** on this Mac. OtOpcUa's `CLAUDE.md` mentions an `lmxopcua-fix` helper "in
|
||||||
|
> `~/bin`" for controlling the `10.100.0.35` fixture containers — it is **not present here** (it's a
|
||||||
|
> Windows-side helper). On this machine, drive the fixture host with direct SSH, e.g.
|
||||||
|
> `ssh dohertj2@10.100.0.35 'docker compose -f /opt/otopcua-<driver>/docker-compose.yml up -d'`.
|
||||||
|
> Treat the exact remote paths/commands as _(referenced, not scripted in-repo)_ — verify on the host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ScadaBridge deployment
|
||||||
|
|
||||||
|
.NET 10 + Akka.NET. One image `scadabridge:latest` (built by `docker/build.sh`) backs every node;
|
||||||
|
role is chosen by `SCADABRIDGE_CONFIG` (`Central`|`Site`) → `appsettings.{role}.json`. Central is a
|
||||||
|
2-node Akka cluster (split-brain resolver = `keep-oldest`); each Site is its **own** 2-node Akka
|
||||||
|
cluster reached from Central via ClusterClient.
|
||||||
|
|
||||||
|
### 3.1 `docker/` — primary 3-site cluster (network `scadabridge-net`)
|
||||||
|
|
||||||
|
| Service | Container | Host→container ports | Role | Volumes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| central-a | `scadabridge-central-a` | `9001:5000` (UI+Inbound API), `9011:8081` (Akka) | Central | `central-node-a/appsettings.Central.json` (ro), `…/logs` |
|
||||||
|
| central-b | `scadabridge-central-b` | `9002:5000`, `9012:8081` | Central | `central-node-b/…` |
|
||||||
|
| site-a-a | `scadabridge-site-a-a` | `9021:8082` (Akka), `9023:8083` (gRPC) | Site | `site-a-node-a/{appsettings.Site.json,data,logs}` |
|
||||||
|
| site-a-b | `scadabridge-site-a-b` | `9022:8082`, `9024:8083` | Site | `site-a-node-b/…` |
|
||||||
|
| site-b-a | `scadabridge-site-b-a` | `9031:8082`, `9033:8083` | Site | `site-b-node-a/…` |
|
||||||
|
| site-b-b | `scadabridge-site-b-b` | `9032:8082`, `9034:8083` | Site | `site-b-node-b/…` |
|
||||||
|
| site-c-a | `scadabridge-site-c-a` | `9041:8082`, `9043:8083` | Site | `site-c-node-a/…` |
|
||||||
|
| site-c-b | `scadabridge-site-c-b` | `9042:8082`, `9044:8083` | Site | `site-c-node-b/…` |
|
||||||
|
| traefik | `scadabridge-traefik` | `9000:80` (Central LB), `8180:8080` (dashboard) | LB | `traefik/{traefik,dynamic}.yml` (ro) |
|
||||||
|
|
||||||
|
All `restart: unless-stopped`; image `scadabridge:latest` (traefik `traefik:v3.4`).
|
||||||
|
**Access:** Central UI/API via LB `http://localhost:9000`; direct nodes `:9001`/`:9002`; Traefik
|
||||||
|
dashboard `http://localhost:8180`; Management API `http://localhost:9000/management`; health
|
||||||
|
`…/health/ready` + `…/health/active`.
|
||||||
|
|
||||||
|
### 3.2 `docker-env2/` — secondary 1-site cluster (same `scadabridge-net`)
|
||||||
|
|
||||||
|
| Service | Container | Host→container ports | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| central-a | `scadabridge-env2-central-a` | `9101:5000`, `9111:8081` | Central |
|
||||||
|
| central-b | `scadabridge-env2-central-b` | `9102:5000`, `9112:8081` | Central |
|
||||||
|
| site-x-a | `scadabridge-env2-site-x-a` | `9121:8082`, `9123:8083` | Site |
|
||||||
|
| site-x-b | `scadabridge-env2-site-x-b` | `9122:8082`, `9124:8083` | Site |
|
||||||
|
| traefik | `scadabridge-env2-traefik` | `9100:80` (LB), `8181:8080` (dashboard) | LB |
|
||||||
|
|
||||||
|
**Access:** LB `http://localhost:9100`; direct `:9101`/`:9102`; dashboard `http://localhost:8181`.
|
||||||
|
This cluster's DBs and **auth cookie name** are distinct from `docker/` so the two can run on
|
||||||
|
`localhost` at once — cookie `ZB.MOM.WW.ScadaBridge.Auth.env2` vs the default; see
|
||||||
|
[[scadabridge-local-deploy-gotchas]].
|
||||||
|
|
||||||
|
### 3.3 `infra/` — shared backing services (network `scadabridge-net`)
|
||||||
|
|
||||||
|
| Service | Container | Image | Host ports | Purpose |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| mssql | `scadabridge-mssql` | `mcr.microsoft.com/mssql/server:2022-latest` | `1433:1433` | SQL Server — Central DBs for **both** clusters; named vol `scadabridge-mssql-data`; init via `/docker-entrypoint-initdb.d/{setup,machinedata_seed,setup-env2}.sql` |
|
||||||
|
| opcua | `scadabridge-opcua` | `mcr.microsoft.com/iotedge/opc-plc:latest` | `50000:50000`, `8080:8080` | OPC-UA simulator 1 (`--unsecuretransport --autoaccept`) |
|
||||||
|
| opcua2 | `scadabridge-opcua2` | `…/opc-plc:latest` | `50010:50010`, `8081:8080` | OPC-UA simulator 2 |
|
||||||
|
| smtp | `scadabridge-smtp` | `axllent/mailpit:latest` | `1025:1025`, `8025:8025` | SMTP sink + web UI (`http://localhost:8025`) |
|
||||||
|
| restapi | `scadabridge-restapi` | local build `./restapi` | `5200:5200` | Test REST endpoint |
|
||||||
|
| playwright | `scadabridge-playwright` | `mcr.microsoft.com/playwright:v1.58.2-noble` | `3000:3000` | Browser-automation server |
|
||||||
|
|
||||||
|
> **LDAP is NOT started by `infra/`.** The per-app `scadabridge-ldap` container has been retired
|
||||||
|
> (commented out in `infra/docker-compose.yml`). All three apps (ScadaBridge, OtOpcUa, MxAccessGateway)
|
||||||
|
> now share a single **`zb-shared-glauth`** container on the Linux fixture host **`10.100.0.35:3893`**
|
||||||
|
> (`baseDN dc=zb,dc=local`, Transport=None). Source of truth and deploy/verify runbook:
|
||||||
|
> **`scadaproj/infra/glauth/`** (`config.toml` + `docker-compose.yml` + `README.md`); deploy by
|
||||||
|
> scp-ing those two files to `10.100.0.35` and running `docker compose up -d`.
|
||||||
|
|
||||||
|
### 3.4 Traefik (ScadaBridge)
|
||||||
|
|
||||||
|
Both clusters use a file provider + insecure API dashboard. `traefik.yml`: entrypoint `web:80`,
|
||||||
|
`api.dashboard: true / insecure: true`, file provider `dynamic.yml`. `dynamic.yml` router
|
||||||
|
`central` (`PathPrefix(/)` → service `central`) load-balances the two Central containers with an
|
||||||
|
**active health check** on `/health/active` (interval 5s, timeout 3s) — so traffic only routes to
|
||||||
|
the active leader (standby returns 503 and is dropped from rotation):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker/traefik/dynamic.yml (env2 points at scadabridge-env2-central-a/-b)
|
||||||
|
http:
|
||||||
|
routers: { central: { rule: "PathPrefix(`/`)", service: central, entryPoints: [web] } }
|
||||||
|
services:
|
||||||
|
central:
|
||||||
|
loadBalancer:
|
||||||
|
healthCheck: { path: /health/active, interval: 5s, timeout: 3s }
|
||||||
|
servers: [ {url: "http://scadabridge-central-a:5000"}, {url: "http://scadabridge-central-b:5000"} ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Databases (ScadaBridge)
|
||||||
|
|
||||||
|
- **Central → MSSQL** (`scadabridge-mssql:1433`), app login `scadabridge_app` / `ScadaBridge_Dev1#` 🔒(dev-only):
|
||||||
|
- `docker/`: `ScadaBridgeConfig` + `ScadaBridgeMachineData`
|
||||||
|
- `docker-env2/`: `ScadaBridgeConfig2` + `ScadaBridgeMachineData2`
|
||||||
|
- Created by `infra/mssql/setup.sql` + `setup-env2.sql` at MSSQL init; EF Core migrations run on Central startup; `docker-env2/init-db.sh` ensures the env2 DBs before deploy; `seed-sites.sh` seeds Site rows post-deploy.
|
||||||
|
- **Site → SQLite**, per node under the mounted `…/data` volume (`SiteDbPath`, plus a store-and-forward DB). Not networked, not replicated across hosts.
|
||||||
|
|
||||||
|
### 3.6 Deploy commands (ScadaBridge)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Desktop/ScadaBridge
|
||||||
|
cd infra && docker compose up -d # 1) backing services (MSSQL, OPC-UA, SMTP, REST) — LDAP is shared glauth on 10.100.0.35 (scadaproj/infra/glauth/)
|
||||||
|
bash docker/build.sh # 2) create scadabridge-net (if missing) + build scadabridge:latest
|
||||||
|
bash docker/deploy.sh # 3) up -d --force-recreate; prints access points (9000/9001/9002/8180)
|
||||||
|
bash docker/seed-sites.sh # 4) seed sites + data-connections (optional)
|
||||||
|
# env2 cluster:
|
||||||
|
bash docker-env2/deploy.sh # reuses the image; runs init-db.sh; ports 9100/9101/9102/8181
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Caveat:** `deploy.sh` does `up -d --force-recreate`, starting both Central nodes at once — they
|
||||||
|
> can split-brain on a simultaneous start. Start Central **sequenced** (central-a → wait `/health/active`
|
||||||
|
> 200 → central-b). Central also requires `ScadaBridge__InboundApi__ApiKeyPepper` (dev value is inline in
|
||||||
|
> both composes). Full detail: [[scadabridge-local-deploy-gotchas]].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. OtOpcUa deployment (`otopcua-dev`)
|
||||||
|
|
||||||
|
.NET 10 OPC-UA server. **Three independent Akka clusters** share the single `OtOpcUa` ConfigDb
|
||||||
|
(multi-tenancy via the `ServerCluster` table); Akka isolation is by disjoint seed lists (same
|
||||||
|
system name `otopcua`, internal remoting port `4053`). Built locally from `docker-dev/Dockerfile`
|
||||||
|
→ image `otopcua-host:dev`. **No per-app LDAP container** — `docker-dev` is un-stubbed
|
||||||
|
(`Authentication__Ldap__DevStubMode` removed) and binds the **shared GLAuth** at
|
||||||
|
`10.100.0.35:3893` (`baseDN dc=zb,dc=local`, Transport=None). Start the shared glauth first via
|
||||||
|
`scadaproj/infra/glauth/` if it is not already running.
|
||||||
|
|
||||||
|
| Service | Container | Host→container ports | Cluster / role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| sql | (`otopcua-dev-sql-1`) | `14330:1433` | SQL Server 2022 — the shared `OtOpcUa` ConfigDb |
|
||||||
|
| cluster-seed | one-shot | — | `mssql-tools` running `/seed/entrypoint.sh` (idempotent ServerCluster/ClusterNode seed) |
|
||||||
|
| admin-a | host | _(none — internal `:9000` UI behind Traefik)_ | MAIN, role `admin` (seed) |
|
||||||
|
| admin-b | host | _(none)_ | MAIN, role `admin` (joins admin-a) |
|
||||||
|
| driver-a | host | `4840:4840` (OPC UA) | MAIN, role `driver` |
|
||||||
|
| driver-b | host | `4841:4840` | MAIN, role `driver` |
|
||||||
|
| site-a-1 | host | `4842:4840` | SITE-A, `admin,driver` (seed) |
|
||||||
|
| site-a-2 | host | `4843:4840` | SITE-A, `admin,driver` |
|
||||||
|
| site-b-1 | host | `4844:4840` | SITE-B, `admin,driver` (seed) |
|
||||||
|
| site-b-2 | host | `4845:4840` | SITE-B, `admin,driver` |
|
||||||
|
| traefik | host | `9200:80` (Admin UI LB), `8089:8080` (dashboard) | `traefik:v3.1` |
|
||||||
|
|
||||||
|
- **OPC UA endpoints:** `opc.tcp://localhost:4840` (driver-a) … `:4845` (site-b-2). Admin nodes serve no OPC UA.
|
||||||
|
- **Admin UI (Traefik, sticky cookie `otopcua_lb`, health-checked on `/health/active`):**
|
||||||
|
- MAIN cluster: `http://localhost:9200`
|
||||||
|
- SITE-A: `http://site-a.localhost:9200` · SITE-B: `http://site-b.localhost:9200` (Host-header routing; macOS auto-resolves `*.localhost`)
|
||||||
|
- Traefik dashboard: `http://localhost:8089`
|
||||||
|
- **DB:** `sql` service, `14330:1433`, SA `OtOpcUa!Dev123` 🔒(dev-only), database `OtOpcUa`; EF auto-migrates on host start, then `cluster-seed` inserts the 3 ServerCluster + 6 ClusterNode rows.
|
||||||
|
- **Deploy:** `docker compose -f docker-dev/docker-compose.yml up -d --build` ; tear down with `… down -v`.
|
||||||
|
- **Galaxy link:** driver nodes resolve `GALAXY_MXGW_API_KEY` and connect out to MxAccessGateway (see §5).
|
||||||
|
|
||||||
|
> **Integration-test fixtures (separate from this stack)** run on the Linux **fixture host
|
||||||
|
> `10.100.0.35`** (Modbus `:5020`, Allen-Bradley `:44818`, S7 `:102`, OPC-UA `:50000`, SQL `:14330`).
|
||||||
|
> Those are test endpoints, not the deployed app; per-fixture env defaults are in [`env_vars.md`](env_vars.md) §1.3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MxAccessGateway deployment (Windows-native, no Docker)
|
||||||
|
|
||||||
|
Two processes: an **x64 .NET 10 Server** (ASP.NET Core gRPC + Blazor dashboard) and a **per-session
|
||||||
|
x86 .NET 4.8 Worker** that owns the 32-bit AVEVA MXAccess COM/STA. Windows-only. Deployed on
|
||||||
|
**`windev` (10.100.0.48)** and **VD03**, run as a **Windows Service via NSSM** (config delivered as
|
||||||
|
`Kestrel__Endpoints__…` environment variables, not `appsettings.json`).
|
||||||
|
|
||||||
|
### 5.1 Endpoint/port map
|
||||||
|
|
||||||
|
| Endpoint | Default URL | Protocol | Config key | Purpose |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Http (gRPC)** | `http://0.0.0.0:5120` (h2c) | HTTP/2 cleartext | `Kestrel__Endpoints__Http__Url` / `__Protocols=Http2` | Public gRPC: sessions, MxCommand/MxEvent, Galaxy browse |
|
||||||
|
| **Dashboard** | `http://0.0.0.0:5130` | HTTP/1.1 | `Kestrel__Endpoints__Dashboard__Url` | Blazor dashboard + SignalR hubs + `/login` |
|
||||||
|
|
||||||
|
Local dev (`launchSettings.json`): gRPC `http://localhost:5120` (https dev profile adds `7121`).
|
||||||
|
TLS optional — set `…Http__Url=https://…`; the gateway auto-generates a self-signed cert if none is
|
||||||
|
supplied (`docs/GatewayConfiguration.md`). Dashboard cookie name is now configurable
|
||||||
|
(`MxGateway:Dashboard:CookieName`).
|
||||||
|
|
||||||
|
### 5.2 Run / host
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# local dev
|
||||||
|
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
|
||||||
|
# the x86 worker must be published first; path = MxGateway:Worker:ExecutablePath
|
||||||
|
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Worker model:** the Server spawns one `ZB.MOM.WW.MxGateway.Worker.exe` (x86) **per gRPC session**;
|
||||||
|
IPC over a named pipe (`\\.\pipe\mxgateway-<session>` + a per-session `MXGATEWAY_WORKER_NONCE`);
|
||||||
|
heartbeat 5s / grace 15s; max 64 concurrent sessions. The worker exits when the session closes.
|
||||||
|
- **Production hosts:** both `10.100.0.48` and `wonder-app-vd03` serve gRPC on `:5120` (per
|
||||||
|
`docs/GatewayConfiguration.md`).
|
||||||
|
|
||||||
|
### 5.3 Who connects to it
|
||||||
|
|
||||||
|
| Client | Connects to | Auth |
|
||||||
|
|---|---|---|
|
||||||
|
| OtOpcUa `GalaxyDriver` | `http://10.100.0.48:5120` (gRPC) | API key via `GALAXY_MXGW_API_KEY` (`mxgw_…` bearer) 🔒 |
|
||||||
|
| ScadaBridge MxGateway adapter | same gRPC endpoint `:5120` | API key |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cross-project runtime data flow (deployed)
|
||||||
|
|
||||||
|
```
|
||||||
|
AVEVA Galaxy (Wonderware) ──MXAccess COM (32-bit)──► MxAccessGateway (windev:5120 gRPC / :5130 dashboard)
|
||||||
|
▲ ▲
|
||||||
|
OtOpcUa GalaxyDriver ───gRPC────┘ │ gRPC
|
||||||
|
(otopcua-dev: opc.tcp :4840–4845) │
|
||||||
|
│ OPC UA │
|
||||||
|
▼ │
|
||||||
|
ScadaBridge DCL ◄──OPC UA──┐ ┌──MxGateway adapter──┘
|
||||||
|
(docker :9000 / env2 :9100) └───┘
|
||||||
|
```
|
||||||
|
|
||||||
|
ScadaBridge reaches Wonderware data two ways: **(1)** OPC UA → OtOpcUa → gateway, or **(2)** its
|
||||||
|
MxGateway adapter → gateway directly. The break surface is the wire contracts (the gateway `.proto`s
|
||||||
|
and OtOpcUa's OPC-UA address space), not compile references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Consolidated host port map
|
||||||
|
|
||||||
|
Every published host port across the local stacks (no collisions — all can run at once):
|
||||||
|
|
||||||
|
| Port | → Container:port | Service | Stack |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1025 | `scadabridge-smtp`:1025 | SMTP submission | infra |
|
||||||
|
| 1433 | `scadabridge-mssql`:1433 | SQL Server (ScadaBridge Central DBs) | infra |
|
||||||
|
| 3000 | `scadabridge-playwright`:3000 | Playwright server | infra |
|
||||||
|
| 3893 | `zb-shared-glauth`:3893 on **10.100.0.35** | LDAP (shared GLAuth — remote fixture host, not a local container) | scadaproj/infra/glauth/ |
|
||||||
|
| 5200 | `scadabridge-restapi`:5200 | Test REST API | infra |
|
||||||
|
| 8025 | `scadabridge-smtp`:8025 | Mailpit web UI | infra |
|
||||||
|
| 8080 | `scadabridge-opcua`:8080 | OPC-UA sim 1 web UI | infra |
|
||||||
|
| 8081 | `scadabridge-opcua2`:8080 | OPC-UA sim 2 web UI | infra |
|
||||||
|
| 50000 | `scadabridge-opcua`:50000 | OPC-UA sim 1 endpoint | infra |
|
||||||
|
| 50010 | `scadabridge-opcua2`:50010 | OPC-UA sim 2 endpoint | infra |
|
||||||
|
| 9000 | `scadabridge-traefik`:80 | **Central UI/API (LB)** | docker |
|
||||||
|
| 8180 | `scadabridge-traefik`:8080 | Traefik dashboard | docker |
|
||||||
|
| 9001 / 9002 | central-a / central-b :5000 | Central UI+Inbound API (direct) | docker |
|
||||||
|
| 9011 / 9012 | central-a / central-b :8081 | Akka remoting | docker |
|
||||||
|
| 9021–9024 | site-a-a/b :8082 / :8083 | Site A Akka / gRPC | docker |
|
||||||
|
| 9031–9034 | site-b-a/b :8082 / :8083 | Site B Akka / gRPC | docker |
|
||||||
|
| 9041–9044 | site-c-a/b :8082 / :8083 | Site C Akka / gRPC | docker |
|
||||||
|
| 9100 | `scadabridge-env2-traefik`:80 | **Central UI/API (LB)** | docker-env2 |
|
||||||
|
| 8181 | `scadabridge-env2-traefik`:8080 | Traefik dashboard | docker-env2 |
|
||||||
|
| 9101 / 9102 | env2 central-a / central-b :5000 | Central (direct) | docker-env2 |
|
||||||
|
| 9111 / 9112 | env2 central-a / central-b :8081 | Akka remoting | docker-env2 |
|
||||||
|
| 9121–9124 | env2 site-x-a/b :8082 / :8083 | Site X Akka / gRPC | docker-env2 |
|
||||||
|
| 14330 | `otopcua-dev` sql :1433 | SQL Server (`OtOpcUa` DB) | otopcua-dev |
|
||||||
|
| 4840 / 4841 | driver-a / driver-b :4840 | OPC UA (MAIN) | otopcua-dev |
|
||||||
|
| 4842 / 4843 | site-a-1 / site-a-2 :4840 | OPC UA (SITE-A) | otopcua-dev |
|
||||||
|
| 4844 / 4845 | site-b-1 / site-b-2 :4840 | OPC UA (SITE-B) | otopcua-dev |
|
||||||
|
| 9200 | `otopcua-dev` traefik :80 | **Admin UI (LB)** | otopcua-dev |
|
||||||
|
| 8089 | `otopcua-dev` traefik :8080 | Traefik dashboard | otopcua-dev |
|
||||||
|
|
||||||
|
**Remote (non-local) endpoints:** MxAccessGateway gRPC `10.100.0.48:5120` (h2c) / dashboard `:5130`;
|
||||||
|
production gRPC on `wonder-app-vd03:5120`. SSH: windev/fixture/gitea on `22`, **VD03 on `2222`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Secrets & dev-only values
|
||||||
|
|
||||||
|
Every credential shown above (`OtOpcUa!Dev123`, `ScadaBridge_Dev1#`, the inline API-key peppers,
|
||||||
|
the `docker-dev` JWT signing key, the `mxgw_…` API key) is a **dev-only placeholder** for the local
|
||||||
|
stacks — never reuse as a real secret. Production injects real secrets out-of-band (NSSM env / secret
|
||||||
|
store), per ScadaBridge `docs/operations/inbound-api-key-reissue.md` (the VD03 runbook). The full
|
||||||
|
🔒 secret inventory and the `__`-env-var override forms are in [`env_vars.md`](env_vars.md) §5.
|
||||||
|
|
||||||
|
## 9. Production (VD03) — pointer
|
||||||
|
|
||||||
|
`wonder-app-vd03.zmr.zimmer.com` (SSH `:2222`) runs the production single-node ScadaBridge and the
|
||||||
|
MxGateway (gRPC `:5120`). The production install is **not a scripted in-repo flow** here — the
|
||||||
|
operational procedures live in ScadaBridge `docs/operations/` (`failover-procedures.md`,
|
||||||
|
`maintenance-procedures.md`, `inbound-api-key-reissue.md`, `troubleshooting-guide.md`). Treat any
|
||||||
|
prod service/port specifics not in those runbooks as unverified.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# UI-Theme Adoption — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Approved (brainstorming complete) — ready for `writing-plans`.
|
||||||
|
**Component:** UI Theme (`ZB.MOM.WW.Theme` shared RCL).
|
||||||
|
**Goal:** Adopt the shared `ZB.MOM.WW.Theme` Razor Class Library across all three sister
|
||||||
|
apps (OtOpcUa AdminUI, MxAccessGateway Dashboard, ScadaBridge CentralUI + Host) via a
|
||||||
|
**full canonical cutover** (SPEC §7), after first **promoting nav-expand persistence into
|
||||||
|
the kit** so every app gets it from one shared mechanism.
|
||||||
|
|
||||||
|
> This is the UI-theme analogue of the completed Auth+Audit normalization
|
||||||
|
> (`docs/plans/2026-06-02-auth-audit-normalization*.md`). It is **UI-only**: no data
|
||||||
|
> contracts, no DB migrations, no wire protocols. The dominant risk is **visual
|
||||||
|
> regression**, not data corruption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Verified starting state (2026-06-03)
|
||||||
|
|
||||||
|
Independently verified (the component docs were optimistic — cf. memory
|
||||||
|
`component-status-claims-are-optimistic`):
|
||||||
|
|
||||||
|
- **Library is real but unpublished and unadopted.** `ZB.MOM.WW.Theme/` holds all 10
|
||||||
|
components + a Release `0.1.0` nupkg, but the Gitea feed returns **HTTP 404** for the
|
||||||
|
package and **no app references it**. The shared-contract's "Published to the Gitea NuGet
|
||||||
|
feed" is aspirational. → This is a clean **publish + adopt**.
|
||||||
|
- **Library is plain files tracked by `scadaproj`** (not a nested git repo) — library
|
||||||
|
changes commit in `scadaproj` (cf. memory `shared-libs-are-plain-files-not-nested-repos`).
|
||||||
|
- **Per-app surface** matches `components/ui-theme/GAPS.md`:
|
||||||
|
- **OtOpcUa AdminUI** — already side-rail (`.app-shell`/`.side-rail`/`.rail-link`);
|
||||||
|
interactive `NavSidebar` island (`@rendermode InteractiveServer`) holding `_expanded`,
|
||||||
|
persisted via JS interop (`window.navState.get/.set`) to the `otopcua_nav` cookie
|
||||||
|
(comma-separated section ids, 1-yr, `SameSite=Lax`); bespoke `StatusBadge`; static-POST
|
||||||
|
`Login.razor`; own `theme.css` + vendored fonts. *Lowest risk.*
|
||||||
|
- **ScadaBridge CentralUI** — `.sidebar`/`.nav-link`/`<ul><li>` (`NavMenu` + `NavSection`);
|
||||||
|
`Login.razor` + `LoginLayout`; own `theme.css`; Host owns `App.razor`. *Medium risk.*
|
||||||
|
- **MxAccessGateway Dashboard** — combined `MainLayout` (~210 lines); `.sidebar`/`.nav-link`;
|
||||||
|
`StatusBadge`; **no Blazor login page** (server-redirect); own `theme.css` (font path is
|
||||||
|
absolute `/fonts/…`, not portable). *Highest risk.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Decisions (locked during brainstorming)
|
||||||
|
|
||||||
|
| # | Decision | Choice |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | Adoption depth | **A — Full canonical cutover** (SPEC §7 acceptance, all three apps) |
|
||||||
|
| D2 | Nav persistence | **On all apps, via one shared kit mechanism** (not bespoke per app) |
|
||||||
|
| D3 | Persistence implementation | **CSS `<details>` + localStorage enhancer** (recommended over promoting OtOpcUa's interactive-island+cookie) |
|
||||||
|
| D4 | MxGateway login | **Add a new `<LoginCard>` Blazor login page** (the higher-risk consistency option) |
|
||||||
|
| D5 | Delivery model | **Same as Auth/Audit** — `feat/adopt-zb-theme` per app, local-only, then fast-forward merge to each repo's default + push to gitea on explicit go; scadaproj docs on `docs/ui-theme-adoption` |
|
||||||
|
| D6 | Publish | **Publish the (enhanced) RCL to the Gitea feed first**, then adopt (needs `GITEA_NUGET_KEY`, user-supplied, not persisted) |
|
||||||
|
| D7 | Library version | **Bump `0.1.0 → 0.2.0`** (new feature: persistent nav + `ThemeScripts`); publish `0.2.0` directly (0.1.0 was never released) |
|
||||||
|
| D8 | Accent colors | Preserve each app's current `--accent` value (move the *source* to the RCL, don't shift palettes) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Program shape & sequencing
|
||||||
|
|
||||||
|
A **library-minor-then-adopt waterfall** (same shape as Auth/Audit):
|
||||||
|
|
||||||
|
- **Phase 0 — Library enhancement + publish.** Add shared nav persistence (§3), bump to
|
||||||
|
`0.2.0`, run the bUnit suite, `build/push.sh` to the Gitea feed. Commits in `scadaproj`.
|
||||||
|
- **Phase 1 — OtOpcUa AdminUI** (lowest risk; already side-rail; validates the pattern).
|
||||||
|
- **Phase 2 — ScadaBridge CentralUI + Host** (medium; class migration + AuthorizeView nav).
|
||||||
|
- **Phase 3 — MxAccessGateway Dashboard** (highest; split combined layout **and** add the
|
||||||
|
net-new `LoginCard` page).
|
||||||
|
- **Phase 4 — scadaproj docs + memory** (GAPS adoption banner; CLAUDE.md ui-theme row →
|
||||||
|
*Adopted*; shared-contract → *Published 0.2.0*; memory note).
|
||||||
|
|
||||||
|
**Execution:** subagent-driven, classification-driven reviews (trivial→none; small→code;
|
||||||
|
standard→spec∥code parallel; high-risk→serial spec→code + final integration review).
|
||||||
|
|
||||||
|
**Delivery:** `feat/adopt-zb-theme` branch per app, local-only; full build+test green per
|
||||||
|
repo; fast-forward merge to each default + push to gitea on the user's explicit go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Library enhancement: shared nav persistence (Phase 0)
|
||||||
|
|
||||||
|
Promote **one** shared mechanism into the kit — a simpler generalization of OtOpcUa's
|
||||||
|
proven cookie+interop approach.
|
||||||
|
|
||||||
|
**Mechanism — CSS `<details>` + localStorage enhancer:**
|
||||||
|
|
||||||
|
- `NavRailSection` stays the static-SSR-friendly `<details class="rail-section" open="@Expanded">`
|
||||||
|
it already is. It gains a stable **`Key`** parameter (default = a slug of `Title`) emitted
|
||||||
|
as a `data-nav-key` attribute on the `<details>`.
|
||||||
|
- New vendored asset `wwwroot/js/nav-state.js` in the RCL: on `DOMContentLoaded`, for each
|
||||||
|
`[data-nav-key]`, read `localStorage` and set `el.open`; attach a `toggle` listener that
|
||||||
|
writes `el.open` back to `localStorage` keyed by `data-nav-key`. Pure client-side
|
||||||
|
progressive enhancement — no circuit, no server round-trip.
|
||||||
|
- New `<ThemeScripts/>` component (sibling to `ThemeHead`) emits
|
||||||
|
`<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer></script>`, placed before
|
||||||
|
`</body>`.
|
||||||
|
|
||||||
|
**Why localStorage over promoting OtOpcUa's island+cookie:** keeps the kit
|
||||||
|
**static-SSR-friendly** (no forced `InteractiveServer` island per app), one shared file,
|
||||||
|
uniform across all three. It *simplifies* OtOpcUa — retiring its interactive `NavSidebar`
|
||||||
|
island + `nav-state.js` + `otopcua_nav` cookie in favor of the shared enhancer. localStorage
|
||||||
|
is per-browser/origin (same effective scope as the old cookie) and is never read
|
||||||
|
server-side today, so nothing is lost.
|
||||||
|
|
||||||
|
**Trade-off:** a brief flash-of-default-state on first paint (localStorage isn't readable
|
||||||
|
server-side, so sections render at their server default and JS corrects after load).
|
||||||
|
Negligible for a nav rail. (If zero-flash were required, the alternative is a server-read
|
||||||
|
cookie — rejected as more kit coupling.)
|
||||||
|
|
||||||
|
**Version:** `0.1.0 → 0.2.0` (additive feature). **Tests:** extend the bUnit suite —
|
||||||
|
`NavRailSection` emits `data-nav-key` (derived slug + explicit `Key`); `ThemeScripts` emits
|
||||||
|
the script tag. JS runtime behavior is covered by the per-app manual checklist (§5), since
|
||||||
|
bUnit has no JS engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Per-app adoption scope (full canonical cutover)
|
||||||
|
|
||||||
|
Each app, per SPEC §7: add `PackageReference ZB.MOM.WW.Theme 0.2.0` + `@using ZB.MOM.WW.Theme`
|
||||||
|
in `_Imports.razor`; `<ThemeHead/>` in `App.razor` `<head>` after Bootstrap + `<ThemeScripts/>`
|
||||||
|
before `</body>`; **delete the app's `theme.css` + vendored IBM Plex `.woff2` fonts**; replace
|
||||||
|
`MainLayout` with the thin delegation to `<ThemeShell Product=… Accent=…>`; rebuild nav with
|
||||||
|
`NavRailItem`/`NavRailSection`; `StatusBadge`→`<StatusPill>`; login → `<LoginCard>`; **keep**
|
||||||
|
each app's `site.css` page-layout residual + scoped `.razor.css` unchanged. `--accent`
|
||||||
|
preserves each app's current value (D8).
|
||||||
|
|
||||||
|
| App | Notable specifics | Risk |
|
||||||
|
|---|---|---|
|
||||||
|
| **OtOpcUa** AdminUI | Already-correct rail classes (RCL `layout.css` matches). Retire `NavSidebar` island + `nav-state.js` + `otopcua_nav` cookie → kit `NavRailSection`/`NavRailItem` + shared enhancer. `RailFooter` = the existing `AuthorizeView` session block. `StatusBadge`→`StatusPill`. `Login.razor`→`LoginCard` (keep static POST, `<AntiforgeryToken/>`, server-validate `ReturnUrl`). | Low–Med |
|
||||||
|
| **ScadaBridge** CentralUI + Host | `.sidebar`/`.nav-link`/`<ul><li>` (`NavMenu`+`NavSection`) → kit nav (class migration throughout). Verify `<AuthorizeView>` policy-gated sections render/hide under static SSR (GAPS open Q). `<ThemeHead/>`/`<ThemeScripts/>` go in Host's `App.razor`. `StatusBadge`/inline `.chip-*`→`StatusPill`. `Login.razor`+`LoginLayout`→`LoginCard`. | Med |
|
||||||
|
| **MxGateway** Dashboard | Split combined ~210-line `MainLayout` → thin `MainLayout` + `<ThemeShell>` (nav extracted into the `Nav` slot). `.sidebar`/`.nav-link`→rail classes; portable font path fixed by RCL. `StatusBadge`→`StatusPill`. **Add a new `/login` Blazor page** using `<LoginCard>` posting to a `/auth/login` endpoint wired to the app's existing `ZB.MOM.WW.Auth` LDAP service + dashboard cookie `SignInAsync` (mirror OtOpcUa/ScadaBridge static-POST login). Verify the server auth-redirect now lands on this page. | **High** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Delivery, risk & verification
|
||||||
|
|
||||||
|
- **Build/test gate per repo:** `dotnet build` + the full suite green before merge. Baseline
|
||||||
|
the **known pre-existing reds** first and do not chase them (ScadaBridge IntegrationTests
|
||||||
|
×11 needing live LDAP/SQL/SMTP + flaky `StaleTagMonitor` timer tests; MxGateway 3 FakeWorker
|
||||||
|
tests) — only regressions introduced by this work count.
|
||||||
|
- **Visual regression is the real risk** — a green build does not prove the chrome looks
|
||||||
|
right. Verification per app = a structured manual checklist:
|
||||||
|
1. Rail renders at `lg`+ and collapses to a hamburger toggle below `lg`.
|
||||||
|
2. Nav expand-state persists across navigations and a full reload (shared enhancer).
|
||||||
|
3. `StatusPill` renders correctly in all five states (`Ok`/`Warn`/`Bad`/`Idle`/`Info`).
|
||||||
|
4. Login posts, round-trips `ReturnUrl` safely (server-validated), shows errors.
|
||||||
|
5. IBM Plex fonts load from `_content/ZB.MOM.WW.Theme/fonts/…` (no 404; OtOpcUa's latent
|
||||||
|
font 404 is fixed).
|
||||||
|
- **Optional browser smoke pass:** run each app locally and drive a Claude-in-Chrome smoke
|
||||||
|
pass (screenshots of shell + login) before merge — included only if the user opts in;
|
||||||
|
otherwise the checklist above is run manually.
|
||||||
|
- **MxGateway `/login`** is auth-facing and net-new → `high-risk` classification (serial
|
||||||
|
spec→code review + final integration review).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Acceptance (per app)
|
||||||
|
|
||||||
|
Mirrors SPEC §7: (1) `ZB.MOM.WW.Theme 0.2.0` referenced + in `_Imports.razor`; (2)
|
||||||
|
`<ThemeHead/>` after Bootstrap and per-app `theme.css`/fonts deleted; (3) `MainLayout` is the
|
||||||
|
thin `ThemeShell` delegation; (4) nav rebuilt with `NavRailItem`/`NavRailSection` (+ shared
|
||||||
|
persistence via `<ThemeScripts/>`); (5) local `StatusBadge`/`.chip-*` removed → `<StatusPill>`;
|
||||||
|
(6) login is `<LoginCard>` (static POST, `<AntiforgeryToken/>`, server-validated `ReturnUrl`)
|
||||||
|
— including MxGateway's net-new page; (7) `site.css` residual + scoped `.razor.css` kept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Out of scope
|
||||||
|
|
||||||
|
Per SPEC §0/§6: each app's `site.css` page-layout residual, route/page content, scoped
|
||||||
|
`.razor.css`, authorization logic. The kit owns *chrome and tokens*, not domain screens.
|
||||||
|
No new data grids/modals/toasts (YAGNI). Bootstrap stays per-app (not vendored by the kit).
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
# UI-Theme Adoption Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Enhance the shared `ZB.MOM.WW.Theme` RCL with cross-app nav-expand persistence (bump `0.2.0`, publish to the Gitea feed), then adopt it via full canonical cutover across OtOpcUa AdminUI, ScadaBridge CentralUI+Host, and MxAccessGateway Dashboard.
|
||||||
|
|
||||||
|
**Architecture:** A library-minor-then-adopt waterfall (same shape as the completed Auth/Audit normalization). Phase 0 enhances + publishes the kit. Phases 1–3 are **independent per-repo cutovers** (each on its own `feat/adopt-zb-theme` branch, local-only) ordered by risk. Phase 4 updates scadaproj docs + memory. UI-only — no data contracts, no DB migrations; the dominant risk is **visual regression**, mitigated by per-app build+test gates and a manual visual checklist.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, Blazor SSR, Razor Class Library, bUnit/xUnit, Bootstrap 5, NuGet central package management (OtOpcUa/ScadaBridge) / per-project versions (MxGateway), Gitea NuGet feed.
|
||||||
|
|
||||||
|
**Design:** [`2026-06-03-ui-theme-adoption-design.md`](2026-06-03-ui-theme-adoption-design.md). Decisions D1–D8 there are authoritative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for the executor
|
||||||
|
|
||||||
|
- **Delivery:** scadaproj library + docs changes (Phases 0, 4) commit on the existing `docs/ui-theme-adoption` branch. Each app (Phases 1–3) gets its own `feat/adopt-zb-theme` branch, **committed local-only, never pushed** until the user explicitly authorizes merge+push (same model as Auth/Audit).
|
||||||
|
- **Per-repo green gate:** before declaring an app's phase done, run `dotnet build` + that repo's full test suite. **Baseline known pre-existing reds first** and do not chase them: ScadaBridge IntegrationTests ×11 (need live LDAP/SQL/SMTP), `PartitionPurgeTests.EndToEnd`, flaky `StaleTagMonitor` timer tests; MxGateway 3 FakeWorker tests. Only regressions introduced by this work count.
|
||||||
|
- **Cutover invariant (all apps):** the kit's `theme.css`/`layout.css` define `--*` tokens, the side-rail layout, and the `.chip`/`.chip-ok|warn|bad|idle|info` status classes. Before deleting an app's `wwwroot/css/theme.css`, **diff it against the kit's `theme.css`/`layout.css` and migrate any app-only rules** (e.g. OtOpcUa's `.chip-alert`/`.chip-caution`) into that app's `site.css`. The app's `site.css` page-layout residual and scoped `.razor.css` stay.
|
||||||
|
- **Status policy (per SPEC §6/§7):** inline `.chip-*` spans and Bootstrap `.badge` in *domain pages* are page content — they keep working under kit CSS and are **not** rewritten. Only a bespoke status *component* gets removed/redirected to `<StatusPill>`.
|
||||||
|
- **Cross-repo parallelism:** Phases 1, 2, 3 touch disjoint repos and are mutually independent — they MAY run concurrently, but are listed in risk order (OtOpcUa → ScadaBridge → MxGateway). All three are blocked by Task 0.4 (published package).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Library enhancement + publish (scadaproj, branch `docs/ui-theme-adoption`)
|
||||||
|
|
||||||
|
### Task 0.1: NavRailSection persistence key
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 0.2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor`
|
||||||
|
- Test: `ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs`
|
||||||
|
|
||||||
|
**Context:** `NavRailSection` renders `<details class="rail-section" open="@Expanded"><summary class="rail-eyebrow-toggle">@Title</summary>…`. Add an optional `Key` parameter (default = a stable slug of `Title`) emitted as `data-nav-key` on the `<details>` so the localStorage enhancer (Task 0.2) can persist per-section open state.
|
||||||
|
|
||||||
|
**Step 1 — failing tests** in `NavRailTests.cs`:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, "Site Calls")
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavRailSection_emits_explicit_key_when_supplied()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<NavRailSection>(p => p
|
||||||
|
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
|
||||||
|
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||||
|
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 — run, expect FAIL** (no `Key`/`data-nav-key`):
|
||||||
|
`dotnet test ZB.MOM.WW.Theme/ --filter "FullyQualifiedName~NavRailSection_emits"`
|
||||||
|
|
||||||
|
**Step 3 — implement.** Edit `NavRailSection.razor`:
|
||||||
|
```razor
|
||||||
|
@namespace ZB.MOM.WW.Theme
|
||||||
|
<details class="rail-section" open="@Expanded" data-nav-key="@ResolvedKey">
|
||||||
|
<summary class="rail-eyebrow-toggle">@Title</summary>
|
||||||
|
<div class="rail-section-body">@ChildContent</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
|
||||||
|
[Parameter] public bool Expanded { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Stable identifier used to persist this section's open/closed state in
|
||||||
|
/// localStorage (via the kit's nav-state.js). Defaults to a slug of <see cref="Title"/>.</summary>
|
||||||
|
[Parameter] public string? Key { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
private string ResolvedKey => string.IsNullOrWhiteSpace(Key) ? Slug(Title) : Key!;
|
||||||
|
|
||||||
|
private static string Slug(string s)
|
||||||
|
{
|
||||||
|
var chars = s.Trim().ToLowerInvariant()
|
||||||
|
.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray();
|
||||||
|
return string.Join('-', new string(chars).Split('-', StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 — run, expect PASS** (plus the existing NavRail tests stay green).
|
||||||
|
|
||||||
|
**Step 5 — commit:** `git add -A && git commit -m "feat(theme): NavRailSection data-nav-key for persistence"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0.2: localStorage nav enhancer + ThemeScripts
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 0.1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/js/nav-state.js`
|
||||||
|
- Create: `ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeScripts.razor`
|
||||||
|
- Test: `ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeScriptsTests.cs` (new)
|
||||||
|
- Test: `ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs` (extend)
|
||||||
|
|
||||||
|
**Step 1 — create `wwwroot/js/nav-state.js`** (progressive enhancement; no framework):
|
||||||
|
```javascript
|
||||||
|
// ZB.MOM.WW.Theme nav-state.js — persists <details data-nav-key> open/closed
|
||||||
|
// state in localStorage so NavRailSection expand state survives navigation and
|
||||||
|
// reloads. Pure client-side; works with static Blazor SSR. Keyed per section.
|
||||||
|
(function () {
|
||||||
|
var PREFIX = "zbnav:";
|
||||||
|
function apply() {
|
||||||
|
document.querySelectorAll("details.rail-section[data-nav-key]").forEach(function (el) {
|
||||||
|
var key = PREFIX + el.getAttribute("data-nav-key");
|
||||||
|
var saved = null;
|
||||||
|
try { saved = window.localStorage.getItem(key); } catch (e) { return; }
|
||||||
|
if (saved === "1") el.open = true;
|
||||||
|
else if (saved === "0") el.open = false;
|
||||||
|
el.addEventListener("toggle", function () {
|
||||||
|
try { window.localStorage.setItem(key, el.open ? "1" : "0"); } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.readyState === "loading")
|
||||||
|
document.addEventListener("DOMContentLoaded", apply);
|
||||||
|
else
|
||||||
|
apply();
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 — create `Components/ThemeScripts.razor`:**
|
||||||
|
```razor
|
||||||
|
@namespace ZB.MOM.WW.Theme
|
||||||
|
@* Components/ThemeScripts.razor — drop before </body>. Emits the kit's nav-state
|
||||||
|
enhancer that persists NavRailSection open/closed state in localStorage. *@
|
||||||
|
<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — failing tests.** `ThemeScriptsTests.cs`:
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.Theme.Tests;
|
||||||
|
|
||||||
|
public class ThemeScriptsTests : TestContext
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ThemeScripts_emits_nav_state_script_tag()
|
||||||
|
{
|
||||||
|
var cut = RenderComponent<ThemeScripts>();
|
||||||
|
var script = cut.Find("script");
|
||||||
|
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
|
||||||
|
Assert.True(script.HasAttribute("defer"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `StaticAssetsTests.cs`, add an assertion that the JS file ships (mirror its existing CSS/font asset checks — read the file first to match its exact assertion style, e.g. verifying the file exists on disk under `wwwroot/js/nav-state.js`).
|
||||||
|
|
||||||
|
**Step 4 — run tests, expect PASS:** `dotnet test ZB.MOM.WW.Theme/`
|
||||||
|
|
||||||
|
**Step 5 — commit:** `git commit -am "feat(theme): ThemeScripts + localStorage nav-state enhancer"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0.3: Version bump 0.2.0 + full suite
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** none (depends on 0.1, 0.2)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ZB.MOM.WW.Theme/Directory.Build.props:7`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Change `<Version>0.1.0</Version>` → `<Version>0.2.0</Version>`.
|
||||||
|
2. Run `cd ZB.MOM.WW.Theme && dotnet build -c Release` — expect **0 warnings** (TreatWarningsAsErrors).
|
||||||
|
3. Run `dotnet test` — expect all green (38 existing + the new persistence/ThemeScripts tests).
|
||||||
|
4. Commit: `git commit -am "chore(theme): bump 0.1.0 -> 0.2.0 (nav persistence + ThemeScripts)"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0.4: Publish 0.2.0 to Gitea feed
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~2 min (blocks on user-supplied token)
|
||||||
|
**Parallelizable with:** none (depends on 0.3)
|
||||||
|
|
||||||
|
**⚠ Requires the user's `GITEA_NUGET_KEY`** (Gitea token with `package:write`). It is not persisted — ask the user to export it (or run the push command themselves via `! …`). Do not invent or store it.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Confirm 404 pre-state: `curl -s -o /dev/null -w "%{http_code}\n" https://gitea.dohertylan.com/api/packages/dohertj2/nuget/registration/zb.mom.ww.theme/index.json` (expect `404`).
|
||||||
|
2. Publish:
|
||||||
|
```bash
|
||||||
|
cd ZB.MOM.WW.Theme
|
||||||
|
export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
|
||||||
|
export GITEA_NUGET_KEY="<user-supplied>"
|
||||||
|
./build/push.sh
|
||||||
|
```
|
||||||
|
3. Verify published: re-run the curl — expect `200`; confirm version `0.2.0` is listed.
|
||||||
|
4. No commit needed (artifacts are gitignored). Record the publish in the task log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — OtOpcUa AdminUI cutover (repo `~/Desktop/OtOpcUa`, branch `feat/adopt-zb-theme`)
|
||||||
|
|
||||||
|
> Blocked by Task 0.4. Lowest risk: already side-rail with the kit's exact CSS classes.
|
||||||
|
> **First:** `cd ~/Desktop/OtOpcUa && git checkout -b feat/adopt-zb-theme`.
|
||||||
|
|
||||||
|
### Task 1.1: NuGet wiring + usings
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none (gates 1.2–1.5)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Directory.Packages.props` (repo root) — add `<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />`
|
||||||
|
- Modify: `NuGet.config` (repo root) — under `<packageSource key="dohertj2-gitea">` add `<package pattern="ZB.MOM.WW.Theme" />`
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add `<PackageReference Include="ZB.MOM.WW.Theme" />` (versionless; central PM)
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor` — add `@using ZB.MOM.WW.Theme`
|
||||||
|
|
||||||
|
**Verify:** `dotnet restore src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` resolves `ZB.MOM.WW.Theme 0.2.0` from the Gitea feed. Commit.
|
||||||
|
|
||||||
|
### Task 1.2: App.razor — ThemeHead + ThemeScripts
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** Task 1.3, 1.4, 1.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor`
|
||||||
|
|
||||||
|
**Edits:** Replace line `…/css/theme.css` `<link>` with `<ThemeHead />` (keep the Bootstrap `<link>` *above* it and the `…/css/site.css` `<link>` *below* it). Replace `<script src="…/js/nav-state.js"></script>` with `<ThemeScripts />`. Keep the bootstrap bundle + `blazor.web.js` scripts. Commit.
|
||||||
|
|
||||||
|
### Task 1.3: Migrate app-only CSS, delete theme.css + fonts + nav-state.js
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 1.2, 1.4, 1.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css`
|
||||||
|
- Delete: `wwwroot/css/theme.css`, `wwwroot/fonts/ibm-plex-*.woff2` (×3), `wwwroot/js/nav-state.js`
|
||||||
|
|
||||||
|
**Steps:** Diff `wwwroot/css/theme.css` against the kit's `theme.css`+`layout.css`. Any rule present in the app copy but NOT the kit (notably **`.chip-alert`, `.chip-caution`**, and any app-only tweak) → append to `site.css` under a clearly-commented "App-specific status variants (not in ZB.MOM.WW.Theme)" block. Then delete the four asset files. Keep `wwwroot/js/monaco-loader.js`. Commit.
|
||||||
|
|
||||||
|
### Task 1.4: MainLayout → ThemeShell + kit nav
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 1.2, 1.3, 1.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor`
|
||||||
|
- Delete: `Components/Layout/NavSidebar.razor`, `Components/Layout/NavSection.razor`
|
||||||
|
|
||||||
|
**Context:** Replaces the interactive `NavSidebar` island + bespoke `NavSection` with the kit's static `<ThemeShell>` + `NavRailSection`/`NavRailItem` (persistence now comes from `ThemeScripts`). All sections default `Expanded=true`; the URL-based auto-expand behavior is intentionally dropped (D2/D3 — localStorage persistence replaces it). Reproduce the 3 sections / 17 links / footer exactly.
|
||||||
|
|
||||||
|
**Target `MainLayout.razor`:**
|
||||||
|
```razor
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
<ThemeShell Product="OtOpcUa" Accent="#2f5fd0">
|
||||||
|
<Nav>
|
||||||
|
<NavRailSection Title="Navigation" Key="nav">
|
||||||
|
<NavRailItem Href="/" Text="Overview" Match="NavLinkMatch.All" />
|
||||||
|
<NavRailItem Href="/fleet" Text="Fleet status" />
|
||||||
|
<NavRailItem Href="/hosts" Text="Host status" />
|
||||||
|
<NavRailItem Href="/clusters" Text="Clusters" />
|
||||||
|
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||||
|
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||||
|
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||||
|
</NavRailSection>
|
||||||
|
<NavRailSection Title="Scripting" Key="scripting">
|
||||||
|
<NavRailItem Href="/virtual-tags" Text="Virtual tags" />
|
||||||
|
<NavRailItem Href="/scripted-alarms" Text="Scripted alarms" />
|
||||||
|
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||||
|
<NavRailItem Href="/script-log" Text="Script log" />
|
||||||
|
</NavRailSection>
|
||||||
|
<NavRailSection Title="Live" Key="live">
|
||||||
|
<NavRailItem Href="/deployments" Text="Deployments" />
|
||||||
|
<NavRailItem Href="/alerts" Text="Alerts" />
|
||||||
|
<NavRailItem Href="/alarms-historian" Text="Alarms historian" />
|
||||||
|
</NavRailSection>
|
||||||
|
</Nav>
|
||||||
|
<RailFooter>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
||||||
|
<div class="rail-roles">@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))</div>
|
||||||
|
<form method="post" action="/auth/logout"><AntiforgeryToken /><button class="rail-btn" type="submit">Sign out</button></form>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<a class="rail-btn" href="/login">Sign in</a>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</RailFooter>
|
||||||
|
<ChildContent>@Body</ChildContent>
|
||||||
|
</ThemeShell>
|
||||||
|
```
|
||||||
|
**Note:** confirm `ThemeShell` exposes `Nav`/`RailFooter`/`ChildContent` slots and that the hamburger/collapse behavior comes from the kit's `layout.css` (Bootstrap collapse JS already loaded). If the kit shell wraps the rail in its own collapse, drop the app's old hamburger markup (now in the shell). Build the AdminUI project; verify it compiles. Commit.
|
||||||
|
|
||||||
|
### Task 1.5: Delete dead StatusBadge + Login → LoginCard
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 1.2, 1.3, 1.4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Delete: `Components/Shared/StatusBadge.razor` (verified unused — confirm with a repo grep for `<StatusBadge` returning 0 hits before deleting)
|
||||||
|
- Modify: `Components/Pages/Login.razor`
|
||||||
|
|
||||||
|
**Login target** (preserve static POST to `/auth/login`, the `Error`/`ReturnUrl` query params, and `LoginLayout`):
|
||||||
|
```razor
|
||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
|
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||||
|
<LoginCard Product="OtOpcUa Admin" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
</LoginCard>
|
||||||
|
</div>
|
||||||
|
@code {
|
||||||
|
[SupplyParameterFromQuery] private string? Error { get; set; }
|
||||||
|
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Note:** the `/auth/login` endpoint already round-trips `returnUrl` and signs in (unchanged). Confirm `<LoginCard>` renders the username/password fields the endpoint reads (`name="username"`, `name="password"`, `name="returnUrl"`); if its field names differ, set them via LoginCard params or keep the existing `<form>` inside a `<TechCard>` instead. Build; commit.
|
||||||
|
|
||||||
|
### Task 1.6: Build, test, visual checklist (OtOpcUa)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none (depends on 1.2–1.5)
|
||||||
|
|
||||||
|
**Steps:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx`; `dotnet test ZB.MOM.WW.OtOpcUa.slnx` (compare against baseline reds). Run the visual checklist (design §5): rail renders ≥lg + hamburger <lg; nav persistence across reload; status chips intact (incl. alert/caution); login posts + returnUrl; IBM Plex fonts load from `_content/ZB.MOM.WW.Theme/fonts/` (the old latent 404 is gone). Report results; do not merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — ScadaBridge CentralUI + Host cutover (repo `~/Desktop/ScadaBridge`, branch `feat/adopt-zb-theme`)
|
||||||
|
|
||||||
|
> Blocked by Task 0.4. Independent of Phase 1. **First:** `cd ~/Desktop/ScadaBridge && git checkout -b feat/adopt-zb-theme`.
|
||||||
|
|
||||||
|
### Task 2.1: NuGet wiring + usings
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Directory.Packages.props` — add `<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />`
|
||||||
|
- Modify: `nuget.config` — under `dohertj2-gitea` add `<package pattern="ZB.MOM.WW.Theme" />`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj` — add `<PackageReference Include="ZB.MOM.WW.Theme" />`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/_Imports.razor` — add `@using ZB.MOM.WW.Theme`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/_Imports.razor` — add `@using ZB.MOM.WW.Theme` (Host's `App.razor` uses `ThemeHead`/`ThemeScripts`; the RCL flows transitively via the CentralUI project reference)
|
||||||
|
|
||||||
|
**Verify** restore resolves 0.2.0; commit.
|
||||||
|
|
||||||
|
### Task 2.2: Host App.razor — ThemeHead + ThemeScripts
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** Task 2.3, 2.4, 2.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor`
|
||||||
|
|
||||||
|
**Edits:** Replace the `_content/ZB.MOM.WW.ScadaBridge.CentralUI/css/theme.css` `<link>` with `<ThemeHead />` (keep Bootstrap + bootstrap-icons links above; keep `/ZB.MOM.WW.ScadaBridge.Host.styles.css` and the CentralUI `site.css` link). Replace the `…CentralUI/js/nav-state.js` `<script>` with `<ThemeScripts />`; keep `treeview-storage.js`, `monaco-init.js`, `audit-grid.js`, `transport.js`, and the bootstrap bundle. Commit.
|
||||||
|
|
||||||
|
### Task 2.3: Migrate app-only CSS, delete theme.css + fonts + nav-state.js
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 2.2, 2.4, 2.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css` (only if the diff surfaces app-only rules)
|
||||||
|
- Delete: `CentralUI/wwwroot/css/theme.css`, `CentralUI/wwwroot/fonts/ibm-plex-*.woff2` (×3), `CentralUI/wwwroot/js/nav-state.js`
|
||||||
|
|
||||||
|
**Steps:** Diff CentralUI `theme.css` vs kit; migrate any app-only rules into `site.css` (ScadaBridge's chips are the standard ok/warn/bad/idle, covered by the kit — expect little/none). Keep the other JS files. Commit.
|
||||||
|
|
||||||
|
### Task 2.4: MainLayout → ThemeShell + kit nav (preserve AuthorizeView gating, DialogHost, SessionExpiry)
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 2.2, 2.3, 2.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor`
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor`
|
||||||
|
- Delete: `Components/Layout/NavSection.razor` (after NavMenu no longer uses it)
|
||||||
|
|
||||||
|
**Context:** Two non-obvious must-preserves: `MainLayout` hosts `<DialogHost />` and `<SessionExpiry />` — keep both in the thin layout (outside `<ThemeShell>` or in `ChildContent` alongside `@Body`). `NavMenu` wraps its sections in `<AuthorizeView Policy="…">` (RequireAdmin/RequireDesign/RequireDeployment/OperationalAudit + mixed-role children) — these policy guards must wrap the new `NavRailSection`s unchanged.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `MainLayout.razor` → thin delegation:
|
||||||
|
```razor
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
<ThemeShell Product="ScadaBridge" Accent="#2f5fd0">
|
||||||
|
<Nav><NavMenu /></Nav>
|
||||||
|
<RailFooter>
|
||||||
|
<AuthorizeView><Authorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<span class="rail-user">@context.User.GetDisplayName()</span>
|
||||||
|
<form method="post" action="/auth/logout" data-enhance="false"><AntiforgeryToken /><button class="rail-btn" type="submit">Sign Out</button></form>
|
||||||
|
</Authorized></AuthorizeView>
|
||||||
|
</RailFooter>
|
||||||
|
<ChildContent>@Body</ChildContent>
|
||||||
|
</ThemeShell>
|
||||||
|
<DialogHost />
|
||||||
|
<SessionExpiry />
|
||||||
|
```
|
||||||
|
(Move the session/sign-out block out of `NavMenu` into `RailFooter`; keep `GetDisplayName()`.)
|
||||||
|
2. Rewrite `NavMenu.razor` body: replace `<nav class="sidebar"><ul class="nav flex-column">…` + `<li><NavLink class="nav-link">` + `<NavSection>` with kit `NavRailSection`/`NavRailItem`, **preserving each `<AuthorizeView Policy="…">` wrapper** around its section. The always-visible Dashboard link becomes a bare `<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />` (outside any section, or in a default section). Reproduce all sections/links from the inventory (Admin, Design, Deployment, Notifications, Site Calls, Monitoring, Audit) and their child links exactly.
|
||||||
|
3. Build CentralUI; **verify `<AuthorizeView>`-wrapped `NavRailSection` renders for an authorized principal and hides for an unauthorized one** under static SSR (GAPS open question) — assert via an existing CentralUI bUnit test or add a focused one. Commit.
|
||||||
|
|
||||||
|
### Task 2.5: Login → LoginCard
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 2.2, 2.3, 2.4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Login.razor`
|
||||||
|
|
||||||
|
**Target** (preserve static POST to `/auth/login`; the endpoint uses `.DisableAntiforgery()` and always redirects `/` — no `returnUrl`, no antiforgery token needed):
|
||||||
|
```razor
|
||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
<LoginCard Product="ScadaBridge" Action="/auth/login" Error="@ErrorMessage" />
|
||||||
|
@code {
|
||||||
|
[SupplyParameterFromQuery(Name = "error")] public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Confirm `<LoginCard>`'s field names match what `/auth/login` reads (`username`/`password`). Build; commit.
|
||||||
|
|
||||||
|
### Task 2.6: Build, test, visual checklist (ScadaBridge)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (depends on 2.2–2.5)
|
||||||
|
|
||||||
|
**Steps:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx`; run the FULL suite (Host/CentralUI/ManagementService/Transport/ConfigurationDatabase) and compare to baseline reds. Visual checklist incl. policy-gated nav sections show/hide by role, DialogHost + SessionExpiry still function. Report; do not merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — MxAccessGateway Dashboard cutover (repo `~/Desktop/MxAccessGateway`, branch `feat/adopt-zb-theme`)
|
||||||
|
|
||||||
|
> Blocked by Task 0.4. Independent of Phases 1–2. Highest risk: combined-layout split + login conversion. **First:** `cd ~/Desktop/MxAccessGateway && git checkout -b feat/adopt-zb-theme`.
|
||||||
|
|
||||||
|
### Task 3.1: NuGet wiring + usings (no central PM)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` — add `<PackageReference Include="ZB.MOM.WW.Theme" Version="0.2.0" />` (explicit version; this repo has no `Directory.Packages.props`)
|
||||||
|
- Modify: `NuGet.config` — under `dohertj2-gitea` add `<package pattern="ZB.MOM.WW.Theme" />`
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/_Imports.razor` — add `@using ZB.MOM.WW.Theme`
|
||||||
|
|
||||||
|
**Verify** restore resolves 0.2.0; commit.
|
||||||
|
|
||||||
|
### Task 3.2: App.razor — ThemeHead + ThemeScripts
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** Task 3.3, 3.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor`
|
||||||
|
|
||||||
|
**Edits:** Replace `<link rel="stylesheet" href="/css/theme.css" />` with `<ThemeHead />` (keep Bootstrap link above + `/css/site.css` below). Replace `<script src="/js/nav-state.js"></script>` with `<ThemeScripts />`. Keep bootstrap bundle + `blazor.web.js`. Commit. *(Note: `<HeadOutlet>`/`<Routes>` keep their `@rendermode="InteractiveServer"`; ThemeHead/ThemeScripts are static markup and unaffected.)*
|
||||||
|
|
||||||
|
### Task 3.3: Migrate app-only CSS, delete theme.css + fonts
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 3.2, 3.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css` (only if diff surfaces app-only rules)
|
||||||
|
- Delete: `wwwroot/css/theme.css`, `wwwroot/fonts/ibm-plex-*.woff2` (×3)
|
||||||
|
|
||||||
|
**Steps:** Diff `theme.css` vs kit; migrate app-only rules to `site.css`. The kit's `@font-face` uses the correct relative path (the app's absolute `/fonts/` path is retired). **Keep** `wwwroot/js/nav-state.js`? No — it is replaced by `ThemeScripts` (Task 3.2 removed its `<script>`); delete `wwwroot/js/nav-state.js` here too. Commit.
|
||||||
|
|
||||||
|
### Task 3.4: Split combined MainLayout → thin MainLayout + ThemeShell
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 3.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor`
|
||||||
|
- Delete: `Dashboard/Components/Layout/NavSection.razor`
|
||||||
|
|
||||||
|
**Context:** The ~211-line combined layout (hamburger + `<nav class="sidebar">` + brand + 3 `NavSection`s + AuthorizeView footer + `<main>`) collapses to a thin `<ThemeShell>` delegation. Reproduce the Runtime / Galaxy / Admin sections + links and the footer (Authorized: user + Sign Out POST `/logout`; NotAuthorized: Sign In `/login`).
|
||||||
|
```razor
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
<ThemeShell Product="MXAccess Gateway" Accent="#2f5fd0">
|
||||||
|
<Nav>
|
||||||
|
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
|
||||||
|
<NavRailSection Title="Runtime" Key="runtime"> … sessions/workers/events/alarms … </NavRailSection>
|
||||||
|
<NavRailSection Title="Galaxy" Key="galaxy"> … repository/browse … </NavRailSection>
|
||||||
|
<NavRailSection Title="Admin" Key="admin"> … API Keys/settings … </NavRailSection>
|
||||||
|
</Nav>
|
||||||
|
<RailFooter>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="rail-eyebrow">Session</div>
|
||||||
|
<span class="rail-user">@context.User.Identity?.Name</span>
|
||||||
|
<form method="post" action="/logout" data-enhance="false"><AntiforgeryToken /><button class="rail-btn" type="submit">Sign Out</button></form>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized><a class="rail-btn" href="/login">Sign In</a></NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</RailFooter>
|
||||||
|
<ChildContent>@Body</ChildContent>
|
||||||
|
</ThemeShell>
|
||||||
|
```
|
||||||
|
Fill the section children from the inventory's exact hrefs/labels. Build the Server project; commit.
|
||||||
|
|
||||||
|
### Task 3.5: StatusBadge → StatusPill adapter
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 3.2, 3.3, 3.4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor`
|
||||||
|
|
||||||
|
**Decision (documented deviation from SPEC §7.5):** `StatusBadge` is used at 12 call sites with a domain `Text`→class switch. Rather than scatter the text→state mapping across 12 pages, **redirect `StatusBadge` to render `<StatusPill>`** — the bespoke `.chip` rendering moves to the kit; only the app's domain text→state mapping (per-project vocabulary, SPEC §6) remains. Call sites stay unchanged.
|
||||||
|
```razor
|
||||||
|
@* Thin adapter: maps MxGateway runtime state text → kit StatusPill state. *@
|
||||||
|
<StatusPill State="MapState(Text)">@Text</StatusPill>
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Text { get; set; }
|
||||||
|
private static StatusState MapState(string? t) => t switch
|
||||||
|
{
|
||||||
|
"Ready" or "Healthy" or "Active" => StatusState.Ok,
|
||||||
|
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" or "Stale" or "Degraded" => StatusState.Warn,
|
||||||
|
"Faulted" or "Unavailable" => StatusState.Bad,
|
||||||
|
_ => StatusState.Idle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Confirm `StatusPill` renders its `ChildContent` as the label and emits `chip chip-ok|warn|bad|idle`. Build; commit. *(If the reviewer insists on literal deletion, the fallback is replacing all 12 call sites with `<StatusPill>` + a shared static `MapState` helper — note it but prefer the adapter.)*
|
||||||
|
|
||||||
|
### Task 3.6: Net-new Blazor LoginCard page (reuse existing hardened endpoint)
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on 3.4 for layout/usings context)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Dashboard/Components/Layout/LoginLayout.razor`
|
||||||
|
- Create: `Dashboard/Components/Pages/Login.razor`
|
||||||
|
- Modify: `Dashboard/DashboardEndpointRouteBuilderExtensions.cs`
|
||||||
|
|
||||||
|
**Context (discovered reality):** MxGateway is NOT login-less — it has a working, hardened login: `POST /login` (`PostLoginAsync`) validates antiforgery, calls `IDashboardAuthenticator.AuthenticateAsync` (LDAP via shared Auth → roles → `ZbClaimTypes` principal), `SignInAsync`, then `LocalRedirect(SanitizeReturnUrl(returnUrl))`. The login *UI* is a raw HTML string from `GetLoginAsync`/`RenderLoginPage`. We swap **only the UI** to a Blazor `<LoginCard>` page; the `POST /login` endpoint and authenticator are reused unchanged. A `<form method="post">` posts natively, so the page's render mode is irrelevant to the POST.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `LoginLayout.razor`: `@inherits LayoutComponentBase` + `@Body` (no rail).
|
||||||
|
2. `Login.razor`:
|
||||||
|
```razor
|
||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
<div class="dashboard-login">
|
||||||
|
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
</LoginCard>
|
||||||
|
</div>
|
||||||
|
@code {
|
||||||
|
[SupplyParameterFromQuery(Name = "returnUrl")] private string? ReturnUrl { get; set; }
|
||||||
|
[SupplyParameterFromQuery(Name = "error")] private string? Error { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. In `DashboardEndpointRouteBuilderExtensions.cs`:
|
||||||
|
- **Remove** the `MapGet("/login", … GetLoginAsync)` registration and the `GetLoginAsync` + `RenderLoginPage` helpers (the Blazor route now serves `GET /login`; the component carries `[AllowAnonymous]` to override the `RequireAuthorization(ViewerPolicy)` on `MapRazorComponents<App>()`).
|
||||||
|
- **Change** `PostLoginAsync`'s failure branch from re-rendering HTML to a redirect: `return Results.Redirect($"/login?error={Uri.EscapeDataString(result.FailureMessage ?? "…")}&returnUrl={Uri.EscapeDataString(returnUrl)}");`. Keep antiforgery validation, `SignInAsync`, and the success `LocalRedirect(returnUrl)`.
|
||||||
|
- Keep `MapPost("/login")`, `/logout` (GET+POST), and `/denied` (still uses `RenderPage`).
|
||||||
|
4. Build the Server project. **Verify the full flow:** unauthenticated request to `/` → cookie challenge → `/login` renders the Blazor `<LoginCard>` anonymously → POST authenticates → cookie set → redirect. Commit.
|
||||||
|
|
||||||
|
### Task 3.7: Build, test, login smoke (MxGateway)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (depends on 3.2–3.6)
|
||||||
|
|
||||||
|
**Steps:** `dotnet build src/MxGateway.sln` (+ worker x86 if the suite needs it); `dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj` (compare to the 3 baseline FakeWorker reds). Visual + auth smoke: layout renders, nav persists, status pills, **login page renders and a valid/invalid credential round-trips correctly** (the highest-risk surface). Report; do not merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — scadaproj docs + memory (branch `docs/ui-theme-adoption`)
|
||||||
|
|
||||||
|
### Task 4.1: Update component docs
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none (depends on 1.6, 2.6, 3.7)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `components/ui-theme/GAPS.md` — add an "✅ ADOPTED 2026-06-03 (local-only)" banner mirroring the Auth/Audit GAPS banners; note persistence promoted to the kit + MxGateway's new LoginCard page.
|
||||||
|
- Modify: `components/ui-theme/shared-contract/ZB.MOM.WW.Theme.md` — status → "Built + Published `0.2.0`"; document `ThemeScripts` + `NavRailSection.Key` + the nav-state.js asset.
|
||||||
|
- Modify: `CLAUDE.md` — UI-Theme component row status → "Adopted (lib `0.2.0`; all 3 apps, local feature branches)"; bump the version/test-count prose (38→ new total).
|
||||||
|
|
||||||
|
Commit.
|
||||||
|
|
||||||
|
### Task 4.2: Update memory
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** Task 4.1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-scadaproj/memory/ui-theme-adoption.md` (project memory: scope, 0.2.0, per-app branches local-only, MxGateway login conversion, persistence-in-kit decision; link `[[component-status-claims-are-optimistic]]`, `[[shared-libs-are-plain-files-not-nested-repos]]`).
|
||||||
|
- Modify: `…/memory/MEMORY.md` — add the index line.
|
||||||
|
|
||||||
|
Commit.
|
||||||
|
|
||||||
|
### Task 4.3: Final integration review
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
Dispatch a final reviewer across all three `feat/adopt-zb-theme` diffs + the scadaproj Phase 0/4 diff: confirm SPEC §7 acceptance per app, no app-only CSS lost, no regressions vs baseline, and the cross-app consistency of the shell/nav/login. Produce a go/no-go for the merge+push decision (which remains the user's call).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency summary
|
||||||
|
|
||||||
|
- `0.1, 0.2` → `0.3` → `0.4`.
|
||||||
|
- `0.4` blocks `1.1`, `2.1`, `3.1`.
|
||||||
|
- Within Phase 1: `1.1` → {`1.2`, `1.3`, `1.4`, `1.5`} (parallel) → `1.6`.
|
||||||
|
- Within Phase 2: `2.1` → {`2.2`, `2.3`, `2.4`, `2.5`} (parallel) → `2.6`.
|
||||||
|
- Within Phase 3: `3.1` → {`3.2`, `3.3`, `3.5`} (parallel) and `3.1`→`3.4`→`3.6`; all → `3.7`.
|
||||||
|
- `{1.6, 2.6, 3.7}` → `4.1`, `4.2` → `4.3`.
|
||||||
|
- Phases 1/2/3 are independent repos (may run concurrently; listed in risk order).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-03-ui-theme-adoption.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 44, "subject": "Task 0.1: NavRailSection persistence key", "status": "pending"},
|
||||||
|
{"id": 45, "subject": "Task 0.2: nav-state.js enhancer + ThemeScripts", "status": "pending"},
|
||||||
|
{"id": 46, "subject": "Task 0.3: Bump 0.2.0 + full suite", "status": "pending", "blockedBy": [44, 45]},
|
||||||
|
{"id": 47, "subject": "Task 0.4: Publish 0.2.0 to Gitea feed", "status": "pending", "blockedBy": [46]},
|
||||||
|
{"id": 48, "subject": "Task 1.1: OtOpcUa NuGet wiring + usings", "status": "pending", "blockedBy": [47]},
|
||||||
|
{"id": 49, "subject": "Task 1.2: OtOpcUa App.razor ThemeHead/ThemeScripts", "status": "pending", "blockedBy": [48]},
|
||||||
|
{"id": 50, "subject": "Task 1.3: OtOpcUa migrate CSS, delete theme.css/fonts/nav-state.js", "status": "pending", "blockedBy": [48]},
|
||||||
|
{"id": 51, "subject": "Task 1.4: OtOpcUa MainLayout to ThemeShell + kit nav", "status": "pending", "blockedBy": [48]},
|
||||||
|
{"id": 52, "subject": "Task 1.5: OtOpcUa delete dead StatusBadge + Login to LoginCard", "status": "pending", "blockedBy": [48]},
|
||||||
|
{"id": 53, "subject": "Task 1.6: OtOpcUa build/test/visual checklist", "status": "pending", "blockedBy": [49, 50, 51, 52]},
|
||||||
|
{"id": 54, "subject": "Task 2.1: ScadaBridge NuGet wiring + usings", "status": "pending", "blockedBy": [47]},
|
||||||
|
{"id": 55, "subject": "Task 2.2: ScadaBridge Host App.razor ThemeHead/ThemeScripts", "status": "pending", "blockedBy": [54]},
|
||||||
|
{"id": 56, "subject": "Task 2.3: ScadaBridge migrate CSS, delete theme.css/fonts/nav-state.js", "status": "pending", "blockedBy": [54]},
|
||||||
|
{"id": 57, "subject": "Task 2.4: ScadaBridge MainLayout/NavMenu to ThemeShell (preserve AuthorizeView/DialogHost/SessionExpiry)", "status": "pending", "blockedBy": [54]},
|
||||||
|
{"id": 58, "subject": "Task 2.5: ScadaBridge Login to LoginCard", "status": "pending", "blockedBy": [54]},
|
||||||
|
{"id": 59, "subject": "Task 2.6: ScadaBridge build/test/visual checklist", "status": "pending", "blockedBy": [55, 56, 57, 58]},
|
||||||
|
{"id": 60, "subject": "Task 3.1: MxGateway NuGet wiring + usings (no central PM)", "status": "pending", "blockedBy": [47]},
|
||||||
|
{"id": 61, "subject": "Task 3.2: MxGateway App.razor ThemeHead/ThemeScripts", "status": "pending", "blockedBy": [60]},
|
||||||
|
{"id": 62, "subject": "Task 3.3: MxGateway migrate CSS, delete theme.css/fonts/nav-state.js", "status": "pending", "blockedBy": [60]},
|
||||||
|
{"id": 63, "subject": "Task 3.4: MxGateway split combined MainLayout to ThemeShell", "status": "pending", "blockedBy": [60]},
|
||||||
|
{"id": 64, "subject": "Task 3.5: MxGateway StatusBadge to StatusPill adapter", "status": "pending", "blockedBy": [60]},
|
||||||
|
{"id": 65, "subject": "Task 3.6: MxGateway net-new Blazor LoginCard page", "status": "pending", "blockedBy": [63]},
|
||||||
|
{"id": 66, "subject": "Task 3.7: MxGateway build/test/login smoke", "status": "pending", "blockedBy": [61, 62, 63, 64, 65]},
|
||||||
|
{"id": 67, "subject": "Task 4.1: Update component docs", "status": "pending", "blockedBy": [53, 59, 66]},
|
||||||
|
{"id": 68, "subject": "Task 4.2: Update memory", "status": "pending", "blockedBy": [53, 59, 66]},
|
||||||
|
{"id": 69, "subject": "Task 4.3: Final integration review", "status": "pending", "blockedBy": [67, 68]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-03"
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Shared GLAuth Standardization — Design
|
||||||
|
|
||||||
|
> **Status:** IMPLEMENTED + verified 2026-06-04 (all 18 plan tasks). See `shared-glauth-on-35` memory.
|
||||||
|
> Plan: [`2026-06-04-shared-glauth-standardization.md`](2026-06-04-shared-glauth-standardization.md).
|
||||||
|
> **Scope:** dev/test only. Production stays on real corporate AD (out of scope).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Consolidate the three sister projects (OtOpcUa, MxAccessGateway, ScadaBridge) onto **one shared
|
||||||
|
GLAuth dev directory** running on the shared Docker host **`10.100.0.35:3893`**, replacing the
|
||||||
|
three separate LDAP setups in use today. This is the natural endpoint of the Auth-component
|
||||||
|
normalization: all three already use the shared `ZB.MOM.WW.Auth.Ldap` library (search-then-bind)
|
||||||
|
and already default to the same base DN `dc=zb,dc=local`.
|
||||||
|
|
||||||
|
## Decisions (locked during brainstorming)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Environments | **Dev/test only** (prod → real AD, untouched) |
|
||||||
|
| Consolidation depth | **Full** — every dev instance points at 35 |
|
||||||
|
| Transport | **Plaintext** (`Transport=None`, `AllowInsecure=true`) — trusted lab subnet |
|
||||||
|
| Source of truth | **`scadaproj/infra/glauth/`** (app-neutral, next to the other shared `ZB.MOM.WW.*` components) — Approach A |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
scadaproj/infra/glauth/ ← single source of truth (git)
|
||||||
|
├── config.toml (merged dc=zb,dc=local directory)
|
||||||
|
├── docker-compose.yml (one `glauth` service, :3893)
|
||||||
|
└── README.md
|
||||||
|
│ deploy on 10.100.0.35: docker compose up -d
|
||||||
|
▼
|
||||||
|
GLAuth @ 10.100.0.35:3893 · datastore=config · baseDN dc=zb,dc=local · ldaps=false
|
||||||
|
▲ ▲
|
||||||
|
plaintext bind │ (None + AllowInsecure) │
|
||||||
|
┌──────────────┴───────────┐ ┌─────────┴─────────────────────┐
|
||||||
|
Mac / OrbStack │ windev (10.100.0.48)
|
||||||
|
• ScadaBridge :9000/:9100 │ • MxGateway (MxAccessGw svc)
|
||||||
|
• OtOpcUa docker-dev │ • OtOpcUa (OtOpcUa svc)
|
||||||
|
(un-stubbed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- One `glauth` container on `10.100.0.35:3893`, `datastore=config`, `baseDN=dc=zb,dc=local`, ldaps disabled.
|
||||||
|
- Every dev consumer: `Server=10.100.0.35`, `Port=3893`, `Transport=None`, `AllowInsecure=true`, `SearchBase=dc=zb,dc=local`.
|
||||||
|
- **Retired:** the `scadabridge-ldap` container (ScadaBridge `infra/docker-compose.yml`) and the windev-local glauth (`C:\publish\glauth`).
|
||||||
|
- **Consequences:** windev gains a runtime dependency on 35 for *new* logins (existing cookie sessions unaffected); deploying to 35 needs working access (see Prerequisites).
|
||||||
|
|
||||||
|
## The merged directory
|
||||||
|
|
||||||
|
One `dc=zb,dc=local` directory; group families partitioned into **non-overlapping gid ranges** (today
|
||||||
|
both existing GLAuth files reuse 5501–5505 — the collision to fix). **Each app maps only its own family
|
||||||
|
and ignores the rest**, so the families coexist with zero conflict.
|
||||||
|
|
||||||
|
**Groups**
|
||||||
|
|
||||||
|
| Family | Used by | Groups (gidnumber) |
|
||||||
|
|---|---|---|
|
||||||
|
| `SCADA-*` (55xx) | ScadaBridge roles (DB-mapped) | Admins 5501, Designers 5502, Deploy-All 5503, Deploy-SiteA 5504, Viewers 5505 |
|
||||||
|
| OPC-perm (560x) | OtOpcUa + MxGateway OPC-UA write model | ReadOnly 5601, WriteOperate 5602, WriteTune 5603, WriteConfigure 5604, AlarmAck 5605 |
|
||||||
|
| `Gw*` (561x) | MxGateway dashboard (config-mapped) | GwAdmin 5610, GwReader 5611 |
|
||||||
|
| `OtOpcUa-*` (57xx) | OtOpcUa AdminUI (DB-mapped) | Admins 5701, Designers 5702, Viewers 5703 |
|
||||||
|
|
||||||
|
`SCADA-*` keeps its canonical 55xx numbers (already deployed). The OPC/`Gw` groups move off the old
|
||||||
|
5501–5505/5510 into 56xx to clear the clash.
|
||||||
|
|
||||||
|
**Users** (all password `password`; uid ranges 50xx ScadaBridge / 51xx MxGateway / 52xx OtOpcUa)
|
||||||
|
|
||||||
|
- **`serviceaccount`** (5999, `cn=serviceaccount,dc=zb,dc=local`, `search *` capability) — the *single*
|
||||||
|
bind account every app uses. Password `serviceaccount123`. ScadaBridge moves to it from `cn=admin`/`password`.
|
||||||
|
- **`multi-role`** (5005) — member of **every** group → all roles in all three apps (canonical cross-app QA login).
|
||||||
|
- **`admin`** (5001) — `SCADA-Admins` + `GwAdmin` + `OtOpcUa-Admins` → Administrator everywhere.
|
||||||
|
- Per-role testers: `designer`/`deployer`/`site-deployer` (ScadaBridge); `gwreader` (MxGateway Viewer);
|
||||||
|
`otdesigner`/`otviewer` (OtOpcUa); `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack` (OPC perms).
|
||||||
|
|
||||||
|
## Per-app config changes
|
||||||
|
|
||||||
|
Each consumer changes only its LDAP `Server` (+ a few keys). Shared service account
|
||||||
|
`cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`.
|
||||||
|
|
||||||
|
- **ScadaBridge** (`docker/` + `docker-env2/`, central-node-a & -b `appsettings.Central.json`):
|
||||||
|
`Ldap:Server` `scadabridge-ldap`→`10.100.0.35`; `ServiceAccountDn` `cn=admin`→`cn=serviceaccount`,
|
||||||
|
`ServiceAccountPassword`→`serviceaccount123`. Rest unchanged (`SCADA-*` DB mappings already seeded).
|
||||||
|
Retire the `ldap` service in `infra/docker-compose.yml`; sequenced-recreate central nodes.
|
||||||
|
- **OtOpcUa docker-dev** (`docker-dev/docker-compose.yml`, all host containers) — **the un-stub**:
|
||||||
|
drop `Security__Ldap__DevStubMode=true`; add `Server=10.100.0.35`, `Port=3893`, `Transport=None`,
|
||||||
|
`AllowInsecure=true`, `SearchBase=dc=zb,dc=local`, `ServiceAccountDn=cn=serviceaccount,…`,
|
||||||
|
`ServiceAccountPassword=serviceaccount123`. Seed OtOpcUa DB mappings
|
||||||
|
`OtOpcUa-Admins→Administrator`, `OtOpcUa-Designers→Designer`, `OtOpcUa-Viewers→Viewer` (system-wide).
|
||||||
|
- **MxGateway** (windev `C:\publish\mxaccessgw\Server\appsettings.json`): `Ldap:Server`
|
||||||
|
`localhost`→`10.100.0.35`; `SearchBase` `dc=lmxopcua`→`dc=zb,dc=local`; `ServiceAccountDn`→`…dc=zb,dc=local`.
|
||||||
|
`Transport=None`/`AllowInsecure=true` already migrated; `GroupToRole` (`GwAdmin`/`GwReader`) unchanged.
|
||||||
|
Restart `MxAccessGw` (+ dependent `OtOpcUa` svc).
|
||||||
|
- **OtOpcUa (windev service)**: locate its deployed overlay; repoint `Server`→`10.100.0.35`,
|
||||||
|
`SearchBase`→`dc=zb,dc=local`, service account, and switch dev transport `Ldaps`→`None`+`AllowInsecure`.
|
||||||
|
- **Then** stop/disable the windev-local `glauth` service.
|
||||||
|
|
||||||
|
## Rollout & rollback
|
||||||
|
|
||||||
|
Incremental; **the old glauths stay up until the very end**, so every step is reversible by pointing
|
||||||
|
`Server` back.
|
||||||
|
|
||||||
|
1. Stand up the shared glauth on 35 → verify via `ldapsearch` (bind `serviceaccount`; `multi-role`
|
||||||
|
`memberOf` spans all families). Nothing repointed yet.
|
||||||
|
2. Prove reachability from an OrbStack container to `10.100.0.35:3893` (the linchpin) before any app edit.
|
||||||
|
3. ScadaBridge `:9000` → recreate → browser-verify `multi-role` = 4 roles. Then `:9100`.
|
||||||
|
4. OtOpcUa docker-dev → un-stub + repoint + seed → recreate → verify.
|
||||||
|
5. windev MxGateway (backup appsettings) → restart → verify. Then windev OtOpcUa overlay.
|
||||||
|
6. Only once all green: stop/disable `scadabridge-ldap` + the windev-local glauth.
|
||||||
|
|
||||||
|
**Rollback** per consumer: revert the one-line `Server` change (git revert on the Mac; `.bak` restore on
|
||||||
|
windev) and recreate/restart. Remove the shared glauth = `docker compose down` on 35.
|
||||||
|
|
||||||
|
## Testing & verification
|
||||||
|
|
||||||
|
- **LDAP layer:** `ldapsearch` bind `serviceaccount`; confirm each test user + `multi-role`'s `memberOf`
|
||||||
|
across all four families; bind each user to confirm `password`.
|
||||||
|
- **Per-app browser (macbook Chrome):** ScadaBridge `:9000`/`:9100` `multi-role` → 4 roles (via
|
||||||
|
`/auth/token`); OtOpcUa `:9200` → seeded roles; MxGateway `10.100.0.48:5130` → Administrator; windev OtOpcUa → AdminUI.
|
||||||
|
- **Role-gating spot-checks:** `gwreader`→MxGateway Viewer-only; `designer`→ScadaBridge design-only;
|
||||||
|
`otviewer`→OtOpcUa read-only.
|
||||||
|
- **Negative:** wrong password rejected everywhere; a user in no family of an app → denied there.
|
||||||
|
|
||||||
|
## Prerequisites & open items (resolve in the plan)
|
||||||
|
|
||||||
|
1. **Access to `10.100.0.35`** — SSH from this Mac is currently refused (`Permission denied`/connection
|
||||||
|
reset) and the windev→35 jump is administratively prohibited. Either re-authorize this Mac's key on 35,
|
||||||
|
or the user runs the final `docker compose up -d`. Artifacts are portable either way.
|
||||||
|
2. **OtOpcUa group key shape** — confirm OtOpcUa maps on the **short RDN** (`OtOpcUa-Admins`) the shared
|
||||||
|
lib returns vs the full-DN its `LdapGroupRoleMapping` entity comment shows, before seeding.
|
||||||
|
3. **OrbStack→LAN reachability** — verify ScadaBridge/OtOpcUa containers can reach `10.100.0.35:3893`
|
||||||
|
early (likely fine; it's the linchpin). `log()` if any consumer can't reach 35 rather than silently failing.
|
||||||
|
4. **windev OtOpcUa config path** — discovery step (less is known about this deployment than MxGateway).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `scadaproj` is a plain-files umbrella that is *also* a local git repo; `infra/glauth/` lives here as the
|
||||||
|
canonical source. Per-app config edits land on a `feat/*` branch per repo (merge on the user's go).
|
||||||
|
windev edits are deployment-only with `.bak` backups (like the GroupToRole / LDAP-key migrations done
|
||||||
|
2026-06-04); repo templates optionally aligned.
|
||||||
|
- Related memory: `multi-role-cross-app-test-user`, `mxgateway-windev-deploy`,
|
||||||
|
`scadabridge-local-deploy-gotchas`, `auth-audit-normalization-in-progress`.
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
# Shared GLAuth Standardization — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Consolidate OtOpcUa, MxAccessGateway, and ScadaBridge **dev/test** auth onto one shared GLAuth directory at `10.100.0.35:3893` (`dc=zb,dc=local`, plaintext), replacing the three separate LDAP setups.
|
||||||
|
|
||||||
|
**Architecture:** A single app-neutral GLAuth `config` directory lives in `scadaproj/infra/glauth/` (source of truth) and runs as one container on the shared Docker host `10.100.0.35`. Group families are partitioned into non-overlapping gid ranges (`SCADA-*` 55xx, OPC-perm/`Gw*` 56xx, `OtOpcUa-*` 57xx); each app maps only its own family. Every dev consumer just repoints its LDAP `Server` at `10.100.0.35`. Rollout is incremental and keeps the old glauths running until each consumer is verified.
|
||||||
|
|
||||||
|
**Tech Stack:** GLAuth (`glauth/glauth:latest`, TOML `config` datastore), Docker Compose / OrbStack (Mac) + Docker on `10.100.0.35`, .NET 10 apps using the shared `ZB.MOM.WW.Auth.Ldap` (search-then-bind), MSSQL config DBs, Windows/NSSM services on windev (`10.100.0.48`), `ldapsearch` + Chrome (macbook) for verification.
|
||||||
|
|
||||||
|
**Design:** [`2026-06-04-shared-glauth-standardization-design.md`](2026-06-04-shared-glauth-standardization-design.md)
|
||||||
|
|
||||||
|
**Reference values**
|
||||||
|
- `password` → sha256 `5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8`
|
||||||
|
- `serviceaccount123` → sha256 `af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419`
|
||||||
|
- Shared service account: `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`
|
||||||
|
- All consumer LDAP keys: `Server=10.100.0.35 Port=3893 Transport=None AllowInsecure=true SearchBase=dc=zb,dc=local`
|
||||||
|
|
||||||
|
**Branching:** scadaproj artifacts on the current `docs/shared-glauth-standardization` branch. Per-app config edits on a `feat/shared-glauth` branch in each app repo (ScadaBridge, OtOpcUa). windev edits are deployment-only (`.bak` backups), repo templates optionally aligned. Merge on the user's go.
|
||||||
|
|
||||||
|
**Operational caveats (read first):**
|
||||||
|
- **`10.100.0.35` access is currently blocked from this Mac** (SSH refused; windev→35 jump prohibited). **Task 4 is a hard gate** — it needs either this Mac's key re-authorized on 35 *or* the user to run the `docker compose up`. The artifact is portable.
|
||||||
|
- Tasks that recreate running clusters (ScadaBridge, OtOpcUa) and touch the **live windev host** are operational; their "tests" are `ldapsearch`/`curl`/browser checks with exact expected output. Sequence cluster recreates seed-first to avoid Akka split-brain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Author + deploy the shared GLAuth
|
||||||
|
|
||||||
|
### Task 0: Write the merged GLAuth `config.toml`
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 1, Task 2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/Desktop/scadaproj/infra/glauth/config.toml`
|
||||||
|
|
||||||
|
**Step 1: Write the file** with this exact content (merged `dc=zb,dc=local` directory; gid families partitioned; `multi-role` is in every group):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ldap]
|
||||||
|
enabled = true
|
||||||
|
listen = "0.0.0.0:3893"
|
||||||
|
|
||||||
|
[ldaps]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[backend]
|
||||||
|
datastore = "config"
|
||||||
|
baseDN = "dc=zb,dc=local"
|
||||||
|
|
||||||
|
[behaviors]
|
||||||
|
# Dev: do not lock out on failed binds (avoids surprises during testing).
|
||||||
|
LimitFailedBinds = false
|
||||||
|
|
||||||
|
# ── Groups ───────────────────────────────────────────────────────────
|
||||||
|
# ScadaBridge role groups (55xx) — DB-mapped (LdapGroupMappings)
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Admins"
|
||||||
|
gidnumber = 5501
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Designers"
|
||||||
|
gidnumber = 5502
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Deploy-All"
|
||||||
|
gidnumber = 5503
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Deploy-SiteA"
|
||||||
|
gidnumber = 5504
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Viewers"
|
||||||
|
gidnumber = 5505
|
||||||
|
|
||||||
|
# OPC-UA permission groups (560x) — OtOpcUa + MxGateway OPC write model
|
||||||
|
[[groups]]
|
||||||
|
name = "ReadOnly"
|
||||||
|
gidnumber = 5601
|
||||||
|
[[groups]]
|
||||||
|
name = "WriteOperate"
|
||||||
|
gidnumber = 5602
|
||||||
|
[[groups]]
|
||||||
|
name = "WriteTune"
|
||||||
|
gidnumber = 5603
|
||||||
|
[[groups]]
|
||||||
|
name = "WriteConfigure"
|
||||||
|
gidnumber = 5604
|
||||||
|
[[groups]]
|
||||||
|
name = "AlarmAck"
|
||||||
|
gidnumber = 5605
|
||||||
|
|
||||||
|
# MxGateway dashboard groups (561x) — config-mapped (GroupToRole)
|
||||||
|
[[groups]]
|
||||||
|
name = "GwAdmin"
|
||||||
|
gidnumber = 5610
|
||||||
|
[[groups]]
|
||||||
|
name = "GwReader"
|
||||||
|
gidnumber = 5611
|
||||||
|
|
||||||
|
# OtOpcUa AdminUI role groups (57xx) — DB-mapped (LdapGroupRoleMapping)
|
||||||
|
[[groups]]
|
||||||
|
name = "OtOpcUa-Admins"
|
||||||
|
gidnumber = 5701
|
||||||
|
[[groups]]
|
||||||
|
name = "OtOpcUa-Designers"
|
||||||
|
gidnumber = 5702
|
||||||
|
[[groups]]
|
||||||
|
name = "OtOpcUa-Viewers"
|
||||||
|
gidnumber = 5703
|
||||||
|
|
||||||
|
# ── Users ────────────────────────────────────────────────────────────
|
||||||
|
# All passwords are "password" except serviceaccount ("serviceaccount123").
|
||||||
|
# sha256("password") = 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
|
||||||
|
# sha256("serviceaccount123") = af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419
|
||||||
|
|
||||||
|
# The single bind account every app uses (search-then-bind).
|
||||||
|
[[users]]
|
||||||
|
name = "serviceaccount"
|
||||||
|
uidnumber = 5999
|
||||||
|
primarygroup = 5601
|
||||||
|
passsha256 = "af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419"
|
||||||
|
[[users.capabilities]]
|
||||||
|
action = "search"
|
||||||
|
object = "*"
|
||||||
|
|
||||||
|
# Cross-app: member of EVERY group → all roles in all three apps.
|
||||||
|
[[users]]
|
||||||
|
name = "multi-role"
|
||||||
|
givenname = "Multi"
|
||||||
|
sn = "Role"
|
||||||
|
mail = "multi-role@zb.local"
|
||||||
|
uidnumber = 5005
|
||||||
|
primarygroup = 5501
|
||||||
|
othergroups = [5502, 5503, 5504, 5505, 5601, 5602, 5603, 5604, 5605, 5610, 5611, 5701, 5702, 5703]
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# Administrator everywhere (admin-equivalent of each app).
|
||||||
|
[[users]]
|
||||||
|
name = "admin"
|
||||||
|
uidnumber = 5001
|
||||||
|
primarygroup = 5501
|
||||||
|
othergroups = [5610, 5701]
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# ScadaBridge single-role testers
|
||||||
|
[[users]]
|
||||||
|
name = "designer"
|
||||||
|
uidnumber = 5002
|
||||||
|
primarygroup = 5502
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "deployer"
|
||||||
|
uidnumber = 5003
|
||||||
|
primarygroup = 5503
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "site-deployer"
|
||||||
|
uidnumber = 5004
|
||||||
|
primarygroup = 5504
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# MxGateway dashboard Viewer tester
|
||||||
|
[[users]]
|
||||||
|
name = "gwreader"
|
||||||
|
uidnumber = 5106
|
||||||
|
primarygroup = 5611
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# OPC-UA permission testers
|
||||||
|
[[users]]
|
||||||
|
name = "readonly"
|
||||||
|
uidnumber = 5101
|
||||||
|
primarygroup = 5601
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "writeop"
|
||||||
|
uidnumber = 5102
|
||||||
|
primarygroup = 5602
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "writetune"
|
||||||
|
uidnumber = 5103
|
||||||
|
primarygroup = 5603
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "writeconfig"
|
||||||
|
uidnumber = 5104
|
||||||
|
primarygroup = 5604
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "alarmack"
|
||||||
|
uidnumber = 5105
|
||||||
|
primarygroup = 5605
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# OtOpcUa single-role testers (admin covers OtOpcUa-Admins)
|
||||||
|
[[users]]
|
||||||
|
name = "otdesigner"
|
||||||
|
uidnumber = 5202
|
||||||
|
primarygroup = 5702
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "otviewer"
|
||||||
|
uidnumber = 5203
|
||||||
|
primarygroup = 5703
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify TOML parses** (sanity, no network):
|
||||||
|
Run: `python3 -c "import tomllib,sys; tomllib.load(open('/Users/dohertj2/Desktop/scadaproj/infra/glauth/config.toml','rb')); print('OK')"`
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Write the GLAuth `docker-compose.yml`
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** Task 0, Task 2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/Desktop/scadaproj/infra/glauth/docker-compose.yml`
|
||||||
|
|
||||||
|
**Step 1: Write** (single service, bind-mount the config read-only, publish 3893 on all interfaces so cross-host clients reach it):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Shared dev GLAuth for OtOpcUa + MxAccessGateway + ScadaBridge.
|
||||||
|
# Deploy on the shared Docker host 10.100.0.35: docker compose up -d
|
||||||
|
# Verify: ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
|
# -D cn=serviceaccount,dc=zb,dc=local -w serviceaccount123 \
|
||||||
|
# -b dc=zb,dc=local "(cn=multi-role)" memberOf
|
||||||
|
name: zb-shared-glauth
|
||||||
|
services:
|
||||||
|
glauth:
|
||||||
|
image: glauth/glauth:latest
|
||||||
|
container_name: zb-shared-glauth
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3893:3893"
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/app/config/config.cfg:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Write the `README.md` (deploy + verify runbook)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 0, Task 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/Desktop/scadaproj/infra/glauth/README.md`
|
||||||
|
|
||||||
|
**Step 1: Write** a runbook covering: purpose (shared dev directory for all 3 apps); the merged directory's group families + gid ranges + the canonical users (`multi-role`/`admin`/`serviceaccount` + per-role testers); **deploy on `10.100.0.35`** (`scp -r infra/glauth dohertj2@10.100.0.35:~/zb-glauth && ssh dohertj2@10.100.0.35 'cd ~/zb-glauth && docker compose up -d'`) with the note that this Mac's SSH access to 35 must be working (else the user runs it); and the **verification** `ldapsearch` commands (bind `serviceaccount`, confirm `multi-role`'s `memberOf` spans all four families; bind each tester). Include the "to add a user/group, edit `config.toml` and `docker compose up -d --force-recreate` (the single-file bind-mount needs a recreate, not a restart)" gotcha.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Commit Phase 0 artifacts
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~1 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** (commit only) — `/Users/dohertj2/Desktop/scadaproj/infra/glauth/*`
|
||||||
|
|
||||||
|
**Step 1:** From `/Users/dohertj2/Desktop/scadaproj` (already on `docs/shared-glauth-standardization`):
|
||||||
|
```bash
|
||||||
|
git add infra/glauth/config.toml infra/glauth/docker-compose.yml infra/glauth/README.md
|
||||||
|
git commit -m "feat(glauth): merged shared dev GLAuth directory + compose + runbook (10.100.0.35)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Deploy to `10.100.0.35` and verify the directory ⟵ HARD GATE / ACCESS-PREREQUISITE
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (blocked on 35 access)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (operational)
|
||||||
|
|
||||||
|
**Step 1: Resolve access.** Confirm `ssh dohertj2@10.100.0.35 'echo ok'` works. If it does NOT (currently the case from this Mac), STOP and either (a) have the user re-authorize this Mac's key on 35, or (b) hand the user `infra/glauth/` + the deploy command to run on 35. Do not proceed past this gate until GLAuth is up on 35.
|
||||||
|
|
||||||
|
**Step 2: Deploy** (once access works). Copy the FILES into the dest dir (not the dir itself) so a
|
||||||
|
re-deploy doesn't nest them at `~/zb-glauth/glauth/` (the `scp -r dir-into-existing-dir` trap):
|
||||||
|
```bash
|
||||||
|
ssh dohertj2@10.100.0.35 'mkdir -p ~/zb-glauth'
|
||||||
|
scp /Users/dohertj2/Desktop/scadaproj/infra/glauth/config.toml \
|
||||||
|
/Users/dohertj2/Desktop/scadaproj/infra/glauth/docker-compose.yml \
|
||||||
|
dohertj2@10.100.0.35:~/zb-glauth/
|
||||||
|
ssh dohertj2@10.100.0.35 'cd ~/zb-glauth && docker compose up -d --force-recreate && docker ps --filter name=zb-shared-glauth'
|
||||||
|
```
|
||||||
|
Expected: `zb-shared-glauth` container `Up`.
|
||||||
|
|
||||||
|
**Step 3 (test): Verify the directory** from the Mac via a throwaway ldap client:
|
||||||
|
```bash
|
||||||
|
docker run --rm alpine:3.20 sh -c 'apk add --no-progress -q openldap-clients >/dev/null 2>&1 && \
|
||||||
|
ldapsearch -x -H ldap://10.100.0.35:3893 -D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
|
||||||
|
-b "dc=zb,dc=local" "(cn=multi-role)" memberOf'
|
||||||
|
```
|
||||||
|
Expected: `result: 0 Success` and `memberOf` listing all four families — `SCADA-*`, `ReadOnly/Write*/AlarmAck`, `GwAdmin/GwReader`, `OtOpcUa-*`.
|
||||||
|
|
||||||
|
**Step 4 (test): Confirm a user binds with `password`:**
|
||||||
|
```bash
|
||||||
|
docker run --rm alpine:3.20 sh -c 'apk add --no-progress -q openldap-clients >/dev/null 2>&1 && \
|
||||||
|
ldapsearch -x -H ldap://10.100.0.35:3893 -D "cn=multi-role,dc=zb,dc=local" -w password \
|
||||||
|
-b "dc=zb,dc=local" "(cn=multi-role)" cn 2>&1 | grep -i "result:"'
|
||||||
|
```
|
||||||
|
Expected: `result: 50 Insufficient access` (bind OK — search denied because multi-role lacks the search capability; a *bad* password would give `result: 49`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — ScadaBridge repoint (Mac docker)
|
||||||
|
|
||||||
|
### Task 5: Repoint the 4 ScadaBridge central-node configs
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 10, Task 11, Task 14, Task 15 (different repos/hosts)
|
||||||
|
|
||||||
|
**Files (4 identical edits):**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker/central-node-a/appsettings.Central.json` (`Ldap` block ~lines 25–32)
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker/central-node-b/appsettings.Central.json`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker-env2/central-node-a/appsettings.Central.json`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/docker-env2/central-node-b/appsettings.Central.json`
|
||||||
|
|
||||||
|
**Step 1:** In each file's `Ldap` block, change three keys (leave `Port`, `Transport`, `AllowInsecure`, `SearchBase` as-is — already `3893`/`None`/`true`/`dc=zb,dc=local`):
|
||||||
|
- `"Server": "scadabridge-ldap"` → `"Server": "10.100.0.35"`
|
||||||
|
- `"ServiceAccountDn": "cn=admin,dc=zb,dc=local"` → `"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local"`
|
||||||
|
- `"ServiceAccountPassword": "password"` → `"ServiceAccountPassword": "serviceaccount123"`
|
||||||
|
|
||||||
|
**Step 2 (test): Confirm all four files updated:**
|
||||||
|
Run: `grep -l '"Server": "10.100.0.35"' /Users/dohertj2/Desktop/ScadaBridge/docker*/central-node-*/appsettings.Central.json | wc -l`
|
||||||
|
Expected: `4`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Retire the `scadabridge-ldap` service + prove OrbStack→35 reachability
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/ScadaBridge/infra/docker-compose.yml` (the `ldap:` service, lines ~44–50)
|
||||||
|
|
||||||
|
**Step 1 (test FIRST — the linchpin): Verify a container on `scadabridge-net` can reach `10.100.0.35:3893`** before retiring anything:
|
||||||
|
```bash
|
||||||
|
docker run --rm --network scadabridge-net alpine:3.20 sh -c \
|
||||||
|
'apk add --no-progress -q openldap-clients >/dev/null 2>&1 && \
|
||||||
|
ldapsearch -x -H ldap://10.100.0.35:3893 -D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 -b "dc=zb,dc=local" "(cn=admin)" cn 2>&1 | grep -i "result:"'
|
||||||
|
```
|
||||||
|
Expected: `result: 0 Success`. **If unreachable, STOP** — fix networking (OrbStack→LAN) before repointing; do not retire the local glauth.
|
||||||
|
|
||||||
|
**Step 2:** Comment out (do not delete — keep for rollback) the `ldap:` service block in `infra/docker-compose.yml`. Stop the old container: `docker stop scadabridge-ldap`. (Leave it stopped, not removed, until Phase 4.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Recreate the `:9000` cluster central nodes + browser-verify
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (operational)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (operational)
|
||||||
|
|
||||||
|
**Step 1:** Recreate the two central nodes to pick up the new config (seed-first to avoid split-brain — recreate `central-a`, wait healthy, then `central-b`):
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/ScadaBridge/docker && docker compose up -d --force-recreate --no-deps central-node-a
|
||||||
|
# wait until central-a is serving, then:
|
||||||
|
docker compose up -d --force-recreate --no-deps central-node-b
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 (test): Token endpoint shows all four roles** (re-runs the full LDAP auth against 35):
|
||||||
|
```bash
|
||||||
|
curl -s -m10 -X POST http://localhost:9000/auth/token --data-urlencode username=multi-role --data-urlencode password=password
|
||||||
|
```
|
||||||
|
Expected JSON contains `"roles":["Administrator","Designer","Deployer","Viewer"]`.
|
||||||
|
|
||||||
|
**Step 3 (test): Browser** (Chrome macbook) — sign out, log in `multi-role`/`password` at `http://localhost:9000/login`; expect the dashboard with ADMIN + DESIGN + DEPLOYMENT nav sections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Recreate the `:9100` cluster central nodes + verify
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (operational)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (operational)
|
||||||
|
|
||||||
|
**Step 1:** As Task 7 but in `/Users/dohertj2/Desktop/ScadaBridge/docker-env2` (recreate `central-node-a` then `-b`).
|
||||||
|
|
||||||
|
**Step 2 (test):** `curl -s -m10 -X POST http://localhost:9100/auth/token --data-urlencode username=multi-role --data-urlencode password=password` → `"roles":["Administrator","Designer","Deployer","Viewer"]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Commit ScadaBridge edits on a branch
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~1 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** (commit) the 4 central-node json + `infra/docker-compose.yml`
|
||||||
|
|
||||||
|
**Step 1:**
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/ScadaBridge && git checkout -b feat/shared-glauth
|
||||||
|
git add docker/central-node-*/appsettings.Central.json docker-env2/central-node-*/appsettings.Central.json infra/docker-compose.yml
|
||||||
|
git commit -m "feat(auth): point dev clusters at shared GLAuth 10.100.0.35; retire local scadabridge-ldap"
|
||||||
|
```
|
||||||
|
(Do not merge/push — wait for the user's go.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — OtOpcUa docker-dev un-stub (Mac docker)
|
||||||
|
|
||||||
|
### Task 10: Confirm group-key shape, then add `LdapGroupRoleMapping` seed rows
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 5, Task 14, Task 15
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/docker-dev/seed/seed-clusters.sql`
|
||||||
|
- Read (gate): `/Users/dohertj2/Desktop/scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs`
|
||||||
|
|
||||||
|
**Step 1 (gate): Confirm the runtime group string is the bare RDN** (`OtOpcUa-Admins`), not a full DN. Read `LdapAuthService.cs` and find where it builds the returned `Groups` from `memberOf`; confirm it strips each DN to its first RDN *value*. Cross-check: ScadaBridge's DB mappings use bare `SCADA-Admins` and work today against the same glauth `groupformat=ou` (so memberOf is `ou=SCADA-Admins,...` → returned as `SCADA-Admins`). Conclusion to lock: seed `LdapGroup = 'OtOpcUa-Admins'` (bare). If the code instead returns full DNs, STOP and seed the full DN form — but the evidence says bare.
|
||||||
|
|
||||||
|
**Step 2:** Append idempotent INSERTs to `seed-clusters.sql` (table `dbo.LdapGroupRoleMapping`; `Role` stored as the enum NAME string; system-wide rows ⇒ `ClusterId = NULL`, `IsSystemWide = 1`):
|
||||||
|
```sql
|
||||||
|
-- Shared-GLAuth dev: OtOpcUa AdminUI role mappings (system-wide).
|
||||||
|
-- Group keys are the BARE RDN names the shared ZB.MOM.WW.Auth.Ldap returns.
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Admins' AND ClusterId IS NULL)
|
||||||
|
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
|
||||||
|
VALUES (NEWID(), 'OtOpcUa-Admins', 'Administrator', NULL, 1, SYSUTCDATETIME(), 'shared-glauth dev seed');
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Designers' AND ClusterId IS NULL)
|
||||||
|
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
|
||||||
|
VALUES (NEWID(), 'OtOpcUa-Designers', 'Designer', NULL, 1, SYSUTCDATETIME(), 'shared-glauth dev seed');
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa-Viewers' AND ClusterId IS NULL)
|
||||||
|
INSERT INTO dbo.LdapGroupRoleMapping (Id, LdapGroup, Role, ClusterId, IsSystemWide, CreatedAtUtc, Notes)
|
||||||
|
VALUES (NEWID(), 'OtOpcUa-Viewers', 'Viewer', NULL, 1, SYSUTCDATETIME(), 'shared-glauth dev seed');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Un-stub the OtOpcUa docker-dev host containers
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 5, Task 14, Task 15
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/OtOpcUa/docker-dev/docker-compose.yml` (the 6 admin/site containers: `admin-a` ~L100, `admin-b` ~L117, `site-a-1` ~L170, `site-a-2` ~L193, `site-b-1` ~L215, `site-b-2` ~L238)
|
||||||
|
|
||||||
|
**Step 1:** In each of the 6 containers' `environment:`, replace the single `Security__Ldap__DevStubMode: "true"` line with the real-LDAP block:
|
||||||
|
```yaml
|
||||||
|
Security__Ldap__Enabled: "true"
|
||||||
|
Security__Ldap__DevStubMode: "false"
|
||||||
|
Security__Ldap__Server: "10.100.0.35"
|
||||||
|
Security__Ldap__Port: "3893"
|
||||||
|
Security__Ldap__Transport: "None"
|
||||||
|
Security__Ldap__AllowInsecure: "true"
|
||||||
|
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||||
|
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||||
|
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||||
|
```
|
||||||
|
(Driver-only `driver-a`/`driver-b` have no LDAP block — leave them.)
|
||||||
|
|
||||||
|
**Step 2 (test): Confirm 6 containers updated, 0 DevStub left:**
|
||||||
|
Run: `grep -c 'Security__Ldap__Server: "10.100.0.35"' /Users/dohertj2/Desktop/OtOpcUa/docker-dev/docker-compose.yml` → `6`; and `grep -c 'DevStubMode: "true"' …/docker-compose.yml` → `0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Apply seed + recreate `otopcua-dev` + browser-verify
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (operational)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (operational)
|
||||||
|
|
||||||
|
**Step 1: Apply the new mapping rows to the running config DB** (host port 14330):
|
||||||
|
```bash
|
||||||
|
docker exec otopcua-dev-sql-1 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Dev123' -No -d OtOpcUa -Q "$(sed -n '/OtOpcUa-Admins/,/shared-glauth dev seed.);/p' /Users/dohertj2/Desktop/OtOpcUa/docker-dev/seed/seed-clusters.sql)"
|
||||||
|
```
|
||||||
|
(or simpler: re-run the seed container `docker compose -f docker-dev/docker-compose.yml up cluster-seed`). Verify: `… -Q "SELECT LdapGroup,Role FROM dbo.LdapGroupRoleMapping WHERE IsSystemWide=1"` → the 3 OtOpcUa-* rows.
|
||||||
|
|
||||||
|
**Step 2: Recreate the 6 admin/site host containers** (seed-first per cluster — recreate `admin-a` then `admin-b`; `site-a-1` then `site-a-2`; `site-b-1` then `site-b-2`):
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa/docker-dev
|
||||||
|
for n in admin-a admin-b site-a-1 site-a-2 site-b-1 site-b-2; do docker compose up -d --force-recreate --no-deps $n; sleep 3; done
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 (test): Browser** — log in `multi-role`/`password` at `http://localhost:9200/login`; expect the AdminUI Overview, SESSION panel showing `multi-role` + **Administrator** (from `OtOpcUa-Admins`→Administrator). Confirms the un-stub + real bind + DB mapping all work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: Commit OtOpcUa edits on a branch
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~1 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** (commit) `docker-dev/docker-compose.yml`, `docker-dev/seed/seed-clusters.sql`
|
||||||
|
|
||||||
|
**Step 1:**
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa && git checkout -b feat/shared-glauth
|
||||||
|
git add docker-dev/docker-compose.yml docker-dev/seed/seed-clusters.sql
|
||||||
|
git commit -m "feat(auth): un-stub docker-dev onto shared GLAuth 10.100.0.35 + seed OtOpcUa-* role mappings"
|
||||||
|
```
|
||||||
|
(Do not merge/push.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — windev repoint + retire windev-local glauth (live host)
|
||||||
|
|
||||||
|
### Task 14: Repoint MxGateway (windev) at the shared GLAuth
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (operational, live host)
|
||||||
|
**Parallelizable with:** Task 5, Task 10, Task 11
|
||||||
|
|
||||||
|
**Files:** (windev, deployment-only) `C:\publish\mxaccessgw\Server\appsettings.json` (`MxGateway:Ldap`)
|
||||||
|
|
||||||
|
**Step 1: Back up** `appsettings.json` → `appsettings.json.bak-20260604-glauth35` (skip if exists).
|
||||||
|
|
||||||
|
**Step 2: Edit `MxGateway:Ldap`** (literal replacements; preserve the rest, incl. the `Transport=None`/`AllowInsecure=true` migrated 2026-06-04, and `GroupToRole`):
|
||||||
|
- `"Server": "localhost"` → `"Server": "10.100.0.35"`
|
||||||
|
- `"SearchBase": "dc=lmxopcua,dc=local"` → `"SearchBase": "dc=zb,dc=local"`
|
||||||
|
- `"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local"` → `"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local"`
|
||||||
|
(`ServiceAccountPassword` stays `serviceaccount123`.) Use a `-File` PowerShell script (`[IO.File]::WriteAllText` after `.Replace(...)`), validate JSON parses.
|
||||||
|
|
||||||
|
**Step 3:** `Restart-Service MxAccessGw -Force; Start-Service OtOpcUa` (cascades to the dependent OtOpcUa svc — start it back).
|
||||||
|
|
||||||
|
**Step 4 (test):** From the Mac, `POST http://10.100.0.48:5130/auth/login` (GET `/login` for the antiforgery token+cookie first) with `username=multi-role&password=password` → `302 Location: /` (success). Browser-verify the dashboard logs in as `multi-role` (Administrator).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 15: Repoint OtOpcUa (windev service) + switch transport to plaintext
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (operational, live host)
|
||||||
|
**Parallelizable with:** Task 5, Task 10, Task 11
|
||||||
|
|
||||||
|
**Files:** (windev, deployment-only) `C:\publish\lmxopcua\appsettings.json` (`Security:Ldap`) — **discover any per-role overlay first** (`appsettings.admin.json`/`appsettings.driver.json` in `C:\publish\lmxopcua\` or `C:\publish\lmxopcua-admin\`; the live binary is `C:\publish\lmxopcua\OtOpcUa.Server.exe`).
|
||||||
|
|
||||||
|
**Step 1: Discovery** — `Get-ChildItem C:\publish\lmxopcua\appsettings*.json` and inspect which file holds the live `Security:Ldap` (base + any `appsettings.admin.json` overlay that sets `Transport=Ldaps`). Back up whatever you edit.
|
||||||
|
|
||||||
|
**Step 2: Edit `Security:Ldap`** in the live config (and the admin overlay if present):
|
||||||
|
- `Server` → `10.100.0.35`; `SearchBase` → `dc=zb,dc=local`; `Transport` `Ldaps` → `None`; add/set `AllowInsecure` `true`; `ServiceAccountDn` → `cn=serviceaccount,dc=zb,dc=local`, `ServiceAccountPassword` → `serviceaccount123`; ensure `DevStubMode=false`.
|
||||||
|
|
||||||
|
**Step 3:** `Restart-Service OtOpcUa` (note the dependency direction: `MxAccessGw` depends on `OtOpcUa` — restarting OtOpcUa may require `-Force` and a follow-up `Start-Service MxAccessGw`; verify both Running).
|
||||||
|
|
||||||
|
**Step 4 (test):** Browser-verify the windev OtOpcUa AdminUI logs in as `multi-role` → Administrator. (Locate its dashboard URL during discovery.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 16: Stop/disable the windev-local glauth
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~2 min (operational)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (windev service)
|
||||||
|
|
||||||
|
**Step 1 (only after Tasks 14 + 15 verify green):** `Stop-Service glauth; Set-Service glauth -StartupType Manual` (disable autostart but keep installed for rollback). Keep `C:\publish\glauth\glauth.cfg` + the `glauth.cfg.bak-multirole-20260604` backup in place.
|
||||||
|
|
||||||
|
**Step 2 (test):** Re-run Task 14/15 logins once more to confirm windev auth still works with the local glauth down (proves they're truly on 35).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Final verification + housekeeping
|
||||||
|
|
||||||
|
### Task 17: Full cross-app verification matrix
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (operational)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (operational)
|
||||||
|
|
||||||
|
**Step 1 (positive):** `multi-role`/`password` logs in on all five surfaces — ScadaBridge `:9000` + `:9100` (4 roles via `/auth/token`), OtOpcUa `:9200` (Administrator), MxGateway `10.100.0.48:5130` (Administrator), windev OtOpcUa.
|
||||||
|
|
||||||
|
**Step 2 (role-gating):** `gwreader`/`password` → MxGateway dashboard **Viewer-only** (no API-Keys/Settings admin pages); `designer`/`password` → ScadaBridge design nav but not ADMIN; `otviewer`/`password` → OtOpcUa read-only.
|
||||||
|
|
||||||
|
**Step 3 (negative):** wrong password rejected on every surface; a `SCADA-*`-only user (`designer`) gets **denied** on the MxGateway dashboard (no `Gw*` group). Record each result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18: Update memory, design status, and finalize branches
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Update memory: `multi-role-cross-app-test-user.md` (now backed by the shared 35 GLAuth), `mxgateway-windev-deploy.md` + `scadabridge-local-deploy-gotchas.md` (repointed to 35), add a new `shared-glauth-on-35.md` (the directory layout, gid families, deploy/verify runbook, access caveat) + `MEMORY.md` index lines.
|
||||||
|
- Update: design doc status → "implemented".
|
||||||
|
- (Optional) align repo template appsettings (MxGateway/ScadaBridge) on the `feat/shared-glauth` branches so a clean redeploy doesn't reintroduce old keys.
|
||||||
|
|
||||||
|
**Step 1:** Write the memory updates. **Step 2:** Mark the design doc implemented. **Step 3:** Summarize branch state (scadaproj `docs/shared-glauth-standardization`; app `feat/shared-glauth` branches committed, not merged) and ask the user about merging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution notes
|
||||||
|
- **Phases 1, 2, 3 are independent** after Task 4 (different repos/hosts) — their first tasks (5, 10/11, 14/15) are mutually `Parallelizable`. Within a phase, recreate/verify tasks are sequential.
|
||||||
|
- Old glauths stay up until Tasks 6/16; every repoint is reversible by reverting the one-line `Server` change and recreating/restarting.
|
||||||
|
- Several tasks are **operational** (recreate clusters, live windev, the 35 deploy) — not code-with-unit-tests; their "tests" are the exact `ldapsearch`/`curl`/browser checks given.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-04-shared-glauth-standardization.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 0, "subject": "Task 0: Write merged GLAuth config.toml", "status": "completed"},
|
||||||
|
{"id": 1, "subject": "Task 1: Write GLAuth docker-compose.yml", "status": "completed"},
|
||||||
|
{"id": 2, "subject": "Task 2: Write GLAuth README runbook", "status": "completed"},
|
||||||
|
{"id": 3, "subject": "Task 3: Commit Phase 0 artifacts", "status": "completed", "blockedBy": [0, 1, 2]},
|
||||||
|
{"id": 4, "subject": "Task 4: Deploy to 10.100.0.35 + verify directory (GATE)", "status": "completed", "blockedBy": [3]},
|
||||||
|
{"id": 5, "subject": "Task 5: Repoint 4 ScadaBridge central-node configs", "status": "completed", "blockedBy": [4]},
|
||||||
|
{"id": 6, "subject": "Task 6: Retire scadabridge-ldap + prove OrbStack->35 reachability", "status": "completed", "blockedBy": [4]},
|
||||||
|
{"id": 7, "subject": "Task 7: Recreate :9000 central nodes + browser-verify", "status": "completed", "blockedBy": [5, 6]},
|
||||||
|
{"id": 8, "subject": "Task 8: Recreate :9100 central nodes + verify", "status": "completed", "blockedBy": [7]},
|
||||||
|
{"id": 9, "subject": "Task 9: Commit ScadaBridge edits on feat/shared-glauth", "status": "completed", "blockedBy": [7, 8]},
|
||||||
|
{"id": 10, "subject": "Task 10: Confirm group-key shape + seed OtOpcUa-* mappings", "status": "completed", "blockedBy": [4]},
|
||||||
|
{"id": 11, "subject": "Task 11: Un-stub OtOpcUa docker-dev host containers", "status": "completed", "blockedBy": [4]},
|
||||||
|
{"id": 12, "subject": "Task 12: Apply seed + recreate otopcua-dev + verify", "status": "completed", "blockedBy": [10, 11]},
|
||||||
|
{"id": 13, "subject": "Task 13: Commit OtOpcUa edits on feat/shared-glauth", "status": "completed", "blockedBy": [12]},
|
||||||
|
{"id": 14, "subject": "Task 14: Repoint MxGateway (windev) at shared GLAuth", "status": "completed", "blockedBy": [4]},
|
||||||
|
{"id": 15, "subject": "Task 15: Repoint OtOpcUa (windev) [resolved by discovery: headless OPC server, no LDAP login]", "status": "completed", "blockedBy": [4]},
|
||||||
|
{"id": 16, "subject": "Task 16: Stop/disable windev-local glauth", "status": "completed", "blockedBy": [14, 15]},
|
||||||
|
{"id": 17, "subject": "Task 17: Full cross-app verification matrix", "status": "completed", "blockedBy": [7, 8, 12, 14, 15, 16]},
|
||||||
|
{"id": 18, "subject": "Task 18: Update memory, design status, finalize branches", "status": "completed", "blockedBy": [17, 9, 13]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-04"
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# ZB.MOM.WW.SPHistorianClient — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Status:** Approved — proceeding to implementation plan.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Repackage the proven, pure-managed .NET 10 `AVEVA.Historian.Client` SDK (delivered in
|
||||||
|
`HistorianSDK_2023R2/histsdk-migration.zip` from `10.100.0.48`) as the family-branded shared
|
||||||
|
library **`ZB.MOM.WW.SPHistorianClient`** (System Platform Historian Client), following the same
|
||||||
|
conventions as the other `ZB.MOM.WW.*` shared libraries in this repo.
|
||||||
|
|
||||||
|
## Context — what the source bundle contains
|
||||||
|
|
||||||
|
`histsdk-migration.zip` → `histsdk-migration/`:
|
||||||
|
|
||||||
|
- `histsdk/` — the SDK git repo. `src/AVEVA.Historian.Client/` is a **pure-managed .NET 10** client
|
||||||
|
for AVEVA Historian (no `aahClientManaged.dll` / `aahClient.dll` / native AVEVA runtime — the wire
|
||||||
|
protocol is reverse-engineered and re-implemented in C#). ~165–188 unit + gated-live tests pass.
|
||||||
|
- `analysis-2023r2/` — reverse-engineering analysis (recovered protos, decompiled stock contract,
|
||||||
|
transport writeup). **Kept separate from the repo on purpose.**
|
||||||
|
|
||||||
|
Two transport families exist in the SDK:
|
||||||
|
|
||||||
|
| Transport | Protocol | Platform | Verification |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `LocalPipe`, `RemoteTcpIntegrated`, `RemoteTcpCertificate` | WCF/MDAS (2020) | **Windows-only** | **live-verified**: raw/aggregate(16 modes)/at-time/event reads, browse, metadata, status, `EnsureTag`/`DeleteTag` |
|
||||||
|
| `RemoteGrpc` | gRPC (2023 R2) | cross-platform (Grpc.Net.Client/.Web) | unit-tested; **not yet live-verified** against a real 2023 R2 server (`ExchangeKey` auth step unproven) |
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Approach: port + rebrand.** Copy the SDK source into `ZB.MOM.WW.SPHistorianClient`, rename the
|
||||||
|
root namespace, adopt ZB conventions, bring the unit tests, drop non-shippable artifacts. One
|
||||||
|
coherent shared library — a published package should not ship a third-party (AVEVA) namespace or
|
||||||
|
non-redistributable reverse-engineering artifacts.
|
||||||
|
2. **Transports: both WCF + gRPC.** Ship everything that works. WCF members keep
|
||||||
|
`[SupportedOSPlatform("windows")]`; the gRPC path runs anywhere. No working code discarded.
|
||||||
|
3. **Not a "component normalization."** There is no duplicated historian code across the three apps
|
||||||
|
to converge — this is a net-new shared library that simply follows ZB packaging conventions.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
Plain files committed into this repo (NOT a nested git repo — see the
|
||||||
|
`shared-libs-are-plain-files-not-nested-repos` convention):
|
||||||
|
|
||||||
|
```
|
||||||
|
ZB.MOM.WW.SPHistorianClient/
|
||||||
|
Directory.Build.props # net10.0, Nullable, ImplicitUsings, LangVersion latest, Version 0.1.0, central pkg mgmt
|
||||||
|
Directory.Packages.props # central PackageVersion entries
|
||||||
|
ZB.MOM.WW.SPHistorianClient.slnx
|
||||||
|
CLAUDE.md README.md .gitignore
|
||||||
|
src/ZB.MOM.WW.SPHistorianClient/ # the single package
|
||||||
|
HistorianClient.cs, HistorianClientOptions.cs, HistorianTransport.cs
|
||||||
|
Models/ Protocol/ Transport/ Wcf/ Wcf/Contracts/ Grpc/ Grpc/Protos/*.proto
|
||||||
|
DependencyInjection/AddZbSpHistorianClient (ZB-idiomatic DI extension)
|
||||||
|
tests/ZB.MOM.WW.SPHistorianClient.Tests/ # offline unit/golden-byte + gated-live integration
|
||||||
|
artifacts/ # dotnet pack output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port mechanics
|
||||||
|
|
||||||
|
- Copy `src/AVEVA.Historian.Client/` and `tests/AVEVA.Historian.Client.Tests/` from the bundle.
|
||||||
|
- Rename the C# root namespace `AVEVA.Historian.Client` → `ZB.MOM.WW.SPHistorianClient` across all
|
||||||
|
files: 74 `namespace` declarations spanning the root + 6 sub-namespaces
|
||||||
|
(`.Models`, `.Wcf`, `.Wcf.Contracts`, `.Protocol`, `.Transport`, `.Grpc`), all `using` directives,
|
||||||
|
and the `InternalsVisibleTo` to the test assembly. Drop the `InternalsVisibleTo` to
|
||||||
|
`AVEVA.Historian.ReverseEngineering` (tool not shipped).
|
||||||
|
- **Leave the proto wire contracts untouched:** the 6 `Grpc/Protos/*.proto` keep
|
||||||
|
`option csharp_namespace = "ArchestrA.Grpc.Contract.*"` — that is AVEVA's wire contract, not ours.
|
||||||
|
`Grpc.Tools` keeps generating the client stubs at build.
|
||||||
|
- Convert inline `PackageReference` versions to central management in `Directory.Packages.props`,
|
||||||
|
matching the `ZB.MOM.WW.Telemetry` template.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Library:** `Google.Protobuf`, `Grpc.Net.Client`, `Grpc.Net.Client.Web`, `Grpc.Tools` (build-only,
|
||||||
|
`PrivateAssets=all`), `System.ServiceModel.NetNamedPipe`, `System.ServiceModel.NetTcp`,
|
||||||
|
`System.Security.Cryptography.Xml`. Add `Microsoft.Extensions.DependencyInjection.Abstractions` +
|
||||||
|
`Microsoft.Extensions.Options` for the DI extension.
|
||||||
|
- **Tests:** `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `coverlet.collector`,
|
||||||
|
`Microsoft.Data.SqlClient` (SQL post-check tests).
|
||||||
|
|
||||||
|
## Excluded (safety / non-redistributable / Windows-native)
|
||||||
|
|
||||||
|
- `tools/` reverse-engineering harnesses (.NET Framework, reference native AVEVA binaries).
|
||||||
|
- `analysis-2023r2/decompiled/` — proprietary AVEVA decompilations (not redistributable).
|
||||||
|
- `scripts/` — Frida / PowerShell / Python capture tooling.
|
||||||
|
- `docs/reverse-engineering/` — identity-bearing `.ndjson` / capture evidence.
|
||||||
|
|
||||||
|
**Kept:** the recovered `.proto` files (needed to build), the offline unit tests, and a sanitized
|
||||||
|
architecture/surface summary folded into `CLAUDE.md` / `README.md`. `.gitignore` blocks the
|
||||||
|
identity-bearing patterns (`*.ndjson`, `current/`, `aveva-install-*/`, `artifacts/`-raw, etc.).
|
||||||
|
|
||||||
|
## Public surface (preserved 1:1)
|
||||||
|
|
||||||
|
`HistorianClient` + `HistorianClientOptions` façade; `Models/*`; `HistorianTransport` enum
|
||||||
|
(`LocalPipe` / `RemoteTcpIntegrated` / `RemoteTcpCertificate` / `RemoteGrpc`); operations:
|
||||||
|
`ProbeAsync`, `ReadRawAsync` / `ReadAggregateAsync` / `ReadAtTimeAsync`, `ReadEventsAsync`,
|
||||||
|
`BrowseTagNamesAsync`, `GetTagMetadataAsync`, status calls, `EnsureTagAsync` / `DeleteTagAsync`.
|
||||||
|
|
||||||
|
**One ZB-idiomatic addition:** `AddZbSpHistorianClient(...)` DI extension mirroring `AddZbTelemetry`
|
||||||
|
— thin: binds `HistorianClientOptions` and registers `HistorianClient`. Optional to consumers.
|
||||||
|
|
||||||
|
## Cross-platform & testing posture
|
||||||
|
|
||||||
|
- WCF members already carry `[SupportedOSPlatform("windows")]`; the library builds and unit-tests on
|
||||||
|
macOS/Linux. gRPC path is portable.
|
||||||
|
- Offline unit/golden-byte tests run anywhere. Live integration tests stay gated by `HISTORIAN_*`
|
||||||
|
env vars and skip cleanly when unset.
|
||||||
|
- Verify `dotnet build` + `dotnet test` pass locally (macOS) before finishing.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
`dotnet pack -c Release -o ./artifacts` → `ZB.MOM.WW.SPHistorianClient.0.1.0.nupkg`. Gitea URLs in
|
||||||
|
package metadata. **Not pushed/published** to any feed unless explicitly requested.
|
||||||
|
|
||||||
|
## Out of scope (this pass)
|
||||||
|
|
||||||
|
- Wiring `ZB.MOM.WW.SPHistorianClient` into any consumer (e.g. OtOpcUa Phase C HistoryRead) — a
|
||||||
|
separate follow-on.
|
||||||
|
- Live-verifying the gRPC `RemoteGrpc` path against a real 2023 R2 server.
|
||||||
|
- Writing samples (`AddS2`) — architecturally blocked in the source SDK; remains out of scope.
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
# ZB.MOM.WW.SPHistorianClient Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Repackage the proven, pure-managed .NET 10 `AVEVA.Historian.Client` SDK from the migration bundle as the family-branded shared library `ZB.MOM.WW.SPHistorianClient`, following the same conventions as the other `ZB.MOM.WW.*` libraries in this repo.
|
||||||
|
|
||||||
|
**Architecture:** This is a **port + rebrand**, not a rewrite. Copy the SDK `src/` and `tests/` into a new `ZB.MOM.WW.SPHistorianClient/` directory, rewrite the C# root namespace `AVEVA.Historian.Client` → `ZB.MOM.WW.SPHistorianClient` (leaving the proto-generated `ArchestrA.Grpc.Contract.*` wire contracts untouched), adopt ZB conventions (`Directory.Build.props` / `Directory.Packages.props` central package management, `.slnx`, `CLAUDE.md`/`README.md`), drop the non-shippable reverse-engineering tooling and proprietary decompilations, add one ZB-idiomatic DI extension, then build/test/pack.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, C# (net10.0), WCF/MDAS (`System.ServiceModel.*`, Windows-only transports), gRPC (`Grpc.Net.Client` + `Grpc.Tools`, cross-platform 2023 R2 transport), xUnit. Central package management.
|
||||||
|
|
||||||
|
**Design doc:** `docs/plans/2026-06-19-sphistorianclient-design.md`
|
||||||
|
|
||||||
|
**Branch:** `feat/sphistorianclient` (already created; design doc already committed at `bbb7942`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source bundle location (read-only inputs)
|
||||||
|
|
||||||
|
The SDK source lives in an extracted bundle under `/tmp`:
|
||||||
|
|
||||||
|
- Extracted root: `/tmp/histsdk/extracted/histsdk-migration/histsdk/`
|
||||||
|
- SDK source: `…/histsdk/src/AVEVA.Historian.Client/` — **74 `.cs` + 6 `.proto`**
|
||||||
|
- SDK tests: `…/histsdk/tests/AVEVA.Historian.Client.Tests/` — **25 `.cs`**
|
||||||
|
- Re-extract fallback (if `/tmp` was cleaned): `cd /tmp/histsdk && unzip -o -q histsdk-migration.zip -d extracted`
|
||||||
|
|
||||||
|
**Never copy:** `tools/` (RE harnesses, .NET Framework + native AVEVA refs), `analysis-2023r2/decompiled/` (proprietary, non-redistributable), `scripts/`, `docs/reverse-engineering/` (identity-bearing captures), `bin/`/`obj/`, the bundle's `.git/`, and the bundle's original `.csproj` files (we author fresh ZB ones).
|
||||||
|
|
||||||
|
**Gotchas baked into this plan (from prior repo experience):**
|
||||||
|
- Do **not** set `TreatWarningsAsErrors` — the WCF/SSPI code carries `[SupportedOSPlatform("windows")]` and will emit CA platform warnings on macOS that must stay warnings.
|
||||||
|
- Central package management means **no inline `Version=` on any `PackageReference`** (that is `NU1008`). All versions live in `Directory.Packages.props`.
|
||||||
|
- `Microsoft.Data.SqlClient` may surface an `NU1903` advisory on restore. Without `TreatWarningsAsErrors` it is a warning. If a restore ever hard-fails on it, add `-p:NuGetAudit=false` to the build/test command.
|
||||||
|
- macOS `sed -i` requires an explicit empty backup arg: `sed -i '' 's/…/…/g'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold the library skeleton
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (every later task depends on this)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/Directory.Build.props`
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/Directory.Packages.props`
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/.gitignore`
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/ZB.MOM.WW.SPHistorianClient.slnx`
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/ZB.MOM.WW.SPHistorianClient.csproj`
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/ZB.MOM.WW.SPHistorianClient.Tests.csproj`
|
||||||
|
|
||||||
|
**Step 1: `Directory.Build.props`** (mirrors `ZB.MOM.WW.Telemetry/Directory.Build.props`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: `Directory.Packages.props`** (versions lifted verbatim from the bundle's two `.csproj` files)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Historian SDK runtime deps (WCF/MDAS transports — Windows-only at runtime) -->
|
||||||
|
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
|
||||||
|
<PackageVersion Include="System.ServiceModel.NetNamedPipe" Version="10.0.652802" />
|
||||||
|
<PackageVersion Include="System.ServiceModel.NetTcp" Version="10.0.652802" />
|
||||||
|
|
||||||
|
<!-- 2023 R2 gRPC transport (cross-platform) -->
|
||||||
|
<PackageVersion Include="Google.Protobuf" Version="3.24.4" />
|
||||||
|
<PackageVersion Include="Grpc.Net.Client" Version="2.58.0" />
|
||||||
|
<PackageVersion Include="Grpc.Net.Client.Web" Version="2.58.0" />
|
||||||
|
<PackageVersion Include="Grpc.Tools" Version="2.59.0" />
|
||||||
|
|
||||||
|
<!-- ZB-idiomatic DI extension (only non-BCL lib dependency) -->
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: `.gitignore`**
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
# identity-bearing / non-redistributable — never commit
|
||||||
|
*.ndjson
|
||||||
|
current/
|
||||||
|
aveva-install-*/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: `ZB.MOM.WW.SPHistorianClient.slnx`**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/ZB.MOM.WW.SPHistorianClient/ZB.MOM.WW.SPHistorianClient.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/ZB.MOM.WW.SPHistorianClient.Tests/ZB.MOM.WW.SPHistorianClient.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: `src/ZB.MOM.WW.SPHistorianClient/ZB.MOM.WW.SPHistorianClient.csproj`**
|
||||||
|
|
||||||
|
(Derived from the bundle's `AVEVA.Historian.Client.csproj`: inline versions removed for central
|
||||||
|
management; ZB package metadata added; `InternalsVisibleTo` retargeted to the ZB test assembly and
|
||||||
|
the `…ReverseEngineering` one dropped; proto glob uses forward slashes for cross-platform MSBuild.)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageId>ZB.MOM.WW.SPHistorianClient</PackageId>
|
||||||
|
<Authors>ZB.MOM.WW</Authors>
|
||||||
|
<Description>Pure-managed .NET 10 client for AVEVA System Platform Historian (Wonderware) for the ZB.MOM.WW SCADA family. The wire protocol is reverse-engineered and re-implemented in C# — no native AVEVA runtime dependency. Surfaces history reads (raw / aggregate / at-time / event), tag browse + metadata, status, and tag create/delete over the WCF/MDAS transports (Windows) plus a cross-platform gRPC transport for 2023 R2.</Description>
|
||||||
|
<PackageTags>aveva;wonderware;historian;system-platform;scada;timeseries;grpc;wcf;zb-mom-ww</PackageTags>
|
||||||
|
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-sphistorianclient</PackageProjectUrl>
|
||||||
|
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-sphistorianclient</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Security.Cryptography.Xml" />
|
||||||
|
<PackageReference Include="System.ServiceModel.NetNamedPipe" />
|
||||||
|
<PackageReference Include="System.ServiceModel.NetTcp" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- 2023 R2 gRPC transport (RemoteGrpc). Pure-managed: Grpc.Net.Client + Google.Protobuf.
|
||||||
|
Grpc.Tools is build-only (PrivateAssets=all) and generates the client stubs from the
|
||||||
|
recovered contract under Grpc/Protos at build. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Google.Protobuf" />
|
||||||
|
<PackageReference Include="Grpc.Net.Client" />
|
||||||
|
<PackageReference Include="Grpc.Net.Client.Web" />
|
||||||
|
<PackageReference Include="Grpc.Tools">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Protobuf Include="Grpc/Protos/*.proto" GrpcServices="Client" ProtoRoot="Grpc/Protos" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>ZB.MOM.WW.SPHistorianClient.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: `tests/ZB.MOM.WW.SPHistorianClient.Tests/ZB.MOM.WW.SPHistorianClient.Tests.csproj`**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.SPHistorianClient\ZB.MOM.WW.SPHistorianClient.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 7: Verify the skeleton is well-formed (build will fail — no sources yet — that is expected)**
|
||||||
|
|
||||||
|
Run: `cd ZB.MOM.WW.SPHistorianClient && dotnet restore ZB.MOM.WW.SPHistorianClient.slnx`
|
||||||
|
Expected: restore **succeeds** (proves the props/csproj XML and central package versions resolve). A
|
||||||
|
follow-up `dotnet build` would fail only because no `.cs` exist yet — do not build here.
|
||||||
|
|
||||||
|
**Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/scadaproj
|
||||||
|
git add ZB.MOM.WW.SPHistorianClient/
|
||||||
|
git commit -m "feat(sphistorianclient): scaffold shared library skeleton (props, csprojs, slnx)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Port source + tests with namespace rewrite
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
**Blocked by:** Task 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create (scripted copy): `ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/**/*.{cs,proto}` (74 `.cs` + 6 `.proto`)
|
||||||
|
- Create (scripted copy): `ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/**/*.cs` (25 `.cs`)
|
||||||
|
|
||||||
|
This task is a deterministic copy + namespace rewrite — run the script, then verify counts.
|
||||||
|
|
||||||
|
**Step 1: Copy + rewrite (single script)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
BUNDLE=/tmp/histsdk/extracted/histsdk-migration/histsdk
|
||||||
|
DEST=/Users/dohertj2/Desktop/scadaproj/ZB.MOM.WW.SPHistorianClient
|
||||||
|
|
||||||
|
# Guard: re-extract if /tmp was cleaned
|
||||||
|
if [ ! -d "$BUNDLE/src/AVEVA.Historian.Client" ]; then
|
||||||
|
cd /tmp/histsdk && unzip -o -q histsdk-migration.zip -d extracted
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- src: copy .cs + .proto, preserving subdirs (NOT the old .csproj) ---
|
||||||
|
SRC="$DEST/src/ZB.MOM.WW.SPHistorianClient"
|
||||||
|
cd "$BUNDLE/src/AVEVA.Historian.Client"
|
||||||
|
find . \( -name '*.cs' -o -name '*.proto' \) | while read -r f; do
|
||||||
|
mkdir -p "$SRC/$(dirname "$f")"
|
||||||
|
cp "$f" "$SRC/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- tests: copy .cs only (NOT the old .csproj) ---
|
||||||
|
TST="$DEST/tests/ZB.MOM.WW.SPHistorianClient.Tests"
|
||||||
|
cd "$BUNDLE/tests/AVEVA.Historian.Client.Tests"
|
||||||
|
find . -name '*.cs' | while read -r f; do
|
||||||
|
mkdir -p "$TST/$(dirname "$f")"
|
||||||
|
cp "$f" "$TST/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- namespace rewrite in .cs ONLY (proto wire contracts stay ArchestrA.Grpc.Contract.*) ---
|
||||||
|
find "$SRC" "$TST" -name '*.cs' -print0 \
|
||||||
|
| xargs -0 sed -i '' 's/AVEVA\.Historian\.Client/ZB.MOM.WW.SPHistorianClient/g'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify counts and that the rename is total**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEST=/Users/dohertj2/Desktop/scadaproj/ZB.MOM.WW.SPHistorianClient
|
||||||
|
echo "src cs: $(find "$DEST/src" -name '*.cs' | wc -l) (expect 74)"
|
||||||
|
echo "src proto: $(find "$DEST/src" -name '*.proto' | wc -l) (expect 6)"
|
||||||
|
echo "test cs: $(find "$DEST/tests" -name '*.cs' | wc -l) (expect 25)"
|
||||||
|
echo "leftover AVEVA.Historian.Client in .cs: $(grep -rl 'AVEVA\.Historian\.Client' "$DEST" --include='*.cs' | wc -l) (expect 0)"
|
||||||
|
echo "proto namespace preserved: $(grep -l 'ArchestrA.Grpc.Contract' "$DEST"/src/ZB.MOM.WW.SPHistorianClient/Grpc/Protos/*.proto | wc -l) (expect 6)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `74`, `6`, `25`, `0`, `6`. If "leftover" is non-zero, inspect those files — the only legitimate
|
||||||
|
remaining mentions would be inside comments/strings that happen to differ in casing/spacing; a clean
|
||||||
|
port should show `0`.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/scadaproj
|
||||||
|
git add ZB.MOM.WW.SPHistorianClient/src ZB.MOM.WW.SPHistorianClient/tests
|
||||||
|
git commit -m "feat(sphistorianclient): port SDK source + tests, rebrand namespace to ZB.MOM.WW.SPHistorianClient"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Build + test green
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min (plus restore/build wall-time)
|
||||||
|
**Parallelizable with:** none
|
||||||
|
**Blocked by:** Task 2
|
||||||
|
|
||||||
|
This is the integration gate. The port must compile and the offline test suite must pass on this macOS host.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify (only if the build surfaces a defect): any ported file under `ZB.MOM.WW.SPHistorianClient/src` or `…/tests`, or the two `.csproj`.
|
||||||
|
|
||||||
|
**Step 1: Build**
|
||||||
|
|
||||||
|
Run: `cd ZB.MOM.WW.SPHistorianClient && dotnet build ZB.MOM.WW.SPHistorianClient.slnx`
|
||||||
|
Expected: **Build succeeded.** Platform-compatibility (CAxxxx `[SupportedOSPlatform("windows")]`) warnings
|
||||||
|
are acceptable and must remain warnings. If restore hard-fails on `NU1903`, re-run with
|
||||||
|
`-p:NuGetAudit=false`.
|
||||||
|
|
||||||
|
**Step 2: Test**
|
||||||
|
|
||||||
|
Run: `dotnet test ZB.MOM.WW.SPHistorianClient.slnx`
|
||||||
|
Expected: all tests pass; the live integration tests (`HistorianClientIntegrationTests`,
|
||||||
|
`HistorianGrpcIntegrationTests`, `RemoteTcpIntegrationTests`) **skip cleanly** because no `HISTORIAN_*`
|
||||||
|
env vars are set. The bundle's `MIGRATION-README.md` documents ~188 tests passing on macOS with the
|
||||||
|
live ones skipped — treat a comparable count with **zero failures** as success.
|
||||||
|
|
||||||
|
**Step 3: Triage rules (if not green)**
|
||||||
|
- Compile error referencing `AVEVA.Historian.Client` → a file was missed by the rewrite; re-run the
|
||||||
|
Task 2 sed on that file.
|
||||||
|
- `NU1008` (version on PackageReference) → an inline `Version=` slipped into a `.csproj`; remove it
|
||||||
|
(version belongs in `Directory.Packages.props`).
|
||||||
|
- Missing generated gRPC type (e.g. `ArchestrA.Grpc.Contract.*` not found) → confirm the `<Protobuf>`
|
||||||
|
glob in the src `.csproj` resolves the 6 `Grpc/Protos/*.proto` and that `Grpc.Tools` restored.
|
||||||
|
- A genuine test failure (not a skip) → this is a real port defect; fix the ported code, do **not**
|
||||||
|
delete/weaken the test.
|
||||||
|
|
||||||
|
**Step 4: Commit (only if Step 3 required edits)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A ZB.MOM.WW.SPHistorianClient/
|
||||||
|
git commit -m "fix(sphistorianclient): resolve port build/test fallout"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add the `AddZbSpHistorianClient` DI extension (TDD)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 5
|
||||||
|
**Blocked by:** Task 3
|
||||||
|
|
||||||
|
`HistorianClientOptions` uses `required` + `init`-only properties, so the extension takes a fully-built
|
||||||
|
options instance (not an `Action<T>` configurator). It depends only on
|
||||||
|
`Microsoft.Extensions.DependencyInjection.Abstractions`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/DependencyInjection/ZbSpHistorianClientServiceCollectionExtensions.cs`
|
||||||
|
- Test: `ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/DependencyInjectionTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.SPHistorianClient;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.SPHistorianClient.Tests;
|
||||||
|
|
||||||
|
public class DependencyInjectionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AddZbSpHistorianClient_resolves_client_and_options()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var options = new HistorianClientOptions { Host = "localhost" };
|
||||||
|
|
||||||
|
services.AddZbSpHistorianClient(options);
|
||||||
|
|
||||||
|
using var sp = services.BuildServiceProvider();
|
||||||
|
Assert.Same(options, sp.GetRequiredService<HistorianClientOptions>());
|
||||||
|
Assert.NotNull(sp.GetRequiredService<HistorianClient>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddZbSpHistorianClient_throws_when_host_missing()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var options = new HistorianClientOptions { Host = "" };
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(() => services.AddZbSpHistorianClient(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddZbSpHistorianClient_throws_on_null_options()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
Assert.Throws<ArgumentNullException>(() => services.AddZbSpHistorianClient(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run — verify it fails to compile** (`AddZbSpHistorianClient` not defined)
|
||||||
|
|
||||||
|
Run: `dotnet test ZB.MOM.WW.SPHistorianClient.slnx --filter "FullyQualifiedName~DependencyInjectionTests"`
|
||||||
|
Expected: FAIL (does not compile / method missing).
|
||||||
|
|
||||||
|
**Step 3: Implement**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.SPHistorianClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ZB.MOM.WW DI registration for <see cref="HistorianClient"/>. Mirrors the family's
|
||||||
|
/// <c>AddZb*</c> convention. Because <see cref="HistorianClientOptions"/> is <c>required</c>/
|
||||||
|
/// <c>init</c>-only, callers pass a fully-built options instance (bind it from configuration in the
|
||||||
|
/// consuming app, e.g. <c>config.GetSection("Historian").Get<HistorianClientOptions>()</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static class ZbSpHistorianClientServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddZbSpHistorianClient(
|
||||||
|
this IServiceCollection services,
|
||||||
|
HistorianClientOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Host))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"HistorianClientOptions.Host must be set.", nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton(options);
|
||||||
|
// HistorianClient opens a fresh channel per operation and has a no-op DisposeAsync,
|
||||||
|
// so transient is safe and avoids assuming the shared dialect is concurrency-safe.
|
||||||
|
services.AddTransient<HistorianClient>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run — verify pass**
|
||||||
|
|
||||||
|
Run: `dotnet test ZB.MOM.WW.SPHistorianClient.slnx --filter "FullyQualifiedName~DependencyInjectionTests"`
|
||||||
|
Expected: PASS (3/3).
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ZB.MOM.WW.SPHistorianClient/src/ZB.MOM.WW.SPHistorianClient/DependencyInjection \
|
||||||
|
ZB.MOM.WW.SPHistorianClient/tests/ZB.MOM.WW.SPHistorianClient.Tests/DependencyInjectionTests.cs
|
||||||
|
git commit -m "feat(sphistorianclient): add AddZbSpHistorianClient DI extension"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Author `CLAUDE.md` + `README.md`
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 4
|
||||||
|
**Blocked by:** Task 3
|
||||||
|
|
||||||
|
Sanitized docs only — **no hostnames, credentials, customer tag names, or capture data.** Model the
|
||||||
|
structure on `ZB.MOM.WW.Telemetry/CLAUDE.md` (overview, package table, build/test/pack commands,
|
||||||
|
status) but adapt to a single-package library.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/CLAUDE.md`
|
||||||
|
- Create: `ZB.MOM.WW.SPHistorianClient/README.md`
|
||||||
|
|
||||||
|
**`CLAUDE.md` must cover:**
|
||||||
|
- One-paragraph overview: pure-managed .NET 10 AVEVA System Platform Historian client, no native AVEVA
|
||||||
|
dependency, reverse-engineered wire protocol. Ported from the `histsdk` migration bundle.
|
||||||
|
- The supported operation surface table (copy the README table from the bundle:
|
||||||
|
`ProbeAsync`, `ReadRawAsync`, `ReadAggregateAsync` (16 modes), `ReadAtTimeAsync`, `ReadEventsAsync`,
|
||||||
|
`BrowseTagNamesAsync`, `GetTagMetadataAsync`, `GetConnectionStatusAsync`,
|
||||||
|
`GetStoreForwardStatusAsync`, `GetSystemParameterAsync`, `EnsureTagAsync`, `DeleteTagAsync`).
|
||||||
|
- Transport matrix: `LocalPipe` / `RemoteTcpIntegrated` / `RemoteTcpCertificate` (WCF, Windows-only,
|
||||||
|
live-verified) vs `RemoteGrpc` (2023 R2, cross-platform, **not yet live-verified**).
|
||||||
|
- Out of scope: writing samples (`AddS2` architecturally blocked), discrete/string tag creation.
|
||||||
|
- DI: the `AddZbSpHistorianClient(options)` extension + the bind-from-config note.
|
||||||
|
- Build/test/pack commands (from this dir):
|
||||||
|
`dotnet build ZB.MOM.WW.SPHistorianClient.slnx` / `dotnet test …` /
|
||||||
|
`dotnet pack ZB.MOM.WW.SPHistorianClient.slnx -c Release -o ./artifacts`.
|
||||||
|
- Live integration tests gated by `HISTORIAN_*` env vars (skip cleanly when unset). List the env vars.
|
||||||
|
|
||||||
|
**`README.md`:** a trimmed public-facing version — overview, quick-start snippet (the bundle's
|
||||||
|
`HistorianClient` usage example, namespace updated to `ZB.MOM.WW.SPHistorianClient`), supported surface
|
||||||
|
table, build/test commands.
|
||||||
|
|
||||||
|
**Commit:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ZB.MOM.WW.SPHistorianClient/CLAUDE.md ZB.MOM.WW.SPHistorianClient/README.md
|
||||||
|
git commit -m "docs(sphistorianclient): add CLAUDE.md + README.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Pack verification
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
**Blocked by:** Task 4, Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create (build output): `ZB.MOM.WW.SPHistorianClient/artifacts/ZB.MOM.WW.SPHistorianClient.0.1.0.nupkg`
|
||||||
|
|
||||||
|
**Step 1: Full green build + test once more, then pack**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/scadaproj/ZB.MOM.WW.SPHistorianClient
|
||||||
|
dotnet test ZB.MOM.WW.SPHistorianClient.slnx
|
||||||
|
dotnet pack ZB.MOM.WW.SPHistorianClient.slnx -c Release -o ./artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: tests pass (live ones skip); pack produces `artifacts/ZB.MOM.WW.SPHistorianClient.0.1.0.nupkg`.
|
||||||
|
|
||||||
|
**Step 2: Sanity-check the package contents**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip -l artifacts/ZB.MOM.WW.SPHistorianClient.0.1.0.nupkg | grep -E 'ZB.MOM.WW.SPHistorianClient.dll|.nuspec'
|
||||||
|
```
|
||||||
|
Expected: the lib DLL and nuspec are present.
|
||||||
|
|
||||||
|
**Step 3: Commit the nupkg** (matches the family convention — `ZB.MOM.WW.Telemetry` commits its `artifacts/*.nupkg`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/scadaproj
|
||||||
|
git add -f ZB.MOM.WW.SPHistorianClient/artifacts/ZB.MOM.WW.SPHistorianClient.0.1.0.nupkg
|
||||||
|
git commit -m "build(sphistorianclient): pack 0.1.0 nupkg"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Do NOT push or publish** to the Gitea feed. Per repo experience, "published/adopted" claims must
|
||||||
|
> not be made without explicit user direction + feed verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Index the new library in the umbrella `CLAUDE.md` (optional)
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
**Blocked by:** Task 6
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (repo root umbrella index)
|
||||||
|
|
||||||
|
Add a short reference so the umbrella index reflects the newly-hosted library (the intro paragraph
|
||||||
|
that enumerates the hosted `ZB.MOM.WW.*` sources, and/or a one-line pointer near the component table
|
||||||
|
noting `ZB.MOM.WW.SPHistorianClient` is a net-new shared library — **not** a component normalization).
|
||||||
|
|
||||||
|
> **Caveat:** repo-root `CLAUDE.md` already has **pre-existing uncommitted edits** (unrelated to this
|
||||||
|
> work). Before editing, run `git diff CLAUDE.md` and make sure your commit message reflects that it
|
||||||
|
> may bundle those edits — or stage only the hunks you add. If this risks entangling unrelated changes,
|
||||||
|
> skip this task and leave it for the user.
|
||||||
|
|
||||||
|
**Commit:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: index ZB.MOM.WW.SPHistorianClient in umbrella CLAUDE.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
|
||||||
|
- `ZB.MOM.WW.SPHistorianClient/` exists with `src/`, `tests/`, props, `.slnx`, `CLAUDE.md`, `README.md`.
|
||||||
|
- `dotnet build` + `dotnet test` are green on macOS (live integration tests skip cleanly).
|
||||||
|
- `AddZbSpHistorianClient` DI extension present + tested.
|
||||||
|
- `artifacts/ZB.MOM.WW.SPHistorianClient.0.1.0.nupkg` produced.
|
||||||
|
- All work committed on `feat/sphistorianclient`. Not pushed/published.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-19-sphistorianclient.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 1: Scaffold library skeleton", "status": "completed"},
|
||||||
|
{"id": 2, "subject": "Task 2: Port source + tests with namespace rewrite", "status": "completed", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 3: Build + test green", "status": "completed", "blockedBy": [2]},
|
||||||
|
{"id": 4, "subject": "Task 4: Add AddZbSpHistorianClient DI extension (TDD)", "status": "completed", "blockedBy": [3]},
|
||||||
|
{"id": 5, "subject": "Task 5: Author CLAUDE.md + README.md", "status": "completed", "blockedBy": [3]},
|
||||||
|
{"id": 6, "subject": "Task 6: Pack verification", "status": "completed", "blockedBy": [4, 5]},
|
||||||
|
{"id": 7, "subject": "Task 7: Index new lib in umbrella CLAUDE.md (optional)", "status": "completed", "blockedBy": [6]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-19"
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# ZB.MOM.WW.HistorianGateway — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-23
|
||||||
|
**Status:** Design approved (brainstorming complete) — ready for implementation planning
|
||||||
|
**Author:** brainstorming session (Joseph Doherty + Claude)
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
A new **full-feature sidecar** in the SCADA/OT sister-project family, modelled on
|
||||||
|
`MxAccessGateway` (`mxaccessgw`). It does two things:
|
||||||
|
|
||||||
|
1. **Read-only Galaxy metadata server** — exposes the AVEVA System Platform
|
||||||
|
("Wonderware") **Galaxy object hierarchy** (areas / objects / templates /
|
||||||
|
instances / attributes), sourced from the **Galaxy Repository SQL DB**, the same
|
||||||
|
data `mxaccessgw`'s `galaxy_repository` feature serves today.
|
||||||
|
2. **Full read/write gRPC API to the AVEVA (Wonderware) Historian** — reads (raw,
|
||||||
|
aggregate with all 15 retrieval modes, at-time, blocks, events, browse, metadata,
|
||||||
|
status) **and writes** (historical/backfill values, event send, tag-config
|
||||||
|
create/delete/rename/extended-properties, plus resilience helpers).
|
||||||
|
|
||||||
|
It reuses the family's **common shared packages and styles**: `ZB.MOM.WW.Auth`,
|
||||||
|
`ZB.MOM.WW.Theme`, `ZB.MOM.WW.Telemetry`(+`.Serilog`), `ZB.MOM.WW.Health`,
|
||||||
|
`ZB.MOM.WW.Configuration`, `ZB.MOM.WW.Audit`.
|
||||||
|
|
||||||
|
### Key reframing discovered during brainstorming
|
||||||
|
|
||||||
|
- The historian write surface lives in the **`histsdk`** repo
|
||||||
|
(`gitea.dohertylan.com/dohertj2/histsdk`, namespace `AVEVA.Historian.Client`),
|
||||||
|
which is **far ahead** of the stale `scadaproj/ZB.MOM.WW.SPHistorianClient` port
|
||||||
|
(2026-06-19 snapshot, reads + tag create/delete only, value-writes marked
|
||||||
|
"blocked"). `histsdk` has since added a **live-validated gRPC write surface**:
|
||||||
|
`AddHistoricalValuesAsync` (historical/backfill, gRPC-only), `SendEventAsync`
|
||||||
|
(events, both transports), `EnsureTags`/`DeleteTags`/`RenameTags`/
|
||||||
|
`AddTagExtendedProperties` (config writes, gRPC), plus higher-level
|
||||||
|
`HistorianStoreForwardWriter` (durable outbox) and a redundant-write cluster.
|
||||||
|
- **Hard server-side limit no client can lift:** `AddS2` *streaming live
|
||||||
|
process-sample* writes are GATED — the historian runtime cache only ingests from
|
||||||
|
configured IOServer / Application Server pipelines. "Live current value" writes are
|
||||||
|
therefore done via the **SQL path** (`aaAnalogTagInsert` → `INSERT INTO History`),
|
||||||
|
not gRPC.
|
||||||
|
- **No COM anywhere.** The historian SDK is pure-managed and Galaxy browse is plain
|
||||||
|
SQL, so — unlike `mxaccessgw` — this sidecar needs **no x86 worker and no
|
||||||
|
two-process split**. It is a single .NET 10 x64 process.
|
||||||
|
|
||||||
|
## 2. Decisions (locked during brainstorming)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Purpose | **General-purpose gateway** — reusable modern façade over Historian + Galaxy metadata for any gRPC client (the way `mxaccessgw` is the façade for MXAccess). |
|
||||||
|
| Historian client code | **Vendor `histsdk`** (`AVEVA.Historian.Client`) into the sidecar repo as self-contained vendored source; namespace kept as-is to ease future re-sync. |
|
||||||
|
| Galaxy browse code | **New shared lib `ZB.MOM.WW.GalaxyRepository`** in scadaproj, extracted from `mxaccessgw`'s `galaxy_repository` browse; consumed by both `mxaccessgw` and this sidecar. |
|
||||||
|
| Dashboard | **Full Blazor dashboard** on `ZB.MOM.WW.Theme` (login + Galaxy browser + Historian console + API-key admin + status/health). |
|
||||||
|
| Historian write scope | **All:** historical/backfill writes, event send, tag-config writes, **and** resilience extras (store-and-forward outbox, redundant write fan-out, SQL live-value path). |
|
||||||
|
| Connection model | **Approach A — stateless gateway over pooled service-identity connections.** Clients authenticate to the gateway (ZB API key); the gateway owns historian credentials and reuses pooled, pre-authenticated connections. |
|
||||||
|
| Repo location | **New standalone sibling repo** `~/Desktop/HistorianGateway`, gitea remote `historiangw`. |
|
||||||
|
| Name / namespace | **`ZB.MOM.WW.HistorianGateway`**. |
|
||||||
|
|
||||||
|
## 3. Architecture & solution structure
|
||||||
|
|
||||||
|
Single .NET 10 x64 ASP.NET Core process.
|
||||||
|
|
||||||
|
```
|
||||||
|
~/Desktop/HistorianGateway/
|
||||||
|
src/
|
||||||
|
ZB.MOM.WW.HistorianGateway.Server/ ASP.NET Core host: gRPC services + Blazor dashboard + /healthz + /metrics
|
||||||
|
ZB.MOM.WW.HistorianGateway.Contracts/ the gateway's own .proto + generated types (for client codegen distribution)
|
||||||
|
vendor/AVEVA.Historian.Client/ VENDORED from histsdk; ArchestrA.Grpc.Contract.* protos + reads/writes/store-forward/redundancy
|
||||||
|
tests/
|
||||||
|
ZB.MOM.WW.HistorianGateway.Tests/ unit + env-gated live integration + bUnit dashboard
|
||||||
|
docs/plans/, CLAUDE.md, README.md
|
||||||
|
ZB.MOM.WW.HistorianGateway.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cross-repo pieces:**
|
||||||
|
|
||||||
|
- **`scadaproj/ZB.MOM.WW.GalaxyRepository`** (new shared lib, plain files — NOT a
|
||||||
|
nested git repo): carries the **canonical `galaxy_repository.proto`** (adopted from
|
||||||
|
`mxaccessgw`'s existing contract so OtOpcUa's wire shape is not broken), the SQL
|
||||||
|
browse provider (connect to Galaxy Repository SQL → hierarchy model), and a
|
||||||
|
reusable gRPC service implementation both hosts can `MapGrpcService<>()`.
|
||||||
|
`mxaccessgw` adopting it is a **tracked follow-on** (same "built → adopted" pattern
|
||||||
|
as the other normalized components); this sidecar consumes it from the start.
|
||||||
|
- **Shared ZB packages consumed:** `ZB.MOM.WW.Auth`
|
||||||
|
(Abstractions+Ldap+ApiKeys+AspNetCore), `ZB.MOM.WW.Theme`, `ZB.MOM.WW.Telemetry`
|
||||||
|
(+`.Serilog`), `ZB.MOM.WW.Health`, `ZB.MOM.WW.Configuration`, `ZB.MOM.WW.Audit`.
|
||||||
|
|
||||||
|
## 4. gRPC API surface
|
||||||
|
|
||||||
|
Gateway's own curated contract (`ZB.MOM.WW.HistorianGateway.Grpc.V1`), grouped by
|
||||||
|
concern — not a 1:1 SDK dump. The vendored `ArchestrA.Grpc.Contract.*` protos stay
|
||||||
|
internal; clients see only the gateway contract.
|
||||||
|
|
||||||
|
| Service | RPCs | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `HistorianRead` | `ReadRaw`, `ReadAggregate`, `ReadBlocks`, `ReadEvents` *(server-streaming)*; `ReadAtTime` | `ReadAggregate` exposes all 15 retrieval modes |
|
||||||
|
| `HistorianWrite` | `AddHistoricalValues`, `SendEvent`, `WriteLiveValues` | `WriteLiveValues` = SQL path (gRPC streaming is gated) |
|
||||||
|
| `HistorianTags` | `BrowseTagNames` *(streaming)*, `GetTagMetadata`, `EnsureTags`, `DeleteTags`, `RenameTags`, `AddTagExtendedProperties` | |
|
||||||
|
| `HistorianStatus` | `Probe`, `GetConnectionStatus`, `GetStoreForwardStatus`, `GetSystemParameter` | |
|
||||||
|
| `GalaxyRepository` | Browse areas / objects / templates / instances / attributes *(read-only)* | canonical proto from the shared lib |
|
||||||
|
|
||||||
|
**Authorization** is via **API-key scopes at the gateway** (Approach A trust
|
||||||
|
boundary): `historian:read`, `historian:write`, `historian:tags:write`,
|
||||||
|
`galaxy:read`.
|
||||||
|
|
||||||
|
## 5. Connection & data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
gRPC client ──(ZB API key)──► HistorianGateway ──┬─ pooled, pre-authed gRPC conn ──► AVEVA Historian (RemoteGrpc 2023R2)
|
||||||
|
├─ store-forward outbox (SQLite) ─ replays writes on reconnect
|
||||||
|
├─ redundant-write fan-out ──────► historian members (All/Any ack)
|
||||||
|
├─ SqlConnection ──► Runtime DB (live-value writes via aaAnalogTagInsert/History)
|
||||||
|
└─ ZB.MOM.WW.GalaxyRepository ──► Galaxy Repository SQL (read-only browse)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pooled connections:** the expensive auth handshake (`ValidateClientCredential` /
|
||||||
|
ECDH `ExchangeKey`) runs **once per connection on open**, then is reused across
|
||||||
|
requests; connections are health-checked with auto-reconnect. Write operations use
|
||||||
|
the write-enabled session mode (`0x401`).
|
||||||
|
- **Store-forward:** writes flow through the SDK's `HistorianStoreForwardWriter` — on
|
||||||
|
an unreachable historian, enqueue to durable SQLite; a background drain replays on
|
||||||
|
reconnect.
|
||||||
|
- **Redundancy:** `HistorianRedundantWriteResult` fan-out to configured members under
|
||||||
|
an All/Any ack policy; per-member result surfaced to the caller.
|
||||||
|
- **SQL live-write** and **Galaxy browse** are independent SQL paths, each with its
|
||||||
|
own validated connection config.
|
||||||
|
|
||||||
|
**Configuration** (all `ZB.MOM.WW.Configuration`-validated, aggregated by
|
||||||
|
`ConfigPreflight` at startup): historian (host, gRPC port 32565, transport=RemoteGrpc,
|
||||||
|
TLS, service identity/credentials), redundant members, store-forward path, Galaxy
|
||||||
|
Repository SQL connection string, Runtime DB connection string (SQL live-write),
|
||||||
|
Auth (LDAP + API-key pepper). Secrets live in the operator environment, never in repo.
|
||||||
|
|
||||||
|
## 6. Cross-cutting infrastructure + dashboard
|
||||||
|
|
||||||
|
- **Auth (`ZB.MOM.WW.Auth`):** gRPC clients use peppered-HMAC API keys (keyId/Bearer),
|
||||||
|
validated by a gRPC interceptor enforcing per-service scopes. Dashboard uses LDAP
|
||||||
|
login (`.Ldap`+`.AspNetCore`), cookie auth, `IGroupRoleMapper<TRole>`, canonical
|
||||||
|
`ZbClaimTypes`/`ZbCookieDefaults`, canonical-six roles, dev against the shared
|
||||||
|
GLAuth (`10.100.0.35:3893`, `dc=zb,dc=local`). `DisableLogin` dev/deploy switch.
|
||||||
|
- **Telemetry (`.Telemetry`+`.Serilog`):** `AddZbTelemetry` (Resource
|
||||||
|
`service.name=historian-gateway` + standard instrumentation + always-on Prometheus
|
||||||
|
`/metrics`, OTLP opt-in) + `AddZbSerilog`. App Meters: read/write counts + latency,
|
||||||
|
store-forward queue depth, pool connection state, redundancy ack outcomes.
|
||||||
|
- **Health (`.Health`):** three-tier ready/active/healthz + canonical JSON writer.
|
||||||
|
Probes: historian gRPC (`GrpcDependencyHealthCheck`), Galaxy Repository SQL +
|
||||||
|
Runtime DB (`DatabaseHealthCheck`), store-forward drain status.
|
||||||
|
- **Configuration (`.Configuration`):** `OptionsValidatorBase` / `ValidationBuilder` /
|
||||||
|
`AddValidatedOptions` / `ConfigPreflight` (§5).
|
||||||
|
- **Audit (`.Audit`, DEEP-adopt):** canonical `AuditEvent` + SQLite `IAuditWriter`
|
||||||
|
(MxGateway-style). Audited: tag-config writes, historical/event writes, API-key
|
||||||
|
admin, login/logout. `Actor` wired from the Auth principal via `IAuditActorAccessor`.
|
||||||
|
- **Dashboard (Blazor, `.Theme`):** Technical-Light side-rail shell + `LoginCard`
|
||||||
|
`/login`. Pages: **Status** (pool / store-forward / redundancy / version),
|
||||||
|
**Galaxy browser** (read-only hierarchy tree), **Historian console** (query with
|
||||||
|
raw/aggregate + mode picker + time range; role-gated write test for value insert /
|
||||||
|
event send), **API-key admin** (list/create/revoke keys + scopes), **Health**.
|
||||||
|
|
||||||
|
## 7. Error handling
|
||||||
|
|
||||||
|
- **gRPC status mapping:** `ProtocolEvidenceMissingException` (unsupported op/type —
|
||||||
|
e.g. non-analog tag, non-string event property) → `Unimplemented`/`FailedPrecondition`
|
||||||
|
with a clear "not in reverse-engineered surface" message; auth →
|
||||||
|
`Unauthenticated`/`PermissionDenied`; historian down → `Unavailable`; bad range /
|
||||||
|
unknown tag → `InvalidArgument`/`NotFound`.
|
||||||
|
- **Gated ops:** live streaming-sample writes (`AddS2`) are **not exposed** (no RPC);
|
||||||
|
live-value writes route through SQL `WriteLiveValues`.
|
||||||
|
- **Write resilience:** with store-forward enabled, an unreachable historian returns
|
||||||
|
*accepted + queued* (not an error); otherwise `Unavailable`. Redundancy surfaces a
|
||||||
|
per-member result; All-policy fails if any member fails, Any-policy succeeds on ≥1 ack.
|
||||||
|
- **Pool:** transient failures → reconnect + bounded retry; auth-handshake failure →
|
||||||
|
fail fast with diagnostic. No secrets/real hostnames in errors or logs (histsdk
|
||||||
|
safety rule).
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
- **Unit:** gRPC services against a faked historian-client seam + faked Galaxy
|
||||||
|
provider; scope/auth interceptor; config validators; SDK-model ↔ proto mapping.
|
||||||
|
- **Golden/protocol:** carry over `histsdk`'s golden byte tests for the vendored
|
||||||
|
client (historical "ON" buffer, event "OS" buffer, registration buffers) so the
|
||||||
|
vendored copy stays faithful.
|
||||||
|
- **Integration (env-gated, live, CI/macOS-safe):** real 2023 R2 historian + Galaxy
|
||||||
|
Repository SQL — read/write round-trips and browse via the self-cleaning
|
||||||
|
sandbox-tag lifecycle (`HISTORIAN_GRPC_WRITE_SANDBOX_TAG`); skipped when env vars
|
||||||
|
absent.
|
||||||
|
- **Dashboard:** bUnit component tests. **Smoke:** `/healthz`, `/metrics`, gRPC
|
||||||
|
`Probe`.
|
||||||
|
|
||||||
|
## 9. Out of scope / non-goals
|
||||||
|
|
||||||
|
- `AddS2` live streaming process-sample writes (GATED server-side; SQL path covers
|
||||||
|
live values instead).
|
||||||
|
- Non-analog tag creation, revision/edit writes, bit-faithful store-forward framing
|
||||||
|
(per `histsdk` capability matrix — `BOUNDED`/`HARD`/`GATED` items not selected).
|
||||||
|
- A two-process / x86 worker split (not needed — no COM).
|
||||||
|
- Re-syncing or replacing the existing stale `scadaproj/ZB.MOM.WW.SPHistorianClient`
|
||||||
|
port (we vendor `histsdk` instead; the stale port is left as-is).
|
||||||
|
|
||||||
|
## 10. Implementation components (high level)
|
||||||
|
|
||||||
|
1. **`ZB.MOM.WW.GalaxyRepository` shared lib** (scadaproj) — extract from
|
||||||
|
`mxaccessgw`, canonical proto + SQL browse provider + reusable gRPC service.
|
||||||
|
2. **Vendor `histsdk`** `AVEVA.Historian.Client` into the new repo + carry its golden
|
||||||
|
tests.
|
||||||
|
3. **Repo scaffold + host + shared-package wiring** (Auth/Telemetry/Health/
|
||||||
|
Configuration/Audit) + validated options + `ConfigPreflight`.
|
||||||
|
4. **gRPC contract + services** (Read / Write / Tags / Status / GalaxyRepository).
|
||||||
|
5. **Connection layer** — pooled pre-authed connections, store-forward, redundancy,
|
||||||
|
SQL live-write path.
|
||||||
|
6. **Auth** — API-key scope interceptor + LDAP dashboard auth + Audit wiring.
|
||||||
|
7. **Blazor dashboard** pages (Theme).
|
||||||
|
8. **Telemetry + Health** probes/meters.
|
||||||
|
9. **Tests** — unit / golden / env-gated integration / bUnit.
|
||||||
|
10. **Docs + repo/gitea setup** — `CLAUDE.md`, `README.md`, gitea remote.
|
||||||
|
|
||||||
|
> `mxaccessgw` adoption of `ZB.MOM.WW.GalaxyRepository` is a separate tracked
|
||||||
|
> follow-on, not part of the initial sidecar delivery.
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
# ZB.MOM.WW.HistorianGateway Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Build a single .NET 10 x64 sidecar that exposes (1) a read-only Galaxy object-hierarchy metadata gRPC server and (2) a full read/write gRPC API to the AVEVA Historian, with a Blazor dashboard, reusing the family's shared `ZB.MOM.WW.*` packages.
|
||||||
|
|
||||||
|
**Architecture:** One ASP.NET Core process hosting gRPC services + Blazor (no COM, no x86 worker). The historian write/read surface comes from the **vendored `histsdk` client** (`AVEVA.Historian.Client`). The Galaxy browse comes from a **new shared lib `ZB.MOM.WW.GalaxyRepository`** in scadaproj (extracted from mxaccessgw, wire-compatible `galaxy_repository.v1`). Connection model: stateless gateway over a **pooled, pre-authenticated service-identity connection**; clients authenticate to the gateway via peppered-HMAC API keys with per-service scopes.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, ASP.NET Core, Grpc.AspNetCore 2.76, Grpc.Net.Client 2.58 (vendored), Google.Protobuf, Microsoft.Data.SqlClient, Microsoft.Data.Sqlite, Blazor InteractiveServer, `ZB.MOM.WW.Theme` 0.3.1, `ZB.MOM.WW.Auth` 0.1.2, `ZB.MOM.WW.Telemetry`/`.Serilog` 0.1.0, `ZB.MOM.WW.Health` 0.1.0, `ZB.MOM.WW.Audit` 0.1.0, `ZB.MOM.WW.Configuration` 0.1.0, xUnit, bUnit.
|
||||||
|
|
||||||
|
**Reference sources (read these for exact patterns — do NOT re-discover):**
|
||||||
|
- Design doc: `docs/plans/2026-06-23-historian-gateway-design.md`
|
||||||
|
- mxaccessgw (the model): `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/` — `GatewayApplication.cs` (host wiring), `Security/Authorization/*` (gRPC API-key interceptor + scope resolver), `Galaxy/GalaxyRepository.cs` (the SQL to extract), `Galaxy/GalaxyRepositoryOptions.cs`, `Galaxy/GalaxyHierarchyCache.cs`, `Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs`, `Contracts/Protos/galaxy_repository.proto`, `Dashboard/Components/*` (Blazor + Theme).
|
||||||
|
- histsdk clone (to vendor): `/tmp/histsdk-explore/src/AVEVA.Historian.Client/` + `/tmp/histsdk-explore/tests/AVEVA.Historian.Client.Tests/`.
|
||||||
|
- Shared package signatures: captured in the design session; key paths under `~/Desktop/scadaproj/ZB.MOM.WW.{Telemetry,Health,Configuration,Audit,Auth,Theme}/`.
|
||||||
|
|
||||||
|
**Conventions for every task:** TDD where a seam exists (write the failing test first). Exact file paths in the `Files:` block ARE the implementer's contract. Commit after each task. Tests must stay green on macOS with no live historian/SQL (live tests are env-gated and skip when env vars are absent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Shared `ZB.MOM.WW.GalaxyRepository` lib (in scadaproj)
|
||||||
|
|
||||||
|
> Built in `~/Desktop/scadaproj/ZB.MOM.WW.GalaxyRepository/` as plain files (NOT a nested git repo — see memory `shared-libs-are-plain-files-not-nested-repos`). Wire-compatible: keep proto `package galaxy_repository.v1` and all field numbers identical to mxaccessgw's so OtOpcUa is unaffected; only the C# `csharp_namespace` becomes neutral. mxaccessgw adoption of this lib is a separate follow-on, NOT in this plan.
|
||||||
|
|
||||||
|
### Task 1: Scaffold the GalaxyRepository lib project
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 7 (vendoring), Task 9 (repo scaffold)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.GalaxyRepository/ZB.MOM.WW.GalaxyRepository.slnx`
|
||||||
|
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/ZB.MOM.WW.GalaxyRepository.csproj`
|
||||||
|
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/ZB.MOM.WW.GalaxyRepository.Tests.csproj`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Create the `.csproj` (net10.0, `Nullable`/`ImplicitUsings` enabled, packable, `PackageId=ZB.MOM.WW.GalaxyRepository`, `Version=0.1.0`). PackageReferences: `Microsoft.Data.SqlClient` 6.0.2, `Grpc.AspNetCore` 2.76.0, `Google.Protobuf`, `Microsoft.Extensions.Hosting.Abstractions`, `Microsoft.Extensions.Options.ConfigurationExtensions`. Add `<Protobuf Include="Protos\*.proto" GrpcServices="Server" />`.
|
||||||
|
2. Create the test `.csproj` (net10.0, `IsPackable=false`, xUnit 2.9.3 + `Microsoft.NET.Test.Sdk` 17.14.1 + `Microsoft.Data.SqlClient`), ProjectReference to the lib.
|
||||||
|
3. Create the `.slnx` listing both projects.
|
||||||
|
4. Run: `dotnet build ~/Desktop/scadaproj/ZB.MOM.WW.GalaxyRepository/ZB.MOM.WW.GalaxyRepository.slnx` — Expected: builds (no sources yet, 0 warnings).
|
||||||
|
5. Commit: `git -C ~/Desktop/scadaproj add ZB.MOM.WW.GalaxyRepository && git -C ~/Desktop/scadaproj commit -m "feat(galaxyrepo): scaffold ZB.MOM.WW.GalaxyRepository shared lib"`
|
||||||
|
|
||||||
|
### Task 2: Port the canonical galaxy_repository.proto (neutral namespace)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (Task 3+ depend on generated types)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/Protos/galaxy_repository.proto`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Copy mxaccessgw's `Contracts/Protos/galaxy_repository.proto` verbatim, changing ONLY `option csharp_namespace` to `"ZB.MOM.WW.GalaxyRepository.Grpc"`. Keep `package galaxy_repository.v1`, all services (`TestConnection`, `GetLastDeployTime`, `DiscoverHierarchy`, `WatchDeployEvents`, `BrowseChildren`), and every message/field number identical (wire compatibility).
|
||||||
|
2. Run: `dotnet build .../ZB.MOM.WW.GalaxyRepository.slnx` — Expected: PASS; generated `GalaxyRepository.GalaxyRepositoryBase`, `GalaxyObject`, `GalaxyAttribute`, etc. appear under namespace `ZB.MOM.WW.GalaxyRepository.Grpc`.
|
||||||
|
3. Commit: `feat(galaxyrepo): canonical galaxy_repository.v1 proto (neutral namespace)`
|
||||||
|
|
||||||
|
### Task 3: Port the SQL browse provider (`GalaxyRepository` + rows + options)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs`
|
||||||
|
- Create: `.../src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRow.cs`
|
||||||
|
- Create: `.../src/ZB.MOM.WW.GalaxyRepository/GalaxyAttributeRow.cs`
|
||||||
|
- Create: `.../src/ZB.MOM.WW.GalaxyRepository/IGalaxyRepository.cs`
|
||||||
|
- Create: `.../src/ZB.MOM.WW.GalaxyRepository/GalaxyRepository.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Port `GalaxyRepositoryOptions` from mxaccessgw `Galaxy/GalaxyRepositoryOptions.cs` — rename section const to `ZB.MOM.WW.GalaxyRepository` (the consuming app picks its own section path at registration), drop MxGateway-specific defaults. Keep `ConnectionString`, `CommandTimeoutSeconds`, `DashboardRefreshIntervalSeconds`, `PersistSnapshot`, `SnapshotCachePath`.
|
||||||
|
2. Port `GalaxyHierarchyRow` / `GalaxyAttributeRow` DTOs and the `IGalaxyRepository` interface (`TestConnectionAsync`, `GetLastDeployTimeAsync`, `GetHierarchyAsync`, `GetAttributesAsync`).
|
||||||
|
3. Port `GalaxyRepository.cs` **verbatim** including the two SQL blocks (`HierarchySql`, `AttributesSql`) and the `SqlConnection`/`SqlDataReader` mapping loops — these are validated reverse-engineered queries; do NOT modify the SQL.
|
||||||
|
4. Run: `dotnet build` — Expected: PASS.
|
||||||
|
5. Commit: `feat(galaxyrepo): SQL browse provider (hierarchy + attributes)`
|
||||||
|
|
||||||
|
### Task 4: Port the in-memory hierarchy cache + snapshot + deploy notifier + refresh service
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../GalaxyHierarchyCacheEntry.cs`, `.../IGalaxyHierarchyCache.cs`, `.../GalaxyHierarchyCache.cs`
|
||||||
|
- Create: `.../IGalaxyDeployNotifier.cs`, `.../GalaxyDeployNotifier.cs`
|
||||||
|
- Create: `.../IGalaxyHierarchySnapshotStore.cs`, `.../GalaxyHierarchySnapshotStore.cs`
|
||||||
|
- Create: `.../GalaxyHierarchyRefreshService.cs` (`BackgroundService`)
|
||||||
|
- Create: `.../GalaxyHierarchyProjector.cs` (paging/filter projection used by the gRPC service)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Port these from mxaccessgw's `Galaxy/` folder, adjusting namespaces to `ZB.MOM.WW.GalaxyRepository`. Keep the cache's first-load gate, refresh semaphore, snapshot restore, and deploy-poll refresh trigger.
|
||||||
|
2. Port `GalaxyHierarchyProjector` (the `Project(...)` + `ComputeFilterSignature(...)` used by `DiscoverHierarchy`/`BrowseChildren` paging).
|
||||||
|
3. Run: `dotnet build` — Expected: PASS.
|
||||||
|
4. Commit: `feat(galaxyrepo): hierarchy cache + snapshot + refresh service`
|
||||||
|
|
||||||
|
### Task 5: Port the reusable gRPC service + DI extension
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Grpc/GalaxyRepositoryGrpcService.cs`
|
||||||
|
- Create: `.../DependencyInjection/GalaxyRepositoryServiceCollectionExtensions.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Port `GalaxyRepositoryGrpcService` from mxaccessgw's `Grpc/GalaxyRepositoryGrpcService.cs`, but REMOVE the mxaccessgw-specific `IGatewayRequestIdentityAccessor`/`ApiKeyConstraints` browse-subtree filtering (the gateway will apply its own auth at the interceptor layer). Keep `DiscoverHierarchy`, `BrowseChildren`, `TestConnection`, `GetLastDeployTime`, `WatchDeployEvents`. Base class: `ZB.MOM.WW.GalaxyRepository.Grpc.GalaxyRepository.GalaxyRepositoryBase`.
|
||||||
|
2. Write `AddZbGalaxyRepository(this IServiceCollection, IConfiguration, string sectionPath)` modeled on mxaccessgw's `AddGalaxyRepository` — bind options from `sectionPath`, register `GalaxyRepository`/`IGalaxyRepository`, notifier, snapshot store, cache, and the refresh `HostedService`. Add a companion `MapZbGalaxyRepository(this IEndpointRouteBuilder)` that `MapGrpcService<GalaxyRepositoryGrpcService>()`.
|
||||||
|
3. Run: `dotnet build` — Expected: PASS.
|
||||||
|
4. Commit: `feat(galaxyrepo): reusable gRPC service + AddZbGalaxyRepository DI`
|
||||||
|
|
||||||
|
### Task 6: Unit tests for the projector + DI smoke; pack
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyProjectorTests.cs`
|
||||||
|
- Create: `.../tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyCacheTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing tests first:** projector paging (page_token round-trip, max_depth, `historized_only`/`alarm_bearing_only` filters, attribute include toggle) against a hand-built `GalaxyHierarchyCacheEntry` fixture; cache first-load gate + snapshot restore using a fake `IGalaxyRepository`. (SQL provider itself is exercised by env-gated integration later — no live DB in unit tests.)
|
||||||
|
2. Run: `dotnet test .../ZB.MOM.WW.GalaxyRepository.slnx` — Expected: FAIL (types/asserts).
|
||||||
|
3. Implement any small helper gaps surfaced; re-run — Expected: PASS.
|
||||||
|
4. Run: `dotnet pack .../src/ZB.MOM.WW.GalaxyRepository/ZB.MOM.WW.GalaxyRepository.csproj -c Release` — Expected: `ZB.MOM.WW.GalaxyRepository.0.1.0.nupkg` produced.
|
||||||
|
5. Commit: `test(galaxyrepo): projector + cache tests; pack 0.1.0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Sidecar repo scaffold + vendor histsdk
|
||||||
|
|
||||||
|
### Task 7: Vendor the histsdk client + its golden tests
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `~/Desktop/HistorianGateway/src/vendor/AVEVA.Historian.Client/**` (copied)
|
||||||
|
- Create: `~/Desktop/HistorianGateway/tests/AVEVA.Historian.Client.Tests/**` (copied)
|
||||||
|
- Create: `~/Desktop/HistorianGateway/src/vendor/AVEVA.Historian.Client/VENDORING.md`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `mkdir -p ~/Desktop/HistorianGateway/src/vendor ~/Desktop/HistorianGateway/tests`. Copy `/tmp/histsdk-explore/src/AVEVA.Historian.Client/` and `/tmp/histsdk-explore/tests/AVEVA.Historian.Client.Tests/` into those locations.
|
||||||
|
2. In the vendored test `.csproj`, REMOVE the `ProjectReference` to `tools/AVEVA.Historian.ReverseEngineering` (not vendored) and delete any test classes that depend on that tooling namespace (the RE-sanitizer tests). KEEP the protocol/golden tests: `HistorianTagWriteProtocolTests`, `HistorianEventRowProtocolTests`, `GrpcEventSendProtocolTests`, `WcfDataQueryProtocolTests`, `StoreForwardOutboxTests`, `RedundancyTests`, version-gate tests. Fix the surviving test `.csproj` ProjectReference path to the new vendored client location.
|
||||||
|
3. Keep namespace `AVEVA.Historian.Client` as-is (eases re-sync). Write `VENDORING.md` recording: source repo `gitea.dohertylan.com/dohertj2/histsdk`, the commit/date of the snapshot, and "do not hand-edit; re-vendor from upstream."
|
||||||
|
4. Run: `dotnet build ~/Desktop/HistorianGateway/src/vendor/AVEVA.Historian.Client/AVEVA.Historian.Client.csproj` then `dotnet test ~/Desktop/HistorianGateway/tests/AVEVA.Historian.Client.Tests/` — Expected: build PASS; golden/offline tests PASS (live env-gated tests skip).
|
||||||
|
5. Commit (in the new repo, after Task 8 inits it — if running before Task 8, defer the commit): `chore(vendor): vendor histsdk AVEVA.Historian.Client + golden tests`
|
||||||
|
|
||||||
|
### Task 8: Initialize the sidecar repo + solution + Directory.Build.props
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none (Task 7 output is added here)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `~/Desktop/HistorianGateway/.gitignore`
|
||||||
|
- Create: `~/Desktop/HistorianGateway/Directory.Build.props`
|
||||||
|
- Create: `~/Desktop/HistorianGateway/ZB.MOM.WW.HistorianGateway.slnx`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `git -C ~/Desktop/HistorianGateway init` (this IS its own app repo — unlike shared libs). Add a .NET `.gitignore`.
|
||||||
|
2. `Directory.Build.props`: `net10.0`, `Nullable`/`ImplicitUsings` enable, `<Platforms>x64</Platforms>`, `<PlatformTarget>x64</PlatformTarget>`, common `LangVersion`.
|
||||||
|
3. Create `.slnx` referencing: `src/vendor/AVEVA.Historian.Client`, `tests/AVEVA.Historian.Client.Tests` (and the projects added in later phases — add them as created).
|
||||||
|
4. Run: `dotnet build ~/Desktop/HistorianGateway/ZB.MOM.WW.HistorianGateway.slnx` — Expected: PASS.
|
||||||
|
5. Commit: `chore: init repo + solution + Directory.Build.props` (then re-commit Task 7's vendored tree if it was deferred).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Host + configuration + shared-package wiring
|
||||||
|
|
||||||
|
### Task 9: Create the Contracts project + historian_gateway.proto skeleton
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `~/Desktop/HistorianGateway/src/ZB.MOM.WW.HistorianGateway.Contracts/ZB.MOM.WW.HistorianGateway.Contracts.csproj`
|
||||||
|
- Create: `.../Contracts/Protos/historian_gateway.proto`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `.csproj` net10.0, `Grpc.AspNetCore` 2.76.0, `<Protobuf Include="Protos\*.proto" GrpcServices="Both" />`.
|
||||||
|
2. Author `historian_gateway.proto` (`package historian_gateway.v1; option csharp_namespace = "ZB.MOM.WW.HistorianGateway.Contracts.Grpc";`) with the **service stubs and message shells** for the 4 historian services: `HistorianRead` (ReadRaw/ReadAggregate/ReadBlocks/ReadEvents server-streaming, ReadAtTime unary), `HistorianWrite` (AddHistoricalValues, SendEvent, WriteLiveValues), `HistorianTags` (BrowseTagNames streaming, GetTagMetadata, EnsureTags, DeleteTags, RenameTags, AddTagExtendedProperties), `HistorianStatus` (Probe, GetConnectionStatus, GetStoreForwardStatus, GetSystemParameter). Map the message fields to the vendored `HistorianSample`/`HistorianAggregateSample`/`HistorianEvent`/`HistorianTagMetadata`/`HistorianHistoricalValue` shapes (timestamps as `google.protobuf.Timestamp`, `RetrievalMode` as an enum mirroring the SDK's 15 modes).
|
||||||
|
3. Run: `dotnet build` — Expected: PASS; gateway gRPC base classes generated. Add project to `.slnx`.
|
||||||
|
4. Commit: `feat(contracts): historian_gateway.v1 proto + generated types`
|
||||||
|
|
||||||
|
### Task 10: Create the Server project + minimal boot
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../src/ZB.MOM.WW.HistorianGateway.Server/ZB.MOM.WW.HistorianGateway.Server.csproj`
|
||||||
|
- Create: `.../Server/Program.cs`
|
||||||
|
- Create: `.../Server/appsettings.json`, `.../Server/appsettings.Development.json`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `.csproj` (Sdk `Microsoft.NET.Sdk.Web`): PackageReferences exactly mirroring mxaccessgw's Server csproj versions — `Grpc.AspNetCore` 2.76.0, `ZB.MOM.WW.Auth.{Abstractions,Ldap,ApiKeys,AspNetCore}` 0.1.2, `ZB.MOM.WW.Audit` 0.1.0, `ZB.MOM.WW.Theme` 0.3.1, `ZB.MOM.WW.Configuration` 0.1.0, `ZB.MOM.WW.Health` 0.1.0, `ZB.MOM.WW.Telemetry`+`.Serilog` 0.1.0, `Serilog.AspNetCore`/`.Sinks.Console`/`.Sinks.File`, `Microsoft.Data.Sqlite` 10.0.7, `Microsoft.Data.SqlClient` 6.0.2, `Polly.Core` 8.6.6. ProjectReferences: Contracts + vendored `AVEVA.Historian.Client` + `ZB.MOM.WW.GalaxyRepository` (project ref to the scadaproj lib, or pkg ref to its 0.1.0 nupkg).
|
||||||
|
2. `Program.cs`: minimal `WebApplication` that calls `AddZbSerilog`/`AddZbTelemetry` (ServiceName `historian-gateway`), `builder.Services.AddGrpc()`, maps `/healthz` + `/metrics` via `MapZbHealth`/`MapZbMetrics`, boots. (Subsystems wired in later tasks.)
|
||||||
|
3. Run: `dotnet build` then `dotnet run --project .../Server` and `curl -s localhost:<port>/healthz` — Expected: 200; `curl /metrics` returns Prometheus text. Add project to `.slnx`.
|
||||||
|
4. Commit: `feat(server): host scaffold + telemetry/serilog/health boot`
|
||||||
|
|
||||||
|
### Task 11: Configuration options + validators + ConfigPreflight
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Configuration/HistorianOptions.cs` + `HistorianOptionsValidator.cs`
|
||||||
|
- Create: `.../Server/Configuration/GalaxyOptions.cs` (thin wrapper / reuse `GalaxyRepositoryOptions`)
|
||||||
|
- Create: `.../Server/Configuration/RuntimeDbOptions.cs` + validator (SQL live-write)
|
||||||
|
- Create: `.../Server/Configuration/RedundancyOptions.cs` + validator
|
||||||
|
- Create: `.../Server/Configuration/StoreForwardOptions.cs` + validator
|
||||||
|
- Modify: `.../Server/Program.cs` (register `AddValidatedOptions<,>` + run `ConfigPreflight`)
|
||||||
|
- Test: `.../tests/ZB.MOM.WW.HistorianGateway.Tests/Configuration/ValidatorTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing validator tests first** using `OptionsValidatorBase`/`ValidationBuilder` semantics (e.g., missing `Historian:Host` → failure; bad port → failure; `Transport` one-of; redundancy `MinCount(members,1)` when enabled). Run — Expected: FAIL.
|
||||||
|
2. Implement options records + validators (subclass `OptionsValidatorBase<T>`, use `ValidationBuilder.Required/Port/HostPort/OneOf/PositiveTimeSpan/MinCount`). Map `HistorianOptions` → vendored `HistorianClientOptions` (Host, Port default 32565, `Transport=RemoteGrpc`, `GrpcUseTls`, credentials, `AllowUntrustedServerCertificate`).
|
||||||
|
3. In `Program.cs`, `AddValidatedOptions<,>` each, and run a `ConfigPreflight` (RequireValue host, RequirePort) before host build.
|
||||||
|
4. Run: `dotnet test` — Expected: PASS.
|
||||||
|
5. Commit: `feat(server): validated options + ConfigPreflight`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Connection layer (vendored client → gateway)
|
||||||
|
|
||||||
|
### Task 12: `IHistorianClient` seam over the vendored client
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Historian/IHistorianClient.cs` (interface mirroring the read/write methods the services need)
|
||||||
|
- Create: `.../Server/Historian/VendoredHistorianClient.cs` (adapts `AVEVA.Historian.Client.HistorianClient`)
|
||||||
|
- Test: `.../tests/.../Historian/HistorianClientSeamTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing test** that a `FakeHistorianClient : IHistorianClient` can be substituted and returns canned samples (this seam is what makes the gRPC services unit-testable without a live historian). Run — Expected: FAIL.
|
||||||
|
2. Define `IHistorianClient` with the methods the services call (ReadRaw/ReadAggregate/ReadAtTime/ReadBlocks/ReadEvents/BrowseTagNames/GetTagMetadata/Probe/GetConnectionStatus/GetStoreForwardStatus/GetSystemParameter/AddHistoricalValues/SendEvent/EnsureTag/DeleteTag/RenameTags/AddTagExtendedProperties). Implement `VendoredHistorianClient` delegating to the real `HistorianClient`.
|
||||||
|
3. Run: `dotnet test` — Expected: PASS.
|
||||||
|
4. Commit: `feat(historian): IHistorianClient seam + vendored adapter`
|
||||||
|
|
||||||
|
### Task 13: Connection pool (pre-authenticated, reused, health-checked)
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Historian/HistorianConnectionPool.cs` (+ `IHistorianConnectionPool`)
|
||||||
|
- Modify: `.../Server/Program.cs` (DI singleton)
|
||||||
|
- Test: `.../tests/.../Historian/HistorianConnectionPoolTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing test** asserting the pool opens/authenticates a connection once and reuses it across N borrow calls (count handshakes via a fake transport/lease factory), and that a faulted connection is evicted + re-created. Run — Expected: FAIL.
|
||||||
|
2. Implement a lease-based pool keyed by target; lazy-open with the auth handshake once; reuse; `SemaphoreSlim`-guarded reconnect on fault; expose `Lease()` returning a pooled `IHistorianClient`. (The vendored client is `IAsyncDisposable`; the pool owns lifecycle.)
|
||||||
|
3. Run: `dotnet test` — Expected: PASS.
|
||||||
|
4. Commit: `feat(historian): pooled pre-authenticated connection pool`
|
||||||
|
|
||||||
|
### Task 14: Store-forward + redundancy + SQL live-write wiring
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Historian/HistorianWriteCoordinator.cs` (routes writes → pool, store-forward, or redundancy per config)
|
||||||
|
- Create: `.../Server/Historian/SqlLiveValueWriter.cs` (`WriteLiveValues` via `aaAnalogTagInsert` + `INSERT INTO History`)
|
||||||
|
- Modify: `.../Server/Program.cs`
|
||||||
|
- Test: `.../tests/.../Historian/HistorianWriteCoordinatorTests.cs`, `.../SqlLiveValueWriterTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing tests:** (a) when store-forward enabled + historian unreachable, the coordinator enqueues (uses vendored `HistorianStoreForwardWriter` over a fake sink) and reports `Queued`; (b) when redundancy configured, it fans out via `HistorianRedundantClient` and returns per-member results under All/Any; (c) `SqlLiveValueWriter` builds the correct parameterized command sequence (assert against a fake `IDbCommand` recorder — no live SQL). Run — Expected: FAIL.
|
||||||
|
2. Implement the coordinator (compose vendored `HistorianStoreForwardWriter` + `HistorianRedundantClient` from config) and `SqlLiveValueWriter` (omit the server-managed `Quality` column; honor the storage-activation note from the SQL reference memory).
|
||||||
|
3. Run: `dotnet test` — Expected: PASS.
|
||||||
|
4. Commit: `feat(historian): write coordinator (store-forward + redundancy) + SQL live-write`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — gRPC services + auth interceptor
|
||||||
|
|
||||||
|
### Task 15: `HistorianRead` service (representative TDD task; sets the pattern)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 17 after the mapper exists
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Grpc/HistorianReadService.cs`
|
||||||
|
- Create: `.../Server/Grpc/HistorianProtoMapper.cs` (SDK model ↔ proto)
|
||||||
|
- Modify: `.../Server/Program.cs` (`MapGrpcService`)
|
||||||
|
- Test: `.../tests/.../Grpc/HistorianReadServiceTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing test:** with a `FakeHistorianClient` yielding 3 `HistorianSample`s, calling `ReadRaw` streams 3 mapped proto rows; `ReadAggregate` passes the right `RetrievalMode`+interval; an unknown tag → `RpcException(NotFound)`; bad time range → `InvalidArgument`. Use an in-memory `IServerStreamWriter<T>` capture. Run — Expected: FAIL.
|
||||||
|
2. Implement `HistorianReadService : HistorianRead.HistorianReadBase` consuming `IHistorianConnectionPool.Lease()`; implement `HistorianProtoMapper` (Timestamp conversions, RetrievalMode enum map). Map exceptions per design §7.
|
||||||
|
3. Run: `dotnet test` — Expected: PASS.
|
||||||
|
4. Commit: `feat(grpc): HistorianRead service + proto mapper`
|
||||||
|
|
||||||
|
### Task 16: `HistorianWrite` service
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 17, Task 18 (no file overlap)
|
||||||
|
|
||||||
|
**Files:** Create `.../Server/Grpc/HistorianWriteService.cs`; Modify `Program.cs`; Test `.../Grpc/HistorianWriteServiceTests.cs`
|
||||||
|
|
||||||
|
**Steps:** TDD per the Task 15 pattern. `AddHistoricalValues`/`SendEvent` route through `HistorianWriteCoordinator`; `WriteLiveValues` through `SqlLiveValueWriter`. Map `ProtocolEvidenceMissingException` → `Unimplemented`, unreachable+store-forward → `OK` with `Queued` status, redundancy per-member results into the reply. Commit: `feat(grpc): HistorianWrite service`.
|
||||||
|
|
||||||
|
### Task 17: `HistorianTags` service
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 16, Task 18
|
||||||
|
|
||||||
|
**Files:** Create `.../Server/Grpc/HistorianTagsService.cs`; Modify `Program.cs`; Test `.../Grpc/HistorianTagsServiceTests.cs`
|
||||||
|
|
||||||
|
**Steps:** TDD. `BrowseTagNames` (streaming), `GetTagMetadata`, `EnsureTags`/`DeleteTags`/`RenameTags`/`AddTagExtendedProperties` via the seam/pool. Map unsupported tag types (`ProtocolEvidenceMissingException`) → `FailedPrecondition`. Commit: `feat(grpc): HistorianTags service`.
|
||||||
|
|
||||||
|
### Task 18: `HistorianStatus` service
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 16, Task 17
|
||||||
|
|
||||||
|
**Files:** Create `.../Server/Grpc/HistorianStatusService.cs`; Modify `Program.cs`; Test `.../Grpc/HistorianStatusServiceTests.cs`
|
||||||
|
|
||||||
|
**Steps:** TDD. `Probe`/`GetConnectionStatus`/`GetStoreForwardStatus`/`GetSystemParameter`. Commit: `feat(grpc): HistorianStatus service`.
|
||||||
|
|
||||||
|
### Task 19: Galaxy gRPC wiring (consume the shared lib)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 16–18
|
||||||
|
|
||||||
|
**Files:** Modify `.../Server/Program.cs` (`AddZbGalaxyRepository(config, "Galaxy")` + `MapZbGalaxyRepository()`); Modify `appsettings.json`
|
||||||
|
|
||||||
|
**Steps:** Register the shared lib's service + refresh hosted service; add `Galaxy:ConnectionString` config. Run: `dotnet run` + grpcurl `DiscoverHierarchy` against a fake/empty config returns `Unavailable` until cache loads (no live DB needed to prove wiring). Commit: `feat(server): wire shared GalaxyRepository gRPC service`.
|
||||||
|
|
||||||
|
### Task 20: API-key auth interceptor + scope resolver
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Security/GatewayGrpcScopeResolver.cs` (maps request type → scope)
|
||||||
|
- Create: `.../Server/Security/GatewayGrpcAuthorizationInterceptor.cs`
|
||||||
|
- Create: `.../Server/Security/GatewayScopes.cs` (`historian:read|write`, `historian:tags:write`, `galaxy:read`)
|
||||||
|
- Modify: `.../Server/Program.cs` (`AddZbApiKeyAuth` + `AddGrpc(o => o.Interceptors.Add<...>())`)
|
||||||
|
- Test: `.../tests/.../Security/GrpcAuthorizationTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing tests:** missing/invalid key → `Unauthenticated`; valid key without the required scope → `PermissionDenied`; valid key with scope → continuation runs. Fake `IApiKeyVerifier`. Run — Expected: FAIL.
|
||||||
|
2. Implement modeled on mxaccessgw's `GatewayGrpcAuthorizationInterceptor` + `GatewayGrpcScopeResolver` (switch on request type → scope), using shared `IApiKeyVerifier.VerifyAsync`. Respect a `Disabled` auth mode for dev.
|
||||||
|
3. Run: `dotnet test` — Expected: PASS.
|
||||||
|
4. Commit: `feat(security): gRPC API-key interceptor + scope enforcement`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Audit
|
||||||
|
|
||||||
|
### Task 21: Canonical SQLite audit writer + actor accessor + wiring
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 22 (dashboard auth) after interfaces exist
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Audit/SqliteAuditWriter.cs` (`IAuditWriter`), `.../Server/Audit/HttpAuditActorAccessor.cs` (`IAuditActorAccessor`)
|
||||||
|
- Modify: write services (Tasks 16,17) + interceptor (Task 20) to emit `AuditEvent`s
|
||||||
|
- Modify: `.../Server/Program.cs` (`AddZbAudit` + register writer/actor)
|
||||||
|
- Test: `.../tests/.../Audit/SqliteAuditWriterTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing test:** writing an `AuditEvent` persists a row with the canonical 9 fields (`EventId`/`OccurredAtUtc`/`Actor`/`Action`/`Outcome`/`Category`/`Target`/`SourceNode`/`DetailsJson`), domain fields in `DetailsJson`; writer swallows internal errors. Use an in-memory SQLite. Run — Expected: FAIL.
|
||||||
|
2. Implement the SQLite writer (table create-if-missing) modeled on MxGateway's audit store; `HttpAuditActorAccessor` reads the Auth principal. Emit audit at tag/value/event writes, API-key admin, login/logout, with `Actor` from the accessor.
|
||||||
|
3. Run: `dotnet test` — Expected: PASS.
|
||||||
|
4. Commit: `feat(audit): canonical SQLite audit writer + actor wiring`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Blazor dashboard
|
||||||
|
|
||||||
|
### Task 22: Dashboard shell, LDAP cookie auth, login/logout
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.../Server/Dashboard/Components/{App,Routes,_Imports}.razor`, `Layout/{MainLayout,LoginLayout}.razor`, `Pages/Login.razor`
|
||||||
|
- Create: `.../Server/Dashboard/DashboardServiceCollectionExtensions.cs`, `.../Dashboard/DashboardEndpointRouteBuilderExtensions.cs`, `.../Dashboard/DashboardAuthenticator.cs`, `.../Dashboard/DashboardGroupRoleMapper.cs`
|
||||||
|
- Modify: `Program.cs` (`AddGatewayDashboard` + `MapRazorComponents<App>` + auth/antiforgery middleware)
|
||||||
|
- Test: `.../tests/ZB.MOM.WW.HistorianGateway.Tests/bUnit/LayoutRenderTests.cs`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Write failing bUnit test** that `MainLayout` renders `<ThemeShell>` with the nav rail and `LoginCard` renders on the login page. Run — Expected: FAIL.
|
||||||
|
2. Port the dashboard shell from mxaccessgw (`App.razor` with `ThemeHead`/`ThemeScripts`, `MainLayout` with `ThemeShell`+`NavRailSection`/`NavRailItem`, `Login.razor` using `LoginCard` posting to `/auth/login`). Wire `AddZbLdapAuth(config,"Ldap")`, cookie auth via `ZbCookieDefaults.Apply`, `IGroupRoleMapper<CanonicalRole>`, `DisableLogin` switch, `IAuditActorAccessor`.
|
||||||
|
3. Run: `dotnet test` (bUnit) then `dotnet run` and load `/login` in a browser/curl — Expected: tests PASS; login page renders themed.
|
||||||
|
4. Commit: `feat(dashboard): Theme shell + LDAP cookie auth + login`
|
||||||
|
|
||||||
|
### Task 23: Status + Health pages
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 24, Task 25
|
||||||
|
|
||||||
|
**Files:** Create `.../Dashboard/Components/Pages/{StatusPage,HealthPage}.razor` (+ a `DashboardStatusService`); Test bUnit render.
|
||||||
|
|
||||||
|
**Steps:** TDD bUnit render. Status shows pool state, store-forward queue depth, redundancy members, version (from a status service reading the pool/coordinator). Commit: `feat(dashboard): status + health pages`.
|
||||||
|
|
||||||
|
### Task 24: Galaxy browser page
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 23, Task 25
|
||||||
|
|
||||||
|
**Files:** Create `.../Dashboard/Components/Pages/GalaxyBrowserPage.razor` + tree node view (port mxaccessgw `BrowsePage`/`BrowseTreeNodeView`, read-only, no add-tag); Test bUnit.
|
||||||
|
|
||||||
|
**Steps:** TDD bUnit render against the shared lib's cache. Commit: `feat(dashboard): read-only Galaxy browser`.
|
||||||
|
|
||||||
|
### Task 25: Historian console page (query + role-gated write test)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 23, Task 24
|
||||||
|
|
||||||
|
**Files:** Create `.../Dashboard/Components/Pages/HistorianConsolePage.razor` (+ `DashboardHistorianService` calling the seam/pool); Test bUnit.
|
||||||
|
|
||||||
|
**Steps:** TDD bUnit. Query form (tag, time range, raw/aggregate + mode picker) renders results; write-test panel (historical value insert / event send) visible only to Engineer+ roles via `AuthorizeView`. Commit: `feat(dashboard): historian query + role-gated write console`.
|
||||||
|
|
||||||
|
### Task 26: API-key admin page
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 23–25
|
||||||
|
|
||||||
|
**Files:** Create `.../Dashboard/Components/Pages/ApiKeysPage.razor` (+ `DashboardApiKeyManagementService` over the shared ApiKeys store); Test bUnit.
|
||||||
|
|
||||||
|
**Steps:** TDD bUnit. List/create (show secret once)/revoke keys with scope selection. Commit: `feat(dashboard): API-key admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Telemetry meters + Health probes
|
||||||
|
|
||||||
|
### Task 27: App meters
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 28
|
||||||
|
|
||||||
|
**Files:** Create `.../Server/Observability/GatewayMetrics.cs`; Modify services/coordinator/pool to record; Modify `Program.cs` (`o.Meters=[GatewayMetrics.MeterName]`); Test `.../Observability/GatewayMetricsTests.cs`.
|
||||||
|
|
||||||
|
**Steps:** TDD with `MeterListener`. Counters/histograms: read/write counts + latency, store-forward queue depth (observable gauge), pool connection state, redundancy ack outcomes. Commit: `feat(obs): gateway meters`.
|
||||||
|
|
||||||
|
### Task 28: Health probes
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 27
|
||||||
|
|
||||||
|
**Files:** Create `.../Server/Health/{HistorianConnectionHealthCheck,StoreForwardDrainHealthCheck}.cs`; Modify `Program.cs` (`AddHealthChecks` with `GrpcDependencyHealthCheck` for historian, SQL checks for Galaxy + Runtime DB, custom checks, tagged `ZbHealthTags.Ready`); Test health-check unit tests.
|
||||||
|
|
||||||
|
**Steps:** TDD. Probes flip Unhealthy when a dependency is down (fake deps). Commit: `feat(health): historian/galaxy/runtime-db/store-forward probes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — Integration, docs, repo
|
||||||
|
|
||||||
|
### Task 29: Env-gated live integration tests
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** Create `.../tests/.../Integration/{HistorianRoundTripTests,GalaxyBrowseTests}.cs`
|
||||||
|
|
||||||
|
**Steps:** Gated on `HISTORIAN_GRPC_HOST`/`HISTORIAN_GRPC_WRITE_SANDBOX_TAG` and a Galaxy SQL connection env var; `Skip` when absent. Cover read→write→read-back via the self-cleaning sandbox-tag lifecycle and a Galaxy `DiscoverHierarchy`. Run `dotnet test` (skips locally). Commit: `test: env-gated live integration`.
|
||||||
|
|
||||||
|
### Task 30: Full-suite green gate + smoke
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Steps:** Run `dotnet build ZB.MOM.WW.HistorianGateway.slnx` + `dotnet test` (whole solution) on macOS with no live env — Expected: ALL green, live tests skipped. `dotnet run` + curl `/healthz` (200), `/metrics` (text), grpcurl `HistorianStatus/Probe`. Fix any gaps. Commit: `chore: green gate + smoke`.
|
||||||
|
|
||||||
|
### Task 31: CLAUDE.md + README + gitea remote + scadaproj index
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** Create `~/Desktop/HistorianGateway/{CLAUDE.md,README.md}`; copy the two design/plan docs into its `docs/plans/`; Modify `~/Desktop/scadaproj/CLAUDE.md` (index the new sidecar + note the GalaxyRepository follow-on for mxaccessgw).
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Write `CLAUDE.md` (overview, build/run/test commands, the no-COM single-process note, the vendored-histsdk + shared-GalaxyRepository dependencies, config sections, env vars) and `README.md`.
|
||||||
|
2. Create the gitea repo `historiangw` and push: `git -C ~/Desktop/HistorianGateway remote add origin https://gitea.dohertylan.com/dohertj2/historiangw.git && git push -u origin main` (confirm remote name/visibility with the user first).
|
||||||
|
3. Update scadaproj's umbrella `CLAUDE.md` runtime/implementation table with the new project row; commit scadaproj separately.
|
||||||
|
4. Commit: `docs: CLAUDE.md + README; index in scadaproj`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency summary (for parallel dispatch)
|
||||||
|
|
||||||
|
- **Foundational, no blockers:** Task 1 (galaxy lib scaffold), Task 7 (vendor histsdk), Task 8 (repo init) — Task 8 consumes Task 7's tree.
|
||||||
|
- **Galaxy lib chain:** 2→3→4→5→6 (sequential; share files).
|
||||||
|
- **Sidecar chain:** 8→9→10→11→12→13→14, then gRPC services 15→(16,17,18 parallel),19, then 20, then 21.
|
||||||
|
- **Dashboard:** 22→(23,24,25,26 parallel) after Task 20 (auth) + Task 13/14 (data) + Task 5/19 (galaxy).
|
||||||
|
- **Obs:** 27,28 parallel after Task 14.
|
||||||
|
- **Close-out:** 29→30→31 after everything.
|
||||||
|
|
||||||
|
## Notes / non-goals (from design §9)
|
||||||
|
- No `AddS2` live streaming-sample writes (GATED) — live values only via SQL `WriteLiveValues`.
|
||||||
|
- No two-process/x86 worker (no COM).
|
||||||
|
- mxaccessgw adopting `ZB.MOM.WW.GalaxyRepository` is a tracked follow-on, NOT in this plan.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-23-historian-gateway-implementation.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 1: Scaffold the GalaxyRepository lib project", "status": "pending"},
|
||||||
|
{"id": 2, "subject": "Task 2: Port the canonical galaxy_repository.proto (neutral namespace)", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 3: Port the SQL browse provider", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 4, "subject": "Task 4: Port the in-memory hierarchy cache + snapshot + refresh service", "status": "pending", "blockedBy": [3]},
|
||||||
|
{"id": 5, "subject": "Task 5: Port the reusable gRPC service + DI extension", "status": "pending", "blockedBy": [4]},
|
||||||
|
{"id": 6, "subject": "Task 6: Unit tests for projector/cache; pack 0.1.0", "status": "pending", "blockedBy": [5]},
|
||||||
|
{"id": 7, "subject": "Task 7: Vendor histsdk AVEVA.Historian.Client + golden tests", "status": "pending"},
|
||||||
|
{"id": 8, "subject": "Task 8: Init sidecar repo + solution + Directory.Build.props", "status": "pending", "blockedBy": [7]},
|
||||||
|
{"id": 9, "subject": "Task 9: Contracts project + historian_gateway.proto skeleton", "status": "pending", "blockedBy": [8]},
|
||||||
|
{"id": 10, "subject": "Task 10: Server project + minimal boot (telemetry/serilog/health)", "status": "pending", "blockedBy": [9, 7, 6]},
|
||||||
|
{"id": 11, "subject": "Task 11: Configuration options + validators + ConfigPreflight", "status": "pending", "blockedBy": [10]},
|
||||||
|
{"id": 12, "subject": "Task 12: IHistorianClient seam over vendored client", "status": "pending", "blockedBy": [10]},
|
||||||
|
{"id": 13, "subject": "Task 13: Connection pool (pre-authenticated, reused)", "status": "pending", "blockedBy": [12]},
|
||||||
|
{"id": 14, "subject": "Task 14: Write coordinator (store-forward + redundancy) + SQL live-write", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 15, "subject": "Task 15: HistorianRead service + proto mapper", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 16, "subject": "Task 16: HistorianWrite service", "status": "pending", "blockedBy": [14, 15]},
|
||||||
|
{"id": 17, "subject": "Task 17: HistorianTags service", "status": "pending", "blockedBy": [13, 15]},
|
||||||
|
{"id": 18, "subject": "Task 18: HistorianStatus service", "status": "pending", "blockedBy": [13, 15]},
|
||||||
|
{"id": 19, "subject": "Task 19: Galaxy gRPC wiring (consume shared lib)", "status": "pending", "blockedBy": [10, 5]},
|
||||||
|
{"id": 20, "subject": "Task 20: API-key auth interceptor + scope resolver", "status": "pending", "blockedBy": [15]},
|
||||||
|
{"id": 21, "subject": "Task 21: SQLite audit writer + actor accessor + wiring", "status": "pending", "blockedBy": [16, 17, 20]},
|
||||||
|
{"id": 22, "subject": "Task 22: Dashboard shell + LDAP cookie auth + login", "status": "pending", "blockedBy": [20]},
|
||||||
|
{"id": 23, "subject": "Task 23: Status + Health pages", "status": "pending", "blockedBy": [22, 14]},
|
||||||
|
{"id": 24, "subject": "Task 24: Galaxy browser page", "status": "pending", "blockedBy": [22, 19]},
|
||||||
|
{"id": 25, "subject": "Task 25: Historian console page (role-gated write)", "status": "pending", "blockedBy": [22, 15]},
|
||||||
|
{"id": 26, "subject": "Task 26: API-key admin page", "status": "pending", "blockedBy": [22]},
|
||||||
|
{"id": 27, "subject": "Task 27: App meters", "status": "pending", "blockedBy": [14]},
|
||||||
|
{"id": 28, "subject": "Task 28: Health probes", "status": "pending", "blockedBy": [14, 19]},
|
||||||
|
{"id": 29, "subject": "Task 29: Env-gated live integration tests", "status": "pending", "blockedBy": [16, 17, 18, 19]},
|
||||||
|
{"id": 30, "subject": "Task 30: Full-suite green gate + smoke", "status": "pending", "blockedBy": [21, 26, 27, 28, 29]},
|
||||||
|
{"id": 31, "subject": "Task 31: CLAUDE.md + README + gitea remote + scadaproj index", "status": "pending", "blockedBy": [30]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-23"
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
# Environment Variables — SCADA/OT family
|
||||||
|
|
||||||
|
> Cross-project audit of every environment variable used or read by the sister projects
|
||||||
|
> and the shared `ZB.MOM.WW.*` libraries. Compiled **2026-06-03** by sweeping C# reads,
|
||||||
|
> Docker/compose, `launchSettings.json`, shell/PowerShell scripts, and CI for each repo.
|
||||||
|
> This is a **summary index** — when a value matters operationally, confirm against the
|
||||||
|
> cited `file:line` in the owning repo (paths below are relative to each project root).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
| Project | Root | Covered |
|
||||||
|
|---|---|---|
|
||||||
|
| OtOpcUa | `~/Desktop/OtOpcUa` | Host, Galaxy/Historian drivers, docker-dev, tests, CI |
|
||||||
|
| MxAccessGateway | `~/Desktop/MxAccessGateway` | Server (x64), Worker (x86), client CLI, tests, pack script |
|
||||||
|
| ScadaBridge | `~/Desktop/ScadaBridge` | Host (Central/Site), CLI, `docker/`, `docker-env2/`, `infra/`, tests |
|
||||||
|
| Shared libs | `~/Desktop/scadaproj/ZB.MOM.WW.*` | Auth, Theme, Health, Telemetry, Configuration, Audit (code + build scripts) |
|
||||||
|
|
||||||
|
## How env vars reach these apps
|
||||||
|
|
||||||
|
All four .NET apps call `AddEnvironmentVariables()`, so **any** configuration key is overridable
|
||||||
|
from the environment using the **double-underscore (`__`) → colon (`:`)** convention
|
||||||
|
(`ScadaBridge__InboundApi__ApiKeyPepper` overrides `ScadaBridge:InboundApi:ApiKeyPepper`). Array
|
||||||
|
indices use a trailing `__0`, `__1` (`Cluster__SeedNodes__0`). Because *every* options key is
|
||||||
|
technically settable this way, the tables below split into:
|
||||||
|
|
||||||
|
- **Direct reads / operationally-set** — `Environment.GetEnvironmentVariable(...)` in code, or
|
||||||
|
values actually set in compose/launchSettings/scripts. These are the ones you'll really touch.
|
||||||
|
- **Config keys overridable via `__`** — the validated/notable options that are normally in
|
||||||
|
`appsettings*.json` but are commonly (or required to be) supplied via environment in containers.
|
||||||
|
Not every options key is reproduced — only validated, secret, or container-set ones.
|
||||||
|
|
||||||
|
> **Secrets:** rows marked 🔒 are secrets. Per the Auth/Config normalization, peppers/keys/passwords
|
||||||
|
> are **per-environment secrets injected out-of-band** (secret store / orchestrator), never committed.
|
||||||
|
> The dev-only values that *do* appear in compose are explicitly insecure placeholders for the local
|
||||||
|
> clusters — see `scadabridge-local-deploy-gotchas` and `docs/operations/inbound-api-key-reissue.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. OtOpcUa
|
||||||
|
|
||||||
|
### 1.1 Direct reads / operationally-set (runtime)
|
||||||
|
|
||||||
|
| Variable | Where | Purpose | Req? / default | Process |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `OTOPCUA_ROLES` | `src/Server/.../Host/Program.cs:31` | Comma-list of roles (admin, driver) for conditional wiring | optional | Host |
|
||||||
|
| `ASPNETCORE_ENVIRONMENT` | `Host/Properties/launchSettings.json:9` | ASP.NET Core environment | optional / `Production` | Host |
|
||||||
|
| `ASPNETCORE_URLS` | `docker-dev/docker-compose.yml` | Kestrel bind address/port | optional | Host |
|
||||||
|
| `GALAXY_MXGW_API_KEY` 🔒 | `docker-dev/docker-compose.yml`; resolved in `GalaxyDriver.cs:466` | mxaccessgw API key for the Galaxy driver | required if Galaxy driver deployed | Driver (Galaxy) |
|
||||||
|
| `OTOPCUA_CONFIG_CONNECTION` 🔒 | `Configuration/DesignTimeDbContextFactory.cs:25` | SQL connection for EF design-time (`dotnet ef`) | required for migrations tooling | build-time |
|
||||||
|
| `OTOPCUA_HISTORIAN_PIPE` | `Driver.Historian.Wonderware/Program.cs:32` | Named-pipe name for the Historian sidecar IPC | required | Historian sidecar |
|
||||||
|
| `OTOPCUA_ALLOWED_SID` | `…/Program.cs:34` | Windows SID allowed to connect to the sidecar | required | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_SECRET` 🔒 | `…/Program.cs:36` | Shared secret for named-pipe auth | required | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_ENABLED` | `…/Program.cs:48` | Init the Historian SDK (else pipe-only) | optional / `false` | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_SERVER` | `…/Program.cs:89` | Wonderware Historian host | optional / `localhost` | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_PORT` | `…/Program.cs:90` | Historian port | optional / `32568` | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_INTEGRATED` | `…/Program.cs:91` | Use Windows Integrated Security | optional / `true` | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_USER` | `…/Program.cs:92` | SQL user (when not integrated) | optional | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_PASS` 🔒 | `…/Program.cs:93` | SQL password (when not integrated) | optional | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_TIMEOUT_SEC` | `…/Program.cs:94` | SQL command timeout (s) | optional / `30` | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_MAX_VALUES` | `…/Program.cs:95` | Max values per read query | optional / `10000` | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_COOLDOWN_SEC` | `…/Program.cs:96` | Failure cooldown before retry (s) | optional / `60` | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_SERVERS` | `…/Program.cs:99` | Comma-list of historian servers for failover | optional | Historian sidecar |
|
||||||
|
| `OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED` | `…/Program.cs:125` | Enable alarm-event writer to historian | optional / `true` when enabled | Historian sidecar |
|
||||||
|
|
||||||
|
> The Galaxy API key supports prefix resolution (`GalaxyDriver.cs:466`): `env:VAR` (read another
|
||||||
|
> env var), `file:PATH` (read a file), `dev:LITERAL` (dev only), or an unprefixed literal.
|
||||||
|
|
||||||
|
### 1.2 Config keys overridable via `__` (set in `docker-dev/docker-compose.yml`)
|
||||||
|
|
||||||
|
| Variable | Purpose | Req? |
|
||||||
|
|---|---|---|
|
||||||
|
| `ConnectionStrings__ConfigDb` 🔒 | SQL connection for the OtOpcUa config DB | required |
|
||||||
|
| `Cluster__Hostname` / `Cluster__Port` / `Cluster__PublicHostname` | Akka remoting bind/advertise | required |
|
||||||
|
| `Cluster__SeedNodes__0` | Initial seed node URL | required |
|
||||||
|
| `Cluster__Roles__0` (`__1`) | Cluster role assignment (admin/driver) | required |
|
||||||
|
| `Security__Jwt__SigningKey` 🔒 | JWT signing key (≥32 bytes) | required |
|
||||||
|
| `Security__Jwt__Issuer` / `Security__Jwt__Audience` | JWT claims | required |
|
||||||
|
| `Security__Ldap__*` | LDAP host/bind (in `appsettings.admin*.json`) | per-deploy |
|
||||||
|
| `Authentication__Ldap__DevStubMode` | Dev LDAP stub (any user → FleetAdmin) — **removed from docker-dev**; `docker-dev` now binds the shared GLAuth on `10.100.0.35:3893` | optional / `false` |
|
||||||
|
|
||||||
|
### 1.3 Docker infra & test fixtures (OtOpcUa)
|
||||||
|
|
||||||
|
- **docker-dev SQL:** `ACCEPT_EULA=Y`, `SA_PASSWORD` 🔒 (`OtOpcUa!Dev123` dev-only), `MSSQL_PID=Developer`.
|
||||||
|
- **docker-dev seed (`seed/entrypoint.sh`):** `SQL_HOST` / `SQL_USER` / `SQL_PASSWORD` 🔒 / `SQL_DATABASE` (defaults `sql`/`sa`/`OtOpcUa!Dev123`/`OtOpcUa`).
|
||||||
|
- **Integration compose:** SQL (`SA_PASSWORD` 🔒 `OtOpcUa!Harness123`) + OpenLDAP (`LDAP_ROOT`, `LDAP_ADMIN_USERNAME`, `LDAP_ADMIN_PASSWORD` 🔒, `LDAP_USERS`, `LDAP_PASSWORDS` 🔒, `LDAP_USER_DC`) — this OpenLDAP instance is **integration-test-only**; the standard DEV auth is the shared GLAuth at `10.100.0.35:3893` (`dc=zb,dc=local`, see `scadaproj/infra/glauth/`).
|
||||||
|
- **Test-fixture overrides (all optional, default to the shared host `10.100.0.35`):** `OPCUA_SIM_ENDPOINT`, `MODBUS_SIM_ENDPOINT`, `MODBUS_SIM_PROFILE`, `AB_SERVER_ENDPOINT`, `AB_SERVER_PROFILE`, `S7_SIM_ENDPOINT`, `OTOPCUA_FOCAS_SIM_ENDPOINT`, `OTOPCUA_FOCAS_SIM_PROFILE`, `TWINCAT_HOST`, `TWINCAT_NETID`, `TWINCAT_PORT`, `AB_LEGACY_ENDPOINT`, `AB_LEGACY_CIP_PATH`, `AB_LEGACY_COMPOSE_PROFILE`, `OTOPCUA_CONFIG_TEST_SERVER`, `OTOPCUA_CONFIG_TEST_SA_PASSWORD` 🔒, `OTOPCUA_HARNESS_USE_SQL`, `OTOPCUA_HARNESS_USE_LDAP`, `MXGW_ENDPOINT`, `D1_SMOKE_OUT`.
|
||||||
|
- **CI (`.github/workflows/v2-*.yml`):** `DOTNET_NOLOGO=1`, `DOTNET_CLI_TELEMETRY_OPTOUT=1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MxAccessGateway
|
||||||
|
|
||||||
|
> Ships **no** Docker/compose assets — it's a Windows-native app (x64 .NET 10 Server + x86 .NET 4.8 Worker).
|
||||||
|
> The Server spawns the Worker via `ProcessStartInfo.Environment`, passing the two `MXGATEWAY_WORKER_*` vars below.
|
||||||
|
|
||||||
|
### 2.1 Direct reads / operationally-set (runtime)
|
||||||
|
|
||||||
|
| Variable | Where | Purpose | Req? / default | Process |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `MXGATEWAY_WORKER_NONCE` 🔒 | Server `WorkerProcessLauncher.cs:180` → Worker `WorkerOptionsParser.cs:78` | Per-session handshake nonce (kept off the command line) | required (generated per session) | Server→Worker |
|
||||||
|
| `MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS` | Server `WorkerProcessLauncher.cs:181` → Worker `WorkerPipeClient.cs:255` | Per-attempt named-pipe connect timeout | optional / `2000` | Server→Worker |
|
||||||
|
| `ASPNETCORE_CONTENTROOT` | `GatewayApplication.cs:130` | Content-root override (logs/wwwroot) | optional / auto | Server |
|
||||||
|
| `ASPNETCORE_ENVIRONMENT` | `Server/Properties/launchSettings.json:10` | Environment selector | optional / `Production` | Server |
|
||||||
|
|
||||||
|
### 2.2 Config keys overridable via `__` (notable)
|
||||||
|
|
||||||
|
| Variable | Purpose | Req? |
|
||||||
|
|---|---|---|
|
||||||
|
| `MxGateway__ApiKeyPepper` 🔒 | HMAC pepper for API-key secrets in the SQLite auth DB | required when auth Mode=ApiKey |
|
||||||
|
| `MxGateway__Authentication__Mode` / `__SqlitePath` / `__PepperSecretName` | Auth mode, auth DB path, pepper config-key name | per-deploy |
|
||||||
|
| `MxGateway__Worker__ExecutablePath` / `__WorkingDirectory` / `__StartupTimeoutSeconds` / `__PipeConnectAttemptTimeoutMilliseconds` | x86 worker launch config | per-deploy |
|
||||||
|
| `MxGateway__Sessions__*` / `MxGateway__Events__QueueCapacity` | Session pool & event queue tuning | optional |
|
||||||
|
| `MxGateway__Galaxy__ConnectionString` 🔒 | SQL connection for Galaxy browse RPCs | per-deploy |
|
||||||
|
| `MxGateway__Alarms__*` / `MxGateway__Dashboard__*` | Alarm monitor & dashboard config | optional |
|
||||||
|
| `MxGateway__Telemetry__Exporter` / `__OtlpEndpoint` | OpenTelemetry exporter selection / OTLP endpoint | optional |
|
||||||
|
| `MxGateway__Tls__SelfSignedCertPath` | Self-signed PFX path | optional |
|
||||||
|
| `Kestrel__Endpoints__Http__Url` / `__Protocols`, `Kestrel__Endpoints__Dashboard__Url` | gRPC (h2c) + dashboard endpoints | per-deploy |
|
||||||
|
|
||||||
|
### 2.3 Client CLI, tests & build script (MxGateway)
|
||||||
|
|
||||||
|
- **Client CLI / smoke tests:** `MXGATEWAY_ENDPOINT` (default `http://localhost:5000`), `MXGATEWAY_API_KEY` 🔒 (`MxGatewayClientCli.cs:289`).
|
||||||
|
- **Live-test opt-in gates (set to `1` to enable; otherwise skipped):** `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS`, `MXGATEWAY_RUN_LIVE_LDAP_TESTS`.
|
||||||
|
- **Live-test params (optional):** `MXGATEWAY_LIVE_MXACCESS_WORKER_EXE`, `_ITEM`, `_CLIENT_NAME`, `_EVENT_TIMEOUT_SECONDS`, `_WRITE_SECURED_USER`, `_WRITE_SECURED_PASSWORD` 🔒, `MXGATEWAY_LIVE_GALAXY_CONN` 🔒.
|
||||||
|
- **Pack/publish (`scripts/pack-clients.ps1`):** `GITEA_USERNAME`, `GITEA_TOKEN` 🔒 (required with `-Publish`), `JAVA_HOME` (Java client build).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ScadaBridge
|
||||||
|
|
||||||
|
> Role is selected by `SCADABRIDGE_CONFIG` (`Central`|`Site`), which picks `appsettings.{role}.json`
|
||||||
|
> (falls back to `DOTNET_ENVIRONMENT`, then `Production`). The pre-host `StartupValidator` /
|
||||||
|
> `ConfigPreflight` enforces the **required** keys below and fails fast if any are missing/invalid.
|
||||||
|
|
||||||
|
### 3.1 Direct reads (C#)
|
||||||
|
|
||||||
|
| Variable | Where | Purpose | Req? / default | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SCADABRIDGE_CONFIG` | `Host/Program.cs:31` | Role selector → appsettings file | optional (→ `DOTNET_ENVIRONMENT` → `Production`) | both |
|
||||||
|
| `DOTNET_ENVIRONMENT` | `Host/Program.cs:32` | Fallback role/env selector | optional | both |
|
||||||
|
| `ASPNETCORE_ENVIRONMENT` | `Host/Program.cs:245` | Dev-mode check | optional | both |
|
||||||
|
| `SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING` 🔒 | `ConfigurationDatabase/DesignTimeDbContextFactory.cs:48` | EF tooling connection (`dotnet ef`) | optional (build-time) | design-time |
|
||||||
|
| `SCADABRIDGE_MANAGEMENT_URL` | `CLI/CliConfig.cs:72` | Management API URL for the CLI | optional | CLI |
|
||||||
|
| `SCADABRIDGE_FORMAT` | `CLI/CliConfig.cs:76` | CLI default output format | optional / `json` | CLI |
|
||||||
|
| `SCADABRIDGE_USERNAME` | `CLI/CliConfig.cs:81` | CLI LDAP username (safer than `--password`) | optional | CLI |
|
||||||
|
| `SCADABRIDGE_PASSWORD` 🔒 | `CLI/CliConfig.cs:85` | CLI LDAP password | optional | CLI |
|
||||||
|
|
||||||
|
### 3.2 Config keys required at startup (`__` form, enforced by `StartupValidator`)
|
||||||
|
|
||||||
|
| Variable | Purpose | Constraint | Scope |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ScadaBridge__Node__Role` | `Central` or `Site` | required | all |
|
||||||
|
| `ScadaBridge__Node__NodeHostname` | Hostname advertised to cluster | required, non-empty | all |
|
||||||
|
| `ScadaBridge__Node__RemotingPort` | Akka remoting TCP port | required, 1–65535 (default 8081) | all |
|
||||||
|
| `ScadaBridge__Cluster__SeedNodes__0` / `__1` | Akka seed addresses | required, ≥2 entries | all |
|
||||||
|
| `ScadaBridge__Database__ConfigurationDb` 🔒 | SQL config-DB connection | required | Central |
|
||||||
|
| `ScadaBridge__Security__JwtSigningKey` 🔒 | Cookie-JWT HMAC key | required, ≥32 chars | Central |
|
||||||
|
| `ScadaBridge__Security__Ldap__Server` | LDAP host | required | Central |
|
||||||
|
| `ScadaBridge__InboundApi__ApiKeyPepper` 🔒 | Peppered-HMAC inbound API-key pepper | **required, ≥16 chars** | Central |
|
||||||
|
| `ScadaBridge__Node__SiteId` | Site identifier | required | Site |
|
||||||
|
| `ScadaBridge__Database__SiteDbPath` | Site-local SQLite path | required | Site |
|
||||||
|
| `ScadaBridge__Node__GrpcPort` | gRPC streaming port | default 8083; ≠ remoting/metrics | Site |
|
||||||
|
| `ScadaBridge__Node__MetricsPort` | Prometheus `/metrics` port | default 8084; ≠ remoting/grpc | Site |
|
||||||
|
|
||||||
|
> LDAP service-account fields (`ScadaBridge__Security__Ldap__ServiceAccountDn`,
|
||||||
|
> `__ServiceAccountPassword` 🔒, `__SearchBase`) are validated **post-host** by `LdapOptionsValidator`
|
||||||
|
> for Central, not in the fail-fast pre-host pass.
|
||||||
|
|
||||||
|
### 3.3 Config keys overridable via `__` (optional, not validated)
|
||||||
|
|
||||||
|
Large surface — all bound from the role appsettings and overridable via env. Grouped by module
|
||||||
|
(each prefixed `ScadaBridge__`):
|
||||||
|
|
||||||
|
- **Node:** `Node__NodeName`. **Database:** `Database__MachineDataDb` 🔒, `Database__SkipMigrations`.
|
||||||
|
- **Security:** `Security__Ldap__Port|Transport|AllowInsecure`, `Security__JwtExpiryMinutes`, `Security__IdleTimeoutMinutes`, `Security__JwtRefreshThresholdMinutes`, `Security__RequireHttpsCookie`.
|
||||||
|
- **Cluster (SBR):** `Cluster__SplitBrainResolverStrategy|StableAfter|HeartbeatInterval|FailureDetectionThreshold|MinNrOfMembers|DownIfAlone`.
|
||||||
|
- **Communication:** `Communication__DeploymentTimeout|LifecycleTimeout|QueryTimeout|TransportHeartbeatInterval|TransportFailureThreshold`, `Communication__CentralContactPoints__0…` (Site→Central ClusterClient).
|
||||||
|
- **HealthMonitoring:** `HealthMonitoring__ReportInterval|OfflineTimeout|CentralOfflineTimeout`.
|
||||||
|
- **InboundApi:** `InboundApi__DefaultMethodTimeout|MaxRequestBodyBytes`.
|
||||||
|
- **Notification (SMTP):** `Notification__SmtpServer|SmtpPort|AuthMode|FromAddress|ConnectionTimeoutSeconds|MaxConcurrentConnections`.
|
||||||
|
- **NotificationOutbox (Central):** `NotificationOutbox__DispatchInterval|DispatchBatchSize|StuckAgeThreshold|TerminalRetention|PurgeInterval|DeliveredKpiWindow`.
|
||||||
|
- **Transport (Central):** `Transport__SourceEnvironment|BundleSessionTtlMinutes|MaxBundleSizeMb|MaxBundleEntryDecompressedMb|MaxBundleEntryCount|MaxBundleEntryCompressionRatio|MaxUnlockAttemptsPerSession|MaxUnlockAttemptsPerIpPerHour|Pbkdf2Iterations`.
|
||||||
|
- **Logging:** `Logging__MinimumLevel`.
|
||||||
|
- **DataConnection (Site):** `DataConnection__ReconnectInterval|TagResolutionRetryInterval|WriteTimeout|StableConnectionThreshold`.
|
||||||
|
- **StoreAndForward (Site):** `StoreAndForward__SqliteDbPath|ReplicationEnabled|DefaultRetryInterval|DefaultMaxRetries`.
|
||||||
|
- **SiteEventLog (Site):** `SiteEventLog__RetentionDays|MaxStorageMb|DatabasePath|PurgeInterval`.
|
||||||
|
- **SiteRuntime (Site):** `SiteRuntime__StartupBatchSize|StartupBatchDelayMs|MaxScriptCallDepth|ScriptExecutionTimeoutSeconds|StreamBufferSize|ScriptExecutionThreadCount`.
|
||||||
|
- **Telemetry (Site):** `Telemetry__Exporter|OtlpEndpoint`.
|
||||||
|
|
||||||
|
### 3.4 Docker / infra / build (ScadaBridge)
|
||||||
|
|
||||||
|
- **`docker/docker-compose.yml`** (3-site cluster) — Central: `SCADABRIDGE_CONFIG=Central`, `ASPNETCORE_ENVIRONMENT=Development`, `ASPNETCORE_URLS=http://+:5000`, `ScadaBridge__InboundApi__ApiKeyPepper` 🔒 = `dev-only-insecure-pepper-docker-cluster-0001`; Sites: `SCADABRIDGE_CONFIG=Site`.
|
||||||
|
- **`docker-env2/docker-compose.yml`** (1-site cluster) — same shape, pepper 🔒 = `dev-only-insecure-pepper-env2-cluster-0001` (distinct per environment).
|
||||||
|
- **`infra/docker-compose.yml`** — MSSQL (`ACCEPT_EULA=Y`, `MSSQL_SA_PASSWORD` 🔒 `ScadaBridge_Dev1#`, `MSSQL_PID=Developer`), Mailpit SMTP (`MP_SMTP_AUTH_ACCEPT_ANY=1`, `MP_SMTP_AUTH_ALLOW_INSECURE=1`, `MP_MAX_MESSAGES=500`), REST API (`API_NO_AUTH=0`, `PORT=5200`).
|
||||||
|
- **`docker/Dockerfile` build args:** `NUGET_GITEA_USER` / `NUGET_GITEA_PASS` 🔒 → injected as `NuGetPackageSourceCredentials_dohertj2-gitea`. **`docker/build.sh`** reads `MXGW_NUGET_USER` / `MXGW_NUGET_PASS` 🔒 from host env (blank ⇒ anonymous feed).
|
||||||
|
- **Seed/init scripts** (`docker*/init-db.sh`, `seed-sites.sh`) hardcode the dev SA password 🔒 `ScadaBridge_Dev1#`.
|
||||||
|
- **`launchSettings.json`** profiles set `DOTNET_ENVIRONMENT`, `ASPNETCORE_ENVIRONMENT`, `SCADABRIDGE_CONFIG` (`Central`/`Site`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Shared libraries (`ZB.MOM.WW.*`)
|
||||||
|
|
||||||
|
The libraries deliberately read almost nothing from the environment directly — config flows through
|
||||||
|
strongly-typed options bound by the **consuming** app. Notable exceptions:
|
||||||
|
|
||||||
|
| Variable | Where | Purpose | Req? / default |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GITEA_NUGET_SOURCE` | `ZB.MOM.WW.{Auth,Theme,Audit}/build/push.sh:16` | Gitea NuGet feed URL for `dotnet nuget push` | required to publish |
|
||||||
|
| `GITEA_NUGET_KEY` 🔒 | `…/build/push.sh:17` | Gitea token (`package:write`) | required to publish |
|
||||||
|
| `ZB_LDAP_IT` | `ZB.MOM.WW.Auth/tests/.../GLAuthIntegrationTests.cs:48` | Gate flag (`1`) to run the live LDAP test | optional (skipped if unset) |
|
||||||
|
| `ZB_LDAP_SERVER` / `_PORT` / `_BASE` / `_SVC_DN` / `_SVC_PW` 🔒 / `_USER` / `_PW` 🔒 / `_USERATTR` | `…/GLAuthIntegrationTests.cs:52-59` | Live LDAP test connection params | optional (defaults: `localhost`/`3893`/`dc=zb,dc=local`/…); point at the shared GLAuth (`10.100.0.35:3893`, `dc=zb,dc=local`) for the live test |
|
||||||
|
|
||||||
|
**Telemetry:** `ZB.MOM.WW.Telemetry` does **not** read standard `OTEL_*` env vars — OTel identity/exporter
|
||||||
|
come from `ZbTelemetryOptions` passed to `AddZbTelemetry()`. It only reads system properties
|
||||||
|
(`Environment.MachineName`, `Environment.ProcessId` in `ZbResource.cs:20`) to form
|
||||||
|
`service.instance.id` / `host.name`. Consuming apps that want OTLP wire it via their own
|
||||||
|
`…Telemetry__OtlpEndpoint` config key (see MxGateway §2.2, ScadaBridge §3.3).
|
||||||
|
|
||||||
|
**Health, Configuration, Audit, Theme:** no direct environment-variable reads (code or build).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cross-cutting cheat sheet
|
||||||
|
|
||||||
|
### Standard framework vars (honored everywhere)
|
||||||
|
`ASPNETCORE_ENVIRONMENT`, `ASPNETCORE_URLS`, `ASPNETCORE_CONTENTROOT`, `DOTNET_ENVIRONMENT`,
|
||||||
|
`DOTNET_NOLOGO`, `DOTNET_CLI_TELEMETRY_OPTOUT`.
|
||||||
|
|
||||||
|
### Build/publish (Gitea NuGet feed) — naming differs per repo
|
||||||
|
| Repo | Vars |
|
||||||
|
|---|---|
|
||||||
|
| Shared libs | `GITEA_NUGET_SOURCE`, `GITEA_NUGET_KEY` 🔒 |
|
||||||
|
| MxGateway pack | `GITEA_USERNAME`, `GITEA_TOKEN` 🔒 |
|
||||||
|
| ScadaBridge image build | `MXGW_NUGET_USER`, `MXGW_NUGET_PASS` 🔒 (→ `NUGET_GITEA_USER`/`NUGET_GITEA_PASS` build args) |
|
||||||
|
|
||||||
|
### Secrets inventory (🔒) — inject out-of-band, never commit real values
|
||||||
|
- **Peppers:** `ScadaBridge__InboundApi__ApiKeyPepper` (≥16, Central-only), `MxGateway__ApiKeyPepper`.
|
||||||
|
- **Signing/JWT keys:** `Security__Jwt__SigningKey` (OtOpcUa), `ScadaBridge__Security__JwtSigningKey`.
|
||||||
|
- **API keys / nonces:** `GALAXY_MXGW_API_KEY`, `MXGATEWAY_API_KEY`, `MXGATEWAY_WORKER_NONCE`, `OTOPCUA_HISTORIAN_SECRET`.
|
||||||
|
- **DB / LDAP / SMTP passwords:** all `*SA_PASSWORD`/`MSSQL_SA_PASSWORD`, `ConnectionStrings__ConfigDb`, `ScadaBridge__Database__ConfigurationDb`, `*Ldap*Password`, `SCADABRIDGE_PASSWORD`, `OTOPCUA_HISTORIAN_PASS`.
|
||||||
|
- **Feed tokens:** `GITEA_NUGET_KEY`, `GITEA_TOKEN`, `MXGW_NUGET_PASS`/`NUGET_GITEA_PASS`.
|
||||||
|
|
||||||
|
> The only secret-typed values that legitimately appear in source are the **dev-only, insecure**
|
||||||
|
> local-cluster placeholders in `docker*/docker-compose.yml` and the dev SA passwords in the
|
||||||
|
> `infra`/seed scripts — usable for the local stacks only, never as real secrets.
|
||||||
+114605
File diff suppressed because it is too large
Load Diff
+324
@@ -0,0 +1,324 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a fake company's Unified Namespace (UNS) for OtOpcUa, grounded in the
|
||||||
|
REAL AVEVA Galaxy "DEV" hierarchy pulled from the MxAccess gateway
|
||||||
|
(galaxy-hierarchy.json).
|
||||||
|
|
||||||
|
Mapping onto OtOpcUa's config-DB model (ServerCluster -> Namespace(Kind=Equipment)
|
||||||
|
-> UnsArea -> UnsLine -> Equipment -> Tag), with the canonical UNS path:
|
||||||
|
|
||||||
|
Enterprise / Site / Area / Line / Equipment / Signal
|
||||||
|
|
||||||
|
Real grounding:
|
||||||
|
* The 40 $TestMachine instances (TestMachine_001..040) become Equipment ("machines").
|
||||||
|
* The Galaxy areas they are deployed in (TestArea / TestArea2 / TestArea3)
|
||||||
|
become UNS Areas.
|
||||||
|
* Each machine's real DELMIA + MES receiver children (resolved from Galaxy
|
||||||
|
containment) become tag sub-folders ("delmia" / "mes") whose Tags carry the
|
||||||
|
REAL MXAccess fullTagReference.
|
||||||
|
* Process Tags map the real $TestMachine UDAs (TestChangingInt, TestDouble, ...)
|
||||||
|
to believable signal names while preserving the real reference.
|
||||||
|
|
||||||
|
Synthesized ("fake company") parts: enterprise/site/area/line names, machine
|
||||||
|
archetypes, and asset metadata (manufacturer/model/serial/SAP id). Everything
|
||||||
|
synthetic is clearly under generated identifiers; every node keeps a `source`
|
||||||
|
block linking back to the real Galaxy object.
|
||||||
|
|
||||||
|
OtOpcUa UNS-segment rule enforced: Area/Line/Equipment names match ^[a-z0-9-]{1,32}$.
|
||||||
|
"""
|
||||||
|
import json, re, collections, hashlib
|
||||||
|
|
||||||
|
SRC = "galaxy-hierarchy.json"
|
||||||
|
SEG = re.compile(r"^[a-z0-9-]{1,32}$")
|
||||||
|
|
||||||
|
def seg(s):
|
||||||
|
assert SEG.match(s), f"invalid UNS segment: {s!r}"
|
||||||
|
return s
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- load source
|
||||||
|
gal = json.load(open(SRC))
|
||||||
|
objs = gal["objects"]
|
||||||
|
byid = {o["gobjectId"]: o for o in objs}
|
||||||
|
def name(i): return byid[i]["tagName"] if i in byid else None
|
||||||
|
def children(pid): return [o for o in objs if o.get("parentGobjectId") == pid]
|
||||||
|
|
||||||
|
def chain_has(o, frag):
|
||||||
|
return any(frag in c for c in o.get("templateChain", []))
|
||||||
|
|
||||||
|
# the 40 top-level machines, grouped by their Galaxy area
|
||||||
|
AREAS_GAL = {} # galaxy area tag -> list of machine objects (sorted by number)
|
||||||
|
for o in objs:
|
||||||
|
if "$TestMachine" in o.get("templateChain", []): # exactly $TestMachine (not .DelmiaReceiver etc.)
|
||||||
|
area = name(o.get("parentGobjectId"))
|
||||||
|
AREAS_GAL.setdefault(area, []).append(o)
|
||||||
|
for a in AREAS_GAL:
|
||||||
|
AREAS_GAL[a].sort(key=lambda o: o["tagName"])
|
||||||
|
|
||||||
|
# resolve each machine's real DELMIA / MES receiver child tag names
|
||||||
|
def receiver_refs(machine):
|
||||||
|
delmia = mes = None
|
||||||
|
for c in children(machine["gobjectId"]):
|
||||||
|
if chain_has(c, "DelmiaReceiver"): delmia = c
|
||||||
|
elif chain_has(c, "MESReceiver"): mes = c
|
||||||
|
return delmia, mes
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- fake company
|
||||||
|
ENTERPRISE = "northwind" # fictional consumer-goods manufacturer
|
||||||
|
SITE = "birmingham" # fictional plant (maps to Galaxy node/galaxy DEV)
|
||||||
|
GALAXY_NODE = "DESKTOP-6JL3KKO"
|
||||||
|
GALAXY = "DEV"
|
||||||
|
|
||||||
|
# Galaxy area -> (uns area segment, friendly label, line plan)
|
||||||
|
# line plan: list of (line-segment, count) consuming machines in order.
|
||||||
|
AREA_PLAN = {
|
||||||
|
"TestArea": ("filling", "Filling & Capping", [("line-1", 7), ("line-2", 6), ("line-3", 6)]),
|
||||||
|
"TestArea2": ("blending", "Blending & CIP", [("cip-1", 1)]),
|
||||||
|
"TestArea3": ("packaging", "Packaging & Palletizing",
|
||||||
|
[("pack-1", 5), ("pack-2", 5), ("pack-3", 5), ("pack-4", 5)]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# believable machine archetypes cycled within a line, per area
|
||||||
|
ARCHETYPES = {
|
||||||
|
"filling": [("rinser","krones","Hydra"),("filler","sidel","SF300"),
|
||||||
|
("capper","khs","Innofill"),("labeler","krones","Contiroll"),
|
||||||
|
("inspector","antares-vision","Vmax"),("coder","videojet","1580")],
|
||||||
|
"blending": [("blender","spx-flow","APV-R5")],
|
||||||
|
"packaging": [("cartoner","marchesini","MC820"),("case-packer","bosch","Elematic"),
|
||||||
|
("palletizer","fanuc","M410"),("stretch-wrapper","lantech","Q300"),
|
||||||
|
("checkweigher","mettler-toledo","C3570")],
|
||||||
|
}
|
||||||
|
|
||||||
|
def asset(area_seg, archetype, model_series, gtag, gidx):
|
||||||
|
mfr_model = {a[0]:(a[1],a[2]) for a in ARCHETYPES[area_seg]}
|
||||||
|
mfr, model = mfr_model[archetype]
|
||||||
|
h = int(hashlib.sha1(gtag.encode()).hexdigest(), 16)
|
||||||
|
return {
|
||||||
|
"manufacturer": mfr,
|
||||||
|
"model": f"{model} Srs{2 + h % 4}",
|
||||||
|
"serialNumber": f"SN26{gidx:05d}",
|
||||||
|
"sapId": f"100{200000 + h % 700000}",
|
||||||
|
"zTag": f"Z{area_seg[:3].upper()}{gidx:04d}",
|
||||||
|
"hardwareRevision": f"H{1 + h % 4}.{h % 10}",
|
||||||
|
"softwareRevision": f"S{2 + h % 3}.{(h>>4) % 10}",
|
||||||
|
"equipmentClassRef": f"urn:northwind:equipclass:{archetype}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------- signal (Tag) templates
|
||||||
|
def dtype(galaxy_type):
|
||||||
|
return {
|
||||||
|
"Boolean":"Boolean","Integer":"Int32","Double":"Double","Float":"Float",
|
||||||
|
"String":"String","Time":"DateTime","ElapsedTime":"Double",
|
||||||
|
"InternationalizedString":"String",
|
||||||
|
}.get(galaxy_type, "String")
|
||||||
|
|
||||||
|
# process signals: (uns tag name, real $TestMachine UDA, galaxy type, access, flags)
|
||||||
|
PROCESS_SIGNALS = [
|
||||||
|
("speed-rpm", "TestChangingInt", "Integer", "ReadOnly", {}),
|
||||||
|
("production-count", "TestHistoryValue","Integer", "ReadOnly", {"historize": True}),
|
||||||
|
("temperature-c", "TestDouble", "Double", "ReadOnly", {}),
|
||||||
|
("pressure-bar", "TestFloat", "Float", "ReadOnly", {}),
|
||||||
|
("cycle-time-s", "TestDuration", "ElapsedTime","ReadOnly",{}),
|
||||||
|
("last-cycle-ts", "TestDateTime", "Time", "ReadOnly", {}),
|
||||||
|
("safety-interlock", "ProtectedValue", "Boolean", "ReadWrite", {"secured": True}),
|
||||||
|
("maint-lockout", "ProtectedValue1", "Boolean", "ReadWrite", {"secured": True}),
|
||||||
|
("motor-fault", "TestAlarm001", "Boolean", "ReadOnly", {"alarm": True}),
|
||||||
|
("over-temp", "TestAlarm002", "Boolean", "ReadOnly", {"alarm": True}),
|
||||||
|
("jam-detected", "TestAlarm003", "Boolean", "ReadOnly", {"alarm": True}),
|
||||||
|
("in-alarm", "InAlarm", "Boolean", "ReadOnly", {}),
|
||||||
|
]
|
||||||
|
# DELMIA receiver signals (real $DelmiaReceiver attributes)
|
||||||
|
DELMIA_SIGNALS = [
|
||||||
|
("work-order", "WorkOrderNumber", "String", "ReadWrite", {}),
|
||||||
|
("part-number", "PartNumber", "String", "ReadWrite", {}),
|
||||||
|
("job-step", "JobStepNumber", "String", "ReadWrite", {}),
|
||||||
|
("recipe-path", "DownloadPath", "String", "ReadWrite", {}),
|
||||||
|
("recipe-dl-req", "RecipeDownloadFlag","Boolean","ReadWrite", {}),
|
||||||
|
("recipe-done", "RecipeProcessedFlag","Boolean","ReadOnly", {}),
|
||||||
|
("recipe-result", "RecipeProcessResult","Boolean","ReadOnly", {}),
|
||||||
|
("ready", "ReadyFlag", "Boolean","ReadOnly", {}),
|
||||||
|
]
|
||||||
|
# MES receiver signals (real $MESReceiver attributes)
|
||||||
|
MES_SIGNALS = [
|
||||||
|
("move-in-req", "MoveInFlag", "Boolean","ReadWrite", {}),
|
||||||
|
("move-in-batch", "MoveInBatchID", "Integer","ReadWrite", {}),
|
||||||
|
("move-in-operator","MoveInOperatorName", "String", "ReadOnly", {}),
|
||||||
|
("move-in-ok", "MoveInSuccessFlag", "Boolean","ReadOnly", {}),
|
||||||
|
("move-out-req", "MoveOutFlag", "Boolean","ReadWrite", {}),
|
||||||
|
("move-out-batch", "MoveOutBatchID", "Integer","ReadWrite", {}),
|
||||||
|
("move-out-ok", "MoveOutSuccessfulFlag","Boolean","ReadOnly", {}),
|
||||||
|
("container-id", "MoveOutMesContainerNum","String","ReadOnly", {}),
|
||||||
|
]
|
||||||
|
|
||||||
|
def attr_set(obj):
|
||||||
|
return {a["attributeName"] for a in obj["attributes"]} if obj else set()
|
||||||
|
|
||||||
|
def make_tags(equipment_id, machine, delmia, mes):
|
||||||
|
tags = []
|
||||||
|
def add(folder, signals, ref_obj):
|
||||||
|
if ref_obj is None:
|
||||||
|
return
|
||||||
|
ref_tag = ref_obj["tagName"]
|
||||||
|
have = attr_set(ref_obj)
|
||||||
|
for uns_name, attr, gtype, access, flags in signals:
|
||||||
|
if attr not in have:
|
||||||
|
continue # only emit signals whose real attribute exists on THIS instance
|
||||||
|
full = f"{ref_tag}.{attr}"
|
||||||
|
tid = f"tag-{equipment_id}-{(folder+'-' if folder else '')}{uns_name}"
|
||||||
|
t = {
|
||||||
|
"tagId": tid,
|
||||||
|
"name": uns_name,
|
||||||
|
"folderPath": folder,
|
||||||
|
"dataType": dtype(gtype),
|
||||||
|
"accessLevel": access,
|
||||||
|
"historize": bool(flags.get("historize", False)),
|
||||||
|
"tagConfig": {
|
||||||
|
"isAlarm": bool(flags.get("alarm", False)),
|
||||||
|
"secured": bool(flags.get("secured", False)),
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"namespaceKind": "SystemPlatform",
|
||||||
|
"fullTagReference": full,
|
||||||
|
"galaxyType": gtype,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tags.append(t)
|
||||||
|
add("", PROCESS_SIGNALS, machine)
|
||||||
|
add("delmia", DELMIA_SIGNALS, delmia)
|
||||||
|
add("mes", MES_SIGNALS, mes)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- build UNS
|
||||||
|
cluster_id = f"{ENTERPRISE}-{SITE}"
|
||||||
|
uns_areas, uns_lines, equipment = [], [], []
|
||||||
|
src_hierarchy = {"galaxyNode": GALAXY_NODE, "galaxy": GALAXY, "areas": []}
|
||||||
|
|
||||||
|
gidx = 0
|
||||||
|
for gal_area, (area_seg, area_label, line_plan) in AREA_PLAN.items():
|
||||||
|
machines = AREAS_GAL.get(gal_area, [])
|
||||||
|
area_id = f"area-{area_seg}"
|
||||||
|
uns_areas.append({
|
||||||
|
"unsAreaId": area_id, "clusterId": cluster_id, "name": seg(area_seg),
|
||||||
|
"notes": area_label,
|
||||||
|
"source": {"galaxyArea": gal_area,
|
||||||
|
"galaxyGobjectId": next((o["gobjectId"] for o in objs
|
||||||
|
if o["tagName"] == gal_area), None)},
|
||||||
|
})
|
||||||
|
src_area = {"galaxyArea": gal_area, "machines": []}
|
||||||
|
|
||||||
|
# consume machines line-by-line per the plan
|
||||||
|
cursor = 0
|
||||||
|
for line_seg, count in line_plan:
|
||||||
|
line_id = f"line-{area_seg}-{line_seg}"
|
||||||
|
uns_lines.append({
|
||||||
|
"unsLineId": line_id, "unsAreaId": area_id, "name": seg(line_seg),
|
||||||
|
"notes": f"{area_label} {line_seg}",
|
||||||
|
})
|
||||||
|
archetypes = ARCHETYPES[area_seg]
|
||||||
|
for j in range(count):
|
||||||
|
if cursor >= len(machines): break
|
||||||
|
m = machines[cursor]; cursor += 1; gidx += 1
|
||||||
|
archetype, _, model_series = archetypes[j % len(archetypes)]
|
||||||
|
eq_seg = seg(f"{archetype}-{gidx:02d}")
|
||||||
|
eq_id = f"eq-{area_seg}-{line_seg}-{eq_seg}"
|
||||||
|
delmia, mes = receiver_refs(m)
|
||||||
|
a = asset(area_seg, archetype, model_series, m["tagName"], gidx)
|
||||||
|
tags = make_tags(eq_id, m, delmia, mes)
|
||||||
|
equipment.append({
|
||||||
|
"equipmentId": eq_id, "unsLineId": line_id, "name": eq_seg,
|
||||||
|
"machineCode": m["tagName"],
|
||||||
|
"manufacturer": a["manufacturer"], "model": a["model"],
|
||||||
|
"serialNumber": a["serialNumber"], "sapId": a["sapId"],
|
||||||
|
"zTag": a["zTag"], "hardwareRevision": a["hardwareRevision"],
|
||||||
|
"softwareRevision": a["softwareRevision"],
|
||||||
|
"assetLocation": f"{ENTERPRISE}/{SITE}/{area_seg}/{line_seg}",
|
||||||
|
"equipmentClassRef": a["equipmentClassRef"],
|
||||||
|
"unsPath": f"{ENTERPRISE}/{SITE}/{area_seg}/{line_seg}/{eq_seg}",
|
||||||
|
"source": {
|
||||||
|
"namespaceKind": "SystemPlatform",
|
||||||
|
"galaxyTag": m["tagName"], "galaxyGobjectId": m["gobjectId"],
|
||||||
|
"templateChain": m["templateChain"], "galaxyArea": gal_area,
|
||||||
|
"delmiaReceiverTag": delmia["tagName"] if delmia else None,
|
||||||
|
"mesReceiverTag": mes["tagName"] if mes else None,
|
||||||
|
},
|
||||||
|
"tags": tags,
|
||||||
|
})
|
||||||
|
src_area["machines"].append({
|
||||||
|
"tag": m["tagName"], "gobjectId": m["gobjectId"],
|
||||||
|
"unsEquipment": f"{ENTERPRISE}/{SITE}/{area_seg}/{line_seg}/{eq_seg}",
|
||||||
|
"delmiaReceiver": delmia["tagName"] if delmia else None,
|
||||||
|
"mesReceiver": mes["tagName"] if mes else None,
|
||||||
|
})
|
||||||
|
src_hierarchy["areas"].append(src_area)
|
||||||
|
|
||||||
|
tag_count = sum(len(e["tags"]) for e in equipment)
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"$schema": "internal://otopcua/uns-export/v1",
|
||||||
|
"kind": "fake-company-uns",
|
||||||
|
"description": ("Fictional company UNS generated around the real AVEVA Galaxy "
|
||||||
|
"'DEV' TestMachine instances and the areas they are deployed in. "
|
||||||
|
"Modeled on OtOpcUa's Cluster->Namespace->UnsArea->UnsLine->"
|
||||||
|
"Equipment->Tag schema. Synthetic naming/asset metadata; every "
|
||||||
|
"node links back to its real Galaxy source."),
|
||||||
|
"generatedFrom": {
|
||||||
|
"file": SRC, "galaxyNode": GALAXY_NODE, "galaxy": GALAXY,
|
||||||
|
"gatewayEndpoint": "http://10.100.0.48:5120 (MxAccess gateway, gRPC)",
|
||||||
|
"sourceObjectCount": len(objs),
|
||||||
|
},
|
||||||
|
"company": {"name": "Northwind Consumer Products",
|
||||||
|
"enterprise": ENTERPRISE, "site": SITE,
|
||||||
|
"siteDescription": "Birmingham bottling & packaging plant"},
|
||||||
|
"cluster": {"clusterId": cluster_id, "name": f"{ENTERPRISE}-{SITE}-uns",
|
||||||
|
"enterprise": ENTERPRISE, "site": SITE,
|
||||||
|
"redundancyMode": "Hot", "nodeCount": 2},
|
||||||
|
"namespace": {"namespaceId": f"{cluster_id}-equipment", "clusterId": cluster_id,
|
||||||
|
"kind": "Equipment",
|
||||||
|
"namespaceUri": f"urn:{ENTERPRISE}:{SITE}:uns"},
|
||||||
|
"uns": {"unsAreas": uns_areas, "unsLines": uns_lines, "equipment": equipment},
|
||||||
|
"stats": {"areas": len(uns_areas), "lines": len(uns_lines),
|
||||||
|
"equipment": len(equipment), "tags": tag_count},
|
||||||
|
"sourceGalaxyHierarchy": src_hierarchy,
|
||||||
|
}
|
||||||
|
|
||||||
|
json.dump(doc, open("company-uns.json", "w"), indent=2)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- tree view
|
||||||
|
lines = []
|
||||||
|
lines.append(f"Northwind Consumer Products — Unified Namespace")
|
||||||
|
lines.append(f"(generated from Galaxy {GALAXY_NODE}\\{GALAXY}; "
|
||||||
|
f"{len(equipment)} machines, {tag_count} signals)")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{ENTERPRISE}")
|
||||||
|
lines.append(f"└─ {SITE}")
|
||||||
|
area_by_id = {a["unsAreaId"]: a for a in uns_areas}
|
||||||
|
lines_by_area = collections.defaultdict(list)
|
||||||
|
for l in uns_lines: lines_by_area[l["unsAreaId"]].append(l)
|
||||||
|
eq_by_line = collections.defaultdict(list)
|
||||||
|
for e in equipment: eq_by_line[e["unsLineId"]].append(e)
|
||||||
|
|
||||||
|
for ai, a in enumerate(uns_areas):
|
||||||
|
a_last = ai == len(uns_areas) - 1
|
||||||
|
a_pre = " " + ("└─ " if a_last else "├─ ")
|
||||||
|
lines.append(f" {'└─' if a_last else '├─'} {a['name']}/ ({a['notes']}; from Galaxy {a['source']['galaxyArea']})")
|
||||||
|
als = lines_by_area[a["unsAreaId"]]
|
||||||
|
a_cont = " " if a_last else " │ "
|
||||||
|
for li, l in enumerate(als):
|
||||||
|
l_last = li == len(als) - 1
|
||||||
|
lines.append(f"{a_cont}{'└─' if l_last else '├─'} {l['name']}/")
|
||||||
|
eqs = eq_by_line[l["unsLineId"]]
|
||||||
|
l_cont = a_cont + (" " if l_last else "│ ")
|
||||||
|
for ei, e in enumerate(eqs):
|
||||||
|
e_last = ei == len(eqs) - 1
|
||||||
|
ntags = len(e["tags"])
|
||||||
|
lines.append(f"{l_cont}{'└─' if e_last else '├─'} {e['name']} "
|
||||||
|
f"[{e['manufacturer']} {e['model']}] ← {e['source']['galaxyTag']} ({ntags} signals)")
|
||||||
|
open("company-uns.tree.txt", "w").write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
print(json.dumps(doc["stats"], indent=2))
|
||||||
|
print("\nsample equipment:")
|
||||||
|
e = equipment[0]
|
||||||
|
print(f" {e['unsPath']} machineCode={e['machineCode']} delmia={e['source']['delmiaReceiverTag']} mes={e['source']['mesReceiverTag']}")
|
||||||
|
print(" sample tags:")
|
||||||
|
for t in e["tags"][:3] + [e["tags"][12]] + [e["tags"][-1]]:
|
||||||
|
print(f" {t['folderPath'] or '.'}/{t['name']:18s} {t['dataType']:8s} {t['accessLevel']:9s} -> {t['source']['fullTagReference']}")
|
||||||
|
print("\nwrote: company-uns.json, company-uns.tree.txt")
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Shared dev GLAuth (`dc=zb,dc=local`)
|
||||||
|
|
||||||
|
One [GLAuth](https://github.com/glauth/glauth) directory that **all three sister apps use for
|
||||||
|
dev/test auth** — OtOpcUa, MxAccessGateway, ScadaBridge. It runs as a single container on the
|
||||||
|
shared Docker host **`10.100.0.35:3893`** (plaintext LDAP). This is the app-neutral source of
|
||||||
|
truth; each app just points its `…Ldap:Server` at `10.100.0.35`.
|
||||||
|
|
||||||
|
> Scope: **dev/test only**. Production uses real corporate AD. See
|
||||||
|
> [`../../docs/plans/2026-06-04-shared-glauth-standardization-design.md`](../../docs/plans/2026-06-04-shared-glauth-standardization-design.md).
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
Group families are partitioned into **non-overlapping gid ranges** so the three apps coexist —
|
||||||
|
each app maps only its own family and ignores the rest.
|
||||||
|
|
||||||
|
| Family | Used by | Groups (gidnumber) |
|
||||||
|
|---|---|---|
|
||||||
|
| `SCADA-*` (55xx) | ScadaBridge roles (DB-mapped) | Admins 5501, Designers 5502, Deploy-All 5503, Deploy-SiteA 5504, Viewers 5505 |
|
||||||
|
| OPC-perm (560x) | OtOpcUa + MxGateway OPC-UA write model | ReadOnly 5601, WriteOperate 5602, WriteTune 5603, WriteConfigure 5604, AlarmAck 5605 |
|
||||||
|
| `Gw*` (561x) | MxGateway dashboard (config-mapped) | GwAdmin 5610, GwReader 5611 |
|
||||||
|
| `OtOpcUa-*` (57xx) | OtOpcUa AdminUI (DB-mapped) | Admins 5701, Designers 5702, Viewers 5703 |
|
||||||
|
|
||||||
|
**Users** (all password `password` except `serviceaccount` → `serviceaccount123`):
|
||||||
|
|
||||||
|
- **`serviceaccount`** (`cn=serviceaccount,dc=zb,dc=local`) — the single bind account every app
|
||||||
|
uses for search-then-bind. Has a `search *` capability.
|
||||||
|
- **`multi-role`** — member of **every** group → all roles in all three apps (canonical cross-app login).
|
||||||
|
- **`admin`** — `SCADA-Admins` + `GwAdmin` + `OtOpcUa-Admins` → Administrator everywhere.
|
||||||
|
- Per-role testers: `designer` / `deployer` / `site-deployer` (ScadaBridge); `gw-viewer`
|
||||||
|
(MxGateway Viewer); `otdesigner` / `otviewer` (OtOpcUa); `opc-readonly` / `opc-writeop` /
|
||||||
|
`opc-writetune` / `opc-writeconfig` / `opc-alarmack` (OPC perms).
|
||||||
|
|
||||||
|
> **Naming rule:** a tester username must **not** case-collide with a group name. GLAuth exposes
|
||||||
|
> each group as `cn=<Group>` under `ou=users`, so a case-insensitive `(cn=x)` search would match
|
||||||
|
> both the user and the group (two entries → the shared lib's "exactly one entry" rule fails the
|
||||||
|
> bind). That's why the OPC/Gw testers are `opc-*` / `gw-viewer`, not `readonly` / `gwreader`.
|
||||||
|
|
||||||
|
## Deploy on `10.100.0.35`
|
||||||
|
|
||||||
|
> **Access note:** deploying needs working SSH/Docker access to `10.100.0.35`. If your key is not
|
||||||
|
> authorized there, hand this folder to whoever administers the box and have them run the same
|
||||||
|
> `docker compose up -d`. The artifact is self-contained.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From this repo root. Copy the FILES into the dest (not the dir) so a re-deploy
|
||||||
|
# doesn't nest them at ~/zb-glauth/glauth/ (scp -r of a dir into an existing dir).
|
||||||
|
ssh dohertj2@10.100.0.35 'mkdir -p ~/zb-glauth'
|
||||||
|
scp infra/glauth/config.toml infra/glauth/docker-compose.yml dohertj2@10.100.0.35:~/zb-glauth/
|
||||||
|
ssh dohertj2@10.100.0.35 'cd ~/zb-glauth && docker compose up -d --force-recreate && docker ps --filter name=zb-shared-glauth'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
Bind as the service account and confirm `multi-role` spans all four families:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
|
-D cn=serviceaccount,dc=zb,dc=local -w serviceaccount123 \
|
||||||
|
-b dc=zb,dc=local "(cn=multi-role)" memberOf
|
||||||
|
# → result: 0 Success. memberOf comes back as group DNs (e.g. ou=SCADA-Admins,ou=groups,dc=zb,dc=local)
|
||||||
|
# spanning all four families: SCADA-*, ReadOnly/Write*/AlarmAck, GwAdmin/GwReader, OtOpcUa-*.
|
||||||
|
# (The shared ZB.MOM.WW.Auth.Ldap lib strips each to its bare RDN, e.g. "SCADA-Admins", at login.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm a user authenticates with `password` (a bad password returns `result: 49`; this user lacks
|
||||||
|
the search capability, so a successful bind shows `result: 50 Insufficient access` on the search):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
|
-D cn=multi-role,dc=zb,dc=local -w password \
|
||||||
|
-b dc=zb,dc=local "(cn=multi-role)" cn
|
||||||
|
```
|
||||||
|
|
||||||
|
No `ldapsearch` locally? Run it from a throwaway container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm alpine:3.20 sh -c 'apk add -q openldap-clients >/dev/null 2>&1 && \
|
||||||
|
ldapsearch -x -H ldap://10.100.0.35:3893 -D cn=serviceaccount,dc=zb,dc=local -w serviceaccount123 \
|
||||||
|
-b dc=zb,dc=local "(cn=multi-role)" memberOf'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Editing the directory
|
||||||
|
|
||||||
|
GLAuth uses the `config` datastore (this `config.toml`, mounted read-only). To add/change a user
|
||||||
|
or group, edit `config.toml` and **recreate** the container — a plain `restart` keeps the stale
|
||||||
|
file (single-file Docker bind-mount inode trap):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
Group `gidnumber`s and user `uidnumber`s must stay **unique** across the whole file; keep new
|
||||||
|
groups inside the per-app range (55xx / 56xx / 57xx) so the families don't collide.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
[ldap]
|
||||||
|
enabled = true
|
||||||
|
listen = "0.0.0.0:3893"
|
||||||
|
|
||||||
|
[ldaps]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[backend]
|
||||||
|
datastore = "config"
|
||||||
|
baseDN = "dc=zb,dc=local"
|
||||||
|
|
||||||
|
[behaviors]
|
||||||
|
# Dev: do not lock out on failed binds (avoids surprises during testing).
|
||||||
|
LimitFailedBinds = false
|
||||||
|
|
||||||
|
# ── Groups ───────────────────────────────────────────────────────────
|
||||||
|
# ScadaBridge role groups (55xx) — DB-mapped (LdapGroupMappings)
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Admins"
|
||||||
|
gidnumber = 5501
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Designers"
|
||||||
|
gidnumber = 5502
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Deploy-All"
|
||||||
|
gidnumber = 5503
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Deploy-SiteA"
|
||||||
|
gidnumber = 5504
|
||||||
|
[[groups]]
|
||||||
|
name = "SCADA-Viewers"
|
||||||
|
gidnumber = 5505
|
||||||
|
|
||||||
|
# OPC-UA permission groups (560x) — OtOpcUa + MxGateway OPC write model
|
||||||
|
[[groups]]
|
||||||
|
name = "ReadOnly"
|
||||||
|
gidnumber = 5601
|
||||||
|
[[groups]]
|
||||||
|
name = "WriteOperate"
|
||||||
|
gidnumber = 5602
|
||||||
|
[[groups]]
|
||||||
|
name = "WriteTune"
|
||||||
|
gidnumber = 5603
|
||||||
|
[[groups]]
|
||||||
|
name = "WriteConfigure"
|
||||||
|
gidnumber = 5604
|
||||||
|
[[groups]]
|
||||||
|
name = "AlarmAck"
|
||||||
|
gidnumber = 5605
|
||||||
|
|
||||||
|
# MxGateway dashboard groups (561x) — config-mapped (GroupToRole)
|
||||||
|
[[groups]]
|
||||||
|
name = "GwAdmin"
|
||||||
|
gidnumber = 5610
|
||||||
|
[[groups]]
|
||||||
|
name = "GwReader"
|
||||||
|
gidnumber = 5611
|
||||||
|
|
||||||
|
# OtOpcUa AdminUI role groups (57xx) — DB-mapped (LdapGroupRoleMapping)
|
||||||
|
[[groups]]
|
||||||
|
name = "OtOpcUa-Admins"
|
||||||
|
gidnumber = 5701
|
||||||
|
[[groups]]
|
||||||
|
name = "OtOpcUa-Designers"
|
||||||
|
gidnumber = 5702
|
||||||
|
[[groups]]
|
||||||
|
name = "OtOpcUa-Viewers"
|
||||||
|
gidnumber = 5703
|
||||||
|
|
||||||
|
# ── Users ────────────────────────────────────────────────────────────
|
||||||
|
# All passwords are "password" except serviceaccount ("serviceaccount123").
|
||||||
|
# sha256("password") = 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
|
||||||
|
# sha256("serviceaccount123") = af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419
|
||||||
|
|
||||||
|
# The single bind account every app uses (search-then-bind).
|
||||||
|
[[users]]
|
||||||
|
name = "serviceaccount"
|
||||||
|
uidnumber = 5999
|
||||||
|
primarygroup = 5601
|
||||||
|
passsha256 = "af29d0e5c9801ae98a999ed3915e1cf428a64b4b62b3cf221b6336cce0398419"
|
||||||
|
[[users.capabilities]]
|
||||||
|
action = "search"
|
||||||
|
object = "*"
|
||||||
|
|
||||||
|
# Cross-app: member of EVERY group → all roles in all three apps.
|
||||||
|
[[users]]
|
||||||
|
name = "multi-role"
|
||||||
|
givenname = "Multi"
|
||||||
|
sn = "Role"
|
||||||
|
mail = "multi-role@zb.local"
|
||||||
|
uidnumber = 5005
|
||||||
|
primarygroup = 5501
|
||||||
|
othergroups = [5502, 5503, 5504, 5505, 5601, 5602, 5603, 5604, 5605, 5610, 5611, 5701, 5702, 5703]
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# Administrator everywhere (admin-equivalent of each app).
|
||||||
|
[[users]]
|
||||||
|
name = "admin"
|
||||||
|
uidnumber = 5001
|
||||||
|
primarygroup = 5501
|
||||||
|
othergroups = [5610, 5701]
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# ScadaBridge single-role testers
|
||||||
|
[[users]]
|
||||||
|
name = "designer"
|
||||||
|
uidnumber = 5002
|
||||||
|
primarygroup = 5502
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "deployer"
|
||||||
|
uidnumber = 5003
|
||||||
|
primarygroup = 5503
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "site-deployer"
|
||||||
|
uidnumber = 5004
|
||||||
|
primarygroup = 5504
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# MxGateway dashboard Viewer tester.
|
||||||
|
# NOTE: tester usernames must NOT case-collide with a group name — glauth exposes
|
||||||
|
# each group as cn=<Group> under ou=users, so a case-insensitive (cn=X) search
|
||||||
|
# would match both the user and the group (ambiguous → auth fails). Hence gw-viewer
|
||||||
|
# (not "gwreader" which collides with the GwReader group), opc-* below, etc.
|
||||||
|
[[users]]
|
||||||
|
name = "gw-viewer"
|
||||||
|
uidnumber = 5106
|
||||||
|
primarygroup = 5611
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# OPC-UA permission testers
|
||||||
|
[[users]]
|
||||||
|
name = "opc-readonly"
|
||||||
|
uidnumber = 5101
|
||||||
|
primarygroup = 5601
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "opc-writeop"
|
||||||
|
uidnumber = 5102
|
||||||
|
primarygroup = 5602
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "opc-writetune"
|
||||||
|
uidnumber = 5103
|
||||||
|
primarygroup = 5603
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "opc-writeconfig"
|
||||||
|
uidnumber = 5104
|
||||||
|
primarygroup = 5604
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "opc-alarmack"
|
||||||
|
uidnumber = 5105
|
||||||
|
primarygroup = 5605
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
|
||||||
|
# OtOpcUa single-role testers (admin covers OtOpcUa-Admins)
|
||||||
|
[[users]]
|
||||||
|
name = "otdesigner"
|
||||||
|
uidnumber = 5202
|
||||||
|
primarygroup = 5702
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
|
[[users]]
|
||||||
|
name = "otviewer"
|
||||||
|
uidnumber = 5203
|
||||||
|
primarygroup = 5703
|
||||||
|
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Shared dev GLAuth for OtOpcUa + MxAccessGateway + ScadaBridge.
|
||||||
|
# Deploy on the shared Docker host 10.100.0.35: docker compose up -d
|
||||||
|
# Verify: ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
|
# -D cn=serviceaccount,dc=zb,dc=local -w serviceaccount123 \
|
||||||
|
# -b dc=zb,dc=local "(cn=multi-role)" memberOf
|
||||||
|
name: zb-shared-glauth
|
||||||
|
services:
|
||||||
|
glauth:
|
||||||
|
image: glauth/glauth:latest
|
||||||
|
container_name: zb-shared-glauth
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3893:3893"
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/app/config/config.cfg:ro
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
# MES and Delmia-DNC integrations — API & MXAccess write specification
|
||||||
|
|
||||||
|
Documents two existing Wonderware integrations hosted on the `ZimmerBiomet` Gitea org
|
||||||
|
(`http://wonder-app-vd03.zmr.zimmer.com:3000`):
|
||||||
|
|
||||||
|
| Integration | Repo | What it does | Who does the MXAccess write |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **MES** | [`ZimmerBiomet/MESAPI`](http://wonder-app-vd03.zmr.zimmer.com:3000/ZimmerBiomet/MESAPI) (solution `WWSupport`) | REST API the Camstar MES calls to move-in / move-out work orders against a machine, and to read machine alarm status | **The service itself** (`MesNotifier`, in-repo) |
|
||||||
|
| **Delmia DNC** | [`ZimmerBiomet/DelmiaIntegration`](http://wonder-app-vd03.zmr.zimmer.com:3000/ZimmerBiomet/DelmiaIntegration) | Pull an NC/recipe document from the DELMIA/Apriso (Intercim) DNC server and push the resulting recipe-download notification into Wonderware | **An external receiver service** at `wonder-app-vd01:9001/notify`; the actual flag handshake is implemented in the Galaxy `$DelmiaReceiver` object (`ProcessRecipe`/`Reset` scripts) — *not* in this repo |
|
||||||
|
|
||||||
|
Both integrations talk to AVEVA System Platform ("Galaxy") through **MXAccess COM**
|
||||||
|
(`ArchestrA.MxAccess.LMXProxyServerClass`) and use the same general pattern:
|
||||||
|
|
||||||
|
> **Handshake pattern** — read a *ready* flag → write the data tags → set a *trigger* flag
|
||||||
|
> → wait (bounded by a timeout) for a *complete* flag → read a *success* flag + *error text*
|
||||||
|
> → return the result and unsubscribe.
|
||||||
|
|
||||||
|
All facts below are taken verbatim from source at the repo `master` branch (cloned
|
||||||
|
2026-06-17) unless explicitly marked **(inferred)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MES integration — `MESAPI` / `WWSupport`
|
||||||
|
|
||||||
|
### 1.1 Topology & hosting
|
||||||
|
|
||||||
|
```
|
||||||
|
Camstar MES ──HTTPS/JSON──▶ WWSupport API (ServiceStack self-host)
|
||||||
|
│ ├─ SQL Server "BT" (machine lookup by SAPID, alarm catalog)
|
||||||
|
│ └─ MXAccess COM (LMXProxyServerClass, client "MesNotifier")
|
||||||
|
▼
|
||||||
|
Galaxy object {MachineCode}.MesReceiver.* (move-in / move-out tags)
|
||||||
|
Galaxy object {MachineCode}.{AlarmName}.* (alarm attributes)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Framework:** ServiceStack, self-hosted via `AppSelfHostBase` (`AppHost : base("APIServer", typeof(MesServices).Assembly)`), .NET Framework, run as a Windows service.
|
||||||
|
- **Listen URL (per environment, `App.config` → `HttpListener`):**
|
||||||
|
- DEV `http://*:9501/` · QA `http://*:9500/` · PROD `http://*:9500/`
|
||||||
|
- **Database (`App.config` → connection `BatchDB`, DB `BT`):**
|
||||||
|
- DEV `wonder-sql-vd01.zmr.zimmer.com` · QA `wondersqlqa.zmr.zimmer.com` · PROD (same form). User `wonderapp`.
|
||||||
|
- **Auth:** every operation is decorated `[Authenticate]` + `[RequiredRole("MESAPI")]` (`MesServices.cs`).
|
||||||
|
`AppHost` registers an `AuthFeature` with two providers: `ApiKeyAuthProvider` and `LdapAuthProvider`.
|
||||||
|
- Unauthenticated → **401**; authenticated without the `MESAPI` role → **403**.
|
||||||
|
- **Serialization:** `JsConfig.IncludeNullValues = true` (null fields ARE emitted in JSON). `PostmanFeature` + `OpenApiFeature` (Swagger) are enabled.
|
||||||
|
- **MES counterpart object:** the live Galaxy attribute listing for the receiver object is in
|
||||||
|
[`mesrec.md`](mesrec.md) (`$MESReceiver` template). Note the API binds tags under the contained
|
||||||
|
name `MesReceiver` (i.e. `{MachineCode}.MesReceiver.<tag>`).
|
||||||
|
|
||||||
|
### 1.2 Endpoints (inputs / outputs)
|
||||||
|
|
||||||
|
Routes come from `[Route(...)]` on the request DTOs; all are **POST**, JSON in / JSON out, handled by `MesServices.Any(...)` which resolves a per-request `MesNotifier`.
|
||||||
|
|
||||||
|
#### `POST /mes/movein` → `MoveInResponse`
|
||||||
|
|
||||||
|
Request `MoveInRequest`:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `SAPID` | string | machine key; looked up in `BT.Machine` to get `Machine.Code` |
|
||||||
|
| `OperatorName` | string | |
|
||||||
|
| `JobSequenceNumber` | string | |
|
||||||
|
| `WorkOrders` | `List<WorkOrderInfo>` | each = `{ WorkOrderNumber: string, PartNumber: string }` |
|
||||||
|
|
||||||
|
Response `MoveInResponse`: `WasSuccessful` (bool), `ErrorText` (string), `BatchID` (int?, only set if machine returns non-zero).
|
||||||
|
|
||||||
|
#### `POST /mes/moveout` → `MoveOutResponse`
|
||||||
|
|
||||||
|
Request `MoveOutRequest`: `SAPID` (string), `OperatorName` (string), `WorkOrders` (`List<WorkOrderInfo>`).
|
||||||
|
*(Move-out has no `JobSequenceNumber`.)*
|
||||||
|
Response `MoveOutResponse`: identical shape to `MoveInResponse` (`WasSuccessful`, `ErrorText`, `BatchID`).
|
||||||
|
|
||||||
|
#### `POST /mes/alarmstatus` → `AlarmStatusResponse`
|
||||||
|
|
||||||
|
Request `AlarmStatusRequest`:
|
||||||
|
- `MachineFilter` = `{ MachineID: int?, SAPID: string, ZTag: string, Code: string }` (any one identifies the machine)
|
||||||
|
- `AlarmFilter` = `{ NameFilter: string, MinSeverity: int?, MaxSeverity: int?, IncludeTriggered: bool=true, IncludeAcked: bool=true, FlaggedOnly: bool=false }`
|
||||||
|
|
||||||
|
#### `POST /mes/simplealarmstatus` → `AlarmStatusResponse`
|
||||||
|
|
||||||
|
Request `SimpleAlarmStatusRequest`: `SAPID` (string). Internally loads only alarms with `FlaggedForMES == true` for that machine.
|
||||||
|
|
||||||
|
Response `AlarmStatusResponse` (both alarm endpoints): `WasSuccessful` (bool), `ErrorText` (string), `Alarms` (`List<AlarmInfo>`).
|
||||||
|
|
||||||
|
`AlarmInfo`: `Name` (string), `HierarchicalName` (string, `{Code}.{AlarmName}`), `Description` (string), `IsFlaggedForMES` (bool), `Severity` (int), `StatusCode` (string — `"Triggered"` or `"Triggered.Acked"`), `TriggeredDT` (DateTime), `AckDT` (DateTime?), `AckComment` (string).
|
||||||
|
|
||||||
|
### 1.3 MXAccess connection model
|
||||||
|
|
||||||
|
`MesNotifier` owns one MXAccess proxy for the request:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_lmxProxy = new ArchestrA.MxAccess.LMXProxyServerClass();
|
||||||
|
_lmxHandle = _lmxProxy.Register("MesNotifier");
|
||||||
|
_lmxProxy.OnDataChange += ...; // value updates resolve pending read/OnValue tasks
|
||||||
|
_lmxProxy.OnWriteComplete += ...; // write acks resolve pending write tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags are added with `AddItem` + `AdviseSupervisory` (subscribe), updated via `OnDataChange`,
|
||||||
|
and removed with `UnAdvise` + `RemoveItem` on cleanup. Reads/writes are wrapped as `Task<bool>`
|
||||||
|
that complete on the corresponding callback or **fail (`false`) on cancellation/timeout**.
|
||||||
|
A read is considered valid only if MXAccess **quality == 192** ("good").
|
||||||
|
|
||||||
|
**Target selection:** request `SAPID` → `db.Single<Machine>(x => x.SAPID == SAPID)` → `Machine.Code`
|
||||||
|
becomes the tag prefix. Move tags live under `{Code}.MesReceiver.*`; alarm tags under
|
||||||
|
`{Code}.{MachineAlarm.Name}.*` (alarm catalog from `db.Select<MachineAlarm>(...)`).
|
||||||
|
|
||||||
|
### 1.4 Tag mappings
|
||||||
|
|
||||||
|
**Move-in** (`MesMoveInTagset`, all `{Code}.MesReceiver.<tag>`):
|
||||||
|
|
||||||
|
| Tag | Type | Dir | Role | Source field |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `MoveInReadyFlag` | bool | read | gate — must be `true` before writing | — |
|
||||||
|
| `MoveInFlag` | bool | **write** | **trigger** — set `true` last | — |
|
||||||
|
| `MoveInCompleteFlag` | bool | read | completion — handshake waits on this | — |
|
||||||
|
| `MoveInSuccessfulFlag` | bool | read | result | → `response.WasSuccessful` |
|
||||||
|
| `MoveInErrorText` | string | read | result | → `response.ErrorText` |
|
||||||
|
| `MoveInBatchID` | int | read | result | → `response.BatchID` (if ≠ 0) |
|
||||||
|
| `MoveInOperatorName` | string | write | data | `request.OperatorName` |
|
||||||
|
| `MoveInJobSequenceNumber` | string | write | data | `request.JobSequenceNumber` |
|
||||||
|
| `MoveInNumberWorkOrders` | int | write | data | `request.WorkOrders.Count` |
|
||||||
|
| `MoveInWorkOrderNumbers[]` | string[] | write | data (fixed length 50) | `WorkOrders.Select(w => w.WorkOrderNumber)` |
|
||||||
|
| `MoveInPartNumbers[]` | string[] | write | data (fixed length 50) | `WorkOrders.Select(w => w.PartNumber)` |
|
||||||
|
|
||||||
|
**Move-out** (`MesMoveOutTagset`): identical set with `MoveOut` prefix, **minus** `JobSequenceNumber`
|
||||||
|
(`MoveOutReadyFlag`, `MoveOutFlag`, `MoveOutCompleteFlag`, `MoveOutSuccessfulFlag`, `MoveOutErrorText`,
|
||||||
|
`MoveOutBatchID`, `MoveOutOperatorName`, `MoveOutNumberWorkOrders`, `MoveOutWorkOrderNumbers[]`, `MoveOutPartNumbers[]`).
|
||||||
|
|
||||||
|
**Alarms** (`AlarmTagset`, all `{Code}.{AlarmName}.<attr>`): `Quality` (int), `InAlarm` (bool),
|
||||||
|
`TimeAlarmOn` (DateTime), `DescAttrName` (string), `Acked` (bool), `TimeAlarmAcked` (DateTime?), `AckMsg` (string).
|
||||||
|
|
||||||
|
### 1.5 The handshake — `MesNotifier.MoveIn` (move-out is identical with `MoveOut*` tags)
|
||||||
|
|
||||||
|
Whole operation is bounded by **`new CancellationTokenSource(30000)` = 30 s**.
|
||||||
|
|
||||||
|
1. **Look up machine** by `SAPID`. Not found → `WasSuccessful=false`, `ErrorText="Failed to find machine with SAPID '{SAPID}'"`, return.
|
||||||
|
2. **Subscribe** to every move-in tag (`Advise(t, cts)`), `await Task.WhenAll(...)`. Any subscription that fails / quality ≠ 192 → `ErrorText="Failed to connect to machine"`.
|
||||||
|
3. **Check ready flag:** `if (!MoveInReadyFlag.Value)` → `ErrorText="Machine move in ready flag not set to true"`, stop.
|
||||||
|
4. **Arm completion watch:** `Task<bool> flagTask = MoveInCompleteFlag.OnValue(true, cts);` (completes when the flag goes `true`, or `false` on the 30 s timeout).
|
||||||
|
5. **Write data + trigger (in parallel, trigger last):** `MoveInOperatorName`, `MoveInJobSequenceNumber`, `MoveInNumberWorkOrders`, `MoveInPartNumbers[]` (padded to 50), `MoveInWorkOrderNumbers[]` (padded to 50), then `MoveInFlag = true`. `await Task.WhenAll(writeTasks)`; any write `!= true` → `ErrorText="Failed to write move in information to machine"`.
|
||||||
|
6. **Wait for completion:** `await Task.WhenAll(flagTask)`.
|
||||||
|
- `flagTask.Result == true` → read results: `WasSuccessful = MoveInSuccessfulFlag.Value`, `ErrorText = MoveInErrorText.Value`, `BatchID = MoveInBatchID.Value` (if ≠ 0).
|
||||||
|
- `flagTask.Result == false` (timed out) → `WasSuccessful=false`, `ErrorText="Timeout waiting for move in information to be processed"`.
|
||||||
|
7. **Cleanup:** `Tags.ForEach(Unadvise)` and return.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using (var cts = new CancellationTokenSource(30000)) { // 30 s budget
|
||||||
|
...
|
||||||
|
if (!moveInTagset.MoveInReadyFlag.Value) { /* not-ready error */ }
|
||||||
|
var flagTask = moveInTagset.MoveInCompleteFlag.OnValue(true, cts); // arm completion watch
|
||||||
|
var writeTasks = new List<Task<bool>> {
|
||||||
|
Write(moveInTagset.MoveInOperatorName.Handle, request.OperatorName, cts),
|
||||||
|
Write(moveInTagset.MoveInJobSequenceNumber.Handle, request.JobSequenceNumber, cts),
|
||||||
|
Write(moveInTagset.MoveInNumberWorkOrders.Handle, request.WorkOrders.Count, cts),
|
||||||
|
Write(moveInTagset.MoveInPartNumbers.Handle, request.WorkOrders.Select(wo => wo.PartNumber).ToFixedLength(50), cts),
|
||||||
|
Write(moveInTagset.MoveInWorkOrderNumbers.Handle, request.WorkOrders.Select(wo => wo.WorkOrderNumber).ToFixedLength(50), cts),
|
||||||
|
Write(moveInTagset.MoveInFlag.Handle, true, cts) // TRIGGER — set last
|
||||||
|
};
|
||||||
|
await Task.WhenAll(writeTasks);
|
||||||
|
await Task.WhenAll(flagTask);
|
||||||
|
if (flagTask.Result) {
|
||||||
|
response.WasSuccessful = moveInTagset.MoveInSuccessfulFlag.Value;
|
||||||
|
response.ErrorText = moveInTagset.MoveInErrorText.Value;
|
||||||
|
if (moveInTagset.MoveInBatchID.Value != 0) response.BatchID = moveInTagset.MoveInBatchID.Value;
|
||||||
|
} else {
|
||||||
|
response.WasSuccessful = false;
|
||||||
|
response.ErrorText = "Timeout waiting for move in information to be processed";
|
||||||
|
}
|
||||||
|
moveInTagset.Tags.ForEach(Unadvise);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> There is **no busy-poll loop**: completion is event-driven via the MXAccess `OnDataChange`
|
||||||
|
> callback; the 30 s `CancellationTokenSource` is the only timeout.
|
||||||
|
|
||||||
|
### 1.6 Alarm-status path
|
||||||
|
|
||||||
|
1. Resolve machine from `MachineFilter` (`SAPID` / `Code` / `ZTag` / `MachineID`) — wrong/missing → error.
|
||||||
|
2. Load `MachineAlarm` rows for the machine; apply filters (`FlaggedOnly`, `MinSeverity`, `MaxSeverity`, case-insensitive `NameFilter.Contains`). *(`IncludeTriggered` is read but not used in the filter.)*
|
||||||
|
3. Subscribe + read each alarm's `Quality` and `InAlarm` (30 s budget). Bad quality / read failure → `ErrorText="Failed to read machine alarm status"`.
|
||||||
|
4. For alarms where `InAlarm == true`, additionally read `TimeAlarmOn`, `DescAttrName`, `Acked`, `TimeAlarmAcked`, `AckMsg`.
|
||||||
|
5. Build `AlarmInfo` per triggered alarm; `StatusCode = "Triggered.Acked"` if acked else `"Triggered"`. If `AlarmFilter.IncludeAcked == false`, acked alarms are skipped.
|
||||||
|
6. Unsubscribe; on failure `Alarms` is cleared.
|
||||||
|
|
||||||
|
### 1.7 Outputs / error handling (MES)
|
||||||
|
|
||||||
|
- **Transport status is always 200** for handled responses — success/failure is carried by the body's `WasSuccessful` flag + `ErrorText`. (401/403 only from the auth layer.)
|
||||||
|
- Success: `{ "WasSuccessful": true, "ErrorText": null, "BatchID": <int|null> }`.
|
||||||
|
- Failure/timeout: `{ "WasSuccessful": false, "ErrorText": "<message>", "BatchID": null }`.
|
||||||
|
- Distinct `ErrorText` values: machine-not-found, "Failed to connect to machine", "…ready flag not set to true", "Failed to write … to machine", "Timeout waiting for … to be processed", "Failed to read machine alarm status".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Delmia-DNC integration — `DelmiaIntegration` (+ Galaxy `$DelmiaReceiver`)
|
||||||
|
|
||||||
|
### 2.1 Topology — three hops
|
||||||
|
|
||||||
|
```
|
||||||
|
Operator (DelmiaIntegration.exe WinForms)
|
||||||
|
│ ① DelmiaClient ──HTTP POST (form-url-encoded)──▶ DELMIA/Apriso DNC "Downloader.asmx"
|
||||||
|
│ (e.g. http://dnc-app-vd01.zmr.zimmer.com/IntercimService/Downloader.asmx)
|
||||||
|
│ ◀── XML (SearchResults / DownloadResult, ns http://intercim.com/ruleset) ──
|
||||||
|
│ ② recipe file written to disk; WWNotifier.exe launched with CLI args
|
||||||
|
▼
|
||||||
|
WWNotifier.exe ──HTTP POST (JSON RecipeDownload)──▶ WW receiver service (http://wonder-app-vd01:9001/notify)
|
||||||
|
◀── JSON RecipeDownloadResult ── │ ③ MXAccess COM write
|
||||||
|
▼
|
||||||
|
Galaxy object {machine}.$DelmiaReceiver.* (recipe tags + flags)
|
||||||
|
+ ArchestrA scripts ProcessRecipe / Reset
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assemblies in the repo:** `DelmiaContracts` (XML DTO library), `DelmiaIntegration` (`DelmiaClient` + WinForms UI),
|
||||||
|
`WWNotifier.exe` (console notifier), plus test harnesses (`AdminTestUtil`, `DownloadTestUtil`, `TestUI`).
|
||||||
|
|
||||||
|
> **Scope note.** Hops ① and ② are fully in this repo. Hop ③ — the service at `:9001/notify`
|
||||||
|
> that actually performs the MXAccess write — is **not** in the `ZimmerBiomet` Gitea org; only the
|
||||||
|
> JSON contract (below) and the Galaxy-side `$DelmiaReceiver` object (scripts + attributes, exported
|
||||||
|
> under `AA_EXPORT/`) are available. `WWNotificationSystem` *also* uses MXAccess but is an unrelated
|
||||||
|
> tag→email alerting service (port `:9876`, client name `WWNotifierMonitor`) — **not** the recipe receiver.
|
||||||
|
|
||||||
|
### 2.2 DNC server interface (`DelmiaClient`, hop ①)
|
||||||
|
|
||||||
|
- **Transport:** `HttpClient.PostAsync` with `FormUrlEncodedContent`; response is XML deserialized
|
||||||
|
with `XmlSerializer`. Base URL is `DelmiaClient.URL`; per-call `Timeout` default **30 s**.
|
||||||
|
Action is appended to the base URL (`URL.TrimEnd('/') + "/<Action>"`).
|
||||||
|
- **Base URL (from `AdminTestUtil` `DefaultURL`):** `http://dnc-app-vd01.zmr.zimmer.com/IntercimService/Downloader.asmx`.
|
||||||
|
- On any exception the client returns a result object with the error in `ErrorMessage` /
|
||||||
|
`TransferSuccessful=false` (it does not throw to the caller).
|
||||||
|
|
||||||
|
| Method (sync + `…Async`) | POST action | Form fields | Returns |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Search` | `/Search` | `username, machineID, partNumber, operationNumber` | `SearchResults` |
|
||||||
|
| `RequestProvenDocument` | `/RequestProvenDocument` | `username, machineID, partNumber, operationNumber, workOrderNumber` | `DownloadResult` |
|
||||||
|
| `RequestDocument` | `/RequestDocument` | `username, machineID, partNumber, operationNumber, workOrderNumber, documentKey` | `DownloadResult` |
|
||||||
|
|
||||||
|
DTO field lists (`DelmiaContracts`, XML namespace `http://intercim.com/ruleset`):
|
||||||
|
|
||||||
|
- **`SearchResults`**: `Results` (`List<SearchResult>`), `ErrorMessage` (string).
|
||||||
|
- **`SearchResult`**: `ShopOrderKey` (int), `ShopOrderID` (string), `ShopOrderStatus` (string),
|
||||||
|
`ShopOrderOperKey` (int), `ShopOrderOperID` (string), `ShopOrderOperStatus` (string),
|
||||||
|
`DocumentKey` (int), `DocumentObjectID` (int, with `…Specified` flag), `DocumentName` (string),
|
||||||
|
`DocumentRev` (string), `DocumentStatus` (string), `DocumentURL` (string), `PartID` (string), `PartRev` (string).
|
||||||
|
- **`DownloadResult`**: `UserKey` (int), `UserName` (string), `UserSite` (string), `MachineKey` (int),
|
||||||
|
`MachineID` (string), `MachineSite` (string), `WorkOrderNumber` (string), `ShopOrderKey` (int),
|
||||||
|
`ShopOrderID` (string), `ShopOrderStatus` (string), `ShopOrderOperKey` (int), `ShopOrderOperID` (string),
|
||||||
|
`ShopOrderOperStatus` (string), `DocumentKey` (int), `DocumentName` (string), `DocumentRev` (string),
|
||||||
|
`DocumentStatus` (string), `PartID` (string), `PartRev` (string), **`TransferSuccessful` (bool)**,
|
||||||
|
**`ErrorMessage` (string)**.
|
||||||
|
- **`MachineInfo`** (contract present; not called by current code): `MachineKey` (int), `MachineID` (string),
|
||||||
|
`MachineName` (string), `DownloadPath` (string), `MachineDescription` (string), `MachineSite` (string), `MachineStatus` (string).
|
||||||
|
- **`UserInfo`**: `UserKey` (int), `UserName` (string), `UserSite` (string), `IsActive` (bool).
|
||||||
|
|
||||||
|
**Recipe file:** the downloaded document is a key/value recipe file parsed by `DelmiaIntegration/Models/RecipeSet.cs`
|
||||||
|
(`KEY,VALUE` lines; typed accessors `GetString/GetInt/GetBool/GetFloat/GetDouble/...`). It is written to disk;
|
||||||
|
its path is what gets handed to `WWNotifier` (`--downloadpath`).
|
||||||
|
|
||||||
|
### 2.3 WWNotifier (hop ②) — invocation & handoff contract
|
||||||
|
|
||||||
|
`WWNotifier.exe` (uses `CommandLineParser`); CLI options (`CommandLineOptions`):
|
||||||
|
|
||||||
|
| Short | Long | Required | Field |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `-d` | `--downloadpath` | yes | `DownloadPath` (recipe file path) |
|
||||||
|
| `-m` | `--machine` | yes | `MachineCode` |
|
||||||
|
| `-w` | `--workorder` | yes | `WorkOrderNumber` |
|
||||||
|
| `-p` | `--partnumber` | yes | `PartNumber` |
|
||||||
|
| `-s` | `--seqop` | no | `JobStepNumber` |
|
||||||
|
| `-u` | `--username` | no | `Username` |
|
||||||
|
|
||||||
|
Config (`WWNotifier/App.config`): `NotifyURL = http://wonder-app-vd01.zmr.zimmer.com:9001/notify`
|
||||||
|
(comma-separated list allowed — tried in order until one succeeds), `NotifyTimeout = 30` (seconds,
|
||||||
|
applied as the global Flurl HTTP timeout).
|
||||||
|
|
||||||
|
Handoff (Flurl): `url.PostJsonAsync(recipeDownload).ReceiveJson<RecipeDownloadResult>()`.
|
||||||
|
|
||||||
|
- **Request body `RecipeDownload`** (JSON): `MachineCode`, `DownloadPath`, `WorkOrderNumber`, `PartNumber`, `JobStepNumber`, `Username` (all string).
|
||||||
|
- **Response body `RecipeDownloadResult`** (JSON): `Result` (bool), `ResultText` (string).
|
||||||
|
- **Outputs:** prints `YES` and exit code `0` on success; prints `NO` + a message and sets exit code `-1`
|
||||||
|
on failure (parse error, missing `NotifyURL`/`NotifyTimeout`, `Result==false`, or HTTP exception).
|
||||||
|
*(Caveat: on a caught exception it logs `error.InnerException.Message`, which throws a NRE when there is no inner exception — so bare transport errors surface only as a generic failure.)*
|
||||||
|
|
||||||
|
### 2.4 MXAccess write — Galaxy `$DelmiaReceiver` object (hop ③)
|
||||||
|
|
||||||
|
The receiver service maps `RecipeDownload` fields onto the `$DelmiaReceiver` object instance selected by
|
||||||
|
`MachineCode`. Object attributes (from `AA_EXPORT/.../$DelmiaReceiver` export) and their roles:
|
||||||
|
|
||||||
|
| Attribute | Type | Role | Maps from |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ReadyFlag` | Boolean | gate — receiver expects `true` before writing | — |
|
||||||
|
| `DownloadPath` | String | data | `RecipeDownload.DownloadPath` |
|
||||||
|
| `WorkOrderNumber` | String | data | `RecipeDownload.WorkOrderNumber` |
|
||||||
|
| `PartNumber` | String | data | `RecipeDownload.PartNumber` |
|
||||||
|
| `JobStepNumber` | String | data | `RecipeDownload.JobStepNumber` |
|
||||||
|
| `Username` | String | data | `RecipeDownload.Username` |
|
||||||
|
| `RecipeDownloadFlag` | Boolean | **trigger** — set `true` to start processing | — |
|
||||||
|
| `RecipeProcessedFlag` | Boolean | completion — handshake waits on this | — |
|
||||||
|
| `RecipeProcessResult` | Boolean | result | → `RecipeDownloadResult.Result` |
|
||||||
|
| `RecipeProcessResultText` | String | result | → `RecipeDownloadResult.ResultText` |
|
||||||
|
|
||||||
|
*(`MachineCode` selects which receiver instance; it is not itself a written attribute.)*
|
||||||
|
|
||||||
|
**Galaxy-side handshake (authoritative — ArchestrA scripts on `$DelmiaReceiver`):**
|
||||||
|
|
||||||
|
`ProcessRecipe` (runs when `RecipeDownloadFlag` is set):
|
||||||
|
```
|
||||||
|
Me.RecipeDownloadFlag = false; ' clear trigger
|
||||||
|
Me.ReadyFlag = false; ' clear ready
|
||||||
|
try
|
||||||
|
Me.RecipeProcessResult = true;
|
||||||
|
Me.RecipeProcessResultText = "Success";
|
||||||
|
catch
|
||||||
|
Me.RecipeProcessResult = false;
|
||||||
|
Me.RecipeProcessResultText = "Failed to read recipe file";
|
||||||
|
endtry;
|
||||||
|
Me.RecipeProcessedFlag = true; ' signal completion
|
||||||
|
```
|
||||||
|
|
||||||
|
`Reset` (clears the slot for the next download):
|
||||||
|
```
|
||||||
|
Me.RecipeDownloadFlag = false; Me.RecipeProcessedFlag = false;
|
||||||
|
Me.RecipeProcessResult = false; Me.RecipeProcessResultText = "";
|
||||||
|
Me.DownloadPath = ""; Me.WorkOrderNumber = ""; Me.PartNumber = "";
|
||||||
|
Me.JobStepNumber = ""; Me.Username = "";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Receiver-side sequence (inferred** — mirrors the MES handshake and is driven by the flags above; the C# source is not in the repo**):**
|
||||||
|
1. resolve the `$DelmiaReceiver` instance from `MachineCode`;
|
||||||
|
2. (optionally) verify `ReadyFlag == true`;
|
||||||
|
3. write `DownloadPath`, `WorkOrderNumber`, `PartNumber`, `JobStepNumber`, `Username`;
|
||||||
|
4. set `RecipeDownloadFlag = true` (trigger) → Galaxy `ProcessRecipe` fires;
|
||||||
|
5. wait for `RecipeProcessedFlag == true`, bounded by the request timeout;
|
||||||
|
6. read `RecipeProcessResult` → `Result`, `RecipeProcessResultText` → `ResultText`; return the `RecipeDownloadResult` JSON;
|
||||||
|
7. `Reset` the object.
|
||||||
|
|
||||||
|
### 2.5 Outputs / error handling (Delmia)
|
||||||
|
|
||||||
|
- **DNC server call:** failures are swallowed into the returned DTO — `SearchResults.ErrorMessage`, or
|
||||||
|
`DownloadResult.TransferSuccessful=false` + `ErrorMessage="Failed to call Delmia web service at '<URL>'."`.
|
||||||
|
- **Notify handoff:** `RecipeDownloadResult.Result` (bool) + `ResultText` (string); `WWNotifier` exit code
|
||||||
|
`0` (`YES`) / `-1` (`NO`).
|
||||||
|
- **Galaxy script:** `RecipeProcessResultText` is `"Success"` or `"Failed to read recipe file"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Side-by-side summary
|
||||||
|
|
||||||
|
| | MES (`MESAPI`) | Delmia DNC (`DelmiaIntegration`) |
|
||||||
|
|---|---|---|
|
||||||
|
| Caller | Camstar MES (HTTP/JSON) | Operator UI → DELMIA DNC server, then WWNotifier |
|
||||||
|
| API style | ServiceStack REST, `POST /mes/*` | DNC = form-url-encoded → XML; notify = JSON POST |
|
||||||
|
| Who writes MXAccess | the service (`MesNotifier`, in-repo) | external `:9001/notify` receiver (source not in repo) |
|
||||||
|
| MXAccess client | `LMXProxyServerClass`, register `"MesNotifier"` | `LMXProxyServerClass` (receiver), Galaxy `$DelmiaReceiver` scripts |
|
||||||
|
| Target object | `{MachineCode}.MesReceiver.*` | `{MachineCode}.$DelmiaReceiver` instance |
|
||||||
|
| Ready / trigger / complete | `MoveInReadyFlag` / `MoveInFlag` / `MoveInCompleteFlag` | `ReadyFlag` / `RecipeDownloadFlag` / `RecipeProcessedFlag` |
|
||||||
|
| Result / error | `MoveInSuccessfulFlag` / `MoveInErrorText` (+`MoveInBatchID`) | `RecipeProcessResult` / `RecipeProcessResultText` |
|
||||||
|
| Timeout | 30 s (`CancellationTokenSource(30000)`), event-driven | 30 s HTTP (`NotifyTimeout`); Galaxy wait at receiver |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Sources & open gaps
|
||||||
|
|
||||||
|
**Repos (Gitea `ZimmerBiomet`, `master`):**
|
||||||
|
- MES: `MESAPI` — `APIServer.ServiceInterface/MesServices.cs`, `MesNotifier.cs`,
|
||||||
|
`Mes{MoveIn,MoveOut}Tagset.cs`, `AlarmTagset.cs`, `Tag.cs`/`OnValueTask.cs`;
|
||||||
|
`APIServer.ServiceModel/Types/*`; `APIServer/AppHost.cs`, `App.config`.
|
||||||
|
- Delmia: `DelmiaIntegration` — `DelmiaIntegration/DelmiaClient.cs`, `Models/RecipeSet.cs`;
|
||||||
|
`WWNotifier/Program.cs`, `CommandLineOptions.cs`, `Models/RecipeDownload(Result).cs`, `App.config`;
|
||||||
|
`DelmiaContracts/*`.
|
||||||
|
|
||||||
|
**Galaxy export (`~/Desktop/AA_EXPORT/EXTRACTED/$DelmiaReceiver`):** `scripts/ProcessRecipe.txt`,
|
||||||
|
`scripts/Reset.txt`, `$DelmiaReceiver.top_level_attributes.csv`. MES receiver object attributes: [`mesrec.md`](mesrec.md).
|
||||||
|
|
||||||
|
**Open gaps / to verify against the box:**
|
||||||
|
1. The Delmia recipe **`/notify` receiver service** source (the actual MXAccess writer at `wonder-app-vd01:9001`)
|
||||||
|
was not found in the Gitea org — §2.4 receiver steps are inferred from the contract + Galaxy scripts.
|
||||||
|
2. MES tag prefix in code is `{Code}.MesReceiver.*`, while the live probe in `mesrec.md` shows a top-level
|
||||||
|
`MESReceiver_002` instance — confirm the exact contained-name/instance convention on the live Galaxy.
|
||||||
|
3. PROD `HttpListener`/DB host values should be read from the deployed `App.config`, not assumed.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# MESReceiver object — attributes (wonder-app-vd03 Galaxy)
|
||||||
|
|
||||||
|
Source: live AVEVA Galaxy DB **`ZB`** on **wonder-app-vd03** (the MxAccessGateway box),
|
||||||
|
read via the gateway's own `AttributesSql` (recursive `deployed_package_chain` over
|
||||||
|
`dynamic_attribute` / `attribute_definition`) run with `sqlcmd` over ssh — `2026-06-16`.
|
||||||
|
|
||||||
|
## Instance probed
|
||||||
|
- **TagName:** `MESReceiver_002` (GobjectId `5909`, ParentGobjectId `5908`, HostedBy `5049`)
|
||||||
|
- **TemplateChain:** `$MESDemo.MESReceiver` → `$MESReceiver` → `$gUserDefined` → `$UserDefined`
|
||||||
|
- **CategoryId:** 10, `IsArea = false`
|
||||||
|
- There are many MESReceiver instances on this Galaxy (1,253 `MESReceiver` references in the
|
||||||
|
`galaxy-snapshot.json` hierarchy cache). `MESReceiver_002` is representative of the template.
|
||||||
|
|
||||||
|
> Note: the cached `galaxy-snapshot.json` (gateway Server dir) holds the **object hierarchy only**
|
||||||
|
> — it carries **no attributes**, so attribute discovery requires the DB query (or gRPC `galaxy-discover`).
|
||||||
|
|
||||||
|
## Container number
|
||||||
|
There is **no attribute literally named `ContainerNumber`**. The MES container number is carried by:
|
||||||
|
- **`MoveInMesContainerNum`** — String (move-in interface)
|
||||||
|
- **`MoveOutMesContainerNum`** — String (move-out interface)
|
||||||
|
|
||||||
|
Full tag references: `MESReceiver_002.MoveInMesContainerNum`, `MESReceiver_002.MoveOutMesContainerNum`.
|
||||||
|
|
||||||
|
## MES interface UDAs (attribute-category 10, security-classification 1 = secured/operate)
|
||||||
|
|
||||||
|
### Move-In
|
||||||
|
| Attribute | Type | Full tag reference |
|
||||||
|
|---|---|---|
|
||||||
|
| **MoveInMesContainerNum** | String | `MESReceiver_002.MoveInMesContainerNum` |
|
||||||
|
| MoveInBatchID | Integer | `MESReceiver_002.MoveInBatchID` |
|
||||||
|
| MoveInJobSequenceNumber | String | `MESReceiver_002.MoveInJobSequenceNumber` |
|
||||||
|
| MoveInNumberWorkOrders | Integer | `MESReceiver_002.MoveInNumberWorkOrders` |
|
||||||
|
| MoveInWorkOrderNumbers | String[] | `MESReceiver_002.MoveInWorkOrderNumbers[]` |
|
||||||
|
| MoveInPartNumbers | String[] | `MESReceiver_002.MoveInPartNumbers[]` |
|
||||||
|
| MoveInOperatorName | String | `MESReceiver_002.MoveInOperatorName` |
|
||||||
|
| MoveInFlag | Boolean | `MESReceiver_002.MoveInFlag` |
|
||||||
|
| MoveInReadyFlag | Boolean | `MESReceiver_002.MoveInReadyFlag` |
|
||||||
|
| MoveInCompleteFlag | Boolean | `MESReceiver_002.MoveInCompleteFlag` |
|
||||||
|
| MoveInSuccessfulFlag | Boolean | `MESReceiver_002.MoveInSuccessfulFlag` |
|
||||||
|
| MoveInErrorText | String | `MESReceiver_002.MoveInErrorText` |
|
||||||
|
|
||||||
|
### Move-Out (symmetric; no JobSequenceNumber)
|
||||||
|
| Attribute | Type | Full tag reference |
|
||||||
|
|---|---|---|
|
||||||
|
| **MoveOutMesContainerNum** | String | `MESReceiver_002.MoveOutMesContainerNum` |
|
||||||
|
| MoveOutBatchID | Integer | `MESReceiver_002.MoveOutBatchID` |
|
||||||
|
| MoveOutNumberWorkOrders | Integer | `MESReceiver_002.MoveOutNumberWorkOrders` |
|
||||||
|
| MoveOutWorkOrderNumbers | String[] | `MESReceiver_002.MoveOutWorkOrderNumbers[]` |
|
||||||
|
| MoveOutPartNumbers | String[] | `MESReceiver_002.MoveOutPartNumbers[]` |
|
||||||
|
| MoveOutOperatorName | String | `MESReceiver_002.MoveOutOperatorName` |
|
||||||
|
| MoveOutFlag | Boolean | `MESReceiver_002.MoveOutFlag` |
|
||||||
|
| MoveOutReadyFlag | Boolean | `MESReceiver_002.MoveOutReadyFlag` |
|
||||||
|
| MoveOutCompleteFlag | Boolean | `MESReceiver_002.MoveOutCompleteFlag` |
|
||||||
|
| MoveOutSuccessfulFlag | Boolean | `MESReceiver_002.MoveOutSuccessfulFlag` |
|
||||||
|
| MoveOutErrorText | String | `MESReceiver_002.MoveOutErrorText` |
|
||||||
|
|
||||||
|
## Standard ArchestrA `$UserDefined` / system attributes (also present)
|
||||||
|
`AlarmCntsBySeverity[]`, `AlarmCntsBySeverityEnableShelved[]`, `AlarmInhibit`, `AlarmMode`,
|
||||||
|
`AlarmModeCmd`, `AlarmMostUrgentAcked`, `AlarmMostUrgentInAlarm`, `AlarmMostUrgentMode`,
|
||||||
|
`AlarmMostUrgentSeverity`, `AlarmMostUrgentShelved`, `AliasName`, `Area`, `CmdData`, `CodeBase`,
|
||||||
|
`ConfigVersion`, `ContainedName`, `Container`, `Errors[]`, `ExecutionRelatedObject`,
|
||||||
|
`ExecutionRelativeOrder`, `Extensions`, `HierarchicalName`, `Host`, `InAlarm`, `MinorVersion`,
|
||||||
|
`PropagatedAlarmInhibit`, `ScanState`, `ScanStateCmd`, `SecurityGroup`, `ShortDesc`, `Tagname`,
|
||||||
|
`UDAs`, `UserAttrData`, `PropagatedAlarmInhibit`.
|
||||||
|
|
||||||
|
## How to reproduce
|
||||||
|
1. `MESReceiver` instances are in the gateway hierarchy cache
|
||||||
|
`E:\ApiInstall\MxGateway\Server\galaxy-snapshot.json` (objects only, no attrs).
|
||||||
|
2. Attribute list: run the gateway's `AttributesSql`
|
||||||
|
(`src/.../Server/Galaxy/GalaxyRepository.cs`) scoped to one instance via
|
||||||
|
`... WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.tag_name = 'MESReceiver_002'`
|
||||||
|
in the `deployed_package_chain` anchor, then `sqlcmd -S (local) -d ZB -U wwadmin -P <pwd>`
|
||||||
|
on the box (Galaxy creds from `MxGateway.Galaxy.ConnectionString` in the gateway appsettings).
|
||||||
|
3. Live values would need an MXAccess read through the gateway (gRPC), not the repo SQL.
|
||||||
|
|
||||||
|
In OtOpcUa (Galaxy-as-standard-driver model) these bind as ordinary equipment tags:
|
||||||
|
`Tag{ DriverInstanceId = GalaxyMxGateway, TagConfig = {"FullName":"MESReceiver_002.MoveInMesContainerNum"} }`.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# New Jersey — DARS reactors Z28061 / Z28062 (wonder Galaxy)
|
||||||
|
|
||||||
|
Source: live AVEVA Galaxy DB **`ZB`** on **wonder-app-vd03** (MxAccessGateway box), via the gateway's `AttributesSql` over `sqlcmd`/ssh — `2026-06-16`. Lists the **meaningful (user-defined, cat-10) attributes**; ArchestrA system attributes (alarm framework, identity, security) and per-field config sub-attributes (`.EngUnits`, `.TrendHi`, `.Dev.*`, history/alarm settings) and scripts are omitted.
|
||||||
|
|
||||||
|
## Hierarchy
|
||||||
|
```
|
||||||
|
NewJersey (area)
|
||||||
|
└─ CVDAisle_1 ($CVDAisle, gobject 7243)
|
||||||
|
├─ Z28061 ($DARSReactor, gobject 7171)
|
||||||
|
│ ├─ Left (Left_002, $DARSReactor.Left, gobject 7172)
|
||||||
|
│ └─ Right (Right_002, $DARSReactor.Right, gobject 7173)
|
||||||
|
└─ Z28062 ($DARSReactor, gobject 7202)
|
||||||
|
├─ Left (Left_003, $DARSReactor.Left, gobject 7203)
|
||||||
|
└─ Right (Right_003, $DARSReactor.Right, gobject 7204)
|
||||||
|
```
|
||||||
|
(`Z28061Sim`, gobject 7146, is a simulator sibling of Z28061.)
|
||||||
|
|
||||||
|
## Objects
|
||||||
|
| Object | GobjectId | Template | Compound path | UDA attrs | Total attrs |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Left_002 | 7172 | `$DARSReactor.Left` | Z28061.Left | 72 | 1907 |
|
||||||
|
| Left_003 | 7203 | `$DARSReactor.Left` | Z28062.Left | 72 | 1907 |
|
||||||
|
| Right_002 | 7173 | `$DARSReactor.Right` | Z28061.Right | 72 | 1907 |
|
||||||
|
| Right_003 | 7204 | `$DARSReactor.Right` | Z28062.Right | 72 | 1907 |
|
||||||
|
| Z28061 | 7171 | `$DARSReactor` | Z28061 | 16 | 195 |
|
||||||
|
| Z28062 | 7202 | `$DARSReactor` | Z28062 | 16 | 195 |
|
||||||
|
|
||||||
|
> Attribute sets are template-defined: `Z28061`≡`Z28062`, `Left_002`≡`Left_003`, `Right_002`≡`Right_003`. Listed once per template.
|
||||||
|
|
||||||
|
## $DARSReactor — Z28061 / Z28062 (reactor body)
|
||||||
|
|
||||||
|
**Instances:** `Z28061` (Z28061, gobject 7171), `Z28062` (Z28062, gobject 7202)
|
||||||
|
**Template:** `$DARSReactor` · **16 meaningful (UDA) attributes** · 195 total incl. system + config sub-attributes.
|
||||||
|
|
||||||
|
| Attribute | Type | Hist | Alarm |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ChlorineFlow | Integer | ✓ | |
|
||||||
|
| ConfirmTimeoutSec | Integer | | |
|
||||||
|
| Deadband | Double | | |
|
||||||
|
| HeartbeatInterval | Integer | | |
|
||||||
|
| HeartbeatRequest | Boolean | | |
|
||||||
|
| HeartbeatTimeout | Integer | | |
|
||||||
|
| HeartbeatTimeoutAlarm | Boolean | | ✓ |
|
||||||
|
| HydrogenFlow | Integer | ✓ | |
|
||||||
|
| LeakTestMaxDelta | Integer | | |
|
||||||
|
| LeakTestMinDuration | Integer | | |
|
||||||
|
| LeakTestTimeout | Integer | | |
|
||||||
|
| MachineCode | String | | |
|
||||||
|
| MachineDescription | String | | |
|
||||||
|
| MachineID | String | | |
|
||||||
|
| Runtime_Alert | Boolean | | |
|
||||||
|
| TrunkPressure | Float | ✓ | |
|
||||||
|
|
||||||
|
## $DARSReactor.Left — Left_002 / Left_003 (left chamber)
|
||||||
|
|
||||||
|
**Instances:** `Left_002` (Z28061.Left, gobject 7172), `Left_003` (Z28062.Left, gobject 7203)
|
||||||
|
**Template:** `$DARSReactor.Left` · **72 meaningful (UDA) attributes** · 1907 total incl. system + config sub-attributes.
|
||||||
|
|
||||||
|
| Attribute | Type | Hist | Alarm |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AIR_SVC | String | | |
|
||||||
|
| AirAvg | Integer | ✓ | |
|
||||||
|
| AirFlow | Integer | ✓ | |
|
||||||
|
| AirFlowDevAlertPcnt | Float | ✓ | |
|
||||||
|
| AR_SVC | String | | |
|
||||||
|
| ArAvg | Integer | ✓ | |
|
||||||
|
| ArgonFlow | Integer | ✓ | |
|
||||||
|
| ChlorineFlowR | Integer | | |
|
||||||
|
| CL2_SVC | String | | |
|
||||||
|
| Cl2Avg | Integer | ✓ | |
|
||||||
|
| ClFlowDevAlertPcnt | Float | ✓ | |
|
||||||
|
| CoilDiameter | Boolean | ✓ | |
|
||||||
|
| CoilHeight | Integer | ✓ | |
|
||||||
|
| ContainerID | String | ✓ | |
|
||||||
|
| ContainerLoaded | Boolean | ✓ | |
|
||||||
|
| CurrentStep | Integer | | |
|
||||||
|
| CycleEndConfirm | Boolean | | |
|
||||||
|
| CycleEndNotify | Boolean | | |
|
||||||
|
| CycleEndTimeoutAlarm | Boolean | | |
|
||||||
|
| CycleRunning | Boolean | ✓ | |
|
||||||
|
| CycleStartConfirm | Boolean | | |
|
||||||
|
| CycleStartNotify | Boolean | | |
|
||||||
|
| CycleStartTimeoutAlarm | Boolean | | |
|
||||||
|
| Deadband | Double | | |
|
||||||
|
| DelmiaJobStep | Integer | | |
|
||||||
|
| FRR_Reset | Boolean | ✓ | |
|
||||||
|
| FRR_Runtime | Integer | ✓ | |
|
||||||
|
| FRR_Warning | Boolean | ✓ | ✓ |
|
||||||
|
| FurnaceTemp | Integer | ✓ | |
|
||||||
|
| FurnaceTempOverAlarmDev | Integer | ✓ | |
|
||||||
|
| FurnaceTempOverAlertDev | Integer | ✓ | |
|
||||||
|
| FurnaceTempUnderAlarmDev | Integer | ✓ | |
|
||||||
|
| FurnaceTempUnderAlertDev | Integer | ✓ | |
|
||||||
|
| H2_SVC | String | | |
|
||||||
|
| H2Avg | Integer | ✓ | |
|
||||||
|
| HydrogenFlowR | Integer | | |
|
||||||
|
| HyFlowDevAlertPcnt | Float | ✓ | |
|
||||||
|
| MachineBatchID | Integer | | |
|
||||||
|
| MachineBatchWOID | Integer | | |
|
||||||
|
| MachineCycleID | Integer | | |
|
||||||
|
| MantleTemp | Integer | ✓ | |
|
||||||
|
| MoveInReady | Boolean | | |
|
||||||
|
| MoveOutReady | Boolean | | |
|
||||||
|
| MTA_Lockout | Boolean | ✓ | ✓ |
|
||||||
|
| MTA_Reset | Boolean | ✓ | |
|
||||||
|
| MTA_Runtime | Integer | ✓ | |
|
||||||
|
| MTA_Warning | Boolean | ✓ | ✓ |
|
||||||
|
| NumOfParts | Integer | ✓ | |
|
||||||
|
| NumOfTurns | Integer | ✓ | |
|
||||||
|
| OperatorID | String | ✓ | |
|
||||||
|
| PartNumber | String | ✓ | |
|
||||||
|
| PID_Alarm | Boolean | | ✓ |
|
||||||
|
| PID_AlarmString | String | | |
|
||||||
|
| PID_CV | Integer | ✓ | |
|
||||||
|
| PotNumber | String | ✓ | |
|
||||||
|
| PotTempAvg | Integer | ✓ | |
|
||||||
|
| PreviousStep | Integer | | |
|
||||||
|
| ReactorTempAvg | Integer | ✓ | |
|
||||||
|
| RunDuration | Integer | | |
|
||||||
|
| RunEndTime | Time | | |
|
||||||
|
| RunStartTime | Time | | |
|
||||||
|
| SampleCount | Integer | | |
|
||||||
|
| StartingWeight | Integer | ✓ | |
|
||||||
|
| TableSide | Boolean | | |
|
||||||
|
| TableStatus | Integer | | |
|
||||||
|
| TC_Furnace_ID | String | | |
|
||||||
|
| TC_Mantle_ID | String | | |
|
||||||
|
| TC_Spare_ID | String | | |
|
||||||
|
| Vacuum | Float | ✓ | |
|
||||||
|
| VacuumAvg | Float | ✓ | |
|
||||||
|
| VacuumMode | Boolean | ✓ | |
|
||||||
|
| WorkOrder | String | | |
|
||||||
|
|
||||||
|
## $DARSReactor.Right — Right_002 / Right_003 (right chamber)
|
||||||
|
|
||||||
|
**Instances:** `Right_002` (Z28061.Right, gobject 7173), `Right_003` (Z28062.Right, gobject 7204)
|
||||||
|
**Template:** `$DARSReactor.Right` · **72 meaningful (UDA) attributes** · 1907 total incl. system + config sub-attributes.
|
||||||
|
|
||||||
|
| Attribute | Type | Hist | Alarm |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AIR_SVC | String | | |
|
||||||
|
| AirAvg | Integer | ✓ | |
|
||||||
|
| AirFlow | Integer | ✓ | |
|
||||||
|
| AirFlowDevAlertPcnt | Float | ✓ | |
|
||||||
|
| AR_SVC | String | | |
|
||||||
|
| ArAvg | Integer | ✓ | |
|
||||||
|
| ArgonFlow | Integer | ✓ | |
|
||||||
|
| ChlorineFlowR | Integer | | |
|
||||||
|
| CL2_SVC | String | | |
|
||||||
|
| Cl2Avg | Integer | ✓ | |
|
||||||
|
| ClFlowDevAlertPcnt | Float | ✓ | |
|
||||||
|
| CoilDiameter | Boolean | ✓ | |
|
||||||
|
| CoilHeight | Integer | ✓ | |
|
||||||
|
| ContainerID | String | ✓ | |
|
||||||
|
| ContainerLoaded | Boolean | ✓ | |
|
||||||
|
| CurrentStep | Integer | | |
|
||||||
|
| CycleEndConfirm | Boolean | | |
|
||||||
|
| CycleEndNotify | Boolean | | |
|
||||||
|
| CycleEndTimeoutAlarm | Boolean | | |
|
||||||
|
| CycleRunning | Boolean | ✓ | |
|
||||||
|
| CycleStartConfirm | Boolean | | |
|
||||||
|
| CycleStartNotify | Boolean | | |
|
||||||
|
| CycleStartTimeoutAlarm | Boolean | | |
|
||||||
|
| Deadband | Double | | |
|
||||||
|
| DelmiaJobStep | Integer | | |
|
||||||
|
| FRR_Reset | Boolean | ✓ | |
|
||||||
|
| FRR_Runtime | Integer | ✓ | |
|
||||||
|
| FRR_Warning | Boolean | ✓ | ✓ |
|
||||||
|
| FurnaceTemp | Integer | ✓ | |
|
||||||
|
| FurnaceTempOverAlarmDev | Integer | ✓ | |
|
||||||
|
| FurnaceTempOverAlertDev | Integer | ✓ | |
|
||||||
|
| FurnaceTempUnderAlarmDev | Integer | ✓ | |
|
||||||
|
| FurnaceTempUnderAlertDev | Integer | ✓ | |
|
||||||
|
| H2_SVC | String | | |
|
||||||
|
| H2Avg | Integer | ✓ | |
|
||||||
|
| HydrogenFlowR | Integer | | |
|
||||||
|
| HyFlowDevAlertPcnt | Float | ✓ | |
|
||||||
|
| MachineBatchID | Integer | | |
|
||||||
|
| MachineBatchWOID | Integer | | |
|
||||||
|
| MachineCycleID | Integer | | |
|
||||||
|
| MantleTemp | Integer | ✓ | |
|
||||||
|
| MoveInReady | Boolean | | |
|
||||||
|
| MoveOutReady | Boolean | | |
|
||||||
|
| MTA_Lockout | Boolean | ✓ | ✓ |
|
||||||
|
| MTA_Reset | Boolean | ✓ | |
|
||||||
|
| MTA_Runtime | Integer | ✓ | |
|
||||||
|
| MTA_Warning | Boolean | ✓ | ✓ |
|
||||||
|
| NumOfParts | Integer | ✓ | |
|
||||||
|
| NumOfTurns | Integer | ✓ | |
|
||||||
|
| OperatorID | String | ✓ | |
|
||||||
|
| PartNumber | String | ✓ | |
|
||||||
|
| PID_Alarm | Boolean | | ✓ |
|
||||||
|
| PID_AlarmString | String | | |
|
||||||
|
| PID_CV | Integer | ✓ | |
|
||||||
|
| PotNumber | String | ✓ | |
|
||||||
|
| PotTempAvg | Integer | ✓ | |
|
||||||
|
| PreviousStep | Integer | | |
|
||||||
|
| ReactorTempAvg | Integer | ✓ | |
|
||||||
|
| RunDuration | Integer | | |
|
||||||
|
| RunEndTime | Time | | |
|
||||||
|
| RunStartTime | Time | | |
|
||||||
|
| SampleCount | Integer | | |
|
||||||
|
| StartingWeight | Integer | ✓ | |
|
||||||
|
| TableSide | Boolean | | |
|
||||||
|
| TableStatus | Integer | | |
|
||||||
|
| TC_Furnace_ID | String | | |
|
||||||
|
| TC_Mantle_ID | String | | |
|
||||||
|
| TC_Spare_ID | String | | |
|
||||||
|
| Vacuum | Float | ✓ | |
|
||||||
|
| VacuumAvg | Float | ✓ | |
|
||||||
|
| VacuumMode | Boolean | ✓ | |
|
||||||
|
| WorkOrder | String | | |
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Python virtualenv + caches (recreate via: python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt)
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Generated by `otopcua_uns.py generate` from ../galaxy-hierarchy.json
|
||||||
|
load-plan.json
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# otopcua-uns-loader
|
||||||
|
|
||||||
|
A **reloadable** populate-and-verify tool for the OtOpcUa galaxy Unified Namespace.
|
||||||
|
Recreates a UNS load grounded in the real AVEVA Galaxy **DEV** hierarchy (the 40
|
||||||
|
`TestMachine` instances) and verifies it streams **live values** on OPC UA — so
|
||||||
|
you can rebuild the OtOpcUa docker-dev instance and get the namespace back with
|
||||||
|
one populate + one deploy click.
|
||||||
|
|
||||||
|
## What it loads
|
||||||
|
|
||||||
|
One **SystemPlatform** `Tag` per `(machine, signal)` bound to the existing
|
||||||
|
`GalaxyMxGateway` driver (`MAIN-galaxy-mxgw`). Each tag's `FolderPath` is the
|
||||||
|
Galaxy object and its `Name` the attribute, so the materialised OPC UA variable
|
||||||
|
`OtOpcUa/<machine>/<signal>` has a NodeId equal to the MXAccess reference the
|
||||||
|
driver subscribes to — which is what makes the value go live.
|
||||||
|
|
||||||
|
Signals mirrored per machine (only those the instance actually has): the
|
||||||
|
`$TestMachine` process UDAs — `TestChangingInt`, `TestHistoryValue`,
|
||||||
|
`TestDouble/Float/Duration/DateTime`, `ProtectedValue(1)`, `TestAlarm001..003`,
|
||||||
|
`InAlarm` → **396 tags across 40 machines**.
|
||||||
|
|
||||||
|
Every row carries the `nw-mirror-` `TagId` prefix, so `clean` removes exactly
|
||||||
|
what the tool created (adopting any pre-existing seed row for the same ref).
|
||||||
|
|
||||||
|
## Why a deploy click is in the middle
|
||||||
|
|
||||||
|
OtOpcUa applies config only from **sealed Deployment snapshots**, and the only
|
||||||
|
way to seal one is the AdminUI **"Deploy current configuration"** button
|
||||||
|
(`http://localhost:9200/deployments`) — there is no SQL/REST/CLI trigger (it's
|
||||||
|
an in-cluster Akka operation). So the flow is:
|
||||||
|
|
||||||
|
```
|
||||||
|
populate ──SQL──▶ live config tables
|
||||||
|
│ (you click Deploy at :9200, sign in multi-role/password)
|
||||||
|
▼
|
||||||
|
driver applies ▶ materialises variables ▶ SubscribeBulk ▶ live values
|
||||||
|
│
|
||||||
|
verify ──OPC UA──▶ browse + read Good values on :4840
|
||||||
|
```
|
||||||
|
|
||||||
|
`populate` and `clean` print the reminder; `verify --wait` polls until the
|
||||||
|
deploy lands.
|
||||||
|
|
||||||
|
> Live values depend on the driver **SubscribeBulk** pass
|
||||||
|
> (OtOpcUa `master` ≥ commit `c1ce583`). On older builds variables materialise
|
||||||
|
> but stay `BadWaitingForInitialData`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd otopcua-uns-loader
|
||||||
|
python3 -m venv .venv
|
||||||
|
./.venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python otopcua_uns.py generate # build load-plan.json from galaxy-hierarchy.json
|
||||||
|
./.venv/bin/python otopcua_uns.py populate # upsert the 396 mirror Tag rows (idempotent)
|
||||||
|
# → open http://localhost:9200/deployments, sign in, click "Deploy current configuration"
|
||||||
|
./.venv/bin/python otopcua_uns.py verify --wait # poll until live values are Good on :4840
|
||||||
|
./.venv/bin/python otopcua_uns.py status # config-DB + address-space snapshot
|
||||||
|
./.venv/bin/python otopcua_uns.py clean # remove all nw-mirror-* tags (then Deploy again)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild recovery (the point of the tool)
|
||||||
|
|
||||||
|
After the docker-dev instance is rebuilt (DB wiped):
|
||||||
|
|
||||||
|
1. Ensure the schema + clusters + the `MAIN-galaxy-mxgw` driver exist
|
||||||
|
(`dotnet ef database update` + the docker-dev `cluster-seed`; see the OtOpcUa
|
||||||
|
`docker-dev/README.md`).
|
||||||
|
2. `populate` → **Deploy** at the AdminUI → `verify --wait`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`verify` stays INCOMPLETE / deployment "Sealed" but drivers never applied it.**
|
||||||
|
If you recreate the **admin/coordinator** node (`admin-a`) around the same time
|
||||||
|
you click Deploy, the dispatch broadcast can be lost — the deployment seals but
|
||||||
|
`NodeDeploymentState` shows no row for the driver nodes, and the address space
|
||||||
|
keeps the old content. Recover by: restart the driver nodes
|
||||||
|
(`docker restart otopcua-dev-driver-a-1 otopcua-dev-driver-b-1`) so they cleanly
|
||||||
|
re-subscribe, then Deploy again. If a no-op "NoChanges" blocks the re-deploy,
|
||||||
|
delete the orphan sealed `Deployment` row that no node applied (it has no
|
||||||
|
`NodeDeploymentState` children) so Deploy sees drift again.
|
||||||
|
|
||||||
|
**A rebuilt/restarted node serves an empty address space until the next Deploy.**
|
||||||
|
On bootstrap a node recovers to its last-*applied* revision and does **not**
|
||||||
|
re-materialise until a new deployment is dispatched — so after any node restart,
|
||||||
|
click Deploy once (a config change bumps the revision) to repopulate + re-subscribe.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Defaults target docker-dev; override via flags or env:
|
||||||
|
|
||||||
|
| Flag | Env | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `--sql-host/-port/-user/-password/-db` | `OTOPCUA_SQL_*` | `localhost:14330` sa / `OtOpcUa!Dev123` / `OtOpcUa` |
|
||||||
|
| `--opcua-endpoint` | `OTOPCUA_OPCUA_ENDPOINT` | `opc.tcp://localhost:4840` |
|
||||||
|
| `--driver` | `OTOPCUA_GALAXY_DRIVER` | `MAIN-galaxy-mxgw` |
|
||||||
|
| `--galaxy-json` | `OTOPCUA_GALAXY_JSON` | `../galaxy-hierarchy.json` |
|
||||||
|
| `--deploy-url` | — | `http://localhost:9200/deployments` |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `otopcua_uns.py` — the CLI (generate / populate / verify / status / clean)
|
||||||
|
- `load-plan.json` — generated load plan (machine → signal → MXAccess ref)
|
||||||
|
- `../galaxy-hierarchy.json` — the source of truth, pulled live from the gateway
|
||||||
|
- `requirements.txt`, `.venv/`
|
||||||
|
|
||||||
|
## Company-shape overlay (`populate-equipment`)
|
||||||
|
|
||||||
|
Besides the galaxy-native mirror, the tool can load the **Northwind company
|
||||||
|
shape** (`filling / line-1 / rinser-01 / speed-rpm`) as a second, **Equipment**-kind
|
||||||
|
namespace (`nw-uns`, in cluster `MAIN`) from `../company-uns.json`. Each company
|
||||||
|
signal is a **VirtualTag** (+ a `Script`) whose script simply mirrors the live
|
||||||
|
galaxy-mirror tag for that signal:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
return ctx.GetTag("TestMachine_001.TestDouble").Value;
|
||||||
|
```
|
||||||
|
|
||||||
|
so the company shape carries live **VALUES** driven off the same Galaxy source — no
|
||||||
|
driver, no `BadWaitingForInitialData` once the galaxy mirror is up. The `ctx.GetTag`
|
||||||
|
literal is the signal's `source.fullTagReference`; the engine's `DependencyExtractor`
|
||||||
|
harvests it and subscribes the VirtualTag to that galaxy-mirror tag. This needs
|
||||||
|
OtOpcUa `master` ≥ the Equipment-namespace VirtualTag materialisation milestone (WS-3),
|
||||||
|
which materialises `VirtualTag`/`Script` rows on deploy and added the **headless
|
||||||
|
deploy** endpoint.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python otopcua_uns.py populate-equipment # 3 areas / 8 lines / 40 equipment / 1036 VirtualTags
|
||||||
|
curl -s -X POST http://localhost:9200/api/deployments -H 'X-Api-Key: docker-dev-deploy-key' # headless deploy
|
||||||
|
./.venv/bin/python otopcua_uns.py verify-equipment --expect 1036 --require-good 396 --wait --wait-seconds 300 # structure + live values
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Verified live 2026-06-07** (OtOpcUa `feat/equipment-namespace-live-values`): galaxy mirror
|
||||||
|
> **396/396 Good**, company overlay **396 Good** on `opc.tcp://localhost:4840`, `VERIFY-EQUIPMENT: PASS`.
|
||||||
|
> Why 396 of 1036? The shipped `company-uns.json` invents **1036 distinct** `ctx.GetTag` refs, but only
|
||||||
|
> **396** of them match a real galaxy-mirror tag — so 396 signals are backed by a live source (and all 396
|
||||||
|
> go Good); the other 640 cite synthetic refs with no galaxy tag (`BadNodeIdUnknown`). That ratio is a
|
||||||
|
> property of the company model, not the streaming path — **every signal with a resolvable live source
|
||||||
|
> streams Good.** So `--require-good 396` is the meaningful gate for the current model. Survives a node
|
||||||
|
> restart with no re-deploy (the bootstrap-restore path re-materialises + re-applies the VirtualTags).
|
||||||
|
|
||||||
|
UNS folders carry the friendly **DisplayName** (`filling`); the BrowseName/NodeId
|
||||||
|
stay the stable logical Id (`nw-area-filling`) — standard OPC UA. **No driver:** the
|
||||||
|
company signals are VirtualTags (which link to Equipment + a Script, not a driver); a
|
||||||
|
placeholder `nw-uns-modbus` driver is kept only because an Equipment namespace is
|
||||||
|
expected to have one, but no `Tag` binds to it. `verify-equipment --require-good N`
|
||||||
|
reads each leaf's value and asserts at least N are Good (default `0` = structure-only,
|
||||||
|
back-compat); `--wait` polls until the deploy + change-triggered evaluations land.
|
||||||
|
Tracked in `OtOpcUa/docs/plans/2026-06-06-equipment-namespace-materialization-scope.md` (WS-3).
|
||||||
|
|
||||||
|
`clean` removes both the mirror tags and the company overlay (the `VirtualTag` +
|
||||||
|
`Script` rows, in FK-safe order, plus the namespace/driver/equipment/areas/lines).
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
otopcua_uns.py — reloadable populate + verify for the OtOpcUa galaxy UNS.
|
||||||
|
|
||||||
|
Recreates and verifies an OtOpcUa Unified-Namespace load grounded in the real
|
||||||
|
AVEVA Galaxy "DEV" hierarchy (the 40 TestMachine instances). Designed to be
|
||||||
|
re-run after the OtOpcUa docker-dev instance is rebuilt.
|
||||||
|
|
||||||
|
Pipeline (see scadaproj/memory otopcua-uns-deploy-and-value-streaming):
|
||||||
|
|
||||||
|
populate ──SQL──▶ live config tables (Tag rows, nw-* prefix)
|
||||||
|
│
|
||||||
|
you click "Deploy current configuration" at :9200
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
driver applies ▶ materialises OtOpcUa/<machine>/<signal> ▶ SubscribeBulk
|
||||||
|
│
|
||||||
|
verify ──OPC UA──▶ browse + read live values on :4840
|
||||||
|
|
||||||
|
What it loads: one SystemPlatform Tag per (machine, signal) bound to the
|
||||||
|
existing GalaxyMxGateway driver. Each tag's FolderPath is the Galaxy object and
|
||||||
|
its Name the attribute, so the materialised variable NodeId is exactly the
|
||||||
|
MXAccess ref the driver subscribes to — giving live values. Every row carries
|
||||||
|
the `nw-` id prefix so `clean` can remove them without touching other config.
|
||||||
|
|
||||||
|
Idempotent: populate upserts by TagId; re-running is a no-op when unchanged.
|
||||||
|
|
||||||
|
There are TWO overlays:
|
||||||
|
• the galaxy-native mirror (`populate`) — SystemPlatform driver Tags, 396 tags;
|
||||||
|
• the Northwind company shape (`populate-equipment`) — an Equipment-kind namespace
|
||||||
|
whose 1036 signals are VirtualTags. Each VirtualTag's Script simply mirrors the
|
||||||
|
live galaxy-mirror tag (`return ctx.GetTag("<fullTagReference>").Value;`), so the
|
||||||
|
company shape carries live VALUES driven off the same Galaxy source.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
generate Build the load plan from galaxy-hierarchy.json (writes load-plan.json)
|
||||||
|
populate Upsert the SystemPlatform mirror Tag rows into the config DB
|
||||||
|
populate-equipment Load the company shape as VirtualTag+Script rows (mirror the galaxy tags)
|
||||||
|
verify Check DB rows present + live OPC UA values are Good on :4840
|
||||||
|
verify-equipment Browse the company tree; --require-good asserts live values
|
||||||
|
status Show config-DB + address-space state
|
||||||
|
clean Delete all nw-* mirror Tags + the company VirtualTag/Script overlay
|
||||||
|
|
||||||
|
Deploy is a human-gated AdminUI action (no SQL/REST trigger exists); populate
|
||||||
|
and clean print the reminder and `verify --wait` polls until it lands.
|
||||||
|
|
||||||
|
Deps: pymssql, asyncua (see requirements.txt; use the bundled .venv).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# ── config (overridable via env / flags) ───────────────────────────────────
|
||||||
|
DEF_MSSQL = dict(
|
||||||
|
host=os.environ.get("OTOPCUA_SQL_HOST", "localhost"),
|
||||||
|
port=int(os.environ.get("OTOPCUA_SQL_PORT", "14330")),
|
||||||
|
user=os.environ.get("OTOPCUA_SQL_USER", "sa"),
|
||||||
|
password=os.environ.get("OTOPCUA_SQL_PASSWORD", "OtOpcUa!Dev123"),
|
||||||
|
database=os.environ.get("OTOPCUA_SQL_DB", "OtOpcUa"),
|
||||||
|
)
|
||||||
|
DEF_OPCUA = os.environ.get("OTOPCUA_OPCUA_ENDPOINT", "opc.tcp://localhost:4840")
|
||||||
|
DEF_DRIVER = os.environ.get("OTOPCUA_GALAXY_DRIVER", "MAIN-galaxy-mxgw")
|
||||||
|
DEF_GALAXY_JSON = os.environ.get(
|
||||||
|
"OTOPCUA_GALAXY_JSON",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "galaxy-hierarchy.json"),
|
||||||
|
)
|
||||||
|
DEF_COMPANY_JSON = os.environ.get(
|
||||||
|
"OTOPCUA_COMPANY_JSON",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "company-uns.json"),
|
||||||
|
)
|
||||||
|
ID_PREFIX = "nw-mirror-" # SystemPlatform galaxy-mirror TagId prefix
|
||||||
|
LOAD_PLAN = os.path.join(os.path.dirname(__file__), "load-plan.json")
|
||||||
|
|
||||||
|
# Equipment-overlay (company-shape) object ids — all carry the nw- prefix so
|
||||||
|
# `clean` can remove them. The Equipment namespace is a SECOND namespace loaded
|
||||||
|
# alongside the galaxy mirror. Each company signal is a VirtualTag (+ Script) whose
|
||||||
|
# script mirrors the live SystemPlatform galaxy-mirror tag for that signal — so the
|
||||||
|
# overlay carries live VALUES (scope doc WS-3), not just structure.
|
||||||
|
EQ_CLUSTER = os.environ.get("OTOPCUA_EQ_CLUSTER", "MAIN")
|
||||||
|
EQ_NS = "nw-uns"
|
||||||
|
EQ_ID_PREFIX = "nweq-" # VirtualTag/Script logical-id prefix (cleanup by prefix scan)
|
||||||
|
|
||||||
|
# galaxy dataTypeName / gen_uns dtype → valid OtOpcUa DriverDataType
|
||||||
|
_DTYPE_FIX = {"Double": "Float64", "Float": "Float32"}
|
||||||
|
_ACCESS = {"ReadOnly": "0", "Read": "0", "ReadWrite": "1"}
|
||||||
|
|
||||||
|
# ── the value signals we mirror, per $TestMachine instance ──────────────────
|
||||||
|
# (galaxy attribute name, OtOpcUa DriverDataType, access '0'=Read/'1'=ReadWrite)
|
||||||
|
SIGNALS = [
|
||||||
|
("TestChangingInt", "Int32", "0"),
|
||||||
|
("TestHistoryValue", "Int32", "0"),
|
||||||
|
("TestDouble", "Float64", "0"),
|
||||||
|
("TestFloat", "Float32", "0"),
|
||||||
|
("TestDuration", "Float64", "0"),
|
||||||
|
("TestDateTime", "DateTime", "0"),
|
||||||
|
("ProtectedValue", "Boolean", "1"),
|
||||||
|
("ProtectedValue1", "Boolean", "1"),
|
||||||
|
("TestAlarm001", "Boolean", "0"),
|
||||||
|
("TestAlarm002", "Boolean", "0"),
|
||||||
|
("TestAlarm003", "Boolean", "0"),
|
||||||
|
("InAlarm", "Boolean", "0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── plan generation (grounded in the real galaxy) ───────────────────────────
|
||||||
|
def build_plan(galaxy_json, driver):
|
||||||
|
with open(galaxy_json) as f:
|
||||||
|
gal = json.load(f)
|
||||||
|
machines = [
|
||||||
|
o for o in gal["objects"]
|
||||||
|
if "$TestMachine" in o.get("templateChain", [])
|
||||||
|
]
|
||||||
|
machines.sort(key=lambda o: o["tagName"])
|
||||||
|
rows = []
|
||||||
|
for m in machines:
|
||||||
|
have = {a["attributeName"] for a in m["attributes"]}
|
||||||
|
for attr, dtype, access in SIGNALS:
|
||||||
|
if attr not in have:
|
||||||
|
continue # only mirror attributes this instance really has
|
||||||
|
rows.append({
|
||||||
|
"tag_id": f"{ID_PREFIX}{m['tagName']}-{attr}".lower(),
|
||||||
|
"driver_instance_id": driver,
|
||||||
|
"name": attr,
|
||||||
|
"folder_path": m["tagName"], # → folder; ref = folder.name
|
||||||
|
"data_type": dtype,
|
||||||
|
"access_level": access,
|
||||||
|
"mxaccess_ref": f"{m['tagName']}.{attr}",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"source": galaxy_json,
|
||||||
|
"driver_instance_id": driver,
|
||||||
|
"machines": len(machines),
|
||||||
|
"tags": len(rows),
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB helpers ──────────────────────────────────────────────────────────────
|
||||||
|
def connect(cfg):
|
||||||
|
import pymssql
|
||||||
|
conn = pymssql.connect(
|
||||||
|
server=cfg["host"], port=str(cfg["port"]), user=cfg["user"],
|
||||||
|
password=cfg["password"], database=cfg["database"], autocommit=False,
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
# The Tag table has filtered indexes / computed columns; writes require this.
|
||||||
|
cur.execute("SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON;")
|
||||||
|
return conn, cur
|
||||||
|
|
||||||
|
|
||||||
|
def driver_exists(cur, driver):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT n.Kind FROM dbo.DriverInstance d "
|
||||||
|
"JOIN dbo.Namespace n ON n.NamespaceId = d.NamespaceId "
|
||||||
|
"WHERE d.DriverInstanceId = %s", (driver,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
return r[0] if r else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── commands ────────────────────────────────────────────────────────────────
|
||||||
|
def cmd_generate(args):
|
||||||
|
plan = build_plan(args.galaxy_json, args.driver)
|
||||||
|
with open(LOAD_PLAN, "w") as f:
|
||||||
|
json.dump(plan, f, indent=2)
|
||||||
|
print(f"plan: {plan['machines']} machines → {plan['tags']} mirror tags (driver {plan['driver_instance_id']})")
|
||||||
|
print(f"wrote {LOAD_PLAN}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_populate(args):
|
||||||
|
plan = build_plan(args.galaxy_json, args.driver)
|
||||||
|
conn, cur = connect(args.mssql)
|
||||||
|
kind = driver_exists(cur, args.driver)
|
||||||
|
if kind is None:
|
||||||
|
print(f"ERROR: driver instance '{args.driver}' not found in config DB.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if kind != "SystemPlatform":
|
||||||
|
print(f"ERROR: driver '{args.driver}' is in a {kind} namespace; the galaxy mirror needs a "
|
||||||
|
f"SystemPlatform/GalaxyMxGateway driver.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
inserted = updated = 0
|
||||||
|
for r in plan["rows"]:
|
||||||
|
# Upsert by the SystemPlatform natural key (DriverInstanceId, FolderPath, Name)
|
||||||
|
# — the UX_Tag_FolderPath unique index. This adopts any pre-existing seed row for
|
||||||
|
# the same ref into our nw-* set (so `clean` can remove it) and stays idempotent on
|
||||||
|
# re-run. RowId/RowVersion are server-managed.
|
||||||
|
cur.execute(
|
||||||
|
"SELECT TagId FROM dbo.Tag WHERE DriverInstanceId=%s AND FolderPath=%s "
|
||||||
|
"AND Name=%s AND EquipmentId IS NULL",
|
||||||
|
(r["driver_instance_id"], r["folder_path"], r["name"]))
|
||||||
|
if cur.fetchone():
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE dbo.Tag SET TagId=%s, DataType=%s, AccessLevel=%s, "
|
||||||
|
"WriteIdempotent=0, TagConfig='{}' "
|
||||||
|
"WHERE DriverInstanceId=%s AND FolderPath=%s AND Name=%s AND EquipmentId IS NULL",
|
||||||
|
(r["tag_id"], r["data_type"], r["access_level"],
|
||||||
|
r["driver_instance_id"], r["folder_path"], r["name"]))
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO dbo.Tag (TagRowId, TagId, DriverInstanceId, EquipmentId, "
|
||||||
|
"Name, FolderPath, DataType, AccessLevel, WriteIdempotent, TagConfig) "
|
||||||
|
"VALUES (NEWID(), %s, %s, NULL, %s, %s, %s, %s, 0, '{}')",
|
||||||
|
(r["tag_id"], r["driver_instance_id"], r["name"], r["folder_path"],
|
||||||
|
r["data_type"], r["access_level"]))
|
||||||
|
inserted += 1
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"populated: {inserted} inserted, {updated} updated "
|
||||||
|
f"({plan['tags']} mirror tags across {plan['machines']} machines)")
|
||||||
|
print()
|
||||||
|
print(">>> NEXT: open the AdminUI, sign in, and click "
|
||||||
|
"'Deploy current configuration' to seal + serve the load:")
|
||||||
|
print(f" {args.deploy_url}")
|
||||||
|
print(">>> then run: otopcua_uns.py verify --wait")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _eq_signal_ids(equipment_id, folder, name):
|
||||||
|
"""Deterministic (VirtualTagId, ScriptId) for a company signal. Both carry the
|
||||||
|
EQ_ID_PREFIX so `clean` removes exactly what was created. The two ids share the
|
||||||
|
same per-signal hash but differ by a kind token so they never collide across the
|
||||||
|
global UX_VirtualTag_LogicalId / UX_Script_LogicalId unique indexes. Capped at the
|
||||||
|
64-char id column width."""
|
||||||
|
base = hashlib.sha1(f"{equipment_id}|{folder}|{name}".encode()).hexdigest()[:20]
|
||||||
|
return EQ_ID_PREFIX + "vt-" + base, EQ_ID_PREFIX + "sc-" + base
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_populate_equipment(args):
|
||||||
|
"""Load the company-shape Equipment namespace from company-uns.json: a second
|
||||||
|
(Equipment-kind) namespace alongside the galaxy mirror, with the Northwind
|
||||||
|
Area/Line/Equipment/Signal tree. Each signal is a VirtualTag whose Script mirrors
|
||||||
|
the live galaxy-mirror tag for that signal — `return ctx.GetTag("<ref>").Value;` —
|
||||||
|
so the company shape streams live VALUES off the same Galaxy source (no driver,
|
||||||
|
no BadWaitingForInitialData once the galaxy mirror is up). Idempotent:
|
||||||
|
drop-and-recreate of the nw- overlay rows."""
|
||||||
|
with open(args.company_json) as f:
|
||||||
|
doc = json.load(f)
|
||||||
|
u = doc["uns"]
|
||||||
|
conn, cur = connect(args.mssql)
|
||||||
|
|
||||||
|
# Drop any prior overlay (child rows first), then recreate. VirtualTag/Script go
|
||||||
|
# before Equipment (VirtualTag.EquipmentId logical-FKs Equipment). Equipment is
|
||||||
|
# scoped by its overlay UnsLine ('nw-line-%') — NOT by the EquipmentId, which is now the
|
||||||
|
# canonical 'EQ-'+uuid form (see DraftValidator) and no longer carries an 'nw-' prefix.
|
||||||
|
cur.execute("DELETE FROM dbo.VirtualTag WHERE VirtualTagId LIKE %s", (EQ_ID_PREFIX + "%",))
|
||||||
|
cur.execute("DELETE FROM dbo.Script WHERE ScriptId LIKE %s", (EQ_ID_PREFIX + "%",))
|
||||||
|
cur.execute("DELETE FROM dbo.Equipment WHERE UnsLineId LIKE 'nw-line-%'")
|
||||||
|
cur.execute("DELETE FROM dbo.UnsLine WHERE UnsLineId LIKE 'nw-line-%'")
|
||||||
|
cur.execute("DELETE FROM dbo.UnsArea WHERE UnsAreaId LIKE 'nw-area-%'")
|
||||||
|
# Equipment is now driver-less, but purge any driver still bound to the overlay namespace —
|
||||||
|
# self-heals environments that ran an older loader which created the 'nw-uns-modbus' placeholder.
|
||||||
|
cur.execute("DELETE FROM dbo.DriverInstance WHERE NamespaceId=%s", (EQ_NS,))
|
||||||
|
cur.execute("DELETE FROM dbo.Namespace WHERE NamespaceId=%s", (EQ_NS,))
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO dbo.Namespace (NamespaceRowId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) "
|
||||||
|
"VALUES (NEWID(), %s, %s, 'Equipment', %s, 1)",
|
||||||
|
(EQ_NS, EQ_CLUSTER, doc.get("namespace", {}).get("namespaceUri", "urn:northwind:birmingham:uns")))
|
||||||
|
|
||||||
|
for a in u["unsAreas"]:
|
||||||
|
cur.execute("INSERT INTO dbo.UnsArea (UnsAreaRowId, UnsAreaId, ClusterId, Name) VALUES (NEWID(), %s, %s, %s)",
|
||||||
|
("nw-" + a["unsAreaId"], EQ_CLUSTER, a["name"]))
|
||||||
|
for l in u["unsLines"]:
|
||||||
|
cur.execute("INSERT INTO dbo.UnsLine (UnsLineRowId, UnsLineId, UnsAreaId, Name) VALUES (NEWID(), %s, %s, %s)",
|
||||||
|
("nw-" + l["unsLineId"], "nw-" + l["unsAreaId"], l["name"]))
|
||||||
|
|
||||||
|
eq_n = vt_n = 0
|
||||||
|
for e in u["equipment"]:
|
||||||
|
eq_uuid = uuid.uuid5(uuid.NAMESPACE_URL, "otopcua-nw-eq/" + e["equipmentId"])
|
||||||
|
# Canonical EquipmentId: matches OtOpcUa DraftValidator.DeriveEquipmentId
|
||||||
|
# ("EQ-" + EquipmentUuid.ToString("N")[..12].ToLowerInvariant()). uuid.UUID.hex is
|
||||||
|
# already lowercase, 32 hex chars, no dashes — .hex[:12] is the first 12.
|
||||||
|
eq_id = "EQ-" + eq_uuid.hex[:12].lower()
|
||||||
|
eq_uuid = str(eq_uuid)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO dbo.Equipment (EquipmentRowId, EquipmentId, EquipmentUuid, UnsLineId, "
|
||||||
|
"Name, MachineCode, Manufacturer, Model, Enabled) VALUES (NEWID(), %s, %s, %s, %s, %s, %s, %s, 1)",
|
||||||
|
(eq_id, eq_uuid, "nw-" + e["unsLineId"], e["name"], e["machineCode"],
|
||||||
|
e.get("manufacturer"), e.get("model")))
|
||||||
|
eq_n += 1
|
||||||
|
for t in e["tags"]:
|
||||||
|
dtype = _DTYPE_FIX.get(t["dataType"], t["dataType"])
|
||||||
|
folder = t.get("folderPath")
|
||||||
|
# The galaxy-mirror MXAccess ref (e.g. TestMachine_001.TestDouble) is the upstream
|
||||||
|
# the VirtualTag mirrors. DependencyExtractor harvests the literal in ctx.GetTag(),
|
||||||
|
# so the engine subscribes to exactly this path on the galaxy-mirror driver.
|
||||||
|
full = t["source"]["fullTagReference"]
|
||||||
|
vt_id, sc_id = _eq_signal_ids(e["equipmentId"], folder, t["name"])
|
||||||
|
source_code = f'return ctx.GetTag("{full}").Value;'
|
||||||
|
source_hash = hashlib.sha256(source_code.encode()).hexdigest()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO dbo.Script (ScriptRowId, ScriptId, Name, SourceCode, SourceHash, Language) "
|
||||||
|
"VALUES (NEWID(), %s, %s, %s, %s, 'CSharp')",
|
||||||
|
(sc_id, t["name"], source_code, source_hash))
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO dbo.VirtualTag (VirtualTagRowId, VirtualTagId, EquipmentId, Name, DataType, "
|
||||||
|
"ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) "
|
||||||
|
"VALUES (NEWID(), %s, %s, %s, %s, %s, 1, NULL, %s, 1)",
|
||||||
|
(vt_id, eq_id, t["name"], dtype, sc_id, 1 if t.get("historize") else 0))
|
||||||
|
vt_n += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"populated equipment overlay: namespace {EQ_NS} ({EQ_CLUSTER}), "
|
||||||
|
f"{len(u['unsAreas'])} areas, {len(u['unsLines'])} lines, {eq_n} equipment, "
|
||||||
|
f"{vt_n} VirtualTags (+ {vt_n} mirror Scripts)")
|
||||||
|
print()
|
||||||
|
print(f">>> NEXT: deploy (headless) — curl -s -X POST {args.deploy_url.replace('/deployments','')}/api/deployments "
|
||||||
|
f"-H 'X-Api-Key: {args.deploy_key}'")
|
||||||
|
print(">>> then run: otopcua_uns.py verify-equipment --require-good 1036 --wait")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_clean(args):
|
||||||
|
conn, cur = connect(args.mssql)
|
||||||
|
cur.execute("DELETE FROM dbo.Tag WHERE TagId LIKE %s", (ID_PREFIX + "%",))
|
||||||
|
n = cur.rowcount
|
||||||
|
# Also drop the company-shape Equipment overlay (child rows first): VirtualTag and
|
||||||
|
# Script (both nweq-*) before Equipment, then the rest.
|
||||||
|
# Equipment is scoped by its overlay UnsLine ('nw-line-%') — NOT by the EquipmentId,
|
||||||
|
# which is now the canonical 'EQ-'+uuid form (see DraftValidator) with no 'nw-' prefix.
|
||||||
|
cur.execute("DELETE FROM dbo.VirtualTag WHERE VirtualTagId LIKE %s", (EQ_ID_PREFIX + "%",))
|
||||||
|
cur.execute("DELETE FROM dbo.Script WHERE ScriptId LIKE %s", (EQ_ID_PREFIX + "%",))
|
||||||
|
cur.execute("DELETE FROM dbo.Equipment WHERE UnsLineId LIKE 'nw-line-%'")
|
||||||
|
cur.execute("DELETE FROM dbo.UnsLine WHERE UnsLineId LIKE 'nw-line-%'")
|
||||||
|
cur.execute("DELETE FROM dbo.UnsArea WHERE UnsAreaId LIKE 'nw-area-%'")
|
||||||
|
# Purge any driver still bound to the overlay namespace (e.g. the legacy 'nw-uns-modbus'
|
||||||
|
# placeholder created by an older loader) so 'clean' fully removes the overlay.
|
||||||
|
cur.execute("DELETE FROM dbo.DriverInstance WHERE NamespaceId=%s", (EQ_NS,))
|
||||||
|
cur.execute("DELETE FROM dbo.Namespace WHERE NamespaceId=%s", (EQ_NS,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"removed {n} nw-* mirror tag(s) + the {EQ_NS} equipment overlay. "
|
||||||
|
f"Deploy again at {args.deploy_url} to drop them from the address space.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args):
|
||||||
|
conn, cur = connect(args.mssql)
|
||||||
|
cur.execute("SELECT COUNT(*) FROM dbo.Tag WHERE TagId LIKE %s", (ID_PREFIX + "%",))
|
||||||
|
db_tags = cur.fetchone()[0]
|
||||||
|
# Deployment.Status: 2 = Sealed (the snapshot driver nodes apply).
|
||||||
|
cur.execute("SELECT TOP 1 RevisionHash, SealedAtUtc FROM dbo.Deployment "
|
||||||
|
"WHERE Status=2 ORDER BY SealedAtUtc DESC")
|
||||||
|
dep = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
print(f"config DB : {db_tags} mirror tags (nw-*) present")
|
||||||
|
print(f"last sealed : {('rev '+dep[0][:12]+'… @ '+str(dep[1])) if dep else '(none)'}")
|
||||||
|
folders, variables, good = browse_summary(args.opcua_endpoint)
|
||||||
|
print(f"address space : {folders} machine folder(s), {variables} variable(s), {good} value(s) Good on {args.opcua_endpoint}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_verify(args):
|
||||||
|
plan = build_plan(args.galaxy_json, args.driver)
|
||||||
|
expected = plan["tags"]
|
||||||
|
deadline = time.time() + (args.wait_seconds if args.wait else 0)
|
||||||
|
while True:
|
||||||
|
folders, variables, good = browse_summary(args.opcua_endpoint)
|
||||||
|
ok = variables >= expected and good >= max(1, int(expected * 0.5))
|
||||||
|
if ok or time.time() >= deadline:
|
||||||
|
break
|
||||||
|
print(f" waiting for deploy… ({variables}/{expected} vars, {good} Good)")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
print(f"expected mirror tags : {expected}")
|
||||||
|
print(f"address-space vars : {variables} (in {folders} folders)")
|
||||||
|
print(f"values Good (live) : {good}")
|
||||||
|
sample = sample_values(args.opcua_endpoint, 6)
|
||||||
|
for nm, val, sc in sample:
|
||||||
|
print(f" {nm} = {val} [{sc}]")
|
||||||
|
passed = variables >= expected and good >= max(1, int(expected * 0.5))
|
||||||
|
print("VERIFY:", "PASS — UNS loaded and live" if passed else "INCOMPLETE — did you Deploy at the AdminUI?")
|
||||||
|
return 0 if passed else 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── OPC UA helpers (asyncua) ────────────────────────────────────────────────
|
||||||
|
def browse_summary(endpoint):
|
||||||
|
import asyncio
|
||||||
|
from asyncua import Client
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
folders = variables = good = 0
|
||||||
|
async with Client(endpoint) as c:
|
||||||
|
for k in await c.nodes.objects.get_children():
|
||||||
|
if (await k.read_browse_name()).Name != "OtOpcUa":
|
||||||
|
continue
|
||||||
|
for f in await k.get_children():
|
||||||
|
folders += 1
|
||||||
|
for v in await f.get_children():
|
||||||
|
variables += 1
|
||||||
|
try:
|
||||||
|
dv = await v.read_data_value()
|
||||||
|
if dv.StatusCode and dv.StatusCode.is_good():
|
||||||
|
good += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return folders, variables, good
|
||||||
|
try:
|
||||||
|
return asyncio.run(run())
|
||||||
|
except Exception as e:
|
||||||
|
return (f"<{type(e).__name__}>", 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_values(endpoint, n):
|
||||||
|
import asyncio
|
||||||
|
from asyncua import Client
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
out = []
|
||||||
|
async with Client(endpoint) as c:
|
||||||
|
for k in await c.nodes.objects.get_children():
|
||||||
|
if (await k.read_browse_name()).Name != "OtOpcUa":
|
||||||
|
continue
|
||||||
|
for f in await k.get_children():
|
||||||
|
for v in await f.get_children():
|
||||||
|
try:
|
||||||
|
dv = await v.read_data_value()
|
||||||
|
sc = dv.StatusCode.name if dv.StatusCode else "?"
|
||||||
|
out.append(((await v.read_browse_name()).Name, dv.Value.Value, sc))
|
||||||
|
except Exception as e:
|
||||||
|
out.append(((await v.read_browse_name()).Name, f"<{type(e).__name__}>", "?"))
|
||||||
|
if len(out) >= n:
|
||||||
|
return out
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
return asyncio.run(run())
|
||||||
|
except Exception as e:
|
||||||
|
return [("<browse error>", str(e), "?")]
|
||||||
|
|
||||||
|
|
||||||
|
def browse_tree(endpoint, max_depth=8, top_prefix=None, read_values=False):
|
||||||
|
"""Recursively descend the OtOpcUa address space and count leaf variables, returning
|
||||||
|
(folder_count, leaf_count, leaf_paths, good_count). A node with no children is a leaf
|
||||||
|
signal — this correctly handles the DEEP Equipment UNS tree
|
||||||
|
(Area/Line/Equipment/[FolderPath]/Signal), unlike browse_summary which assumes the flat
|
||||||
|
2-level Galaxy hierarchy. When top_prefix is set, only top-level OtOpcUa folders whose
|
||||||
|
browse name starts with it are counted (e.g. 'nw-area-' scopes to the company Equipment
|
||||||
|
overlay, excluding the Galaxy mirror folders). When read_values is True, each leaf's value
|
||||||
|
is read and good_count tallies the Good-quality ones (else good_count is 0)."""
|
||||||
|
import asyncio
|
||||||
|
from asyncua import Client
|
||||||
|
|
||||||
|
async def maybe_good(node, acc):
|
||||||
|
if not read_values:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
dv = await node.read_data_value()
|
||||||
|
if dv.StatusCode and dv.StatusCode.is_good():
|
||||||
|
acc["good"] += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def walk(node, path, depth, acc):
|
||||||
|
if depth >= max_depth:
|
||||||
|
return
|
||||||
|
for ch in await node.get_children():
|
||||||
|
try:
|
||||||
|
name = (await ch.read_browse_name()).Name
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
child_path = path + "/" + name
|
||||||
|
grandkids = await ch.get_children()
|
||||||
|
if grandkids:
|
||||||
|
acc["folders"] += 1
|
||||||
|
await walk(ch, child_path, depth + 1, acc)
|
||||||
|
else:
|
||||||
|
acc["leaves"] += 1
|
||||||
|
acc["paths"].append(child_path)
|
||||||
|
await maybe_good(ch, acc)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
acc = {"folders": 0, "leaves": 0, "paths": [], "good": 0}
|
||||||
|
async with Client(endpoint) as c:
|
||||||
|
for k in await c.nodes.objects.get_children():
|
||||||
|
if (await k.read_browse_name()).Name != "OtOpcUa":
|
||||||
|
continue
|
||||||
|
for top in await k.get_children():
|
||||||
|
tn = (await top.read_browse_name()).Name
|
||||||
|
if top_prefix and not tn.startswith(top_prefix):
|
||||||
|
continue
|
||||||
|
if await top.get_children():
|
||||||
|
acc["folders"] += 1
|
||||||
|
await walk(top, "OtOpcUa/" + tn, 1, acc)
|
||||||
|
else:
|
||||||
|
acc["leaves"] += 1
|
||||||
|
acc["paths"].append("OtOpcUa/" + tn)
|
||||||
|
await maybe_good(top, acc)
|
||||||
|
return acc["folders"], acc["leaves"], acc["paths"], acc["good"]
|
||||||
|
try:
|
||||||
|
return asyncio.run(run())
|
||||||
|
except Exception as e:
|
||||||
|
return (f"<{type(e).__name__}: {e}>", 0, [], 0)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_verify_equipment(args):
|
||||||
|
"""Browse the full UNS tree by friendly Area/Line/Equipment/Signal names and report the leaf
|
||||||
|
signal count. With --expect N, exit non-zero unless exactly N leaf signals are present (the
|
||||||
|
equipment-namespace structure-materialisation check). With --require-good N (>0), also read
|
||||||
|
each leaf's value and require at least N Good ones (the live-VALUE check for the VirtualTag
|
||||||
|
overlay) — back-compat default 0 = structure-only. --wait polls so it can wait for the deploy
|
||||||
|
+ change-triggered VirtualTag evaluations to land."""
|
||||||
|
top_prefix = None if args.all else "nw-area-"
|
||||||
|
scope = "whole address space" if args.all else "company overlay (nw-area-*)"
|
||||||
|
read_values = args.require_good > 0
|
||||||
|
deadline = time.time() + (args.wait_seconds if args.wait else 0)
|
||||||
|
while True:
|
||||||
|
folders, leaves, paths, good = browse_tree(
|
||||||
|
args.opcua_endpoint, top_prefix=top_prefix, read_values=read_values)
|
||||||
|
struct_ok = args.expect is None or leaves == args.expect
|
||||||
|
good_ok = good >= args.require_good
|
||||||
|
if (struct_ok and good_ok) or time.time() >= deadline:
|
||||||
|
break
|
||||||
|
print(f" waiting for deploy/values… ({leaves} leaves"
|
||||||
|
+ (f", {good} Good" if read_values else "") + ")")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
suffix = f", {good} Good value(s)" if read_values else ""
|
||||||
|
print(f"equipment tree : {folders} folder(s), {leaves} leaf signal(s){suffix} "
|
||||||
|
f"on {args.opcua_endpoint} [{scope}]")
|
||||||
|
for p in sorted(paths)[:args.show]:
|
||||||
|
print(f" {p}")
|
||||||
|
if len(paths) > args.show:
|
||||||
|
print(f" … and {len(paths) - args.show} more")
|
||||||
|
|
||||||
|
passed = True
|
||||||
|
if args.expect is not None:
|
||||||
|
struct_ok = leaves == args.expect
|
||||||
|
passed = passed and struct_ok
|
||||||
|
print(" structure :",
|
||||||
|
f"PASS ({leaves} == {args.expect})" if struct_ok
|
||||||
|
else f"FAIL (expected {args.expect}, found {leaves})")
|
||||||
|
if args.require_good > 0:
|
||||||
|
good_ok = good >= args.require_good
|
||||||
|
passed = passed and good_ok
|
||||||
|
print(" live good :",
|
||||||
|
f"PASS ({good} >= {args.require_good})" if good_ok
|
||||||
|
else f"FAIL (expected >= {args.require_good} Good, found {good})")
|
||||||
|
if args.expect is None and args.require_good == 0:
|
||||||
|
return 0
|
||||||
|
print("VERIFY-EQUIPMENT:", "PASS" if passed else "FAIL")
|
||||||
|
return 0 if passed else 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── arg parsing ─────────────────────────────────────────────────────────────
|
||||||
|
def main(argv):
|
||||||
|
p = argparse.ArgumentParser(description="Reloadable populate + verify for the OtOpcUa galaxy UNS.")
|
||||||
|
p.add_argument("--galaxy-json", default=DEF_GALAXY_JSON)
|
||||||
|
p.add_argument("--driver", default=DEF_DRIVER, help="SystemPlatform GalaxyMxGateway driver instance id")
|
||||||
|
p.add_argument("--opcua-endpoint", default=DEF_OPCUA)
|
||||||
|
p.add_argument("--deploy-url", default="http://localhost:9200/deployments")
|
||||||
|
p.add_argument("--deploy-key", default=os.environ.get("OTOPCUA_DEPLOY_KEY", "docker-dev-deploy-key"),
|
||||||
|
help="X-Api-Key for the headless POST /api/deployments endpoint")
|
||||||
|
p.add_argument("--company-json", default=DEF_COMPANY_JSON)
|
||||||
|
p.add_argument("--sql-host", default=DEF_MSSQL["host"])
|
||||||
|
p.add_argument("--sql-port", type=int, default=DEF_MSSQL["port"])
|
||||||
|
p.add_argument("--sql-user", default=DEF_MSSQL["user"])
|
||||||
|
p.add_argument("--sql-password", default=DEF_MSSQL["password"])
|
||||||
|
p.add_argument("--sql-db", default=DEF_MSSQL["database"])
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
sub.add_parser("generate")
|
||||||
|
sub.add_parser("populate")
|
||||||
|
sub.add_parser("populate-equipment",
|
||||||
|
help="load the company-shape Equipment namespace from company-uns.json (structure-only)")
|
||||||
|
sub.add_parser("clean")
|
||||||
|
sub.add_parser("status")
|
||||||
|
vp = sub.add_parser("verify")
|
||||||
|
vp.add_argument("--wait", action="store_true", help="poll until the deploy lands")
|
||||||
|
vp.add_argument("--wait-seconds", type=int, default=120)
|
||||||
|
ep = sub.add_parser("verify-equipment",
|
||||||
|
help="recursively browse the Equipment UNS tree + count leaf signals "
|
||||||
|
"(+ optionally assert live Good values)")
|
||||||
|
ep.add_argument("--expect", type=int, default=None, help="assert exactly N leaf signals")
|
||||||
|
ep.add_argument("--require-good", type=int, default=0,
|
||||||
|
help="read each leaf's value and require >= N Good ones (0 = structure-only, default)")
|
||||||
|
ep.add_argument("--show", type=int, default=20, help="how many leaf paths to print")
|
||||||
|
ep.add_argument("--all", action="store_true",
|
||||||
|
help="count the whole address space (default: only the nw-area-* company overlay)")
|
||||||
|
ep.add_argument("--wait", action="store_true",
|
||||||
|
help="poll until the deploy lands + (with --require-good) values go Good")
|
||||||
|
ep.add_argument("--wait-seconds", type=int, default=120)
|
||||||
|
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
a.mssql = dict(host=a.sql_host, port=a.sql_port, user=a.sql_user,
|
||||||
|
password=a.sql_password, database=a.sql_db)
|
||||||
|
return {
|
||||||
|
"generate": cmd_generate, "populate": cmd_populate,
|
||||||
|
"populate-equipment": cmd_populate_equipment, "clean": cmd_clean,
|
||||||
|
"status": cmd_status, "verify": cmd_verify, "verify-equipment": cmd_verify_equipment,
|
||||||
|
}[a.cmd](a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pymssql>=2.3
|
||||||
|
asyncua>=2.0
|
||||||
Reference in New Issue
Block a user