Files
lmxopcua/docs/plans/2026-06-16-stillpending-phase-6-adminui.md
T

13 KiB
Raw Blame History

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. Branch feat/stillpending-phase-6-adminui (off master 156aa900) 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 (add IsHistorized/HistorianTagname round-trip on FromJson/ToJson, keys isHistorized/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-alarm alarm object; native alarms ride TagConfig.alarm, NOT the ScriptedAlarm entity). Likely a new small alarm sub-model + a control in TagModal.razor (or the typed editors' alarm section).
  • Verify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs EquipmentTagAlarmInfo — add/confirm HistorizeToAveva (bool?) parse from TagConfig.alarm, and that HistorianAdapterActor gates native-alarm durable writes on is 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 into TagModal.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 existing Components/Shared/Drivers/Pickers/*AddressPickerBody.razor inside the shared DriverTagPicker shell, writing the built address back to the editor's address field.
  • Test: none new (the *AddressBuilder cores are already unit-tested under AdminUI.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 (add DeleteClusterAsync(clusterId, rowVersion); add RowVersion to ClusterRow + query it in LoadStructureAsync)
  • Modify: Components/Shared/Uns/UnsTree.razor (RenderActions → Delete action for Cluster), Components/Pages/Uns/GlobalUns.razor (ConfirmDeleteAsync:306 Cluster/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 (add CreateScriptAsync(...) → new Script row, generated id, blank source, returns the new id)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/.../VirtualTagModal.razor (:66 — a "New script" branch when ScriptId is 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 17

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 18

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.