docs(phase6): design — AdminUI typed editors, pickers & UX (destaled scope, #7 deferred)

This commit is contained in:
Joseph Doherty
2026-06-16 16:09:06 -04:00
parent 156aa900ee
commit dbff29dddf
@@ -0,0 +1,123 @@
# Still-Pending Phase 6 — AdminUI typed editors, pickers & UX — design
> **Status:** approved 2026-06-16 (scope = "Everything achievable", AskUserQuestion). Parent roadmap:
> `docs/plans/2026-06-15-stillpending-backlog-design.md` (Phase 6). Branch `feat/stillpending-phase-6-adminui`
> off master `156aa900`. Phases 05 already shipped. **Execution is deferred to AFTER a /compact** — this doc +
> the implementation plan are the durable handoff.
## Goal
Close the AdminUI authoring/UX gaps in `stillpending.md` §4 so the `/uns` Tag authoring experience is complete:
typed editors for every driver, historized fields as first-class controls, native-alarm AVEVA opt-out, the
existing address pickers reachable from the Tag modal, UNS-tree deletes for the top node kinds, and
create-new-script from the inline virtual-tag panel. **NO EF migration; Razor proven only by live `/run` (no
bUnit) — the pure-C# `*TagConfigModel` / service / builder cores ARE unit-tested in
`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/`.**
## Grounding that reshaped the scope (the roadmap was materially stale)
1. **`DriverAttributeInfo.IsAlarm` already exists** (`Core.Abstractions/DriverAttributeInfo.cs:55`, plus
`IsArray`/`ArrayDim`). Item 4 needs **no Commons contract change** — only the picker→modal pre-fill wiring.
2. **All 9 driver address-picker bodies + builders already exist** under
`Components/Shared/Drivers/Pickers/` (`{Modbus,S7,AbCip,AbLegacy,TwinCAT,FOCAS,Galaxy,OpcUaClient,
HistorianWonderware}AddressPickerBody.razor` + the `*AddressBuilder.cs` with unit tests under
`tests/.../AdminUI.Tests/Pickers/`). They are **wired into the per-cluster driver pages**
(`ModbusDriverPage.razor` …). Only the **Galaxy** picker is also wired into the `/uns` `TagModal.razor`
(`:115`). So the roadmap's "only OpcUaClient + Galaxy pickers shipped" is **stale**; Item 5's residue is
just wiring a "Build address" affordance into the typed editors in the Tag modal.
3. **`ctx.SetVirtualTag(...)` completions/hover are already wired** — `SetVirtualTag` is already in
`ScriptAnalysisService.TryGetTagPathLiteral`. Item 8's residue is only **create-new-script** from the
inline panel.
4. **`HistorizeToAveva` is a first-class field on *scripted* alarms** (`ScriptedAlarm` entity +
`Phase7Composer.ScriptedAlarmInfo`; the ScriptedAlarmModal toggle already ships). Item 3's residue is the
**native-alarm** side: the `TagConfig.alarm` object's AVEVA opt-out + its UI.
5. **No bUnit anywhere** — the AdminUI test project (`AdminUI.Tests`) is pure C# (model `FromJson/ToJson/
Validate` tests, `UnsTreeService*` against in-memory EF, `ScriptAnalysis/*`, `Pickers/*AddressBuilder`).
## In scope — the genuinely-remaining work (Item 7 deferred)
### A. Typed editors for OpcUaClient + Historian.Wonderware (Item 1) — `standard`
- `TagConfigEditorMap` (`Uns/TagEditors/TagConfigEditorMap.cs`) maps only ModbusTcp/S7/AbCip/AbLegacy/TwinCat/
Focas → a typed editor; OpcUaClient/Galaxy/Historian.Wonderware fall back to raw JSON.
- Add `OpcUaClientTagConfigModel` + `HistorianWonderwareTagConfigModel` (pure `FromJson`/`ToJson`/`Validate`,
preserve-unknown-keys, camelCase) + their razor editor shells under `Components/Shared/Uns/TagEditors/`,
register both in `TagConfigEditorMap` + `TagConfigValidator`. Mirror the Modbus template.
- **Unit-test the two models** (mirror `ModbusTagConfigModelTests`); editors proven by live `/run`.
### B. `isHistorized` / `historianTagname` as first-class fields (Item 2) — `standard`
- Today they ride the raw `TagConfig` JSON (the server already reads them — `docs/Historian.md`).
- Surface a shared **"Historize this tag" checkbox + optional "Historian tagname" textbox** in the Tag modal,
driver-agnostic (applies to any equipment tag). Thread them through the typed-editor models' `FromJson`/
`ToJson` (and the raw-JSON fallback) so a toggle round-trips byte-parity and is preserved across edits.
- **Unit-test** the field round-trip on the models.
### C. Native-alarm `HistorizeToAveva` opt-out (Item 3) — `standard`
- The native-alarm carrier is `TagConfig.alarm` (`Phase7Composer.EquipmentTagAlarmInfo`). Add a
`historizeToAveva` (`bool?`, default-historize when absent — mirror the scripted-alarm wire contract) to the
native-alarm authoring surface + the alarm sub-model, and **confirm the server honors it for native alarms**
in `HistorianAdapterActor` (the scripted path already gates on `is not false`). NO migration (rides
`TagConfig`). Unit-test the model field; live `/run` the toggle.
### D. Galaxy picker pre-fills alarm fields from `IsAlarm` (Item 4) — `small`
- `DriverAttributeInfo.IsAlarm` already exists. When the Galaxy address picker (wired in `TagModal.razor:115`)
returns a selection whose attribute `IsAlarm == true`, pre-fill the native-alarm sub-form (from C) with
sensible defaults so the operator can author the alarm in one pass. Wiring only; live `/run`.
### E. Address pickers reachable from the typed editors (Item 5) — `small`
- The picker bodies + builders exist and are wired on the driver pages. Add a **"Build address"** button to each
protocol-driver typed editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas) in the Tag modal that opens the
matching `*AddressPickerBody` inside the shared `DriverTagPicker` shell (exactly as Galaxy is wired at
`TagModal.razor:115`), writing the built address back into the editor's address field. Reuses existing bodies
— no new picker logic. Live `/run`.
### F. UNS-tree Delete for Cluster / Enterprise (Item 6) — `standard`
- `GlobalUns.ConfirmDeleteAsync` (`:306`) guards Enterprise/Cluster as "not yet available"; `UnsTree`'s
`RenderActions` hides Delete for those kinds. Add `IUnsTreeService.DeleteClusterAsync(clusterId, rowVersion)`
(+ `ClusterRow.RowVersion`, queried in `LoadStructureAsync`), the `UnsTree` Delete action for Cluster, and the
`GlobalUns` branch. **Enterprise** = delete all clusters under that label (Enterprise is a tree label, not a
DB entity). **Cluster cascade is the danger** (it owns driver-instances + areas→lines→equipment→tags) — match
the area/line pattern (refuse-if-children OR ordered cascade) and require RowVersion concurrency. **Service-
layer unit-tested** against in-memory EF (mirror `UnsTreeService*` tests); the tree action is live `/run`.
### G. Create-new-script from the inline virtual-tag panel (Item 8) — `small`
- `VirtualTagModal.razor` (`:66`) shows the inline Monaco editor only for an **existing** bound script. Add a
**"New script"** branch (when `ScriptId` is empty) → `IUnsTreeService.CreateScriptAsync(...)` (new Script row,
generated id, blank source) → bind it → expand the inline editor. **Service-layer unit-tested**; UX live `/run`.
## Out of scope / deferred
- **Item 7 — Hosts page per-driver-instance rows: DEFERRED** (`Hosts.razor` is per-Akka-member; its own comment
says driver-instance child rows "land with F7", which is not yet implemented → blocked on runtime work, not an
AdminUI gap). Recorded as a Phase-6b/F7 follow-up.
- Phase-4b driver structural work; Phases 78.
## Architecture / risk notes
- **Independence:** A+B are coupled (same model/editor files — do as one pass). C+D are coupled (D pre-fills C's
form). E, F, G are independent. F + G are the service-layer (in-memory-EF-testable) items; A,B,C are
model-layer testable; D,E are pure Razor wiring (live-`/run` only).
- **No EF migration, no Commons contract change** (IsAlarm/IsArray already on `DriverAttributeInfo`; historized
+ native-alarm-historize ride `TagConfig` JSON; `ScriptedAlarm.HistorizeToAveva` column already exists).
- The `*TagConfigModel` preserve-unknown-keys contract (per `docs/plans/2026-06-09-driver-typed-tag-editors-
design.md`) MUST hold so adding historized fields never drops a driver's existing keys.
## Testing & verification
- TDD red→green (xUnit + Shouldly) for every pure core: the 2 new `*TagConfigModel`s (A), the historized-field
round-trip (B), the native-alarm-historize model field (C), `DeleteClusterAsync` (F), `CreateScriptAsync` (G)
— all in `AdminUI.Tests` (model tests + `UnsTreeService` in-memory-EF tests). **No bUnit.**
- `dotnet build` clean + `dotnet test` green before merge.
- **Live `/run` (agent-driven, login disabled on the local rig):** drive `http://localhost:9200` — author an
OpcUaClient/Historian tag via the new typed editor; toggle Historize + set a historian tagname and confirm
round-trip on re-open + deploy; toggle native-alarm AVEVA opt-out; open a protocol-driver "Build address"
picker and use a built address; delete a (disposable) Cluster; create-new-script from the vtag panel.
**NOTE (Phase-5 lesson): `dotnet test` of the integration suite is network-sandboxed — run live checks against
the docker-dev rig accordingly; bring up the rig with `docker compose -f docker-dev/docker-compose.yml up -d
central-1`.** Final integration review before merge.
## Hard constraints (carried from the parent roadmap)
- **NO Configuration entity / EF migration.** No Commons contract change. 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`. Never echo or commit secrets. No force-push, no
`--no-verify`. Finish = merge to master + push.