# 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 0–5 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 7–8.

## 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.
