13 KiB
Phase 6 — AdminUI Typed Editors, Pickers & UX — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. Execution is post-compaction — start by reading this plan +
docs/plans/2026-06-16-stillpending-phase-6-adminui-design.md. Branchfeat/stillpending-phase-6-adminui(off master156aa900) already exists with the design committed (dbff29dd).
Goal: Complete the /uns Tag authoring UX — typed editors for every driver, historized fields as first-class controls, native-alarm AVEVA opt-out, address pickers reachable from the Tag modal, UNS-tree deletes for the top node kinds, and create-new-script from the inline vtag panel.
Architecture: Blazor Server AdminUI under src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/. Pure-C# cores (*TagConfigModel FromJson/ToJson/Validate, UnsTreeService methods, alarm sub-model) are unit-tested in tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/; Razor shells are proven only by live /run (NO bUnit). NO EF migration, NO Commons contract change.
Global rules (every task): TDD red→green for the pure cores. Stage by path — never git add .; never stage sql_login.txt, src/Server/.../Host/pki/, pending.md, current.md, docker-dev/docker-compose.yml, stillpending.md. No --no-verify, no force-push. NO EF migration. Preserve the *TagConfigModel preserve-unknown-keys contract. If git commit hits .git/index.lock (concurrent sibling), wait 2s + retry up to 5×.
Dependency graph: Task1→Task2→Task5 (same TagEditors files, serial). Task3→Task4 (alarm form, serial). Task6, Task7 independent. Concurrent start: Tasks 1, 3, 6, 7 (disjoint files). Then 2 (after 1), 4 (after 3), 5 (after 2).
Task 0: Feature branch (done)
Branch feat/stillpending-phase-6-adminui off 156aa900; design committed dbff29dd. No action.
Task 1 (A): Typed editors for OpcUaClient + Historian.Wonderware
Classification: standard · Est: ~5 min · Parallelizable with: Task 3, 6, 7
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs(register"OpcUaClient"+"Historian.Wonderware") - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs(register both) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/OpcUaClientTagConfigModelTests.cs,.../HistorianWonderwareTagConfigModelTests.cs
Approach: Read ModbusTagConfigModel.cs + its editor + ModbusTagConfigModelTests.cs as the exact template. The OpcUaClient tag-config carries the OPC UA FullName/NodeId + (poll) settings; the Historian tag-config carries the historian tagname/source. Inspect each driver's TagConfig shape (the OpcUaClient driver's equipment-tag ref + the Historian client options) for the real fields; keep preserve-unknown-keys. Register exact DriverType strings ("OpcUaClient", "Historian.Wonderware" — confirm against GalaxyDriverFactoryExtensions.DriverTypeName-style constants / DriverFactoryBootstrap).
Steps: (1) Write failing model tests (FromJson/ToJson round-trip + Validate + unknown-key preservation). (2) Run → fail. (3) Implement the 2 models + editors + register. (4) Tests green. (5) dotnet build the AdminUI project clean. (6) Commit feat(adminui): typed TagConfig editors for OpcUaClient + Historian.
Task 2 (B): isHistorized / historianTagname first-class fields
Classification: standard · Est: ~5 min · Parallelizable with: Task 6, 7 · blockedBy: Task 1
Files:
- Modify: every
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/*TagConfigModel.cs(addIsHistorized/HistorianTagnameround-trip onFromJson/ToJson, keysisHistorized/historianTagname) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor(a shared "Historize this tag" checkbox + optional "Historian tagname" textbox, driver-agnostic, above/below the typed-editor slot) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorizedFieldRoundTripTests.cs
Approach: The server already reads isHistorized/historianTagname from the raw TagConfig JSON (docs/Historian.md); this surfaces them as controls without changing the wire format. Decide ONE seam: either each typed model carries the two fields, OR the TagModal merges them into the JSON around the typed editor (simpler + uniform + covers the raw-JSON fallback drivers). Prefer the TagModal-merge seam so it works for ALL drivers incl. raw-JSON ones, and unit-test the merge helper. Confirm round-trip byte-parity (toggle → save → reopen shows the toggle; a changed=1 deploy per the H1 work).
Steps: TDD the merge/round-trip helper → implement → green → build → commit feat(adminui): isHistorized + historianTagname as first-class Tag fields.
Task 3 (C): Native-alarm HistorizeToAveva opt-out
Classification: standard · Est: ~5 min · Parallelizable with: Task 1, 6, 7
Files:
- Modify: the native-alarm authoring surface — find it (grep
TagModal/equipment Alarms tab for the native-alarmalarmobject; native alarms rideTagConfig.alarm, NOT theScriptedAlarmentity). Likely a new small alarm sub-model + a control inTagModal.razor(or the typed editors' alarm section). - Verify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.csEquipmentTagAlarmInfo— add/confirmHistorizeToAveva(bool?) parse fromTagConfig.alarm, and thatHistorianAdapterActorgates native-alarm durable writes onis not false(the scripted path already does — mirror it). - Test:
tests/.../AdminUI.Tests/Uns/NativeAlarmHistorizeModelTests.cs(+ a Phase7Composer parse test if the carrier changes)
Approach: Add historizeToAveva (bool?, absent ⇒ historize) to the native-alarm TagConfig.alarm model + a "Historize to AVEVA" checkbox in the native-alarm form. Confirm the server honors it for native alarms (the scripted-alarm gate shipped in the script-log/alarm round-3 — find the is not false check in HistorianAdapterActor and ensure native transitions pass through the same gate). NO migration (rides TagConfig). TDD the model field; live /run the toggle.
Steps: TDD → implement model + UI + server-honor confirmation → green → build → commit feat(adminui): native-alarm HistorizeToAveva opt-out.
Task 4 (D): Galaxy picker pre-fills alarm fields from IsAlarm
Classification: small · Est: ~4 min · Parallelizable with: Task 5, 6, 7 · blockedBy: Task 3
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor+ its selection callback intoTagModal.razor:115 - Test: none new (pure Razor wiring) — covered by live
/run
Approach: DriverAttributeInfo.IsAlarm already exists (Core.Abstractions/DriverAttributeInfo.cs:55). When the Galaxy picker returns a selection whose attribute IsAlarm == true, pre-fill the native-alarm sub-form (from Task 3) with sensible defaults (alarm type/severity) so the operator authors the alarm in one pass. Wiring only. Commit feat(adminui): Galaxy picker pre-fills native-alarm fields from IsAlarm.
Task 5 (E): "Build address" button in the protocol-driver typed editors
Classification: small · Est: ~5 min · Parallelizable with: Task 6, 7 · blockedBy: Task 2
Files:
- Modify: the 6 typed editors
Components/Shared/Uns/TagEditors/{Modbus,S7,AbCip,AbLegacy,TwinCAT,Focas}TagConfigEditor.razor— add a "Build address" button that opens the matching existingComponents/Shared/Drivers/Pickers/*AddressPickerBody.razorinside the sharedDriverTagPickershell, writing the built address back to the editor's address field. - Test: none new (the
*AddressBuildercores are already unit-tested underAdminUI.Tests/Pickers/) — live/run
Approach: Reuse the EXACT pattern by which Galaxy is wired into TagModal.razor:115 (<DriverTagPicker @bind-Visible=…><XAddressPickerBody …/></DriverTagPicker>), but per typed editor. No new picker logic. Commit feat(adminui): Build-address pickers in the protocol-driver Tag editors.
Task 6 (F): UNS-tree Delete for Cluster / Enterprise
Classification: standard · Est: ~6 min · Parallelizable with: Task 1, 3, 7
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/...IUnsTreeService+UnsTreeService(addDeleteClusterAsync(clusterId, rowVersion); addRowVersiontoClusterRow+ query it inLoadStructureAsync) - Modify:
Components/Shared/Uns/UnsTree.razor(RenderActions→ Delete action for Cluster),Components/Pages/Uns/GlobalUns.razor(ConfirmDeleteAsync:306Cluster/Enterprise branch) - Test:
tests/.../AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs(in-memory EF — delete empty cluster ok; refuse-or-cascade with children; RowVersion mismatch → concurrency error; Enterprise = delete all clusters under the label)
Approach: Mirror the existing DeleteAreaAsync/DeleteLineAsync pattern. Cluster cascade is the danger (owns driver-instances + areas→…→tags) — decide refuse-if-children vs ordered cascade and TEST it; require RowVersion concurrency. Enterprise is a tree label (not a DB entity) ⇒ delete all clusters carrying that Enterprise value. Service unit-tested; tree action live /run. Commit feat(adminui): UNS-tree delete for Cluster + Enterprise.
Task 7 (G): Create-new-script from the inline virtual-tag panel
Classification: standard · Est: ~5 min · Parallelizable with: Task 1, 3, 6
Files:
- Modify:
IUnsTreeService+UnsTreeService(addCreateScriptAsync(...)→ newScriptrow, generated id, blank source, returns the new id) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/.../VirtualTagModal.razor(:66— a "New script" branch whenScriptIdis empty →CreateScriptAsync→ bind → expand the inline Monaco editor) - Test:
tests/.../AdminUI.Tests/Uns/UnsTreeServiceCreateScriptTests.cs(in-memory EF — creates a Script row with a unique id + blank source)
Approach: SetVirtualTag completions/hover already work (no change). Add only the create-new-script flow. Service unit-tested; UX live /run. Commit feat(adminui): create-new-script from the inline virtual-tag panel.
Task 8: Docs + bookkeeping
Classification: small · Est: ~4 min · blockedBy: Task 1–7
Update docs/Uns.md (typed editors for OpcUaClient/Historian, historized first-class fields, Build-address pickers, Cluster/Enterprise delete, create-new-script) + docs/Historian.md (isHistorized/historianTagname now first-class) + a native-alarm HistorizeToAveva note in docs/ScriptedAlarms.md/docs/AlarmTracking.md. Note Item 7 (Hosts page) deferred (F7-blocked). Commit docs(phase6): AdminUI editors, pickers, deletes, new-script.
Task 9: Full build + test + final integration review
Classification: high-risk · Est: ~6 min · blockedBy: Task 1–8
dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests → green (+ any touched OpcUaServer test for Task 3's composer parse). Final integration review: (a) preserve-unknown-keys holds across the historized-field + alarm additions; (b) Cluster delete cascade/concurrency is safe; (c) no EF migration / no Commons contract change leaked; (d) the native-alarm historize gate matches the scripted path. Fix findings, commit.
Task 10: Live /run acceptance (agent-driven)
Classification: high-risk · Est: ~10 min · blockedBy: Task 9
Bring up the local rig: docker compose -f docker-dev/docker-compose.yml up -d central-1 (+ the AdminUI service; login disabled). Drive http://localhost:9200 via the Chrome browser tools: author an OpcUaClient + a Historian tag via the new typed editors; toggle Historize + set a historian tagname → deploy → confirm changed=1 + round-trip on reopen; toggle a native-alarm AVEVA opt-out; open a protocol-driver "Build address" picker and use a built address; create + delete a disposable Cluster; create-new-script from the vtag panel. Phase-5 lesson: the Bash sandbox blocks some network — if a check can't reach a service, retry with dangerouslyDisableSandbox: true. Record results; honestly defer anything the local rig can't exercise.
Done =
Build clean + AdminUI.Tests green + final integration review SHIP + live /run acceptance pass. Then finishing-a-development-branch → merge to master + push (the user's standing directive). Update pending.md banner + the project_stillpending_backlog memory.