From a87bf8a4599a5a3a14827708472740b44b374bf5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 01:57:18 -0400 Subject: [PATCH] =?UTF-8?q?docs(m7):=20implementation=20plan=20+=20task=20?= =?UTF-8?q?graph=20=E2=80=94=2022=20tasks=20across=20waves=20A-E=20(T13-T1?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-18-m7-opcua-mxgateway-ux.md | 546 ++++++++++++++++++ ...-06-18-m7-opcua-mxgateway-ux.md.tasks.json | 38 ++ 2 files changed, 584 insertions(+) create mode 100644 docs/plans/2026-06-18-m7-opcua-mxgateway-ux.md create mode 100644 docs/plans/2026-06-18-m7-opcua-mxgateway-ux.md.tasks.json diff --git a/docs/plans/2026-06-18-m7-opcua-mxgateway-ux.md b/docs/plans/2026-06-18-m7-opcua-mxgateway-ux.md new file mode 100644 index 00000000..8d3503c2 --- /dev/null +++ b/docs/plans/2026-06-18-m7-opcua-mxgateway-ux.md @@ -0,0 +1,546 @@ +# M7 — OPC UA / MxGateway UX Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Ship all five M7 features (T13–T17): operator Alarm Summary page, MxGateway two-person secured writes, OPC UA `BrowseNext` paging + bounded recursive search, browse type-info surfacing + attribute-override CSV import, and a Verify-endpoint button with site-local cert trust. + +**Architecture:** Features layer on infrastructure already in `main`. New cross-cluster verbs ride the existing `CommunicationService → SiteEnvelope → ClusterClient Ask → SiteCommunicationActor → DeploymentManagerActor → DataConnectionManagerActor → DataConnectionActor → adapter` path (the same path `BrowseNodeCommand` uses today). T14 adds a central `PendingSecuredWrite` table + ManagementActor handlers + an approve→site write relay (reusing the existing `WriteTagRequest`). T13 fans out the existing per-instance debug snapshot. All decisions are pinned in `docs/plans/2026-06-18-m7-opcua-mxgateway-ux-design.md` (D1–D7). + +**Tech Stack:** C#/.NET 10, Akka.NET (cluster, ClusterClient), EF Core 10 (central MS SQL), Blazor Server (Bootstrap, no third-party component libs), OPC Foundation SDK, xUnit + bUnit + NSubstitute + Playwright. `TreatWarningsAsErrors=true` everywhere; central package management (`Directory.Packages.props`). + +**Execution conventions (per CLAUDE.md + standing constraints):** +- Implementers do **NOT** create worktrees — this session already runs in `.claude/worktrees/m7-opcua-mxgateway-ux` (branch `worktree-m7-opcua-mxgateway-ux`, off `origin/main` 241a792). +- Commit **pathspec form**: `git commit -m "" -- ` (the `-m` BEFORE the `--`). Never `git add -A`/`-a`. Retry on `index.lock`. +- Keep ≤2–3 concurrent committers per wave; after each wave verify every commit is on HEAD (`git merge-base --is-ancestor HEAD`). +- **Targeted** builds/tests per task (the task's project + filtered tests). Full-solution build + `bash docker/deploy.sh` + Playwright only in the final integration task (M7-E1). +- The `Files:` block on each task is the authoritative edit list. If an implementer needs a file not listed, that's a plan defect — surface it. + +**Reference (verbatim signatures gathered during planning):** see the design doc; key anchors — +`BrowseNode`/`BrowseChildrenResult`/`IBrowsableDataConnection` (`Commons/Interfaces/Protocol/IBrowsableDataConnection.cs:9-51`), `BrowseCommands.cs` (`Commons/Messages/Management/BrowseCommands.cs:1-39`), `RealOpcUaClient.BrowseChildrenAsync` (`DataConnectionLayer/Adapters/RealOpcUaClient.cs:705-765`), `RealOpcUaClient.ConnectAsync`/`RealOpcUaClientFactory` (`:62-156`/`:779-816`), `StubOpcUaClient` (`DataConnectionLayer/Adapters/IOpcUaClient.cs:173-248`, browse throws at `:237-239`), `DataConnectionActor.HandleBrowse` (`:1148-1202`, dispatch `:332-354`), `DataConnectionManagerActor` route-by-name (`:174-190`), `CommunicationService.BrowseNodeAsync`/`SiteEnvelope` (`Communication/CommunicationService.cs:364-372`/`:678`), `BrowseService` (`CentralUI/Services/BrowseService.cs:20-88`), `NodeBrowserDialog.razor` (`CentralUI/Components/Dialogs/`), `OpcUaEndpointConfig` (`Commons/Types/DataConnections/OpcUaEndpointConfig.cs:6-91`), `OpcUaGlobalOptions` (`DataConnectionLayer/OpcUaGlobalOptions.cs:9-19`), `OpcUaEndpointEditor.razor`/`DataConnectionForm.razor` (`CentralUI/Components/Forms/` + `.../Pages/Design/`), `Roles.cs` (`Security/Roles.cs:35-45`), `AuthorizationPolicies.cs` (`Security/AuthorizationPolicies.cs:105-149`), `ManagementActor` dispatch + `GetRequiredRole` (`ManagementService/ManagementActor.cs:93-221,367-368,701-740,845-861`), `SiteCall` entity/config/repo/migration pattern (`Commons/Entities/Audit/SiteCall.cs`, `ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs`, `ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs`, `ScadaBridgeDbContext.cs:132,159`, `ServiceCollectionExtensions.cs:56`, latest migration `20260617234323_AddKpiSampleTable.cs` + `ScadaBridgeDbContextModelSnapshot.cs`), `AuditKind`/`AuditChannel`/`IAuditLogRepository.InsertIfNotExistsAsync` (`Commons/Types/Enums/AuditKind.cs`, `.../AuditChannel.cs`, `Commons/Interfaces/Repositories/IAuditLogRepository.cs:24-35`), MxGateway write (`MxGatewayDataConnection.cs:214-225`, `IMxGatewayClient.cs:17-64`, `WriteTagRequest`/`WriteTagResponse` handled at `DataConnectionActor.cs:332-337`), parked-relay precedent (`RemoteQueryCommands.cs:5-6`, `ManagementActor.cs:845-861`), `DebugViewSnapshot` (`Commons/Messages/DebugView/DebugViewSnapshot.cs:31-39`), `AlarmStateChanged`/`AlarmConditionState`/`AlarmKind` (`Commons/Messages/Streaming/AlarmStateChanged.cs:6-88` + `Commons/Types/...`), debug snapshot Ask (`DebugSnapshotCommand` is Deployer-gated in `GetRequiredRole`; `ITemplateEngineRepository.GetInstancesBySiteIdAsync` `:217`), `NavMenu.razor:86-98`, `Health.razor` poll pattern (`CentralUI/Components/Pages/Monitoring/Health.razor`), DebugView alarm badge markup (`DebugView.razor:202-238,580-611`), `InstanceAttributeOverride`/`AttributeValueCodec`/`SetInstanceOverridesCommand` handler (`Commons/Entities/Instances/InstanceAttributeOverride.cs`, `Commons/Types/AttributeValueCodec.cs:15-107`, `ManagementActor.cs:701-740`), CLI `set-overrides` (`CLI/Commands/InstanceCommands.cs:275-297`), `InputFile` precedent (`CentralUI/Components/Pages/Design/TransportImport.razor.cs`), test harnesses (bUnit `tests/...CentralUI.Tests/Auth/SessionExpiryComponentTests.cs`, Playwright `tests/...CentralUI.PlaywrightTests/NavigationTests.cs` + `PlaywrightFixture.cs`, MSSQL `SkippableFact` `tests/...ConfigurationDatabase.Tests/Migrations/...`). + +> **No CSV library** is referenced in `Directory.Packages.props` — T16 uses a small hand-written quote-aware parser in Commons (pure + unit-tested). Do not add CsvHelper. + +--- + +## Waves & dependency overview + +- **Wave A (foundations, parallel-safe):** A1 (AlarmStateBadges) → A2 (Alarm Summary page); A3 (roles) ∥ A1. +- **Wave B (OPC UA / DCL — serialized on shared DCL files):** B1 → B2 → {B3, B4} → B5 → B6; B7 → {B8, B9} → B10. B-stream is largely sequential because `RealOpcUaClient.cs` / `IBrowsableDataConnection.cs` / `DataConnectionActor.cs` / `BrowseService.cs` recur. +- **Wave C (T14b secured writes):** C1 (entity) ∥ Wave B; C2 (handlers, needs C1 + A3) → C3 (relay) → C4 (audit); C5 (UI, needs C2/C3 + A3). +- **Wave D (T16 CSV):** D1 (parser) ∥ anything; D2 (UI, needs D1) ; D3 (CLI, needs D1). +- **Integration:** E1 (needs everything). + +Disjoint streams that may run concurrently: A-stream, B-stream, C1+C-stream, D-stream. Keep ≤2–3 implementers committing at once. + +--- + +## Wave A — Foundations + +### Task A1: Extract `AlarmStateBadges` shared component (T13 prep) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** A3, B1, C1, D1 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmStateBadges.razor` +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor` (replace the inline alarm-badge markup `:202-238` with ``; move helpers `GetAlarmStateBadge`/`GetAlarmLevelBadge`/`GetKindBadge`/`FormatKind`/`FormatLevel` `:580-611` into the component) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs` + +**Step 1 — Write failing bUnit test.** Render `AlarmStateBadges` with an `AlarmStateChanged` that is `State=Active, Kind=NativeOpcUa, Condition{Active:true,Acknowledged:false,Shelve:Unshelved,Suppressed:false,Severity:700}, Level=High`. Assert the rendered markup contains an "Active" badge (`bg-danger`), a kind badge ("OpcUa"/`bg-info`), an "Unacked" badge, "sev 700", and a "High" level badge. Mirror the harness in `SessionExpiryComponentTests.cs` (`BunitContext`, `Render(...)`). + +**Step 2 — Run, expect FAIL** (component doesn't exist): `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ --filter AlarmStateBadges` + +**Step 3 — Implement.** Create `AlarmStateBadges.razor` with one `[Parameter] public AlarmStateChanged Alarm { get; set; }`. Lift the verbatim markup from `DebugView.razor:202-238` (the `State`/`Kind`/`Unacked`/`Shelved`/`Suppressed`/`sev`/`Level` badges, gating the native-only badges on `Alarm.Kind != AlarmKind.Computed`) and the `@code` helpers from `:580-611`. Then edit `DebugView.razor` to call `` where the inline block was, and delete the now-duplicated helpers (keep `BuildAlarmTooltip` and the `💬` message indicator in DebugView — only the badge cluster moves). + +**Step 4 — Run, expect PASS.** Same filter. Also build the project: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`. + +**Step 5 — Commit.** `git commit -m "feat(centralui): extract AlarmStateBadges shared component from DebugView (T13)" -- src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmStateBadges.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/AlarmStateBadgesTests.cs` + +--- + +### Task A2: Operator Alarm Summary page + fan-out service (T13) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** A3, C1, D1 (NOT A1 — depends on it) +**Blocked by:** A1 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IAlarmSummaryService.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor` (add inside the `RequireDeployment` block at `:90-95`: ``) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Program.cs` (or the CentralUI DI extension where services like `IBrowseService` are registered) — `services.AddScoped();` +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/AlarmSummaryServiceTests.cs` + +**Design notes.** `AlarmSummaryService.GetSiteAlarmsAsync(int siteId, CancellationToken)` returns `AlarmSummaryResult(IReadOnlyList Alarms, IReadOnlyList NotReportingInstances)`. It enumerates Enabled instances via `ITemplateEngineRepository.GetInstancesBySiteIdAsync(siteId)` (filter `State == InstanceState.Enabled`), then fans out the **existing per-instance debug snapshot Ask** (the same `DebugViewSnapshot` DebugView uses for its initial snapshot — issue `DebugSnapshotCommand` per instance through `CommunicationService`/management seam) with a `SemaphoreSlim(maxConcurrency: 8)`. Snapshots that throw/time out add the instance name to `NotReportingInstances`; the rest contribute their `AlarmStates`. Inject an abstraction (`Func>` or a thin `IDebugSnapshotClient`) so the service is unit-testable with NSubstitute. **Confirm the exact snapshot entrypoint** against `DebugStreamService`/`DebugSnapshotCommand` — if no single-shot snapshot Ask is exposed to CentralUI, add a thin `CommunicationService.GetDebugSnapshotAsync(siteId, instanceUniqueName)` mirroring `BrowseNodeAsync`. + +**Step 1 — Failing service unit test.** Mock the snapshot client to return alarms for 2 of 3 instances and throw for the 3rd. Assert aggregated alarm count + that the 3rd instance is in `NotReportingInstances`. Also assert a roll-up helper computes worst severity + active count. + +**Step 2 — Run, expect FAIL.** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ --filter AlarmSummaryService` + +**Step 3 — Implement** the interface + service (capped fan-out, partial-results) + the page. Page skeleton mirrors `Health.razor`: `@page "/monitoring/alarms"`, `@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]`, inject `IAlarmSummaryService` + `ISiteRepository`, a site `` → MxGateway connection `