Compare commits

...

57 Commits

Author SHA1 Message Date
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
31 changed files with 1010 additions and 77 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;
every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via
`ExpectedLastUpdate` → `THROW 50014`.
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` +
`${secret:btdb/ldap/serviceaccountpassword}`, store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`,
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` (the LDAP
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.
- **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
@@ -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
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.
- **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.
+4 -4
View File
@@ -36,11 +36,11 @@ own `CLAUDE.md` for the full picture. See [Refreshing this index](#refreshing-th
| Project | Location | Stack | Repo | Summary |
|---|---|---|---|---|
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **two-subtree address space (v3.0, `master` since 2026-07-16)** — `https://zb.com/otopcua/raw` (device tree Folder→Driver→Device→TagGroup→Tag, NodeId `s=<RawPath>`) and `https://zb.com/otopcua/uns` (equipment tree Area→Line→Equipment→signal); every value has one source fanned to both NodeIds. **This is the wire contract ScadaBridge binds against** (ScadaBridge consumes v3 end-to-end; its #14 is closed). Drivers: Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient, `Sql` (SQL Server / PostgreSQL / Oracle, read-only poll), `Mqtt` (Plain + Sparkplug B, writable where a tag declares a destination), `MTConnect` (read-only), Calculation, and AVEVA Galaxy as a standard Equipment-kind driver via in-process `GalaxyDriver` → gRPC → **mxaccessgw**. Sole historian backend is **HistorianGateway**. Address-space availability guarantee: an unreadable deployment artifact is *no answer*, never an empty configuration — nodes hold last-known-good, and a driver-only node boots and serves from its LocalDb cache with central SQL down (ServiceLevel stays 240/250). Redundancy: one independent 2-node Akka mesh per application `Cluster`; Primary = oldest Up `driver` member (ServiceLevel 250) + one Secondary (240) **per `Cluster`**; cross-mesh commands ride gRPC with per-cluster PSK auth (Akka `ClusterClient` fully deleted 2026-08-06). Data plane is machine-to-machine: OPC UA UserName `apikey` + `otua_<keyId>_<secret>` minted on AdminUI `/api-keys`; `OpcUa:AllowAnonymous` defaults `true` (anonymous is a normal grantable principal); LDAP is AdminUI-login only. Node ACLs are enforced **only** behind `Security:Acl:Enabled` (default `false`); with it on, scoped `NodeAcl` grants decide **every** data-plane operation incl. browse/read/subscribe, fail closed, and reach open sessions. Every driver's authoring path now consults the driver's own runtime parse, so what authoring accepts is what the driver serves (tracker zeroed 2026-08-10, origin `d06e92c6`). **Full change history, live-gate records and the per-issue consumer-impact record for ScadaBridge: `.claude/skills/sister-project-history/otopcua.md` (skill `sister-project-history`).** |
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** |
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **two-subtree address space (v3.0, `master` since 2026-07-16)** — `https://zb.com/otopcua/raw` (device tree Folder→Driver→Device→TagGroup→Tag, NodeId `s=<RawPath>`) and `https://zb.com/otopcua/uns` (equipment tree Area→Line→Equipment→signal); every value has one source fanned to both NodeIds. **This is the wire contract ScadaBridge binds against** (ScadaBridge consumes v3 end-to-end; its #14 is closed). Drivers: Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient, `Sql` (SQL Server / PostgreSQL / Oracle, read-only poll), `Mqtt` (Plain + Sparkplug B, writable where a tag declares a destination), `MTConnect` (read-only), Calculation, and AVEVA Galaxy as a standard Equipment-kind driver via in-process `GalaxyDriver` → gRPC → **mxaccessgw**. Historian backend is pluggable (`ServerHistorian:Backend`): **HistorianGateway** (default), **SqlServer** or **QuestDb** — the latter two add typed values with real status and OPC UA HistoryUpdate (QuestDB: soft delete + TTL retention). Address-space availability guarantee: an unreadable deployment artifact is *no answer*, never an empty configuration — nodes hold last-known-good, and a driver-only node boots and serves from its LocalDb cache with central SQL down (ServiceLevel stays 240/250). Redundancy: one independent 2-node Akka mesh per application `Cluster`; Primary = oldest Up `driver` member (ServiceLevel 250) + one Secondary (240) **per `Cluster`**; cross-mesh commands ride gRPC with per-cluster PSK auth (Akka `ClusterClient` fully deleted 2026-08-06). Data plane is machine-to-machine: OPC UA UserName `apikey` + `otua_<keyId>_<secret>` minted on AdminUI `/api-keys`; `OpcUa:AllowAnonymous` defaults `true` (anonymous is a normal grantable principal); LDAP is AdminUI-login only. Node ACLs are enforced **only** behind `Security:Acl:Enabled` (default `false`); with it on, scoped `NodeAcl` grants decide **every** data-plane operation incl. browse/read/subscribe, fail closed, and reach open sessions. Every driver's authoring path now consults the driver's own runtime parse, so what authoring accepts is what the driver serves (tracker zeroed 2026-08-10, origin `d06e92c6`). **Full change history, live-gate records and the per-issue consumer-impact record for ScadaBridge: `.claude/skills/sister-project-history/otopcua.md` (skill `sister-project-history`).** |
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** Alarm ack (main `488649c`, 2026-10-20) authenticates as the operator and writes `<alarm>.AckMsg` so the Historian records the real operator; `AcknowledgeAlarmRequest` gained `operator_password`/`operator_domain` (proto change; OtOpcUa's `GatewayGalaxyAlarmAcknowledger` must send the password before it deploys against this). |
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | `gitea.dohertylan.com/dohertj2/ScadaBridge` | **Current `main`: `3a217b94` (2026-09-29: stale per-node secret-mirror status rows are now pruned; 2026-09-28: site-secrets follow-ups — a central secret-store outage fails only the sites that reference a secret, removal-only deploys are audited, each site node's mirror failures (standby included) ride the health report via a 12th replicated table `secret_mirror_status`, artifact-deploy CLI commands exit 1 on a failed site; rig gate 9/9). Previous tip `23a139a4` (2026-09-28: site secrets ride the artifact deploy and the gRPC secrets hub is retired — see below). Previous tip `5cc09b08` (2026-09-28: site scripts gain a read-only, allow-listed `Files` global — `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots`, off by default — used by the in-memory MES/Delmia/alarm-API test machines SBTEST01/02 (`docker/seed-mes-test-machine.sh`) to parse downloaded recipe files; DEV vd03 and QA vq03/vq04 on `b3021e7c` since 2026-09-28, migrations 57; register row 126 closed) — adds site artifact staleness (`Site.ArtifactRevisionHash` stamped on every successful artifact deploy, migration `AddSiteArtifactRevision`; Sites page *Artifacts current / out of date / never deployed* badge), a rail-footer **Pending updates** indicator over a process-wide `PendingUpdatesCache` (stale instances + stale-artifact sites, Deployer/Admin), and a scrollable rail nav; design `docs/plans/2026-09-14-pending-updates-design.md`. Previous tip `9d529b82` (2026-09-11) — adds the Gitea #36 reset-aware handshake (`WriteBatchAndWaitAsync` reset-echo overload), the computed-alarm `IsMesReportable` flag (template checkbox + nullable instance override, `Alarms.MesReportableAsync()`, migration `AddAlarmMesReportable`; native alarms stay on the 900–999 band) and a nav fix nesting Flow Debugger in the Deployment section. DEPLOYED: `b3021e7c` on DEV vd03 and the QA pair vq03/vq04 (2026-09-28, migrations 55→57; records `docs/deployment/deployment-records/2026-09-28-wonder-app-{vd03-fe89363b,vq03-vq04-b3021e7c}.md`).** **Merged `23a139a4` (2026-09-28): site secrets now ride the artifact deploy and the pull-only gRPC secrets hub is retired in ScadaBridge** (design `docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md`; central shares one secret store via `Secrets:Store:Provider=SqlServer`; rig live gate PASS 8/8, vd03/QA NOT deployed — rollout tracked as ScadaBridge register row 127 — see `ZB.MOM.WW.Secrets/CLAUDE.md` for the unpublished `0.6.3` hub-logging work this superseded for ScadaBridge). Management/API/CLI parity complete (`docs/plans/2026-09-10-management-api-cli-parity-review.md`): all 149 eligible contracts exposed in 20 CLI groups, 19 missing operations added, deadline/registry/outcome gaps fixed; CLI documentation checked against all 159 leaf commands; 9554 tests verified and primary 8-node rig rebuilt with read-only smoke checks. Previously completed 2026-09-09 whole-system arch review remediation (`docs/plans/2026-09-09-arch-review.md`: 92 findings; all 13 High + 33 Medium fixed; all 46 Low findings also committed and pushed (§11); 9478 solution tests and deployed browser smoke pass, primary 8-node rig rebuilt). Adds the `ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker` child-process project and a coordinated central-pair migration that encrypts DataConnection protocol config at rest (old binaries cannot read protected rows). Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. **Inbound API keys live on the shared SQL Server store on a central pair** (design 2026-09-02, ScadaBridge register row 108): ScadaBridge is Auth's first consumer of the new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package at `0.3.0`, pointing both central nodes at one `zbauth` schema in the configuration database instead of each node's own per-node SQLite file. **Flow Debugger (including the instance picker and v1.4 step logs, 2026-09-09):** Central UI + CLI trace of one inbound-API method or value-change flow across Central / Site / Tags / External swimlanes, Live or Replay, built on `ScriptTrace` audit rows and the outbound-call audit rows; arch-reviewed and remediated the same day (F1–F13 — redacted + byte-capped inline trace copy, route-time site scope on Live runs, Replay open to `OperationalAudit` roles under `RequireFlowDebugger`; `docs/plans/2026-09-06-flow-debugger-arch-review.md` + `-remediation.md`); script entry (site → instance → script, 2026-09-08). |
| **HistorianGateway** | `~/Desktop/HistorianGateway` | .NET 10 x64, gRPC, Blazor | `gitea.dohertylan.com/dohertj2/historiangw` | Single-process gRPC sidecar exposing (1) a full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared `ZB.MOM.WW.GalaxyRepository` lib. No COM, no x86 worker. **Owns `AVEVA.Historian.Client`** at `histsdk/` (ordinary owned source, edited in place — the re-vendor model is retired). Dev: dashboard `:5220` (HTTP/1.1) + gRPC h2c `:5221`; production: one `Kestrel:Endpoints:Https` with `Protocols: Http1AndHttp2` (ALPN). Default-on leased session pools amortize the historian handshake (v6 pool + a separate v8 event-session pool). Client lib `ZB.MOM.WW.HistorianGateway.Client` + Contracts are on the Gitea feed at `0.3.0`; **OtOpcUa is the first consumer** (its sole historian backend since PR #423); ScadaBridge has no coupling. Known 2023 R2 server-side gates: native event reads return 0 rows (C2, won't-fix — SQL-path `ReadEvents` via `Runtime.dbo.Events` is the shipped workaround), `Int1`/`UInt1` analog-tag creation is degenerate (OtOpcUa maps Boolean → `Int2`), string historical writes non-convergent (follow-ups #18–#25). Only remaining archreview item: the secure-by-default TLS flip, deferred to 1.0.0. A Java client, `com.zb.mom.ww.historiangw:zb-mom-ww-historiangw-client:0.1.0` (Gitea Maven feed, `clients/java/`), is consumed by the ignitionoee repo's `modules/historian` Ignition module. **Full change history (ArchReview cycles 1–2, pending.md items, PR list): `.claude/skills/sister-project-history/historiangateway.md`.** |
| **BTDB** (Batch Tracker) | `~/Desktop/btdb` — local `master` @ `695192d` (2026-09-11), **~198 commits ahead of `origin/master` (`d147c10`, 2023) and unpushed**; no branch in flight (`feat/transfer-machine-batch` and `fix/transfer-lot-walk` — DB-6 reopened: forward loop guard, whole-lot chain walks incl. merges — both merged 2026-09-11; `feat/manager-transfers` — the Manager edits, shows and audits transfers — merged `695192d` the same day). **Claude artifacts are gitignored + untracked there** (`CLAUDE.md`, `.claude/`, `docs/plans/`, `docs/reviews/`, `*.tasks.json`) — local only, never to the corporate remote. | SQL Server SSDT project + SQL CLR (net472; **Windows-only build**, the docker rig rebuilds it cross-platform from the same sources); **BTDB Manager = Blazor Web App net10.0** on the family libs (Auth 0.3.0, Theme 0.4.1, Health 0.3.0, Telemetry + Configuration 0.1.0, Secrets 0.6.2), Dapper + embedded queries, self-contained win-x64 NSSM service `ZB.MOM.WW.BTDB.Manager` on `:9877` (→ `:9876` after cut-over) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03:3000` (not on `gitea.dohertylan.com`) | The `BT` SQL Server database that is the plant's **batch / cycle / work-order system of record for data collection**. Galaxy machine objects write through `StartMachineBatch` / `StartMachineCycle` / `End*` / `AddMachineBatchWorkOrder` / `AddOtherData*` / `AddMachineBatchTransfer` / **`TransferMachineBatch`** (new 2026-09-11: end source if open + start destination by `Code` + copy not-moved-out work orders + link, one owned transaction) (own-or-join transaction template, `THROW 50000+`, all `GETUTCDATE()`); reports pull per-batch time-series **out of the AVEVA Historian through CLR sprocs** (`OPENQUERY(INSQL, …)` + `Runtime.dbo.*`), so **BT must be co-tenant with the Historian's `Runtime` DB** (`wonder-sql-*`). MESAPI and **ScadaBridge's inbound API** resolve `SAPID → Machine.Code`; no checked-in ScadaBridge script calls a BT sproc. **State 2026-09-11 (all merged on local `master`):** the Blazor conversion phases 1–3 (legacy net6.0 MVC app, Telerik reports and the mis-named `install.bat` deleted), the 2026-09-04 full review remediation (12 work packages, 60 findings fixed, 0 open; static invariant tests over the SQL + CLR sources, opt-in rig integration tests behind `BTDB_TEST_CONNECTION`, Gitea Actions gate `./build.sh`), and the DARS sprint-1 schema (`MachineBatchTransfer`, `ContainerID`/`MoveOutTimestamp` on `MachineBatchWO`, nestable `MachineGroup.ParentMachineGroupID`, Gen1 reactor load script) — the old `DARS/WW_ARCH/BatchTracker_DB/` fork and the `feat/manager-blazor` worktree are superseded. **Phase-4 cut-over on the DEV box** (`docs/operations/cut-over.md`) is the open step and is **blocked by the 2026-09-11 remote-access hold**. Repo orientation lives in btdb's own (gitignored) `CLAUDE.md`. **Full schema/contract notes, family edges and the gap analysis: `.claude/skills/sister-project-history/btdb.md`.** **Local Docker rig:** `btdb/docker/` — runtime-only image on `:9877` joined to `scadabridge-net` + the redundant glauth pair, registered in Overview as **BTDB Manager**; `docker/deploy-db.sh` publishes `BT` (dacpac + CLR via two side projects under `docker/db/`, plus a `Runtime` stand-in) into `scadabridge-mssql`; `smoke-queries.sh`, `validate-config-import.sh`, `db/validate-clr.sql` and `Database/Scripts/Validation/*.sql` are the rig gates. |
| **BTDB** (Batch Tracker) | `~/Desktop/btdb` — local `master` @ `a81a480` (2026-10-01), **~211 commits ahead of `origin/master` (`d147c10`, 2023) and unpushed**; no branch in flight (`feat/transfer-machine-batch` and `fix/transfer-lot-walk` — DB-6 reopened: forward loop guard, whole-lot chain walks incl. merges — both merged 2026-09-11; `feat/manager-transfers` — the Manager edits, shows and audits transfers — merged `695192d` the same day). **Claude artifacts are gitignored + untracked there** (`CLAUDE.md`, `.claude/`, `docs/plans/`, `docs/reviews/`, `*.tasks.json`) — local only, never to the corporate remote. | SQL Server SSDT project + SQL CLR (net472; **Windows-only build**, the docker rig rebuilds it cross-platform from the same sources); **BTDB Manager = Blazor Web App net10.0** on the family libs (Auth 0.4.1 — **sign-in by user bind, `BindMode=User`, no LDAP service account**, Theme 0.4.1, Health 0.3.0, Telemetry + Configuration 0.1.0, Secrets 0.6.2), Dapper + embedded queries, self-contained win-x64 NSSM service `ZB.MOM.WW.BTDB.Manager` on `:9877` (→ `:9876` after cut-over) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03:3000` (not on `gitea.dohertylan.com`) | The `BT` SQL Server database that is the plant's **batch / cycle / work-order system of record for data collection**. Galaxy machine objects write through `StartMachineBatch` / `StartMachineCycle` / `End*` / `AddMachineBatchWorkOrder` / `AddOtherData*` / `AddMachineBatchTransfer` / **`TransferMachineBatch`** (new 2026-09-11: end source if open + start destination by `Code` + copy not-moved-out work orders + link, one owned transaction) (own-or-join transaction template, `THROW 50000+`, all `GETUTCDATE()`); reports pull per-batch time-series **out of the AVEVA Historian through CLR sprocs** (`OPENQUERY(INSQL, …)` + `Runtime.dbo.*`), so **BT must be co-tenant with the Historian's `Runtime` DB** (`wonder-sql-*`). MESAPI and **ScadaBridge's inbound API** resolve `SAPID → Machine.Code`; no checked-in ScadaBridge script calls a BT sproc. **State 2026-09-11 (all merged on local `master`):** the Blazor conversion phases 1–3 (legacy net6.0 MVC app, Telerik reports and the mis-named `install.bat` deleted), the 2026-09-04 full review remediation (12 work packages, 60 findings fixed, 0 open; static invariant tests over the SQL + CLR sources, opt-in rig integration tests behind `BTDB_TEST_CONNECTION`, Gitea Actions gate `./build.sh`), and the DARS sprint-1 schema (`MachineBatchTransfer`, `ContainerID`/`MoveOutTimestamp` on `MachineBatchWO`, nestable `MachineGroup.ParentMachineGroupID`, Gen1 reactor load script) — the old `DARS/WW_ARCH/BatchTracker_DB/` fork and the `feat/manager-blazor` worktree are superseded. **Phase-4 cut-over on the DEV box** (`docs/operations/cut-over.md`) is the open step and is **blocked by the 2026-09-11 remote-access hold**. Repo orientation lives in btdb's own (gitignored) `CLAUDE.md`. **Full schema/contract notes, family edges and the gap analysis: `.claude/skills/sister-project-history/btdb.md`.** **Local Docker rig:** `btdb/docker/` — runtime-only image on `:9877` joined to `scadabridge-net` + the redundant glauth pair, registered in Overview as **BTDB Manager**; `docker/deploy-db.sh` publishes `BT` (dacpac + CLR via two side projects under `docker/db/`, plus a `Runtime` stand-in) into `scadabridge-mssql`; `smoke-queries.sh`, `validate-config-import.sh`, `db/validate-clr.sql` and `Database/Scripts/Validation/*.sql` are the rig gates. |
## Cross-project relationships
@@ -159,7 +159,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Adopted. Feed carries **`0.4.0`** (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. **0.4.1** fixes the review findings: `Username` is canonicalised from the entry's `UserNameAttribute`, empty passwords are refused in both bind modes (a `ServiceAccount`-mode behaviour change), and the docs prefer the down-level `EXAMPLE\{0}` template over UPN. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| Auth (login / identity / authz) | Adopted. Feed carries **`0.4.1`** (2026-10-01; 0.4.0 the same day) (2026-10-01; opt-in user bind) — the four apps still pin `0.3.0` (released 2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed — and all four are DEPLOYED at those tips on DEV vd03 and QA vq03/vq04 since 2026-09-10: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). **0.4.0 (2026-10-01) — opt-in LDAP user bind:** adds `LdapOptions.BindMode` (`ServiceAccount` default | `User`), `UserBindTemplate` and `AcceptDomainQualifiedNames`; in `User` mode `LdapAuthService` binds as the signing-in user and reads their own groups on the same connection, so no service account is needed. Bind failures map to `BadCredentials` (bad password, unusable name, or bind result 49) or `GroupLookupFailed` (zero/multiple entries or no groups after a good bind), other bind/connect/search errors fail over to the next endpoint, and `ServiceAccount` stays the default so no existing consumer needs to change. **0.4.1** fixes the review findings: `Username` is canonicalised from the entry's `UserNameAttribute`, empty passwords are refused in both bind modes (a `ServiceAccount`-mode behaviour change), and the docs prefer the down-level `EXAMPLE\{0}` template over UPN. First consumer: BTDB Manager; ScadaBridge/HistorianGateway/OtOpcUa/MxAccessGateway stay on `0.3.0` until they move to AD. `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); Every deployed wonder box (DEV vd03 + QA vq03/vq04) runs its repo tip as of 2026-09-10, so the deployed Theme is 0.4.1 everywhere. **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
+9
View File
@@ -54,3 +54,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:
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
}
]
},
{
"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
}
]
}
]
}
+32
View File
@@ -2,6 +2,38 @@
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/`.
## 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. 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)
> 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>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.4.1</Version>
<Version>0.5.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
+3 -1
View File
@@ -3,7 +3,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<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="xunit" Version="2.9.3" />
<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. |
| `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. |
| `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. |
| `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 |
|---|---|
| `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
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
# from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
dotnet test # 38 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
dotnet test # 60 tests verified for 0.5.0
./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
@* 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> }
@ChildContent
</button>
@@ -12,6 +12,12 @@
/// </summary>
[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>
[Parameter] public string Type { get; set; } = "button";
@@ -30,6 +36,14 @@
/// </summary>
[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
{
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>
<IsPackable>true</IsPackable>
<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>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
@@ -1,5 +1,5 @@
/* 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 hard-codes --bs-btn-font-size: 1rem, which is oversized against
@@ -45,6 +45,12 @@
--bs-btn-padding-y: .25rem;
--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 ─────────────────────────────────────────── */
/* The shell is a native <details> disclosure so the narrow-viewport hamburger
@@ -59,6 +65,21 @@
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 {
flex: 1;
min-width: 0;
@@ -72,9 +93,8 @@
}
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing
extra is needed there. On lg+ we force the rail visible (see media query)
and keep the toggle hidden via Bootstrap's d-lg-none. */
/* The native <details> owns its open state; the mobile rule explicitly hides
only #theme-rail when closed. Bootstrap's d-lg-none hides the toggle on lg+. */
/* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail {
@@ -92,20 +112,6 @@
and force it shown regardless of the <details> open state (the hamburger
toggle is hidden at this width). */
@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 {
display: block;
position: sticky;
@@ -116,10 +122,16 @@
}
}
/* When the side rail is collapsed under <lg viewports (the <details> is closed)
the native disclosure hides #theme-rail; when open, restore full width on
mobile so the rail spans the viewport. */
/* The disclosure toggles navigation while the page remains usable underneath. */
@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 {
width: 100%;
min-width: 100%;
@@ -323,3 +335,79 @@
.login-body { padding: 1.4rem 1.1rem 1.25rem; }
.login-error { margin-bottom: 0.85rem; }
.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
(.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; } }
.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
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class BrandBarTests : TestContext
public class BrandBarTests : BunitContext
{
[Fact]
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.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo
}
@@ -13,7 +13,7 @@ public class BrandBarTests : TestContext
[Fact]
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.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
Assert.NotNull(cut.Find(".brand .logo"));
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests;
public class CommonControlsTests : TestContext
public class CommonControlsTests : BunitContext
{
[Theory]
[InlineData(ButtonVariant.Primary, "btn-primary")]
@@ -9,7 +9,7 @@ public class CommonControlsTests : TestContext
[InlineData(ButtonVariant.Ghost, "btn-link")]
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");
Assert.Contains("btn", btn.ClassList);
Assert.Contains(cls, btn.ClassList);
@@ -18,7 +18,7 @@ public class CommonControlsTests : TestContext
[Fact]
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)
.AddUnmatched("id", "save")
.AddChildContent("Save"));
@@ -30,14 +30,14 @@ public class CommonControlsTests : TestContext
[Fact]
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"));
}
[Fact]
public void TechCard_renders_title_and_body()
{
var cut = RenderComponent<TechCard>(p => p
var cut = Render<TechCard>(p => p
.Add(x => x.Title, "Drivers")
.AddChildContent("<div class='b'>x</div>"));
Assert.Contains("Drivers", cut.Find(".panel-head").TextContent);
@@ -47,7 +47,7 @@ public class CommonControlsTests : TestContext
[Fact]
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.Hint, "required")
.Add(x => x.Error, "missing")
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class LoginCardTests : TestContext
public class LoginCardTests : BunitContext
{
[Fact]
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.Action, "/auth/login"));
var form = cut.Find("form");
@@ -19,7 +19,7 @@ public class LoginCardTests : TestContext
[Fact]
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.ReturnUrl, "/clusters"));
var hidden = cut.Find("input[name=returnUrl]");
@@ -29,7 +29,7 @@ public class LoginCardTests : TestContext
[Fact]
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.Error, "Bad credentials"));
Assert.Contains("Bad credentials", cut.Find(".notice").TextContent);
@@ -38,7 +38,7 @@ public class LoginCardTests : TestContext
[Fact]
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]"));
}
@@ -47,7 +47,7 @@ public class LoginCardTests : TestContext
[Fact]
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");
Assert.Equal("OtOpcUa", product.TextContent);
Assert.Contains("sign in", cut.Find(".login-title").TextContent);
@@ -57,7 +57,7 @@ public class LoginCardTests : TestContext
[Fact]
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.Heading, "Welcome back"));
var title = cut.Find(".login-title");
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class NavRailTests : TestContext
public class NavRailTests : BunitContext
{
[Fact]
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.Text, "Clusters"));
var a = cut.Find("a.rail-link");
@@ -17,7 +17,7 @@ public class NavRailTests : TestContext
[Fact]
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.Text, "Clusters")
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
@@ -29,7 +29,7 @@ public class NavRailTests : TestContext
[Fact]
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.Text, "Clusters"));
Assert.Empty(cut.FindAll(".rail-ico"));
@@ -38,7 +38,7 @@ public class NavRailTests : TestContext
[Fact]
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")
.AddChildContent("<a class='rail-link'>X</a>"));
var details = cut.Find("details.rail-section");
@@ -52,7 +52,7 @@ public class NavRailTests : TestContext
[Fact]
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")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -61,7 +61,7 @@ public class NavRailTests : TestContext
[Fact]
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)
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
@@ -71,7 +71,7 @@ public class NavRailTests : TestContext
[Fact]
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)
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -80,7 +80,7 @@ public class NavRailTests : TestContext
[Fact]
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")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -89,7 +89,7 @@ public class NavRailTests : TestContext
[Fact]
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")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -98,7 +98,7 @@ public class NavRailTests : TestContext
[Fact]
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, " ")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -107,7 +107,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_slug_preserves_unicode_letters()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Café")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -39,6 +39,36 @@ public class StaticAssetsTests
public void NavStateScript_ships() =>
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
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
[Fact]
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests;
public class StatusPillTests : TestContext
public class StatusPillTests : BunitContext
{
[Theory]
[InlineData(StatusState.Ok, "chip-ok")]
@@ -10,7 +10,7 @@ public class StatusPillTests : TestContext
[InlineData(StatusState.Info, "chip-info")]
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)
.AddChildContent("Connected"));
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;
public class ThemeHeadTests : TestContext
public class ThemeHeadTests : BunitContext
{
[Fact]
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();
Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs);
Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs);
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeScriptsTests : TestContext
public class ThemeScriptsTests : BunitContext
{
[Fact]
public void ThemeScripts_emits_nav_state_script_tag()
{
var cut = RenderComponent<ThemeScripts>();
var cut = Render<ThemeScripts>();
var script = cut.Find("script");
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
Assert.True(script.HasAttribute("defer"));
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeShellTests : TestContext
public class ThemeShellTests : BunitContext
{
[Fact]
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.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
.AddChildContent("<div class='pagebody'>BODY</div>"));
@@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext
[Fact]
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.Accent, "#2f855a"));
var shell = cut.Find(".app-shell");
@@ -28,14 +28,14 @@ public class ThemeShellTests : TestContext
[Fact]
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"));
}
[Fact]
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.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess"));
@@ -47,7 +47,7 @@ public class ThemeShellTests : TestContext
[Fact]
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.
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-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>
<ItemGroup>
<PackageReference Include="bunit" />
<PackageReference Include="AngleSharp" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
+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) |
| 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) |
| 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) |
| 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 |
@@ -399,7 +399,7 @@ nodes, verified Healthy at setup):
| ScadaBridge `site` | site-a — **Active** | site-b — Standby |
| MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) |
| 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
`2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window,