Compare commits

...

65 Commits

Author SHA1 Message Date
Joseph Doherty 19d59915ea docs(index): HistorianGateway main now gRPC-only (swap-historian-client merged, 99b4a7d) 2026-11-06 15:52:22 -05:00
Joseph Doherty 6b737b12b1 docs(index): refresh ScadaBridge/HistorianGateway rows; record HG 2026-10-16 diagnostics + Int1 milestones 2026-11-06 15:38:26 -05:00
Joseph Doherty 484d530e65 docs: point remaining Auth/observability GAPS items at issues (#20, ScadaBridge#81) 2026-11-06 15:36:33 -05:00
Joseph Doherty fd180e576f docs: point markdown backlogs at Gitea issues (status lives in Gitea)
Adds the family backlog standard to the root index (open work = Gitea issues,
shared label set, BTDB excluded) and converts components/*/GAPS, hosted-lib
CLAUDE.md notes, code-reviews, infra/glauth and plan docs to banner + issue
pointers (scadaproj #5-#19, ScadaBridge#79).
2026-11-06 15:34:30 -05:00
Joseph Doherty 967da5b43b feat(theme): upstream router controls and responsive shell fixes 2026-11-04 00:52:25 -05:00
Joseph Doherty 6ad15fcf93 Index: record mxaccessgw operator-authenticated alarm ack (proto change)
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 08:52:46 -04:00
Joseph Doherty 795af1da71 Record merged-main verification and remaining issue gates 2026-10-13 22:10:40 -04:00
Joseph Doherty 65c6b99cf2 Record verified driver fixture cleanup merge 2026-10-13 21:08:01 -04:00
Joseph Doherty 420c2e0783 Record verified freshness merge and pending cleanup follow-ups 2026-10-13 19:04:46 -04:00
Joseph Doherty 3cacdca861 Record verified ServiceLevel fixes and freshness follow-up 2026-10-13 16:00:45 -04:00
Joseph Doherty ade49c0935 Record verified OPC UA CI closures and pending ServiceLevel work 2026-10-13 13:58:03 -04:00
Joseph Doherty 02480d4fbc docs: record verified OPC UA CI and S7 evidence corrections 2026-10-13 11:57:35 -04:00
Joseph Doherty 87bb647efb Record verified keepalive survival test fix 2026-10-13 11:02:00 -04:00
Joseph Doherty a0cec479b6 Record explicit live-test gates and CI follow-ups 2026-10-13 10:37:45 -04:00
Joseph Doherty 91baba8fcc Record verified corrected Gateway rollout 2026-10-13 10:15:45 -04:00
Joseph Doherty 9688707d82 Record gateway correction and reset-echo verification 2026-10-13 10:02:44 -04:00
Joseph Doherty 509114360d Record failed Gateway event-read rollout and rollback evidence 2026-10-13 09:43:48 -04:00
Joseph Doherty cfd371ce18 Record OtOpcUa documentation merge and CI follow-up evidence 2026-10-13 09:16:31 -04:00
Joseph Doherty 51a956a878 Record verified event route and ScadaBridge issue closures 2026-10-13 08:58:15 -04:00
Joseph Doherty 2a3baae301 Record verified SQL event fault fix and route probe limits 2026-10-13 08:18:12 -04:00
Joseph Doherty 10fc22b87e Record Gateway read cancellation fix and event probe evidence 2026-10-13 07:29:35 -04:00
Joseph Doherty 8825cd966f docs(index): record durable alarm identity fix 2026-10-13 07:12:19 -04:00
Joseph Doherty 69a7f8e1b5 docs(index): record historian cleanup status fix 2026-10-13 06:56:02 -04:00
Joseph Doherty 01a6313fe7 docs: record verified restart wire test fix 2026-10-13 06:30:59 -04:00
Joseph Doherty e7cfa21fda docs: record verified local Gateway metadata rollout 2026-10-13 06:19:29 -04:00
Joseph Doherty 68f1b56acf docs: record Docker auth correction and reconnect reproduction 2026-10-13 05:43:43 -04:00
Joseph Doherty b4da0deacb docs: record aggregate and catalog fixes and verification gates 2026-10-13 05:31:38 -04:00
Joseph Doherty 180403cb17 docs: record verified OtOpcUa cache coverage merge 2026-10-13 05:29:45 -04:00
Joseph Doherty 67c182ce18 docs: record gateway catalog and key follow-up merge 2026-10-13 05:06:02 -04:00
Joseph Doherty 05042e5e36 docs: record historian merge and pending verification issues 2026-10-13 04:49:11 -04:00
Joseph Doherty aed5fbf046 docs(index): record missing-tag and gateway discovery fixes 2026-10-13 04:33:36 -04:00
Joseph Doherty 00ef22823f docs(index): record OtOpcUa historian fixes and live measurement 2026-10-13 03:51:45 -04:00
Joseph Doherty c05a6d93b0 docs(index): OtOpcUa #692–#694 historian paging/AtTime/ceiling batch 2026-10-13 02:12:30 -04:00
Joseph Doherty ec2615a9d2 docs(history): OtOpcUa #689–#691 reverse walk sizing, ReturnBounds, gateway ceiling/events 2026-10-13 00:11:34 -04:00
Joseph Doherty 2a0b34e056 docs(history): OtOpcUa #685–#688 Gateway historian raw/aggregate fixes 2026-10-12 16:04:02 -04:00
Joseph Doherty 4555a3bdad docs(index): OtOpcUa live AVEVA gate — #675/#677/#679/#681 closed, historiangw#27 + #685–#688 filed 2026-10-12 14:21:18 -04:00
Joseph Doherty 681606dad0 docs(index): OtOpcUa batch #681–#684 — Gateway per-sample from raw, advertised aggregates, QuestDB tag index, Good-only aggregates 2026-10-12 13:59:34 -04:00
Joseph Doherty aa7093b53b docs(index): OtOpcUa batch #679–#680 — historian aggregate parity, Gateway Average/Count refused 2026-10-12 08:38:50 -04:00
Joseph Doherty 17cb91a315 docs(index): OtOpcUa follow-up batch #674-#678 (fast OpcUaClient recovery, reverse history reads, HistoryUpdate Primary-only, role overlays) 2026-10-11 13:20:46 -04:00
Joseph Doherty 0adfb244b2 docs(index): OtOpcUa batch #667-#673 (retention, Part 11 modified values, event CPs, OpcUaClient session loss, time-weighted aggregates, unreachable-Primary capture) 2026-10-11 10:18:36 -04:00
Joseph Doherty c6e60cb6c4 docs(index): OtOpcUa pluggable historian (SqlServer #652, QuestDB #666) 2026-10-11 04:42:08 -04:00
Joseph Doherty c713925ac7 docs(index): OtOpcUa #665 dead in-place ApplyDelta seam deleted
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:32:54 -04:00
Joseph Doherty 0b056c6fee docs(index): OtOpcUa #664 mid-connect ApplyDelta config adoption
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:17:15 -04:00
Joseph Doherty 484b176cd6 docs(index): OtOpcUa #663 in-place ApplyDelta re-subscribes data + alarms
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 08:00:11 -04:00
Joseph Doherty dd1eea2106 docs(index): OtOpcUa #662 native-alarm feed survives non-respawning deploys
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:36:04 -04:00
Joseph Doherty 63cc772e8d docs(index): OtOpcUa hw5 batch — #657 alarm restatement, #660, #661, #592 hw evidence, #662 filed 2026-10-09 07:20:35 -04:00
Joseph Doherty 3b4d81e630 docs(index): OtOpcUa hw4 batch #611/#592 history entry
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:36:47 -04:00
Joseph Doherty b6f5eb2535 docs(index): OtOpcUa hw3 batch #654-#656 history entry
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 00:19:30 -04:00
Joseph Doherty a8f5f66647 docs(index): OtOpcUa hardware batch #647-#651 + hw2 live gates 2026-10-08 22:03:14 -04:00
Joseph Doherty df21d365ec docs(history): OtOpcUa hardware-available batch #640-#646 (alarm carry-over, honest deploy acks, OpcUaClient/AbCip alarm routing)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 18:23:34 -04:00
Joseph Doherty a2b40b7922 fix(theme): .rise uses fill-mode backwards so it stops trapping position:fixed descendants (OtOpcUa #643)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:50:08 -04:00
Joseph Doherty 3c91f0f813 docs(history): OtOpcUa issue batch #626-#638 (exact condition severity, MTConnect alarms) 2026-10-08 16:33:45 -04:00
Joseph Doherty a6c4161719 docs(history): OtOpcUa hardware-available issue sweep #593-#625 2026-10-08 09:10:46 -04:00
Joseph Doherty 78ca2a6744 docs(index): OtOpcUa Gitea issue sweep #562-#591 — ScadaBridge-visible ACL/alarm-event changes
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:45:05 -04:00
Joseph Doherty 21a95c7f50 docs(history): OtOpcUa native AB transport merged — AbCip native default, AbLegacy opt-in, real-press gate passed 2026-10-07 22:45:47 -04:00
Joseph Doherty c43d7ee1ad docs(history): OtOpcUa AbCip/AbLegacy gain a native managed transport behind a Transport switch 2026-10-07 19:12:40 -04:00
Joseph Doherty d0b66bf105 docs(history): OtOpcUa TwinCAT native array subscriptions (2026-10-07) 2026-10-07 15:09:04 -04:00
Joseph Doherty 44421dc5aa docs(history): OtOpcUa FOCAS/TwinCAT/AbCip driver fixes ported from ignitionoee + real-device gates (2026-10-07) 2026-10-07 14:32:57 -04:00
Joseph Doherty 8fa7631ec4 overview: register BTDB Manager on vd03 (Wonder overlay) and QA; deployment notes 2026-10-01 02:05:44 -04:00
Joseph Doherty b62cfad48d index: BTDB master a81a480 — LDAP sign-in by user bind (Auth 0.4.1) 2026-10-01 01:44:27 -04:00
Joseph Doherty 625f207e08 index: Auth feed carries 0.4.1 2026-10-01 01:31:16 -04:00
Joseph Doherty 29bfa8fbf9 Merge fix/auth-ldap-0.4.1: Auth 0.4.1 (canonical username, empty-password guard in both modes) 2026-10-01 01:31:06 -04:00
Joseph Doherty b0582eb66a release(auth): 0.4.1 — canonical username, empty passwords refused in both modes, template guidance 2026-10-01 01:27:55 -04:00
Joseph Doherty 9090b1b511 docs(auth): prefer the down-level UserBindTemplate; warn on UPN templates (I1) 2026-10-01 01:27:55 -04:00
Joseph Doherty e3d146aa21 fix(auth): canonical username from the entry; refuse empty passwords in both bind modes
- Request UserNameAttribute in the search (de-duplicated, case-insensitive) and return its
  first value as LdapAuthResult.Username, falling back to the typed name (I2).
- The empty-password guard now applies in ServiceAccount mode too: AD treats an empty-password
  simple bind as an unauthenticated bind and reports success (I3).
- Restore the dropped explanatory comments in CompleteWithServiceAccount / SearchUser.
2026-10-01 01:27:27 -04:00
60 changed files with 1357 additions and 143 deletions
+10 -2
View File
@@ -187,8 +187,8 @@ Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on
Manager queries; every writer sproc follows the own-or-join `TRY/CATCH` template with locked lookups; Manager queries; every writer sproc follows the own-or-join `TRY/CATCH` template with locked lookups;
every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via
`ExpectedLastUpdate` → `THROW 50014`. `ExpectedLastUpdate` → `THROW 50014`.
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` + - **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` (the LDAP
`${secret:btdb/ldap/serviceaccountpassword}`, store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`, service-account secret is gone since 2026-10-01 — see below; only `dotnet run` against local glauth uses it), store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`,
master key only in `ZB_SECRETS_MASTER_KEY`. Rotation is cut-over step 4.2. master key only in `ZB_SECRETS_MASTER_KEY`. Rotation is cut-over step 4.2.
- **Claude artifacts are gitignored + untracked in btdb** (2026-09-11): `CLAUDE.md`, `.claude/`, - **Claude artifacts are gitignored + untracked in btdb** (2026-09-11): `CLAUDE.md`, `.claude/`,
`docs/plans/`, `docs/reviews/`, `*.tasks.json` — they exist on disk (btdb's `CLAUDE.md` is the repo `docs/plans/`, `docs/reviews/`, `*.tasks.json` — they exist on disk (btdb's `CLAUDE.md` is the repo
@@ -197,3 +197,11 @@ Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on
- **Open step:** phase-4 cut-over on the DEV box (`docs/operations/cut-over.md`; 4.1 parity walk-through - **Open step:** phase-4 cut-over on the DEV box (`docs/operations/cut-over.md`; 4.1 parity walk-through
gates the rest) — **blocked by the 2026-09-11 remote-access hold** (no psbridge, no winrm; UNC copies and gates the rest) — **blocked by the 2026-09-11 remote-access hold** (no psbridge, no winrm; UNC copies and
direct SQL logins still allowed). §6 item 6 (which SQL host carries DEV `BT`) is still unrecorded. direct SQL logins still allowed). §6 item 6 (which SQL host carries DEV `BT`) is still unrecorded.
- **2026-10-01 — LDAP sign-in by user bind** (merged `a81a480`): Auth 0.4.0/0.4.1 added opt-in
`LdapOptions.BindMode=User`; BTDB ships it. Sign-in binds as `UserBindTemplate` (`<DOMAIN>\{0}` —
down-level maps exactly to the sAMAccountName the group search uses), the same connection reads the
user's own `memberOf`/`displayName`, and the username returned is the directory's spelling. No service
account, no LDAP secret; the preflight requires the template and refuses `AllowInsecure`/`Transport=None`
with user bind outside Development. Local dev + docker rig stay `BindMode=ServiceAccount` (GLAuth bind DNs
embed the user's OU). Rollout to the three Manager nodes (vd03 first) and retiring the stored secret are
the open steps (plan `docs/plans/2026-10-01-ldap-user-bind.md`, gitignored).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
# ScadaBridge — index history
- **2026-10-13 existing fixes verified and issues closed.** Main `513fa1aa` already includes #35 fail-closed unavailable configured body/SQL redactors (`006202f3`) and #37 Flow Debugger structured/raw validated method inputs (`b5eb08f1`). Fresh full AuditLog project 407 passed/0 failed; FlowDebuggerPageTests 47 passed/0 failed; both terminal exit0. Remote ancestry confirmed; #35/#37 closed with evidence. No source changes/deployment/MES transactions. #36 runtime reset-echo overload is implemented, but QA/prod script port remains tracked; #30 per-site PSK deployment gate remains open.
- **2026-10-13 #36 runtime regression proof refreshed.** Rebuilt HandshakeResetEchoTests passed all three cases with local fakes, terminal exit0; an earlier no-build attempt matched no tests and is excluded from evidence. Tracker now distinguishes implemented runtime b623fdb0 from remaining QA/prod database-owned script rollout (deployment register row119, scripts13/14/36 and15/16/25/26/37/38). No database writes, deployments or fresh live rig claims.
+25 -8
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -16,9 +16,9 @@ Canonical audit event model, best-effort writer seam, and redactor seam for the
| Consumer | ZB.MOM.WW.Audit | | Consumer | ZB.MOM.WW.Audit |
|---|:---:| |---|:---:|
| **OtOpcUa** | yes (adoption deferred) | | **OtOpcUa** | yes (adoption deferred; stale → #17) |
| **MxAccessGateway** | yes (adoption deferred) | | **MxAccessGateway** | yes (adoption deferred; stale → #17) |
| **ScadaBridge** | yes (adoption deferred — "align, don't replace") | | **ScadaBridge** | yes (adoption deferred — "align, don't replace"; stale → #17) |
Adoption is tracked in `components/audit/GAPS.md` in the outer `scadaproj` workspace. Each app brings its own transport (Akka broadcast / SQLite append / SQL ingest) and domain vocabulary (channels / kinds / event-types) — those stay per-project. The shared library provides the canonical record and the two seams that decouple "what to audit" from "how to store it". Adoption is tracked in `components/audit/GAPS.md` in the outer `scadaproj` workspace. Each app brings its own transport (Akka broadcast / SQLite append / SQL ingest) and domain vocabulary (channels / kinds / event-types) — those stay per-project. The shared library provides the canonical record and the two seams that decouple "what to audit" from "how to store it".
+3 -1
View File
@@ -2,6 +2,8 @@
Shared login / identity / API-key library for the **ZB.MOM.WW SCADA family** (4 packages + `ApiKeys.SqlServer`; consumed by OtOpcUa, ScadaBridge, MxAccessGateway, HistorianGateway). Build/test from this directory: `dotnet test` (the `ApiKeys.SqlServer` live suite is gated on `AUTH_SQLSERVER_CONNSTR`). Component spec + gaps: `../components/auth/`. Shared login / identity / API-key library for the **ZB.MOM.WW SCADA family** (4 packages + `ApiKeys.SqlServer`; consumed by OtOpcUa, ScadaBridge, MxAccessGateway, HistorianGateway). Build/test from this directory: `dotnet test` (the `ApiKeys.SqlServer` live suite is gated on `AUTH_SQLSERVER_CONNSTR`). Component spec + gaps: `../components/auth/`.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #5 (roll apps to `0.4.1`), #6 (VD03 NAM AD cutover), #18 (Secrets migrator jitter, ported from here). The history below is a record, not a status tracker; file new follow-ups as issues.
## Index history (migrated from the scadaproj root CLAUDE.md) ## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.** > Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
@@ -36,6 +38,6 @@ MxAccessGateway & HistorianGateway → the original four; ScadaBridge → those
**`0.1.4` (2026-07-09, archreview G-2):** optional `ApiKeyRecord.ExpiresUtc` (nullable = never-expires), `ApiKeyFailure.KeyExpired`, `ApiKeyVerifier` expiry enforcement (inclusive, injected clock), a `CreateKeyAsync(expiresUtc)` overload, and a SQLite **schema v3** additive `expires_utc` column (idempotent `ALTER`; donor v2 DBs upgrade in place, no key invalidated). **All four Auth pkgs published to the Gitea feed at `0.1.4`** (2026-07-09). **HistorianGateway adopted the expiry surface at `0.1.4`** — `apikey create --expires` + dashboard Expires column (Task-10 done). **`0.1.5` (2026-07-18)** is `0.1.4` plus the `SQLitePCLRaw.lib.e_sqlite3 2.1.12` transitive security pin (GHSA-2m69-gcr7-jv3q), no API change; all four apps now pin it (see consumer pins above). **`0.2.0` (2026-08-10) — LDAP backup-DC sticky failover** (plan `docs/plans/2026-08-10-ldap-backup-dc-failover.md`, design `…-design.md`; **4 pkgs published to the feed, restore-verified**; family `Directory.Build.props` now `0.2.0`): one additive `LdapOptions.FallbackServers` list (`"host"` or `"host:port"`, empty default = failover off, single-endpoint behaviour byte-unchanged) makes `LdapAuthService` walk an ordered endpoint list (primary + fallbacks) on **system-side failures only** — connect/TLS, service-account bind, or search. A user-side answer from a healthy directory (`Success`/`UserNotFound`/`AmbiguousUser`/`BadCredentials`/`GroupLookupFailed`) is **final and never retried elsewhere** (re-asking a second DC about a rejected password would multiply one bad-password event into several against the account lockout policy). Endpoint preference is **sticky** — the last endpoint that gave *any* authoritative answer (success **or** a user-side failure) is tried first next time — so a primary outage costs its connect timeout once, not per login; an exhausted walk leaves the preference untouched. Fail-closed contract preserved per-endpoint; boot validation rejects a malformed `FallbackServers` entry naming the field; optional `ILogger<LdapAuthService>` emits a Warning per failed hop (endpoint + stage, no secrets) and an Information on sticky change; wired via `AddZbLdapAuth` (`GetService`, so logging is optional). Motivation: the ZB production directory is NAM AD with two DCs. Unit suite covers the walk/stickiness (14 tests) + env-gated live GLAuth failover test (`ZB_LDAP_IT=1`). **No `Abstractions` contract change beyond the new option; no `LdapAuthFailure` enum change.** App adoption (pin bump to `0.2.0` + authoring `FallbackServers`) is a follow-up. **`0.1.4` (2026-07-09, archreview G-2):** optional `ApiKeyRecord.ExpiresUtc` (nullable = never-expires), `ApiKeyFailure.KeyExpired`, `ApiKeyVerifier` expiry enforcement (inclusive, injected clock), a `CreateKeyAsync(expiresUtc)` overload, and a SQLite **schema v3** additive `expires_utc` column (idempotent `ALTER`; donor v2 DBs upgrade in place, no key invalidated). **All four Auth pkgs published to the Gitea feed at `0.1.4`** (2026-07-09). **HistorianGateway adopted the expiry surface at `0.1.4`** — `apikey create --expires` + dashboard Expires column (Task-10 done). **`0.1.5` (2026-07-18)** is `0.1.4` plus the `SQLitePCLRaw.lib.e_sqlite3 2.1.12` transitive security pin (GHSA-2m69-gcr7-jv3q), no API change; all four apps now pin it (see consumer pins above). **`0.2.0` (2026-08-10) — LDAP backup-DC sticky failover** (plan `docs/plans/2026-08-10-ldap-backup-dc-failover.md`, design `…-design.md`; **4 pkgs published to the feed, restore-verified**; family `Directory.Build.props` now `0.2.0`): one additive `LdapOptions.FallbackServers` list (`"host"` or `"host:port"`, empty default = failover off, single-endpoint behaviour byte-unchanged) makes `LdapAuthService` walk an ordered endpoint list (primary + fallbacks) on **system-side failures only** — connect/TLS, service-account bind, or search. A user-side answer from a healthy directory (`Success`/`UserNotFound`/`AmbiguousUser`/`BadCredentials`/`GroupLookupFailed`) is **final and never retried elsewhere** (re-asking a second DC about a rejected password would multiply one bad-password event into several against the account lockout policy). Endpoint preference is **sticky** — the last endpoint that gave *any* authoritative answer (success **or** a user-side failure) is tried first next time — so a primary outage costs its connect timeout once, not per login; an exhausted walk leaves the preference untouched. Fail-closed contract preserved per-endpoint; boot validation rejects a malformed `FallbackServers` entry naming the field; optional `ILogger<LdapAuthService>` emits a Warning per failed hop (endpoint + stage, no secrets) and an Information on sticky change; wired via `AddZbLdapAuth` (`GetService`, so logging is optional). Motivation: the ZB production directory is NAM AD with two DCs. Unit suite covers the walk/stickiness (14 tests) + env-gated live GLAuth failover test (`ZB_LDAP_IT=1`). **No `Abstractions` contract change beyond the new option; no `LdapAuthFailure` enum change.** App adoption (pin bump to `0.2.0` + authoring `FallbackServers`) is a follow-up.
**`0.2.1` (2026-08-12) — AD continuation referrals no longer fail the search** (**4 pkgs published to the feed, restore-verified**; the assembly restored *from the feed* carries the fix and `0.2.0` does not — checked as a positive control, not by trusting the push). Active Directory answers **any search based at a domain root** with continuation references (to `CN=Configuration`, `DC=ForestDnsZones`, …). With referral following disabled — the default, and what we want, since chasing one would re-bind somewhere we never authenticated — Novell surfaces each by throwing `LdapReferralException` from `Next()`. That escaped `NovellLdapConnection.Search`, and `LdapAuthService` maps a failed search to `ServiceAccountBindFailed`, so **every login against `SearchBase = DC=nam,DC=zimmer,DC=com` failed system-side on both DCs while the directory, the credentials and the service account were all healthy** — ~546 ms, identical on each endpoint, so failover could not help either. Found by the new `tools/LdapProbe` (below) and live-proven fixed against the ZB NAM domain root. The search loop now skips referrals (`ReadEntries(ILdapSearchResults)`, extracted so it is unit-testable through the Novell seam without a live directory) and continues; a 1000-referral cap guards a client that throws without advancing, since that would spin forever inside a fail-closed auth path. **Non-referral faults still propagate** — swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 new tests (`NovellSearchReferralTests`), **6 verified red against the pre-fix code**, the seventh being the non-referral control that must stay green; suite 257 pass / 2 skip / 0 fail. **No `Abstractions` change; no config change.** **Consumer impact: an app on `0.1.5` cannot authenticate against an AD domain root at all**, so the pin bump is a functional fix, not housekeeping. **Closed 2026-08-13: all four apps adopted `0.2.1`** (see the table row for commits), and `FallbackServers` was live-gated the same day against the redundant local GLAuth pair (`infra/glauth-redundant/`). **`0.2.1` (2026-08-12) — AD continuation referrals no longer fail the search** (**4 pkgs published to the feed, restore-verified**; the assembly restored *from the feed* carries the fix and `0.2.0` does not — checked as a positive control, not by trusting the push). Active Directory answers **any search based at a domain root** with continuation references (to `CN=Configuration`, `DC=ForestDnsZones`, …). With referral following disabled — the default, and what we want, since chasing one would re-bind somewhere we never authenticated — Novell surfaces each by throwing `LdapReferralException` from `Next()`. That escaped `NovellLdapConnection.Search`, and `LdapAuthService` maps a failed search to `ServiceAccountBindFailed`, so **every login against `SearchBase = DC=nam,DC=zimmer,DC=com` failed system-side on both DCs while the directory, the credentials and the service account were all healthy** — ~546 ms, identical on each endpoint, so failover could not help either. Found by the new `tools/LdapProbe` (below) and live-proven fixed against the ZB NAM domain root. The search loop now skips referrals (`ReadEntries(ILdapSearchResults)`, extracted so it is unit-testable through the Novell seam without a live directory) and continues; a 1000-referral cap guards a client that throws without advancing, since that would spin forever inside a fail-closed auth path. **Non-referral faults still propagate** — swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 new tests (`NovellSearchReferralTests`), **6 verified red against the pre-fix code**, the seventh being the non-referral control that must stay green; suite 257 pass / 2 skip / 0 fail. **No `Abstractions` change; no config change.** **Consumer impact: an app on `0.1.5` cannot authenticate against an AD domain root at all**, so the pin bump is a functional fix, not housekeeping. **Closed 2026-08-13: all four apps adopted `0.2.1`** (see the table row for commits), and `FallbackServers` was live-gated the same day against the redundant local GLAuth pair (`infra/glauth-redundant/`).
**`0.3.0` (2026-09-02) — `ZB.MOM.WW.Auth.ApiKeys.SqlServer`, a SQL Server provider for the API-key store** (design `ScadaBridge/docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`; **5 pkgs published to the feed**, `ApiKeys.SqlServer` search-verified from the ScadaBridge checkout; family `Directory.Build.props` now `0.3.0`). Motivation: `Auth.ApiKeys`' SQLite store is a per-node file, so on a two-node active/standby host (ScadaBridge central) a key minted on one node exists only there and a failover 401s every inbound caller (ScadaBridge deferred-work row 108). The new package implements `IApiKeyStore`/`IApiKeyAdminStore`/`IApiKeyAuditStore` over `Microsoft.Data.SqlClient` in a `zbauth` schema (v1: `schema_version`, `api_keys`, `api_key_audit`; all timestamps `DATETIMEOFFSET(7)`, tick-exact) with a concurrent-boot-safe migrator (Serializable tx, retry on 1205/2714/1913/2627 with **jittered** backoff — the Secrets replicator's flat `100*attempt` backoff exhausted five retries about once per sixty 8-way boots in the port's soak test, so the same latent defect is an open follow-up there) and a `SqliteApiKeyStoreImporter` that copies an existing SQLite file into the schema insert-where-absent (idempotent, so both nodes of a pair can import their own file and the result is the union). Wiring: `services.AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection)` registers the SQL Server stores + migrator **before** delegating to `AddZbApiKeyAuth`, whose `TryAdd*` registrations then yield (same displacement mechanism as Secrets 0.5.0). One `Abstractions` addition, additive: `IApiKeyStoreMigrator`, which `SqliteAuthStoreMigrator` now implements and `ApiKeyAdminCommands` accepts via a new ctor (old `SqliteAuthStoreMigrator` ctor retained — the three `0.2.1` consumers compile unchanged). Store semantics are pinned to the SQLite contract tests re-run live (mark-used skips revoked keys, delete only removes revoked keys, rotate clears last-used/revoked, list newest-first). 92 tests in the new project, 351 across the solution, 0 failed. A DBA-run copy of the DDL ships at `docs/sql/zbauth-schema.sql`. **`0.3.0` (2026-09-02) — `ZB.MOM.WW.Auth.ApiKeys.SqlServer`, a SQL Server provider for the API-key store** (design `ScadaBridge/docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`; **5 pkgs published to the feed**, `ApiKeys.SqlServer` search-verified from the ScadaBridge checkout; family `Directory.Build.props` now `0.3.0`). Motivation: `Auth.ApiKeys`' SQLite store is a per-node file, so on a two-node active/standby host (ScadaBridge central) a key minted on one node exists only there and a failover 401s every inbound caller (ScadaBridge deferred-work row 108). The new package implements `IApiKeyStore`/`IApiKeyAdminStore`/`IApiKeyAuditStore` over `Microsoft.Data.SqlClient` in a `zbauth` schema (v1: `schema_version`, `api_keys`, `api_key_audit`; all timestamps `DATETIMEOFFSET(7)`, tick-exact) with a concurrent-boot-safe migrator (Serializable tx, retry on 1205/2714/1913/2627 with **jittered** backoff — the Secrets replicator's flat `100*attempt` backoff exhausted five retries about once per sixty 8-way boots in the port's soak test, so the same latent defect is an open follow-up there → #18) and a `SqliteApiKeyStoreImporter` that copies an existing SQLite file into the schema insert-where-absent (idempotent, so both nodes of a pair can import their own file and the result is the union). Wiring: `services.AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection)` registers the SQL Server stores + migrator **before** delegating to `AddZbApiKeyAuth`, whose `TryAdd*` registrations then yield (same displacement mechanism as Secrets 0.5.0). One `Abstractions` addition, additive: `IApiKeyStoreMigrator`, which `SqliteAuthStoreMigrator` now implements and `ApiKeyAdminCommands` accepts via a new ctor (old `SqliteAuthStoreMigrator` ctor retained — the three `0.2.1` consumers compile unchanged). Store semantics are pinned to the SQLite contract tests re-run live (mark-used skips revoked keys, delete only removes revoked keys, rotate clears last-used/revoked, list newest-first). 92 tests in the new project, 351 across the solution, 0 failed. A DBA-run copy of the DDL ships at `docs/sql/zbauth-schema.sql`.
**`tools/LdapProbe`** (`ldapprobe`, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the **real** `LdapAuthService` so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, **nested** groups via `LDAP_MATCHING_RULE_IN_CHAIN`, the **primary group** (which AD omits from `memberOf`, so it can never be a role group), group type (security vs distribution), decoded `userAccountControl`, referral counts, and the TLS chain with key sizes. `--list-groups '<pat>'` enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in [`ldap_setup.md`](ldap_setup.md), including the **`UG_WW_*` Wonderware group convention (39 groups)** that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain. **`tools/LdapProbe`** (`ldapprobe`, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the **real** `LdapAuthService` so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, **nested** groups via `LDAP_MATCHING_RULE_IN_CHAIN`, the **primary group** (which AD omits from `memberOf`, so it can never be a role group), group type (security vs distribution), decoded `userAccountControl`, referral counts, and the TLS chain with key sizes. `--list-groups '<pat>'` enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in [`ldap_setup.md`](ldap_setup.md), including the **`UG_WW_*` Wonderware group convention (39 groups)** that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain.
+3 -1
View File
@@ -8,7 +8,9 @@
<!-- 0.4.0: opt-in LdapOptions.BindMode = User — authenticate by binding as the signing-in user <!-- 0.4.0: opt-in LdapOptions.BindMode = User — authenticate by binding as the signing-in user
(UserBindTemplate) and read their own groups on the same connection; no service account. (UserBindTemplate) and read their own groups on the same connection; no service account.
ServiceAccount stays the default, so existing consumers need no change. --> ServiceAccount stays the default, so existing consumers need no change. -->
<Version>0.4.0</Version> <!-- 0.4.1: Username canonicalised from the entry's UserNameAttribute; empty passwords refused
in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. -->
<Version>0.4.1</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
+11 -4
View File
@@ -30,16 +30,23 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
## Versioning ## Versioning
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages. All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
### 0.4.1 — LDAP review fixes
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
- **Behaviour change for `ServiceAccount`-mode consumers:** an empty (or null) password is now refused by the library as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone.
- **Template guidance:** prefer the down-level `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
- No `Abstractions` contract change.
### 0.4.0 — LDAP sign-in by user bind (opt-in) ### 0.4.0 — LDAP sign-in by user bind (opt-in)
- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `{0}@example.com` or `EXAMPLE\{0}`) and **`AcceptDomainQualifiedNames`** (default `true`). - **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) and **`AcceptDomainQualifiedNames`** (default `true`).
- In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated. - In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated.
- Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`. - Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`.
- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling. - `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling (0.4.1 also canonicalises its case from the entry).
- No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged. - No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged.
- `UserBindTemplate` output is used verbatim as the bind name, so use a UPN (`{0}@example.com`) or down-level (`EXAMPLE\{0}`) template; a DN template is not escaped. - `UserBindTemplate` output is used verbatim as the bind name, so use a down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) template; a DN template is not escaped.
### 0.3.0 — SQL Server API-key store (ScadaBridge central pair) ### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)
@@ -42,8 +42,14 @@ public sealed record LdapOptions
/// <summary> /// <summary>
/// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the /// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the
/// normalised login name — e.g. <c>{0}@example.com</c> (UPN) or <c>EXAMPLE\{0}</c>. Must contain /// normalised login name. Must contain <c>{0}</c> exactly once.
/// <c>{0}</c> exactly once. /// <para>
/// Prefer the down-level form <c>EXAMPLE\{0}</c>: it maps exactly to the <c>sAMAccountName</c> the
/// group search uses, so the account that binds is the account whose groups are read. A UPN
/// template (<c>{0}@example.com</c>) is only correct where every account's UPN prefix equals its
/// <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit <c>userPrincipalName</c>, so
/// a mismatch can fail sign-in or bind a different account than the one whose groups are read.
/// </para>
/// </summary> /// </summary>
public string UserBindTemplate { get; init; } = ""; public string UserBindTemplate { get; init; } = "";
@@ -2,8 +2,11 @@ namespace ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary> /// <summary>
/// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>: /// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>:
/// one spelling flows into the bind template, the search filter and the returned username, so audit /// the qualifier is stripped before the name reaches the bind template and the search filter, so audit
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. /// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. Case is NOT
/// normalised here: the returned username is canonicalised from the directory entry's
/// <c>UserNameAttribute</c> value, so <c>JDoe</c> and <c>jdoe</c> also yield one identity; this name is
/// only the fallback when the entry has no such value.
/// </summary> /// </summary>
internal static class LdapUserName internal static class LdapUserName
{ {
@@ -145,16 +145,19 @@ public sealed class LdapAuthService : ILdapAuthService
// filter and the returned result (avoids two identities for one person). // filter and the returned result (avoids two identities for one person).
username = username.Trim(); username = username.Trim();
// 3b. User-bind mode guards, before any network call. An empty password must never reach // 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a
// the directory: AD answers a simple bind with an empty password as an UNAUTHENTICATED // simple bind with an empty password as an UNAUTHENTICATED bind and reports success —
// bind and reports success, which would admit anyone. The name is reduced to the bare // the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone.
// account name so the bind template, the search filter and the result share one spelling. // Refused here, before any network call, so no consumer depends on its own edge check.
if (_options.BindMode == LdapBindMode.User) if (string.IsNullOrEmpty(password))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
// 3c. User-bind mode: reduce the name to the bare account name the bind template and the
// search filter share. (The returned username is canonicalised from the entry later.)
if (_options.BindMode == LdapBindMode.User
&& !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
{ {
if (string.IsNullOrEmpty(password)) return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
if (!LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
} }
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration // 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
@@ -278,7 +281,8 @@ public sealed class LdapAuthService : ILdapAuthService
private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password) private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
{ {
// 6. Service-account bind so we can search for the user's DN. A bind failure here is a // 6. Service-account bind so we can search for the user's DN. A bind failure here is a
// service-account misconfiguration — DISTINCT from a user-credential failure. // service-account misconfiguration — DISTINCT from a user-credential failure — so it
// maps to ServiceAccountBindFailed.
try try
{ {
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword); conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
@@ -288,7 +292,9 @@ public sealed class LdapAuthService : ILdapAuthService
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind"); throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
} }
// 7–8. Exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser. // 7–8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// Both are authoritative answers from a healthy directory, so they end the walk.
// We never attempt a user bind against an ambiguous DN.
var entries = SearchUser(conn, username); var entries = SearchUser(conn, username);
if (entries.Count == 0) if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound); return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
@@ -297,7 +303,9 @@ public sealed class LdapAuthService : ILdapAuthService
var entry = entries[0]; var entry = entries[0];
// 9. User bind verifies the password; a failure is the user's bad credentials and final. // 9. User bind: re-bind as the resolved DN to verify the password. A bind failure here is
// the end user's bad credentials — the directory answered, so this is returned directly
// and never retried against another endpoint.
try try
{ {
conn.Bind(entry.Dn, password); conn.Bind(entry.Dn, password);
@@ -344,6 +352,10 @@ public sealed class LdapAuthService : ILdapAuthService
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary> /// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username) private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
{ {
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
// to ServiceAccountBindFailed, the closest system-side bucket (same limitation as
// Connect), until a DirectoryUnavailable value exists.
try try
{ {
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})"; var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
@@ -355,7 +367,12 @@ public sealed class LdapAuthService : ILdapAuthService
} }
} }
/// <summary>Steps 10–11: fail-closed group extraction, then success.</summary> /// <summary>
/// Steps 10–11: fail-closed group extraction, then success. The returned username is the
/// entry's own <see cref="LdapOptions.UserNameAttribute"/> value (the directory's spelling), so
/// <c>JDoe</c> and <c>jdoe</c> sign in as one identity; the typed (normalised) name is the
/// fallback when the entry carries no such value.
/// </summary>
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username) private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
{ {
IReadOnlyList<string> groups; IReadOnlyList<string> groups;
@@ -371,7 +388,8 @@ public sealed class LdapAuthService : ILdapAuthService
if (groups.Count == 0) if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed); return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
return LdapAuthResult.Success(username, ExtractDisplayName(entry, username), groups); var canonical = ExtractUserName(entry, username);
return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups);
} }
/// <summary> /// <summary>
@@ -393,16 +411,37 @@ public sealed class LdapAuthService : ILdapAuthService
} }
/// <summary> /// <summary>
/// Builds the distinct attribute list requested from the directory. The display-name /// Builds the distinct attribute list requested from the directory: display name, groups and
/// and group attributes are de-duplicated so we never request the same attribute twice /// the username attribute (read back as the canonical username). They are de-duplicated,
/// when an operator configures them to the same value. /// case-insensitively, so we never request the same attribute twice when an operator
/// configures two of them to the same value.
/// </summary> /// </summary>
private IReadOnlyList<string> BuildSearchAttributes() private IReadOnlyList<string> BuildSearchAttributes()
{ {
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase)) var attributes = new List<string>(3);
return new[] { _options.DisplayNameAttribute }; foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
{
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
attributes.Add(name);
}
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute }; return attributes;
}
/// <summary>
/// Returns the first non-blank value of the configured username attribute — the directory's
/// canonical spelling — falling back to the (already normalised) typed username.
/// </summary>
private string ExtractUserName(LdapSearchEntry entry, string username)
{
if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values)
&& values.Count > 0
&& !string.IsNullOrWhiteSpace(values[0]))
{
return values[0];
}
return username;
} }
/// <summary> /// <summary>
@@ -34,6 +34,9 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// </summary> /// </summary>
public int BindAttempts { get; private set; } public int BindAttempts { get; private set; }
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
public IReadOnlyList<string>? SearchAttributes { get; private set; }
/// <summary>Every call in order: <c>connect</c>, <c>bind:&lt;dn&gt;</c>, <c>search:&lt;filter&gt;</c>.</summary> /// <summary>Every call in order: <c>connect</c>, <c>bind:&lt;dn&gt;</c>, <c>search:&lt;filter&gt;</c>.</summary>
public List<string> Calls { get; } = new(); public List<string> Calls { get; } = new();
@@ -41,9 +44,14 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// <summary> /// <summary>
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call. /// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>. /// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
/// </summary> /// </summary>
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null) public FakeLdapConnection WithUserEntry(
string dn,
string[] memberOf,
string? displayName = null,
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
{ {
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase) var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{ {
@@ -51,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection
}; };
if (displayName is not null) if (displayName is not null)
attrs["displayName"] = new[] { displayName }; attrs["displayName"] = new[] { displayName };
if (extraAttributes is not null)
{
foreach (var (name, values) in extraAttributes)
attrs[name] = values;
}
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs)); _scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
return this; return this;
@@ -177,6 +190,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
IReadOnlyList<string> attributes) IReadOnlyList<string> attributes)
{ {
Calls.Add($"search:{filter}"); Calls.Add($"search:{filter}");
SearchAttributes = attributes;
if (_throwOnSearch) if (_throwOnSearch)
throw new Novell.Directory.Ldap.LdapException( throw new Novell.Directory.Ldap.LdapException(
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase); "Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
@@ -147,4 +147,45 @@ public class LdapAuthServiceTests
Assert.True(r.Succeeded); Assert.True(r.Succeeded);
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups); Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
} }
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
{
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("alice", r.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
{
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("ALICE", r.Username);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
var svc = new LdapAuthService(Opts(), factory);
var r = await svc.AuthenticateAsync("alice", password!, default);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
Assert.Empty(factory.Created);
}
} }
@@ -178,4 +178,45 @@ public class LdapAuthServiceUserBindTests
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure); Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
} }
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "JDOE");
Assert.True(result.Succeeded);
Assert.Equal("JDOE", result.Username);
}
[Fact]
public async Task Search_RequestsTheUserNameAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes);
}
[Fact]
public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory);
await Login(svc, "jdoe");
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
}
} }
+3 -3
View File
@@ -4,7 +4,7 @@ Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtO
The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project. The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project.
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`. **Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes — stale → #17). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
--- ---
@@ -66,7 +66,7 @@ ZB.MOM.WW.Configuration/
## Status ## Status
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes) — per-app result is tracked in: Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes — stale → #17) — per-app result is tracked in:
- `~/Desktop/scadaproj/components/configuration/GAPS.md` - `~/Desktop/scadaproj/components/configuration/GAPS.md`
@@ -97,7 +97,7 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Configurat
The implementation plan is at The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md). [`docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md).
**Adopted across all three apps on 2026-06-01** (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's **Adopted across all three apps on 2026-06-01** (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's
local default branch (`main`/`master`) — merged, **not yet pushed** to remotes; the package was first local default branch (`main`/`master`) — merged, **not yet pushed** to remotes (stale → #17); the package was first
published to the Gitea feed. Behaviour-preserving onto `OptionsValidatorBase`/`AddValidatedOptions` published to the Gitea feed. Behaviour-preserving onto `OptionsValidatorBase`/`AddValidatedOptions`
for MxGateway + ScadaBridge (validator messages byte-identical), `StartupValidator` → `ConfigPreflight` for MxGateway + ScadaBridge (validator messages byte-identical), `StartupValidator` → `ConfigPreflight`
for ScadaBridge, and net-new `Ldap`/`OpcUa` validators for OtOpcUa. Per-app result tracked in for ScadaBridge, and net-new `Ldap`/`OpcUa` validators for OtOpcUa. Per-app result tracked in
+3 -1
View File
@@ -2,6 +2,8 @@
Health-check libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central health process or network hop; probes run in-process alongside the application. Health-check libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central health process or network hop; probes run in-process alongside the application.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #11 (re-verify per-app probe coverage vs the spec). The history below is a record, not a status tracker; file new follow-ups as issues.
The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic. The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic.
**Built at 0.3.0 and published to the Gitea NuGet feed. Adopted by all four apps** — adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`. **Built at 0.3.0 and published to the Gitea NuGet feed. Adopted by all four apps** — adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`.
@@ -99,6 +101,6 @@ orchestrator wiring, and ScadaBridge's distributed health-monitoring pipeline.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/)
(.NET 10; 3 packages — `ZB.MOM.WW.Health`, `ZB.MOM.WW.Health.Akka`, `ZB.MOM.WW.Health.EntityFrameworkCore`; (.NET 10; 3 packages — `ZB.MOM.WW.Health`, `ZB.MOM.WW.Health.Akka`, `ZB.MOM.WW.Health.EntityFrameworkCore`;
64 tests (verified 2026-07-18); `dotnet pack` → 3 nupkgs @ 0.1.0). 64 tests (verified 2026-07-18); `dotnet pack` → 3 nupkgs @ 0.1.0).
**Adopted by all four apps** — verified 2026-07-18 (each references the package and wires `MapZbHealth`); the previous "not yet adopted" claim was stale. Per-app probe coverage vs the spec has NOT been re-verified — see [`components/health/GAPS.md`](components/health/GAPS.md). **Adopted by all four apps** — verified 2026-07-18 (each references the package and wires `MapZbHealth`); the previous "not yet adopted" claim was stale. Per-app probe coverage vs the spec has NOT been re-verified (→ #11) — see [`components/health/GAPS.md`](components/health/GAPS.md).
Build/test from `ZB.MOM.WW.Health/`: `dotnet test`. Consumer matrix: MxAccessGateway → core only; Build/test from `ZB.MOM.WW.Health/`: `dotnet test`. Consumer matrix: MxAccessGateway → core only;
OtOpcUa & ScadaBridge → all three packages. OtOpcUa & ScadaBridge → all three packages.
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -2,6 +2,8 @@
The hosted family dashboard (Blazor Server). Build/test from this directory: `dotnet build ZB.MOM.WW.Overview.slnx`, `dotnet test ZB.MOM.WW.Overview.slnx` (fully offline). The hosted family dashboard (Blazor Server). Build/test from this directory: `dotnet build ZB.MOM.WW.Overview.slnx`, `dotnet test ZB.MOM.WW.Overview.slnx` (fully offline).
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #14 (re-run the two OtOpcUa-blocked Phase 4 acceptance checks). The history below is a record, not a status tracker; file new follow-ups as issues.
## Index history (migrated from the scadaproj root CLAUDE.md) ## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.** > Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
@@ -15,7 +17,7 @@ the `ZB.MOM.WW.Health` `0.2.0`/`0.3.0` per-entry `data` work exists to feed. **N
read-only, holds no credentials** — a requirement, not an omission; it references no Auth/Audit/ read-only, holds no credentials** — a requirement, not an omission; it references no Auth/Audit/
Secrets package. Design + impl plan: `docs/plans/2026-07-22-overview-dashboard-{design,impl-plan}.md` Secrets package. Design + impl plan: `docs/plans/2026-07-22-overview-dashboard-{design,impl-plan}.md`
(ledger 19/20; the open task is Phase 4 live acceptance, recorded in `41013dc` as 7 of 9 design §9 (ledger 19/20; the open task is Phase 4 live acceptance, recorded in `41013dc` as 7 of 9 design §9
checks passing, 2 blocked on an OtOpcUa rig redeploy). checks passing, 2 blocked on an OtOpcUa rig redeploy → #14).
The `Applications` registry ships **empty** on purpose: a deployment supplying none fails startup The `Applications` registry ships **empty** on purpose: a deployment supplying none fails startup
naming the key rather than booting an empty dashboard. Per-deployment overlays are committed — naming the key rather than booting an empty dashboard. Per-deployment overlays are committed —
@@ -54,3 +56,12 @@ cd docker && docker compose up -d --build # local rigs → http://localhost:5320
# wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime: # wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime:
dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out> dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out>
``` ```
**2026-10-01 — BTDB Manager registered on all three dashboards.** `appsettings.Wonder.json` (vd03,
committed) gains one instance `wonder` → `http://127.0.0.1:9876` (management link
`http://10.220.157.247:9876/`); the QA overlay `E:\ApiInstall\Overview\app\appsettings.QA.json` (deployed
only, identical on vq03/vq04) gains `vq03`/`vq04` → `http://wonder-app-vq0N.zmr.zimmer.com:9876`. All
`HasActiveRole: false` (single unclustered Blazor app); `/health/ready` carries one `sql` entry (BT
reachable). The registry is read at startup only, so `ZbOverview` was restarted on each node (previous
files kept as `*.bak-20261001T02xxxx`). Verified: vd03 6/6 Up, vq03 and vq04 12/12 Up.
@@ -150,6 +150,21 @@
"TimeoutSeconds": 10 "TimeoutSeconds": 10
} }
] ]
},
{
"Name": "BTDB Manager",
"ManagementLabel": "Manager",
"Instances": [
{
// HasActiveRole false: a single, unclustered Blazor app — no active/standby role.
// /health/ready carries one "sql" entry (BT on the DEV SQL box reachable) and is 503
// whenever that database is not; /health/active is an empty check set.
"Name": "wonder",
"BaseUrl": "http://127.0.0.1:9876",
"ManagementUrl": "http://10.220.157.247:9876/",
"HasActiveRole": false
}
]
} }
] ]
} }
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -2,6 +2,8 @@
Observability libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central telemetry process; instrumentation runs in-process alongside the application. Observability libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central telemetry process; instrumentation runs in-process alongside the application.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #12 (`AddZbSerilog` min-level governance hook), #13 (OTel collector / OTLP end to end), #17 (stale README text). The history below is a record, not a status tracker; file new follow-ups as issues.
The library normalizes the three-project observability surface: a shared OpenTelemetry Resource driven by a single identity triple (`service.name` / `site.id` / `node.role`), standard instrumentation wiring, Prometheus and OTLP export, and a Serilog bootstrap with enrichers and `TraceContextEnricher` for trace↔log correlation. The library normalizes the three-project observability surface: a shared OpenTelemetry Resource driven by a single identity triple (`service.name` / `site.id` / `node.role`), standard instrumentation wiring, Prometheus and OTLP export, and a Serilog bootstrap with enrichers and `TraceContextEnricher` for trace↔log correlation.
**Built at 0.1.0, published to the Gitea NuGet feed, and adopted across all three apps on 2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's MEL→Serilog migration + metrics export both landed in this pass — they were *not* actually done beforehand despite the earlier claim. ScadaBridge keeps its `LoggerConfigurationFactory` (min-level governance) and only adds the shared `TraceContextEnricher`; it does not call `AddZbSerilog`. Per-repo result + deferred follow-ons tracked in `~/Desktop/scadaproj/components/observability/GAPS.md`. **Built at 0.1.0, published to the Gitea NuGet feed, and adopted across all three apps on 2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's MEL→Serilog migration + metrics export both landed in this pass — they were *not* actually done beforehand despite the earlier claim. ScadaBridge keeps its `LoggerConfigurationFactory` (min-level governance) and only adds the shared `TraceContextEnricher`; it does not call `AddZbSerilog`. Per-repo result + deferred follow-ons tracked in `~/Desktop/scadaproj/components/observability/GAPS.md`.
@@ -65,7 +67,7 @@ Built at **0.1.0**, published to the Gitea NuGet feed, and **adopted across all
2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's 2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's
MEL→Serilog migration and metrics export both landed in this pass (not beforehand, despite the MEL→Serilog migration and metrics export both landed in this pass (not beforehand, despite the
earlier claim). Deferred follow-ons (MxGateway `ms`→`s` + Meter rename, ScadaBridge app instruments earlier claim). Deferred follow-ons (MxGateway `ms`→`s` + Meter rename, ScadaBridge app instruments
+ Site-node HTTP/1.1 metrics listener, OTLP wiring) are tracked in the component backlog: + Site-node HTTP/1.1 metrics listener, OTLP wiring) are tracked in the component backlog (remaining open items are Gitea issues: #12 min-level governance hook, #13 OTLP collector):
- `~/Desktop/scadaproj/components/observability/GAPS.md` — adoption status + deferred follow-ons - `~/Desktop/scadaproj/components/observability/GAPS.md` — adoption status + deferred follow-ons
+1 -1
View File
@@ -154,7 +154,7 @@ lockstep from `Directory.Build.props`.
## Status ## Status
**Built at 0.1.0. MxAccessGateway logging adopted (MEL → Serilog migration, on its own branch). **Built at 0.1.0. MxAccessGateway logging adopted (MEL → Serilog migration, on its own branch).
Broader OtOpcUa and ScadaBridge telemetry adoption deferred.** Adoption is tracked in the Broader OtOpcUa and ScadaBridge telemetry adoption deferred.** (stale → #17) Adoption is tracked in the
component backlog: component backlog:
- `~/Desktop/scadaproj/components/observability/GAPS.md` - `~/Desktop/scadaproj/components/observability/GAPS.md`
+34
View File
@@ -2,6 +2,40 @@
Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`. Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #9 (roll `0.5.0` to the other consumers), #10 (`layout.css` `calc(100vh - 3.3rem)` review). The history below is a record, not a status tracker; file new follow-ups as issues.
## Published 0.5.0 (2026-11-04)
Published the tested ignitionoee backlog package to the shared Gitea feed on
2026-11-04. Independent download matched the reviewed artifact SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`.
The ignitionoee router deployed this package to CR on 2026-11-04. No other
consumer was deployed in this task (rollout → #9). Do not repack or replace published 0.5.0.
Retains 0.4.2's `.rise` fill-mode fix. Adds the remaining shared application CSS
from ignitionoee (including `td.row-actions`), composable `TechButton.Class` and
`ButtonSize`, plus `ITreeRow`/stateless `TreeTable<TRow>` in `ZB.MOM.WW.Theme`.
Tree styles live once in `layout.css`, with arbitrary-depth indentation and
keyboard-focus styling. Caller-owned filtering/collapse behavior stays intact.
Test harness migrated to bUnit 2.7.2 with AngleSharp 1.5.0 to address the existing
NU1902 advisory without disabling audit. Runtime package dependencies are unchanged.
Local verification: Release build passed with zero warnings/errors; Release tests
passed 60 tests, none skipped. `build/pack.sh` produced the local 0.5.0 nupkg.
All 19 remaining ignitionoee app.css rule blocks are present verbatim in the
shared stylesheet; the Impeccable detector reported no findings on changed UI files.
Browser QA also exposed a pre-existing mobile shell bug: closed navigation hid
the page through Chromium's disclosure wrapper. The wrapper override now applies
at all widths, with an explicit mobile rule hiding only the rail and author
display rules for older browsers. Two regression tests cover CSS placement and
closed-shell component structure; computed mobile/desktop behavior was checked in
the disposable ignitionoee local browser fixture.
Browser QA confirmed the visibility fix and isolated an additional mobile
min-content sizing issue: bounding the direct page to 100% width/max-width with
border-box reduced the page/document from 1055px to the 390px viewport and kept
wide-table scrolling inside its wrapper. That measured constraint is included.
See README's 0.5.0 migration section; deployed consumer validation remains separate
from local source/package/browser verification.
## Index history (migrated from the scadaproj root CLAUDE.md) ## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.** > Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.4.1</Version> <Version>0.5.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+3 -1
View File
@@ -3,7 +3,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="bunit" Version="1.40.0" /> <PackageVersion Include="bunit" Version="2.7.2" />
<!-- Test DOM parser: direct pin avoids bUnit's vulnerable 1.2.0 minimum. -->
<PackageVersion Include="AngleSharp" Version="1.5.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
+51 -4
View File
@@ -65,7 +65,8 @@ server-side** before redirecting (open-redirect risk):
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. | | `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. |
| `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. | | `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. |
| `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. | | `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. |
| `TechButton` (`ButtonVariant`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. `ButtonVariant` enum is in `ZB.MOM.WW.Theme`. | | `TechButton` (`ButtonVariant`, `ButtonSize`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Size` (`Default`/`Small`/`Large`), `Class`, `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. Caller classes are composed with required variant/size classes. |
| `TreeTable<TRow>` (`ITreeRow`) | `Rows`*, `Head`*, `Row`*, `Label`*, `Collapsed`, `OnToggle` | Stateless preorder tree table. The caller filters visible rows and owns expansion state. |
| `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. | | `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. |
| `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. | | `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. |
@@ -80,7 +81,7 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers | | `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers |
| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing | | `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing, shared page/group headers, badges, row actions, info notices, payload blocks and tree-table styles |
**Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its **Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its
0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes 0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes
@@ -128,6 +129,52 @@ so a token-block edit re-themes the whole kit.
```bash ```bash
# from ZB.MOM.WW.Theme/ # from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
dotnet test # 38 bUnit tests dotnet test # 60 tests verified for 0.5.0
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg ./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.5.0.nupkg
``` ```
## 0.5.0 migration (published 2026-11-04)
The tested package was published to the shared Gitea feed on 2026-11-04.
An independent feed download exactly matched the reviewed artifact (SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`).
The ignitionoee router deployed this package to CR on 2026-11-04; other consumer
rollouts remain separate. It retains the 0.4.2 `.rise` animation
fill-mode fix. The published package is immutable; this source documentation
update does not repack or replace it.
The shell now keeps page content visible when mobile navigation is collapsed.
The outer disclosure's `::details-content` wrapper is dissolved at every width;
an explicit mobile closed-state selector hides only the rail. Author display
rules retain the page for older browsers without `::details-content` support.
The menu remains a native keyboard-accessible disclosure without custom JavaScript.
Mobile pages are constrained to the shell width so wide tables scroll inside their
table wrapper instead of widening the whole document.
The remaining ignitionoee `app.css` rules now ship in `layout.css`: page and section
headers/meta, group headings/meta, count badges, `.notice.info`, `.row-actions`,
**`td.row-actions`**, and `.check-data`. Consumers may delete matching local copies
after adopting this package. The element-qualified row-action selector must remain:
the bare class loses to `.data-table td` regardless of stylesheet load order.
Use `Size="ButtonSize.Small"` or `Size="ButtonSize.Large"` on `TechButton`.
`Class="my-action"` and an attribute dictionary's `class` value are composed with
the required button classes. A lowercase `class` supplied in Razor binds to `Class`
as normal Blazor parameter binding. Supplying both spellings as separate component
parameters is unnecessary; use `Class` plus `AdditionalAttributes` when merging two
sources. Explicit `Busy` and `Type` continue to control the underlying attributes.
Implement `ZB.MOM.WW.Theme.ITreeRow` on a consumer's row model (`Key`, `Depth`,
`HasChildren`). Feed `TreeTable<TRow>` visible rows in preorder, with each subtree
contiguous after its parent. `Head` supplies all header cells; `Row` supplies cells
after the component's name cell; `Label` supplies the name. `OnToggle` reports the
stable row key, and the caller supplies a new `Collapsed` state and filters `Rows`.
The component never filters or mutates caller state. Indentation supports arbitrary
depth through `--tt-depth`; styles ship once, with no per-instance stylesheet.
Leaf controls preserve label alignment and omit subtree ARIA attributes. Interactive
expand/collapse requires an interactive Blazor host and Bootstrap Icons for chevrons.
The test harness uses bUnit 2.7.2 and directly pins AngleSharp 1.5.0. This avoids
the vulnerable parser minimum ([GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg))
and the binary incompatibility between bUnit 1.x and the patched parser. These
dependencies belong to the test project; the Theme runtime package gains none.
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Theme;
/// <summary>Bootstrap size modifier for <c>TechButton</c>.</summary>
public enum ButtonSize
{
/// <summary>The theme's standard button size, without a Bootstrap size modifier.</summary>
Default,
/// <summary>Compact button, using <c>btn-sm</c>.</summary>
Small,
/// <summary>Large button, using <c>btn-lg</c>.</summary>
Large,
}
@@ -1,6 +1,6 @@
@namespace ZB.MOM.WW.Theme @namespace ZB.MOM.WW.Theme
@* Components/TechButton.razor *@ @* Components/TechButton.razor *@
<button @attributes="AdditionalAttributes" type="@Type" class="btn @VariantClass" disabled="@Busy"> <button @attributes="AdditionalAttributes" type="@Type" class="@ButtonClass" disabled="@Busy">
@if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> } @if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
@ChildContent @ChildContent
</button> </button>
@@ -12,6 +12,12 @@
/// </summary> /// </summary>
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary; [Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
/// <summary>Bootstrap size modifier. Defaults to the theme's standard button size.</summary>
[Parameter] public ButtonSize Size { get; set; }
/// <summary>Additional CSS classes, composed with the variant, size and unmatched class attribute.</summary>
[Parameter] public string? Class { get; set; }
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary> /// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
[Parameter] public string Type { get; set; } = "button"; [Parameter] public string Type { get; set; } = "button";
@@ -30,6 +36,14 @@
/// </summary> /// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string ButtonClass => string.Join(" ", new[]
{
"btn", VariantClass,
Size switch { ButtonSize.Small => "btn-sm", ButtonSize.Large => "btn-lg", _ => null },
Class,
AdditionalAttributes?.FirstOrDefault(pair => string.Equals(pair.Key, "class", StringComparison.OrdinalIgnoreCase)).Value?.ToString()
}.Where(value => !string.IsNullOrWhiteSpace(value)));
private string VariantClass => Variant switch private string VariantClass => Variant switch
{ {
ButtonVariant.Secondary => "btn-outline-secondary", ButtonVariant.Secondary => "btn-outline-secondary",
@@ -0,0 +1,88 @@
@namespace ZB.MOM.WW.Theme
@typeparam TRow where TRow : ITreeRow
@* Stateless preorder tree table. The caller filters Rows and owns collapse state.
Shared styles ship once in layout.css, including arbitrary-depth indentation. *@
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>@Head</tr>
</thead>
<tbody>
@foreach (TRow row in Rows)
{
<tr @key="row.Key" class="tt-d@(row.Depth)">
<td class="tt-name" style="@DepthStyle(row)">
<button type="button" class="@TwistClass(row)" disabled="@(!row.HasChildren)"
aria-expanded="@AriaExpanded(row)" aria-label="@TwistLabel(row)"
@onclick="() => OnToggle.InvokeAsync(row.Key)">
<i class="@ChevronClass(row)" aria-hidden="true"></i>
</button>
@Label(row)
</td>
@Row(row)
</tr>
}
</tbody>
</table>
</div>
@code {
/// <summary>
/// The rows to render, in order — already filtered through
/// the caller's visibility policy.
/// </summary>
/// <remarks>
/// The filtering is the caller's so that this component holds no state at all: the page owns
/// <see cref="Collapsed"/>, and what is on screen is a pure function of it.
/// </remarks>
[Parameter, EditorRequired] public IReadOnlyList<TRow> Rows { get; set; } = [];
/// <summary>
/// The header cells — every <c>&lt;th&gt;</c>, including the one over the name column. The
/// component supplies the <c>&lt;thead&gt;</c> and its <c>&lt;tr&gt;</c>.
/// </summary>
[Parameter, EditorRequired] public RenderFragment Head { get; set; } = builder => { };
/// <summary>
/// One row's cells <b>after</b> the name cell: the component renders the name column, the
/// caller renders the rest.
/// </summary>
[Parameter, EditorRequired] public RenderFragment<TRow> Row { get; set; } = _ => builder => { };
/// <summary>The text of a row's name cell, beside its twist.</summary>
[Parameter, EditorRequired] public Func<TRow, string> Label { get; set; } = _ => string.Empty;
/// <summary>
/// The keys of the collapsed rows — read only to draw each twist the right way round.
/// </summary>
/// <remarks>
/// It is the same set the caller filtered <see cref="Rows"/> with, so a row can be collapsed
/// and on screen (that is what a shut branch looks like) but never expanded and hidden.
/// </remarks>
[Parameter] public IReadOnlySet<string> Collapsed { get; set; } = new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// Raised with a row's <see cref="ITreeRow.Key"/> when its twist is clicked. The caller
/// toggles that key in its own collapse set; this component changes nothing itself.
/// </summary>
[Parameter] public EventCallback<string> OnToggle { get; set; }
private static string DepthStyle(TRow row)
=> "--tt-depth: " + Math.Max(0, row.Depth).ToString(System.Globalization.CultureInfo.InvariantCulture);
private bool IsCollapsed(TRow row) => Collapsed.Contains(row.Key);
private string TwistClass(TRow row) => row.HasChildren ? "tt-twist" : "tt-twist tt-leaf";
private string ChevronClass(TRow row) => IsCollapsed(row) ? "bi bi-chevron-right" : "bi bi-chevron-down";
// Null omits the attribute: a leaf's twist is decoration, and announcing it as a collapsed
// control would promise a subtree that is not there.
private string? AriaExpanded(TRow row)
=> !row.HasChildren ? null : IsCollapsed(row) ? "false" : "true";
private string? TwistLabel(TRow row)
=> !row.HasChildren ? null : (IsCollapsed(row) ? "Expand " : "Collapse ") + Label(row);
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.Theme;
/// <summary>A row in a preorder flat tree, with each subtree contiguous after its parent.</summary>
public interface ITreeRow
{
/// <summary>Stable identity, unique in the table and used for keyed rendering and collapse state.</summary>
string Key { get; }
/// <summary>Nesting depth, starting at zero for a root.</summary>
int Depth { get; }
/// <summary>Whether this row owns a subtree and offers an expand/collapse toggle.</summary>
bool HasChildren { get; }
}
@@ -11,10 +11,12 @@
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Theme</PackageId> <PackageId>ZB.MOM.WW.Theme</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description> <Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" /> <InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
@@ -1,5 +1,5 @@
/* ZB.MOM.WW.Theme — side-rail + login layout /* ZB.MOM.WW.Theme — side-rail + login layout
Tokens live in theme.css; this sheet carries only layout + the side rail. */ Tokens live in theme.css; this sheet carries application layout and shared controls. */
/* ── Bootstrap button sizing ───────────────────────────────────────────────── /* ── Bootstrap button sizing ─────────────────────────────────────────────────
Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against
@@ -45,6 +45,12 @@
--bs-btn-padding-y: .25rem; --bs-btn-padding-y: .25rem;
--bs-btn-padding-x: .6rem; --bs-btn-padding-x: .6rem;
} }
.btn-group-lg > .btn,
.btn-lg {
--bs-btn-font-size: 1rem;
--bs-btn-padding-y: .5rem;
--bs-btn-padding-x: 1rem;
}
/* ── App shell: side rail + page ─────────────────────────────────────────── */ /* ── App shell: side rail + page ─────────────────────────────────────────── */
/* The shell is a native <details> disclosure so the narrow-viewport hamburger /* The shell is a native <details> disclosure so the narrow-viewport hamburger
@@ -59,6 +65,21 @@
min-height: calc(100vh - 3.3rem); min-height: calc(100vh - 3.3rem);
} }
/* A closed shell must hide navigation only, never the page. Modern Chromium
wraps all non-summary children in ::details-content and hides that box while
closed. Dissolve it at EVERY width so both children retain the shell's flex
layout. The mobile closed-rail rule below owns navigation visibility. */
.app-shell::details-content {
display: contents;
content-visibility: visible;
}
/* Older browsers use a UA display rule on closed disclosure children instead
of ::details-content. Author display rules keep the page present there too;
an unsupported pseudo-element rule above is simply ignored. */
.app-shell > main.page { display: block; }
.app-shell > #theme-rail { display: block; }
.app-shell .page { .app-shell .page {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -72,9 +93,8 @@
} }
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; } .app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing /* The native <details> owns its open state; the mobile rule explicitly hides
extra is needed there. On lg+ we force the rail visible (see media query) only #theme-rail when closed. Bootstrap's d-lg-none hides the toggle on lg+. */
and keep the toggle hidden via Bootstrap's d-lg-none. */
/* ── Side rail ───────────────────────────────────────────────────────────── */ /* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail { .side-rail {
@@ -92,20 +112,6 @@
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;
@@ -116,10 +122,16 @@
} }
} }
/* When the side rail is collapsed under <lg viewports (the <details> is closed) /* The disclosure toggles navigation while the page remains usable underneath. */
the native disclosure hides #theme-rail; when open, restore full width on
mobile so the rail spans the viewport. */
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.app-shell:not([open]) > #theme-rail { display: none; }
/* Native disclosure layout can otherwise size the page to a table's
min-content width. Bound the page so .table-wrap scrolls internally. */
.app-shell > .page {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.side-rail { .side-rail {
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
@@ -323,3 +335,79 @@
.login-body { padding: 1.4rem 1.1rem 1.25rem; } .login-body { padding: 1.4rem 1.1rem 1.25rem; }
.login-error { margin-bottom: 0.85rem; } .login-error { margin-bottom: 0.85rem; }
.field-error { font-size: 0.78rem; margin-top: 0.2rem; } .field-error { font-size: 0.78rem; margin-top: 0.2rem; }
/* Shared application presentation, upstreamed from ignitionoee in 0.5.0. */
/* ── Page header ─────────────────────────────────────────────────────────────
Title left, `.spacer` eating the slack, actions/meta pushed hard right. */
.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; }
.page-head h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; margin: 0; text-wrap: balance; }
.page-head .spacer { flex: 1; }
.page-meta { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-soft); }
/* ── Application sections ────────────────────────────────────────────────────
A rule-underlined h2 band; same spacer trick as the page head. */
.app-section { margin-bottom: 1.5rem; }
.app-head {
display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
padding-bottom: 0.4rem; border-bottom: 1px solid var(--rule-strong); margin-bottom: 0.75rem;
}
.app-head h2 { font-size: 1.02rem; font-weight: 600; margin: 0; }
.app-head .spacer { flex: 1; }
.app-meta { font-family: var(--mono); font-size: 0.76rem; color: var(--ink-faint); }
/* ── Group header (per-site issue groups) ────────────────────────────────── */
.group-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
.group-name { font-size: 0.74rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-soft); }
.group-meta { font-family: var(--mono); font-size: 0.74rem; color: var(--ink-faint); }
/* ── Per-group count badge ───────────────────────────────────────────────────
Red by default (it counts problems); `.warn` for the softer amber case. */
.count-badge {
display: inline-block; min-width: 1.4rem; text-align: center;
font-family: var(--mono); font-size: 0.72rem; font-weight: 600;
padding: 0.05rem 0.35rem; border-radius: 999px;
color: var(--bad); background: var(--bad-bg); border: 1px solid var(--bad-border);
}
.count-badge.warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
/* ── Inline notice, info variant ─────────────────────────────────────────────
Theme 0.3.1 ships `.notice` (amber) but no variants; `.info` re-tints it to
the on-palette blue wash. `--info-bg`/`--info-border` come from the theme. */
.notice.info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
/* ── Table row-action cell ─────────────────────────────────────────────────
The bare class is what the mockup ships and what a `<span class="row-actions">`
still needs. It is NOT enough on its own: every use in this app is a table
cell, and Theme 0.3.1's `.data-table th, .data-table td { text-align: left }`
is class+element (0-1-1) against this rule's bare class (0-1-0) — specificity
beats load order, so the alignment silently lost everywhere. Observed in a
browser on 2026-08-05 (every row-action cell left-aligned; `nowrap` applied,
`text-align` did not); the cell-level restatement below is the fix.
See docs/recipes/router-ui-render-validation.md, Task 1 Defect 2. */
.row-actions { text-align: right; white-space: nowrap; }
td.row-actions { text-align: right; }
/* ── Preformatted check/payload block ────────────────────────────────────── */
.check-data {
margin: 0.35rem 0.9rem 0.6rem; padding: 0.4rem 0.6rem; background: var(--zebra-bg);
border: 1px solid var(--rule); border-radius: 4px;
font-family: var(--mono); font-size: 0.72rem; color: var(--ink-soft); line-height: 1.6;
overflow-x: auto; white-space: nowrap;
}
.check-data b { color: var(--accent-deep); font-weight: 500; }
/* ── Tree table ──────────────────────────────────────────────────────────────
Name-cell depth is a number emitted by TreeTable; it adds to the table's
standard 0.8rem cell padding and works at every depth. */
.data-table td.tt-name {
font-weight: 600;
padding-left: calc(0.8rem + var(--tt-depth, 0) * 1.2rem);
}
.tt-twist {
background: none; border: none; padding: 0; margin-right: 0.45rem;
color: var(--ink-faint); font-size: 0.75rem; line-height: 1; cursor: pointer;
}
.tt-twist:hover { color: var(--ink); }
.tt-twist:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* A hidden leaf control preserves the width of its siblings' toggles. */
.tt-leaf { visibility: hidden; }
@@ -225,7 +225,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
Add .rise to top-level sections; stagger with inline animation-delay Add .rise to top-level sections; stagger with inline animation-delay
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */ (.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } @keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.4s ease both; } .rise { animation: rise 0.4s ease backwards; /* backwards: end keyframe equals natural style, preventing a containing-block trap for position:fixed descendants */ }
/* ════════════════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════════════════
COMPONENT LIBRARY COMPONENT LIBRARY
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class BrandBarTests : TestContext public class BrandBarTests : BunitContext
{ {
[Fact] [Fact]
public void Renders_product_with_default_mark() public void Renders_product_with_default_mark()
{ {
var cut = RenderComponent<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Contains("OtOpcUa", cut.Markup); Assert.Contains("OtOpcUa", cut.Markup);
Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo
} }
@@ -13,7 +13,7 @@ public class BrandBarTests : TestContext
[Fact] [Fact]
public void Custom_logo_replaces_default_mark() public void Custom_logo_replaces_default_mark()
{ {
var cut = RenderComponent<BrandBar>(p => p var cut = Render<BrandBar>(p => p
.Add(x => x.Product, "ScadaBridge") .Add(x => x.Product, "ScadaBridge")
.Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>")))); .Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
Assert.NotNull(cut.Find(".brand .logo")); Assert.NotNull(cut.Find(".brand .logo"));
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class CommonControlsTests : TestContext public class CommonControlsTests : BunitContext
{ {
[Theory] [Theory]
[InlineData(ButtonVariant.Primary, "btn-primary")] [InlineData(ButtonVariant.Primary, "btn-primary")]
@@ -9,7 +9,7 @@ public class CommonControlsTests : TestContext
[InlineData(ButtonVariant.Ghost, "btn-link")] [InlineData(ButtonVariant.Ghost, "btn-link")]
public void TechButton_maps_variant(ButtonVariant v, string cls) public void TechButton_maps_variant(ButtonVariant v, string cls)
{ {
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go")); var cut = Render<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
var btn = cut.Find("button"); var btn = cut.Find("button");
Assert.Contains("btn", btn.ClassList); Assert.Contains("btn", btn.ClassList);
Assert.Contains(cls, btn.ClassList); Assert.Contains(cls, btn.ClassList);
@@ -18,7 +18,7 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechButton_busy_disables_and_passes_through_attributes() public void TechButton_busy_disables_and_passes_through_attributes()
{ {
var cut = RenderComponent<TechButton>(p => p var cut = Render<TechButton>(p => p
.Add(x => x.Busy, true) .Add(x => x.Busy, true)
.AddUnmatched("id", "save") .AddUnmatched("id", "save")
.AddChildContent("Save")); .AddChildContent("Save"));
@@ -30,14 +30,14 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechButton_not_busy_is_not_disabled() public void TechButton_not_busy_is_not_disabled()
{ {
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go")); var cut = Render<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
Assert.False(cut.Find("button").HasAttribute("disabled")); Assert.False(cut.Find("button").HasAttribute("disabled"));
} }
[Fact] [Fact]
public void TechCard_renders_title_and_body() public void TechCard_renders_title_and_body()
{ {
var cut = RenderComponent<TechCard>(p => p var cut = Render<TechCard>(p => p
.Add(x => x.Title, "Drivers") .Add(x => x.Title, "Drivers")
.AddChildContent("<div class='b'>x</div>")); .AddChildContent("<div class='b'>x</div>"));
Assert.Contains("Drivers", cut.Find(".panel-head").TextContent); Assert.Contains("Drivers", cut.Find(".panel-head").TextContent);
@@ -47,7 +47,7 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechField_renders_label_hint_error() public void TechField_renders_label_hint_error()
{ {
var cut = RenderComponent<TechField>(p => p var cut = Render<TechField>(p => p
.Add(x => x.Label, "Name") .Add(x => x.Label, "Name")
.Add(x => x.Hint, "required") .Add(x => x.Hint, "required")
.Add(x => x.Error, "missing") .Add(x => x.Error, "missing")
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class LoginCardTests : TestContext public class LoginCardTests : BunitContext
{ {
[Fact] [Fact]
public void Posts_to_action_with_username_password_fields() public void Posts_to_action_with_username_password_fields()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Action, "/auth/login")); .Add(x => x.Action, "/auth/login"));
var form = cut.Find("form"); var form = cut.Find("form");
@@ -19,7 +19,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void ReturnUrl_renders_hidden_input() public void ReturnUrl_renders_hidden_input()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.ReturnUrl, "/clusters")); .Add(x => x.ReturnUrl, "/clusters"));
var hidden = cut.Find("input[name=returnUrl]"); var hidden = cut.Find("input[name=returnUrl]");
@@ -29,7 +29,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Error_renders_notice() public void Error_renders_notice()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Error, "Bad credentials")); .Add(x => x.Error, "Bad credentials"));
Assert.Contains("Bad credentials", cut.Find(".notice").TextContent); Assert.Contains("Bad credentials", cut.Find(".notice").TextContent);
@@ -38,7 +38,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void No_returnUrl_no_hidden_input() public void No_returnUrl_no_hidden_input()
{ {
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Empty(cut.FindAll("input[name=returnUrl]")); Assert.Empty(cut.FindAll("input[name=returnUrl]"));
} }
@@ -47,7 +47,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Product_is_wrapped_in_login_product_span() public void Product_is_wrapped_in_login_product_span()
{ {
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
var product = cut.Find(".login-title .login-product"); var product = cut.Find(".login-title .login-product");
Assert.Equal("OtOpcUa", product.TextContent); Assert.Equal("OtOpcUa", product.TextContent);
Assert.Contains("sign in", cut.Find(".login-title").TextContent); Assert.Contains("sign in", cut.Find(".login-title").TextContent);
@@ -57,7 +57,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Heading_overrides_default_heading_when_set() public void Heading_overrides_default_heading_when_set()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Heading, "Welcome back")); .Add(x => x.Heading, "Welcome back"));
var title = cut.Find(".login-title"); var title = cut.Find(".login-title");
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class NavRailTests : TestContext public class NavRailTests : BunitContext
{ {
[Fact] [Fact]
public void NavRailItem_renders_rail_link_with_href_and_text() public void NavRailItem_renders_rail_link_with_href_and_text()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")); .Add(x => x.Text, "Clusters"));
var a = cut.Find("a.rail-link"); var a = cut.Find("a.rail-link");
@@ -17,7 +17,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied() public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters") .Add(x => x.Text, "Clusters")
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>")))); .Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
@@ -29,7 +29,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailItem_omits_rail_ico_span_when_no_icon() public void NavRailItem_omits_rail_ico_span_when_no_icon()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")); .Add(x => x.Text, "Clusters"));
Assert.Empty(cut.FindAll(".rail-ico")); Assert.Empty(cut.FindAll(".rail-ico"));
@@ -38,7 +38,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_renders_title_and_children_open_by_default() public void NavRailSection_renders_title_and_children_open_by_default()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation") .Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
var details = cut.Find("details.rail-section"); var details = cut.Find("details.rail-section");
@@ -52,7 +52,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_summary_aria_expanded_true_when_open() public void NavRailSection_summary_aria_expanded_true_when_open()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation") .Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -61,7 +61,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_collapsed_when_not_expanded() public void NavRailSection_collapsed_when_not_expanded()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.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"));
@@ -71,7 +71,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_summary_aria_expanded_false_when_collapsed() public void NavRailSection_summary_aria_expanded_false_when_collapsed()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -80,7 +80,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default() public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Site Calls") .Add(x => x.Title, "Site Calls")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -89,7 +89,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_emits_explicit_key_when_supplied() public void NavRailSection_emits_explicit_key_when_supplied()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav") .Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -98,7 +98,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key() public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, " ") .Add(x => x.Title, " ")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -107,7 +107,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_slug_preserves_unicode_letters() public void NavRailSection_slug_preserves_unicode_letters()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Café") .Add(x => x.Title, "Café")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -39,6 +39,36 @@ public class StaticAssetsTests
public void NavStateScript_ships() => public void NavStateScript_ships() =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js"))); Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js")));
[Fact]
public void Shared_layout_ships_row_action_specificity_and_variable_size_modifiers()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
Assert.Matches(@"td\.row-actions\s*\{\s*text-align:\s*right", css);
foreach (var selector in new[] { ".page-head", ".page-meta", ".app-section", ".app-head",
".app-meta", ".group-head", ".group-name", ".group-meta", ".count-badge",
".count-badge.warn", ".notice.info", ".row-actions", ".check-data" })
Assert.Contains(selector, css);
Assert.Contains("var(--tt-depth, 0)", css);
Assert.Matches(@"\.btn-lg\s*\{\s*--bs-btn-font-size:\s*1rem", css);
Assert.Matches(@"\.tt-leaf\s*\{\s*visibility:\s*hidden", css);
}
[Fact]
public void Closed_mobile_shell_hides_only_the_rail_and_keeps_main_content_visible()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
css = Regex.Replace(css, @"/\*.*?\*/", "", RegexOptions.Singleline);
var baseRules = css[..css.IndexOf("@media", StringComparison.Ordinal)];
Assert.Matches(@"\.app-shell::details-content\s*\{[^}]*display:\s*contents", baseRules);
Assert.Matches(@"\.app-shell\s*>\s*main\.page\s*\{[^}]*display:\s*block", baseRules);
var mobile = css[css.IndexOf("@media (max-width: 991.98px)", StringComparison.Ordinal)..];
Assert.Matches(@"\.app-shell:not\(\[open\]\)\s*>\s*#theme-rail\s*\{\s*display:\s*none", mobile);
var page = Regex.Match(mobile, @"\.app-shell\s*>\s*\.page\s*\{([^}]*)\}").Groups[1].Value;
Assert.Matches(@"\bwidth:\s*100%", page);
Assert.Matches(@"\bmax-width:\s*100%", page);
Assert.Matches(@"\bbox-sizing:\s*border-box", page);
}
// 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]
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class StatusPillTests : TestContext public class StatusPillTests : BunitContext
{ {
[Theory] [Theory]
[InlineData(StatusState.Ok, "chip-ok")] [InlineData(StatusState.Ok, "chip-ok")]
@@ -10,7 +10,7 @@ public class StatusPillTests : TestContext
[InlineData(StatusState.Info, "chip-info")] [InlineData(StatusState.Info, "chip-info")]
public void Maps_state_to_chip_class(StatusState state, string expected) public void Maps_state_to_chip_class(StatusState state, string expected)
{ {
var cut = RenderComponent<StatusPill>(p => p var cut = Render<StatusPill>(p => p
.Add(x => x.State, state) .Add(x => x.State, state)
.AddChildContent("Connected")); .AddChildContent("Connected"));
var span = cut.Find("span"); var span = cut.Find("span");
@@ -0,0 +1,66 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TechButtonExtensionsTests : BunitContext
{
[Theory]
[InlineData(ButtonSize.Default, null)]
[InlineData(ButtonSize.Small, "btn-sm")]
[InlineData(ButtonSize.Large, "btn-lg")]
public void Size_preserves_variant_and_composes_both_class_inputs(ButtonSize size, string? expected)
{
var cut = Render<TechButton>(p => p
.Add(x => x.Variant, ButtonVariant.Secondary)
.Add(x => x.Size, size)
.Add(x => x.Class, "explicit-action")
.Add(x => x.AdditionalAttributes, new Dictionary<string, object>
{
["class"] = "caller-action", ["aria-label"] = "Save changes"
})
.AddChildContent("Save"));
var button = cut.Find("button");
foreach (var cls in new[] { "btn", "btn-outline-secondary", "explicit-action", "caller-action" })
Assert.Contains(cls, button.ClassList);
Assert.Equal("Save changes", button.GetAttribute("aria-label"));
if (expected is not null) Assert.Contains(expected, button.ClassList);
else
{
Assert.DoesNotContain("btn-sm", button.ClassList);
Assert.DoesNotContain("btn-lg", button.ClassList);
}
}
[Fact]
public void Class_composition_keeps_busy_type_and_click_behavior()
{
var clicks = 0;
var attributes = new Dictionary<string, object>
{
["class"] = "caller", ["disabled"] = false,
["onclick"] = EventCallback.Factory.Create(this, () => clicks++)
};
var cut = Render<TechButton>(p => p
.Add(x => x.Class, "extra")
.Add(x => x.Type, "submit")
.Add(x => x.Busy, true)
.Add(x => x.AdditionalAttributes, attributes)
.AddChildContent("Save"));
Assert.True(cut.Find("button").HasAttribute("disabled"));
Assert.Equal("submit", cut.Find("button").GetAttribute("type"));
Assert.NotNull(cut.Find(".spinner-border"));
Assert.Equal("caller", attributes["class"]);
cut.Render(p => p.Add(x => x.Busy, false));
cut.Find("button").Click();
Assert.Equal(1, clicks);
Assert.DoesNotContain(cut.FindAll("span"), x => x.ClassList.Contains("spinner-border"));
}
[Fact]
public void Lowercase_class_attribute_preserves_required_button_classes()
{
var cut = Render<TechButton>(p => p.AddUnmatched("class", "caller").AddChildContent("Go"));
foreach (var cls in new[] { "btn", "btn-primary", "caller" })
Assert.Contains(cls, cut.Find("button").ClassList);
}
}
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeHeadTests : TestContext public class ThemeHeadTests : BunitContext
{ {
[Fact] [Fact]
public void Emits_theme_and_layout_links_to_content_path() public void Emits_theme_and_layout_links_to_content_path()
{ {
var cut = RenderComponent<ThemeHead>(); var cut = Render<ThemeHead>();
var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList(); var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList();
Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs); Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs);
Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs); Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs);
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeScriptsTests : TestContext public class ThemeScriptsTests : BunitContext
{ {
[Fact] [Fact]
public void ThemeScripts_emits_nav_state_script_tag() public void ThemeScripts_emits_nav_state_script_tag()
{ {
var cut = RenderComponent<ThemeScripts>(); var cut = Render<ThemeScripts>();
var script = cut.Find("script"); var script = cut.Find("script");
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src")); Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
Assert.True(script.HasAttribute("defer")); Assert.True(script.HasAttribute("defer"));
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeShellTests : TestContext public class ThemeShellTests : BunitContext
{ {
[Fact] [Fact]
public void Renders_product_nav_and_body() public void Renders_product_nav_and_body()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>"))) .Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
.AddChildContent("<div class='pagebody'>BODY</div>")); .AddChildContent("<div class='pagebody'>BODY</div>"));
@@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void Accent_sets_css_variable_on_shell_root() public void Accent_sets_css_variable_on_shell_root()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "ScadaBridge") .Add(x => x.Product, "ScadaBridge")
.Add(x => x.Accent, "#2f855a")); .Add(x => x.Accent, "#2f855a"));
var shell = cut.Find(".app-shell"); var shell = cut.Find(".app-shell");
@@ -28,14 +28,14 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void No_accent_emits_no_style() public void No_accent_emits_no_style()
{ {
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway")); var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
Assert.False(cut.Find(".app-shell").HasAttribute("style")); Assert.False(cut.Find(".app-shell").HasAttribute("style"));
} }
[Fact] [Fact]
public void RailFooter_renders_when_supplied() public void RailFooter_renders_when_supplied()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>")))); .Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess")); Assert.NotNull(cut.Find(".rail-foot .sess"));
@@ -47,7 +47,7 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse() public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
{ {
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
// Root is a <details> whose direct <summary> is the hamburger toggle. // Root is a <details> whose direct <summary> is the hamburger toggle.
var shell = cut.Find("details.app-shell"); var shell = cut.Find("details.app-shell");
@@ -62,4 +62,18 @@ public class ThemeShellTests : TestContext
Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml); Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
Assert.DoesNotContain("data-bs-target", shell.OuterHtml); Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
} }
[Fact]
public void Closed_shell_keeps_page_body_separate_from_the_toggled_rail()
{
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OEE")
.Add(x => x.Nav, builder => builder.AddMarkupContent(0, "<a href='/oee'>OEE</a>"))
.AddChildContent("<h1>OEE report</h1><input aria-label='Filter' />"));
Assert.False(cut.Find("details.app-shell").HasAttribute("open"));
Assert.NotNull(cut.Find("details.app-shell > #theme-rail > nav"));
Assert.Equal("OEE report", cut.Find("details.app-shell > main.page > h1").TextContent);
Assert.NotNull(cut.Find("details.app-shell > main.page > input"));
Assert.Empty(cut.FindAll("#theme-rail main"));
}
} }
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TreeTableTests : BunitContext
{
private sealed record TestRow(string Key, int Depth, bool HasChildren, string Name) : ITreeRow;
private IRenderedComponent<TreeTable<TestRow>> RenderTreeTable(IReadOnlyList<TestRow> rows,
IReadOnlySet<string>? collapsed = null, Action<string>? toggle = null)
=> Render<TreeTable<TestRow>>(p => p
.Add(x => x.Rows, rows)
.Add(x => x.Collapsed, collapsed ?? new HashSet<string>())
.Add(x => x.Label, row => row.Name)
.Add(x => x.Head, builder => builder.AddMarkupContent(0, "<th>Scope</th><th>Value</th>"))
.Add(x => x.Row, row => builder =>
{
builder.OpenElement(0, "td");
builder.AddContent(1, "Value for " + row.Name);
builder.CloseElement();
})
.Add(x => x.OnToggle, key => toggle?.Invoke(key)));
[Fact]
public void Caller_fragments_and_arbitrary_depth_are_rendered_without_inline_stylesheets()
{
var cut = RenderTreeTable([new("root", 0, true, "Site"), new("deep", 7, false, "Machine <7>")]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Equal(2, cut.FindAll("tbody tr").Count);
Assert.Equal("Value for Machine <7>", cut.FindAll("tbody td")[3].TextContent);
Assert.Contains("Machine <7>", cut.Find(".tt-d7 .tt-name").TextContent);
Assert.Contains("--tt-depth: 7", cut.Find(".tt-d7 .tt-name").GetAttribute("style"));
Assert.Empty(cut.FindAll("style"));
}
[Fact]
public void Leaf_keeps_alignment_without_promising_a_subtree()
{
var cut = RenderTreeTable([new("leaf", 1, false, "Cell")]);
var twist = cut.Find("button");
Assert.Contains("tt-leaf", twist.ClassList);
Assert.True(twist.HasAttribute("disabled"));
Assert.False(twist.HasAttribute("aria-expanded"));
Assert.False(twist.HasAttribute("aria-label"));
Assert.Equal("true", cut.Find("i").GetAttribute("aria-hidden"));
}
[Fact]
public void Toggle_reports_stable_key_and_caller_controls_expansion()
{
string? toggled = null;
var cut = RenderTreeTable([new("site-key", 0, true, "Site")],
new HashSet<string> { "site-key" }, key => toggled = key);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Expand Site", cut.Find("button").GetAttribute("aria-label"));
cut.Find("button").Click();
Assert.Equal("site-key", toggled);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
cut.Render(p => p.Add(x => x.Collapsed, new HashSet<string>()));
Assert.Equal("true", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Collapse Site", cut.Find("button").GetAttribute("aria-label"));
Assert.Contains("bi-chevron-down", cut.Find("i").ClassList);
}
[Fact]
public void Empty_rows_keep_the_header_and_empty_body()
{
var cut = RenderTreeTable([]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Empty(cut.FindAll("tbody tr"));
}
}
@@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="bunit" /> <PackageReference Include="bunit" />
<PackageReference Include="AngleSharp" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
+8
View File
@@ -8,6 +8,11 @@ place to see all outstanding work.
> Generated by `regen-readme.py` from the per-library `findings.md` files. Do not > Generated by `regen-readme.py` from the per-library `findings.md` files. Do not
> edit by hand — edit the findings files and re-run the script. > edit by hand — edit the findings files and re-run the script.
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work (including new review findings) as an issue.
## How it works ## How it works
- Reviews are performed one library at a time against a fixed checklist. - Reviews are performed one library at a time against a fixed checklist.
@@ -37,6 +42,9 @@ code-reviews/
6 of 6 libraries reviewed. 0 pending findings across all libraries. 6 of 6 libraries reviewed. 0 pending findings across all libraries.
Coverage gap: Secrets, LocalDb, GalaxyRepository and Overview have never been reviewed here,
and Auth/Health/Theme have changed substantially since their 2026-06-01 review → #15.
| Severity | Open findings | | Severity | Open findings |
|----------|---------------| |----------|---------------|
| Critical | 0 | | Critical | 0 |
+8
View File
@@ -97,6 +97,11 @@ def build_readme(libraries, per_library):
add("> Generated by `regen-readme.py` from the per-library `findings.md` files. Do not") add("> Generated by `regen-readme.py` from the per-library `findings.md` files. Do not")
add("> edit by hand — edit the findings files and re-run the script.") add("> edit by hand — edit the findings files and re-run the script.")
add("") add("")
add("> **Status lives in Gitea.** Open items from this file are tracked as issues on")
add("> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)")
add("> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status")
add("> here; file new work (including new review findings) as an issue.")
add("")
add("## How it works") add("## How it works")
add("") add("")
add("- Reviews are performed one library at a time against a fixed checklist.") add("- Reviews are performed one library at a time against a fixed checklist.")
@@ -127,6 +132,9 @@ def build_readme(libraries, per_library):
add(f"{reviewed} of {len(libraries)} libraries reviewed. " add(f"{reviewed} of {len(libraries)} libraries reviewed. "
f"{len(pending)} pending finding{'s' if len(pending) != 1 else ''} across all libraries.") f"{len(pending)} pending finding{'s' if len(pending) != 1 else ''} across all libraries.")
add("") add("")
add("Coverage gap: Secrets, LocalDb, GalaxyRepository and Overview have never been reviewed here,")
add("and Auth/Health/Theme have changed substantially since their 2026-06-01 review → #15.")
add("")
add("| Severity | Open findings |") add("| Severity | Open findings |")
add("|----------|---------------|") add("|----------|---------------|")
for sev in ("Critical", "High", "Medium", "Low"): for sev in ("Critical", "High", "Medium", "Low"):
+8 -3
View File
@@ -51,8 +51,13 @@ Every normalized component is one subfolder of `components/` with this layout:
- **`current-state/<project>/`** is descriptive, not aspirational. It must match the project's - **`current-state/<project>/`** is descriptive, not aspirational. It must match the project's
real code, with `file:line` references. Each ends in an **Adoption plan**: what that project real code, with `file:line` references. Each ends in an **Adoption plan**: what that project
deletes/replaces to reach the spec, and what stays bespoke. deletes/replaces to reach the spec, and what stays bespoke.
- **`GAPS.md`** is the working backlog: it turns the delta between every `current-state` and the - **`GAPS.md`** turns the delta between every `current-state` and the `spec` into concrete,
`spec` into concrete, prioritized items. prioritized items — but **status lives in Gitea** (family standard, 2026-11-06). Open items are
tracked as issues on [`dohertj2/scadaproj`](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
(the 2026-11-06 sweep filed them with label `migrated`); each GAPS file carries a banner and
`→ #N` pointers next to migrated items, and is otherwise a historical/design record. File new gaps
as issues (family labels `type:*`, `priority:p0..p3`, `blocked`, `cross-repo`) and reference them
from GAPS/current-state docs as `#N` — do not track open/closed status in the markdown.
## Workflow to normalize a component ## Workflow to normalize a component
@@ -66,7 +71,7 @@ Every normalized component is one subfolder of `components/` with this layout:
concrete library API in `shared-contract/`. Keep the surface minimal — extract only what is concrete library API in `shared-contract/`. Keep the surface minimal — extract only what is
genuinely common; leave domain-specific logic in the projects. genuinely common; leave domain-specific logic in the projects.
4. **Log gaps + adoption.** Fill `GAPS.md` with per-project divergences and an adoption/extraction 4. **Log gaps + adoption.** Fill `GAPS.md` with per-project divergences and an adoption/extraction
backlog (priority / effort / risk). Add the per-project "what to delete/replace" to each backlog (priority / effort / risk), and file each open item as a Gitea issue (`→ #N` in the file). Add the per-project "what to delete/replace" to each
`current-state` doc. `current-state` doc.
5. **Register.** Add/maintain the component's row in the registry table above. 5. **Register.** Add/maintain the component's row in the registry table above.
+5
View File
@@ -1,5 +1,10 @@
# Audit — gaps & adoption backlog # Audit — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
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 the shared `ZB.MOM.WW.Audit` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches. reach the shared `ZB.MOM.WW.Audit` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
+7 -2
View File
@@ -1,5 +1,10 @@
# Auth — gaps & adoption backlog # Auth — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
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 the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches. reach the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -134,5 +139,5 @@ adoption is opt-in per project, one consumer version-bump at a time.
## Decisions still open ## Decisions still open
- Shared dev base DN value (A3). - Shared dev base DN value (A3).
- Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3). - Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3). → #20
- Shared JWT/refresh helper or not (#7). - Shared JWT/refresh helper or not (#7). → #20
+5
View File
@@ -1,5 +1,10 @@
# Configuration validation — gaps & adoption backlog # Configuration validation — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to adopt Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to adopt
the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (42 tests, re-run the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (42 tests, re-run
green 2026-07-18) at green 2026-07-18) at
+8 -3
View File
@@ -1,5 +1,10 @@
# Health — gaps & adoption backlog # Health — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
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 the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches. reach the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -13,7 +18,7 @@ reach the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 part
> **Caveat on the evidence:** this was verified by `PackageReference` + registration-call grep, > **Caveat on the evidence:** this was verified by `PackageReference` + registration-call grep,
> which proves the library is wired in — *not* that every per-app probe below is registered or > which proves the library is wired in — *not* that every per-app probe below is registered or
> that the endpoint tiers match the spec. The divergence tables further down were written > that the endpoint tiers match the spec. The divergence tables further down were written
> pre-adoption and have **not** been re-verified against current code; treat them as historical > pre-adoption and have **not** been re-verified against current code (→ #11); treat them as historical
> until someone re-walks them. They also predate HistorianGateway, which is absent from every > until someone re-walks them. They also predate HistorianGateway, which is absent from every
> table below. > table below.
@@ -181,12 +186,12 @@ Plan + design: [`../../docs/plans/2026-06-01-health-library-adoption.md`](../../
### Deferred (verified ill-fitting on adoption — re-scoped from the original backlog) ### Deferred (verified ill-fitting on adoption — re-scoped from the original backlog)
- **#4 downstream gRPC dependency probes — DROPPED for now.** Neither repo holds a host-level - **#4 downstream gRPC dependency probes — DROPPED for now.** (→ #11) Neither repo holds a host-level
`GrpcChannel` to probe: the MxGateway↔worker IPC is **named pipes** (not gRPC), and OtOpcUa's `GrpcChannel` to probe: the MxGateway↔worker IPC is **named pipes** (not gRPC), and OtOpcUa's
gateway channel is created **per-driver** from DB config (no DI-level channel). MxGateway readiness gateway channel is created **per-driver** from DB config (no DI-level channel). MxGateway readiness
instead probes the SQLite auth store (`AuthStoreHealthCheck`). A real worker/downstream probe needs instead probes the SQLite auth store (`AuthStoreHealthCheck`). A real worker/downstream probe needs
a custom non-gRPC check — future work. a custom non-gRPC check — future work.
- **#3 `IActiveNodeGate` seam unification (ScadaBridge) — DEFERRED.** ScadaBridge's `IActiveNodeGate` - **#3 `IActiveNodeGate` seam unification (ScadaBridge) — DEFERRED.** (→ #11) ScadaBridge's `IActiveNodeGate`
is `…ScadaBridge.InboundAPI.IActiveNodeGate`, wired into inbound-API endpoint gating — a different is `…ScadaBridge.InboundAPI.IActiveNodeGate`, wired into inbound-API endpoint gating — a different
interface from the shared `ZB.MOM.WW.Health.IActiveNodeGate`. Unification touches the InboundAPI interface from the shared `ZB.MOM.WW.Health.IActiveNodeGate`. Unification touches the InboundAPI
path; its existing `ActiveNodeGate` (logic identical to the shared `AkkaActiveNodeGate`) was kept. path; its existing `ActiveNodeGate` (logic identical to the shared `AkkaActiveNodeGate`) was kept.
+9 -4
View File
@@ -1,5 +1,10 @@
# Observability — gaps & adoption backlog # Observability — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
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 the shared `ZB.MOM.WW.Telemetry` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches. reach the shared `ZB.MOM.WW.Telemetry` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -17,7 +22,7 @@ reach the shared `ZB.MOM.WW.Telemetry` library. Status legend: ⛔ gap · 🟡 p
> listener, OTLP opt-in wiring) were **all subsequently executed the same day** — see the > listener, OTLP opt-in wiring) were **all subsequently executed the same day** — see the
> "Follow-ons — DONE 2026-06-01" table at the bottom of this file, which this banner previously > "Follow-ons — DONE 2026-06-01" table at the bottom of this file, which this banner previously
> contradicted (reconciled 2026-08-07). Only the operational items under "Still open" there > contradicted (reconciled 2026-08-07). Only the operational items under "Still open" there
> (deploying an OTel collector; further ScadaBridge instruments) remain. > (deploying an OTel collector → #13; further ScadaBridge instruments → ScadaBridge#81) remain.
## Divergence vs spec ## Divergence vs spec
@@ -225,7 +230,7 @@ sister apps in one pass, behaviour-preserving. Each adoption landed on a per-rep
precedence and silently drop the knob. So ScadaBridge keeps the factory and only **adds the shared precedence and silently drop the knob. So ScadaBridge keeps the factory and only **adds the shared
`TraceContextEnricher`** to it — gaining trace↔log correlation without regressing the contract. Full `TraceContextEnricher`** to it — gaining trace↔log correlation without regressing the contract. Full
`AddZbSerilog` adoption for ScadaBridge would first require teaching the shared bootstrap to accept a `AddZbSerilog` adoption for ScadaBridge would first require teaching the shared bootstrap to accept a
caller-supplied minimum-level governance hook. caller-supplied minimum-level governance hook (→ #12).
- **MxGateway keeps `GatewayLogScope` + request-logging middleware as-is.** The Serilog MEL provider - **MxGateway keeps `GatewayLogScope` + request-logging middleware as-is.** The Serilog MEL provider
captures MEL `BeginScope` dictionaries as structured properties, so the scope/correlation code keeps captures MEL `BeginScope` dictionaries as structured properties, so the scope/correlation code keeps
producing the same properties under Serilog. Only the provider swap + the `ILogRedactor` adapter were producing the same properties under Serilog. Only the provider swap + the `ILogRedactor` adapter were
@@ -248,7 +253,7 @@ behaviour-preserving except the intentional, no-consumer-yet metric-shape change
**Still open (not code — operational/future):** **Still open (not code — operational/future):**
- **OTLP is opt-in but unexercised** until an OTel collector endpoint is deployed and the - **OTLP is opt-in but unexercised** (→ #13) until an OTel collector endpoint is deployed and the
`<App>:Telemetry:Exporter=Otlp` + `:OtlpEndpoint` config is set. The wiring is in place; only a `<App>:Telemetry:Exporter=Otlp` + `:OtlpEndpoint` config is set. The wiring is in place; only a
collector is missing. collector is missing.
- **Further ScadaBridge instruments** beyond the first 4 are additive future work (not blocking). - **Further ScadaBridge instruments** beyond the first 4 are additive future work (not blocking). → ScadaBridge#81
+6 -1
View File
@@ -1,5 +1,10 @@
# Secrets — GAPS (adoption backlog) # Secrets — GAPS (adoption backlog)
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
The delta between each project's [`current-state`](current-state/) and the The delta between each project's [`current-state`](current-state/) and the
[`SPEC`](spec/SPEC.md), as prioritized work. The library itself is **built + published + [`SPEC`](spec/SPEC.md), as prioritized work. The library itself is **built + published +
reference-consumer-proven**; everything here is per-app adoption of an existing lib (not lib reference-consumer-proven**; everything here is per-app adoption of an existing lib (not lib
@@ -73,7 +78,7 @@ construction).
every central version a transitive ceiling, demanding `Google.Protobuf`, `Grpc.Net.Client`, every central version a transitive ceiling, demanding `Google.Protobuf`, `Grpc.Net.Client`,
`Microsoft.Data.SqlClient` and `Newtonsoft.Json` bumps. That is a gRPC/data-access change to a `Microsoft.Data.SqlClient` and `Newtonsoft.Json` bumps. That is a gRPC/data-access change to a
production SCADA platform and deserves its own reviewed commit. **Still open as a follow-up** — production SCADA platform and deserves its own reviewed commit. **Still open as a follow-up** —
it is the better long-term posture. it is the better long-term posture. → ScadaBridge#79
- ✅ `0.2.0` **PUBLISHED to the Gitea feed 2026-07-18** (all 5 packages; restore-verified from a - ✅ `0.2.0` **PUBLISHED to the Gitea feed 2026-07-18** (all 5 packages; restore-verified from a
scratch consumer — resolves `SQLitePCLRaw.lib.e_sqlite3 2.1.12`, `Microsoft.Data.SqlClient 6.0.2`, scratch consumer — resolves `SQLitePCLRaw.lib.e_sqlite3 2.1.12`, `Microsoft.Data.SqlClient 6.0.2`,
`Akka.Cluster.Tools 1.5.62`, and scans clean). Merged to `main` @ `15ef1f3`. `Akka.Cluster.Tools 1.5.62`, and scans clean). Merged to `main` @ `15ef1f3`.
+6 -1
View File
@@ -1,5 +1,10 @@
# UI Theme — gaps & adoption backlog # UI Theme — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
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.
@@ -35,7 +40,7 @@ reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap ·
> **MxGateway** shed the dead `.sidebar` block + orphaned `.dashboard-login`/`.login-card` (−106), keeping > **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 > `.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. > 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 > **Still deferred:** a kit-side `layout.css` `calc(100vh - 3.3rem)` review (→ #10); and ScadaBridge's `Host` consumes the
> kit only **transitively via `CentralUI`** (no direct `PackageReference`) — builds green, but an implicit dependency. > 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 > _Feed note: the same audit re-confirmed `ZB.MOM.WW.Theme 0.2.0` **is** genuinely on the Gitea feed (registration
+2 -2
View File
@@ -380,7 +380,7 @@ each was established:
| 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) | | 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) |
| 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) | | 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) |
| 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) | | 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) |
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same 10-instance registry) | | 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same registry — 12 instances since 2026-10-01, BTDB Manager added) |
| 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) | | 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) |
| 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) | | 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) |
| **5985** | ✅ | ✅ | live | **WinRM** (HTTP/NTLM; 5986 refused) — **the supported access path since 2026-09-10** via the `winrm` skill; see §2.1 | | **5985** | ✅ | ✅ | live | **WinRM** (HTTP/NTLM; 5986 refused) — **the supported access path since 2026-09-10** via the `winrm` skill; see §2.1 |
@@ -399,7 +399,7 @@ nodes, verified Healthy at setup):
| ScadaBridge `site` | site-a — **Active** | site-b — Standby | | ScadaBridge `site` | site-a — **Active** | site-b — Standby |
| MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) | | MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) |
| HistorianGateway | Up | Up (since 2026-08-14) | | HistorianGateway | Up | Up (since 2026-08-14) |
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 10-instance registry) | | Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 12-instance registry) |
**Deployed builds on QA (2026-09-10 round, all via the `winrm` skill, both nodes):** ScadaBridge **Deployed builds on QA (2026-09-10 round, all via the `winrm` skill, both nodes):** ScadaBridge
`2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window, `2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window,
@@ -147,7 +147,7 @@ Single page (`/`), `ThemeShell` chassis with its own product name/accent so it r
## 9. Acceptance (v1 definition of done) ## 9. Acceptance (v1 definition of done)
- The §4 ScadaBridge site-node health PR is merged and site rows render with full check detail + Active/Standby (no special-cased rows anywhere). - The §4 ScadaBridge site-node health PR is merged and site rows render with full check detail + Active/Standby (no special-cased rows anywhere).
- Registry pointed at the live docker-dev rigs (OtOpcUa 6-node + ScadaBridge local cluster) renders every instance correctly, including: each Active/Standby pair (central **and** site pairs) showing exactly one Active; **each cluster group showing its current leader, and the leader chip moving when the leader node is stopped**; killing a node flips its card to Unreachable within ~2 cadences and back on restart; a Degraded check (e.g. stop a dependency) shows Warn with the failing entry visible; management links open the right app UIs; the page is reachable with no login. - Registry pointed at the live docker-dev rigs (OtOpcUa 6-node + ScadaBridge local cluster) renders every instance correctly, including: each Active/Standby pair (central **and** site pairs) showing exactly one Active; **each cluster group showing its current leader, and the leader chip moving when the leader node is stopped**; killing a node flips its card to Unreachable within ~2 cadences and back on restart; a Degraded check (e.g. stop a dependency) shows Warn with the failing entry visible; management links open the right app UIs; the page is reachable with no login. (OtOpcUa one-Active + leader-chip checks still to re-run → #14)
- **Cache/staleness proven live:** first page load renders instantly from cache (no poll on request); with auto-refresh paused (or the poller stopped) past `StaleAfterSeconds`, every card flips to Stale, and resumes cleanly. - **Cache/staleness proven live:** first page load renders instantly from cache (no poll on request); with auto-refresh paused (or the poller stopped) past `StaleAfterSeconds`, every card flips to Stale, and resumes cleanly.
- Boot fails with a clear `ConfigPreflight` message on a malformed registry. - Boot fails with a clear `ConfigPreflight` message on a malformed registry.
- `dotnet test` green: options validator, status derivation (incl. flap damping, staleness marking, leader extraction + disagreement flag, JSON parsing golden tests against captured real payloads from all four apps — with and without `data`), snapshot store, and a bUnit render test of `InstanceCard`. - `dotnet test` green: options validator, status derivation (incl. flap damping, staleness marking, leader extraction + disagreement flag, JSON parsing golden tests against captured real payloads from all four apps — with and without `data`), snapshot store, and a bUnit render test of `InstanceCard`.
@@ -1,6 +1,11 @@
# VD03 NAM AD LDAP cutover — plan (NOT executed) # VD03 NAM AD LDAP cutover — plan (NOT executed)
**Written 2026-08-13. Status: PLAN ONLY.** Swap every auth-bearing service on > **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
**Written 2026-08-13. Status: PLAN ONLY.** Execution (VD03, then QA) → #6; consider the Auth `0.4.1` bump first → #5. Swap every auth-bearing service on
`wonder-app-vd03` from its current login posture (mostly login-disabled) to the real `wonder-app-vd03` from its current login posture (mostly login-disabled) to the real
Zimmer NAM Active Directory, with the service-account password held as a Zimmer NAM Active Directory, with the service-account password held as a
`${secret:}` reference in each app's encrypted Secrets store — never in config. `${secret:}` reference in each app's encrypted Secrets store — never in config.
@@ -21,7 +26,7 @@ and the wonder deploy records (memory: `wonder-upgrade-program`).
| Overview | ❌ exempt | — | No login **by design** (holds no credentials; references no Auth package) | | Overview | ❌ exempt | — | No login **by design** (holds no credentials; references no Auth package) |
Out of scope: windev's OtOpcUa/mxgw instances, the QA pair (VQ03/VQ04 — do it as a Out of scope: windev's OtOpcUa/mxgw instances, the QA pair (VQ03/VQ04 — do it as a
follow-on once VD03 has soaked; QA is where the 2-node/failover behaviour actually gets follow-on once VD03 has soaked, → #6; QA is where the 2-node/failover behaviour actually gets
exercised), and OtOpcUa's OPC UA data plane (API-key auth since Gitea #520). exercised), and OtOpcUa's OPC UA data plane (API-key auth since Gitea #520).
> ⚠️ **ignitionoee (router): LDAP setup ON HOLD** — explicit decision 2026-08-13. Its role > ⚠️ **ignitionoee (router): LDAP setup ON HOLD** — explicit decision 2026-08-13. Its role
+3 -1
View File
@@ -2,9 +2,11 @@
One [GLAuth](https://github.com/glauth/glauth) directory that **all three sister apps use for 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 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 shared Docker host **`10.100.0.35:3893`** (plaintext LDAP; LDAPS/StartTLS → #19). This is the app-neutral source of
truth; each app just points its `…Ldap:Server` at `10.100.0.35`. truth; each app just points its `…Ldap:Server` at `10.100.0.35`.
> Open work on this directory is tracked as Gitea issues on `dohertj2/scadaproj` (e.g. #19 LDAPS).
> Scope: **dev/test only**. Production uses real corporate AD. See > 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). > [`../../docs/plans/2026-06-04-shared-glauth-standardization-design.md`](../../docs/plans/2026-06-04-shared-glauth-standardization-design.md).