Files
lmxopcua/docs/plans/2026-06-08-global-uns-management-design.md
T
2026-06-08 12:02:18 -04:00

181 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Global UNS Management — Design
**Date:** 2026-06-08
**Branch:** `feat/global-uns-management`
**Status:** Approved (design); pending implementation plan
## Goal
Replace the per-cluster UNS/Equipment/Tags management UI with a single
**global master tree-table** that spans every cluster, presenting all
layers of the Unified Namespace — Enterprise → Site/Cluster → Area → Line
→ Equipment → Tags/VirtualTags — and lets an operator create, edit, and
delete every editable layer from one surface, styled with the
`ZB.MOM.WW.Theme` library.
## Decisions locked during brainstorming
1. **Scope of "global" = UI only; data model unchanged.** `UnsArea.ClusterId`
stays as the "served-by cluster" assignment. Per-ClusterId scoping
(decision #122) and the deployment pipeline are **not touched**.
2. **Editing model = tree is navigational; editing pops a modal per row.**
Reuses the existing Bootstrap-modal pattern (`CollectionEditor` /
`DriverTagPicker`).
3. **Old pages = replace and remove.** The global tree is the only UNS
management surface; the per-cluster UNS + Equipment + Tags tabs and the
standalone area/line/equipment/tag/virtual-tag edit pages are deleted,
their forms folded into modals.
4. **Tree depth = 6 levels, down to Tags + VirtualTags.** Each Equipment
expands to its equipment-bound Tags and VirtualTags, editable via modal.
The standalone `/virtual-tags` list is folded in and removed.
5. **Loading strategy = hybrid.** Eager-load the bounded structural levels
(Enterprise → Cluster → Area → Line → Equipment with count badges);
**lazy-load** the Tag/VirtualTag children per equipment on first expand.
## Architecture
### Tree shape — faithful to the existing data model
`Enterprise` and `Site` are columns on `ServerCluster` (no tables of their
own), so the top levels are **derived, read-only groupings**; editable UNS
entities start at Area.
| Level | Source | Behaviour in tree |
|---|---|---|
| 1 · Enterprise | distinct `ServerCluster.Enterprise` | read-only group header |
| 2 · Site / Cluster | `ServerCluster` row, keyed by `ClusterId`, labelled by `Site` + cluster name | read-only header; "⚙ settings" links to existing `/clusters/{id}` |
| 3 · Area | `UnsArea` (`ClusterId` FK) | CRUD modal; created under a cluster (sets `ClusterId`) |
| 4 · Line | `UnsLine` (`UnsAreaId` FK) | CRUD modal |
| 5 · Equipment | `Equipment` (`UnsLineId` FK) | CRUD modal (full form) |
| 6 · Tag / VirtualTag | `Tag.EquipmentId` / `VirtualTag.EquipmentId` | CRUD modals; Tag modal dispatches by the equipment's driver type |
Consequences of staying faithful to the model:
- **"Served-by cluster" = which cluster node an area lives under.**
Reassigning is editing `UnsArea.ClusterId` in the area modal, which moves
the branch. No new concept, no migration.
- **Cluster/Enterprise/Site creation stays on the `/clusters` pages.** The
tree manages levels 36 only.
- **Galaxy / SystemPlatform tags (`Tag.EquipmentId = NULL`)** hang off a
driver's folder path and are auto-materialised from the Galaxy browse, so
they are *not* equipment-scoped and do **not** appear in the equipment
tree. They remain on the Galaxy driver page (the Drivers tab is kept).
Removing the Tags tab loses only a flat read-only listing of those
auto-generated rows.
## Components (AdminUI only — runtime/Phase7/engine untouched)
**Page**
- `Components/Pages/Uns/GlobalUns.razor``@page "/uns"`, `[Authorize]`,
`@rendermode InteractiveServer`. Toolbar (global text filter,
expand/collapse-all, "Import equipment CSV"), owns modal state, renders
the tree. Optional `?cluster=` / `?equipment=` query auto-expands a node.
**Tree renderer**
- `Components/Shared/Uns/UnsTree.razor` — recursive renderer modeled on
`DriverBrowseTree`: chevrons (▼/▶), `padding-left:{depth*18}px` indent,
per-row action icons, `.chip` count badges. Drives off a `UnsNode` VM
with `Kind ∈ {Enterprise, Cluster, Area, Line, Equipment, Tag,
VirtualTag}`; switches on `Kind` for icons/actions. Emits
`OnAddChild / OnEdit / OnDelete / OnToggleExpand`. Equipment lazy-loads
Tag/VirtualTag children on first expand.
**Modal editors** (`.modal.fade.show` + backdrop; edit a cloned VM, commit
on Save — the `CollectionEditor` pattern):
- `Uns/AreaModal.razor`, `Uns/LineModal.razor`, `Uns/EquipmentModal.razor`,
`Uns/VirtualTagModal.razor` — folded from the deleted edit pages'
`<EditForm>` markup (forms preserved, rehosted in modals).
- `Uns/TagModal.razor`**driver-typed**, mirroring `DriverEditRouter`: a
`_tagEditorMap` keyed by the equipment's driver type dispatches to a
per-driver tag field set. Each driver's existing tag `EditTemplate`
(today inline in `ModbusDriverPage` etc.) is extracted to a shared
`Drivers/TagEditors/<Driver>TagEditor.razor`, with a **generic
raw-`TagConfig`-JSON editor as the guaranteed fallback** so every driver
type is editable from day one; specialized editors land incrementally.
**Service (thin, testable — replaces inline-EF-in-razor)**
- `IUnsTreeService` + impl:
- `LoadStructureAsync()` → whole structural tree in **6 batched queries**
(clusters, areas, lines, equipment, + two `GROUP BY EquipmentId` count
queries) — flat in count regardless of fleet size, no N+1.
- `LoadEquipmentTagsAsync(equipmentId)` → tags + virtual tags for one
equipment (the lazy leaf load).
- `Create/Update/Delete{Area,Line,Equipment,Tag,VirtualTag}Async(...)`
each carries `RowVersion` and enforces the guard rules below.
## Data flow
1. **Load:** page → `LoadStructureAsync()` → assemble `UnsNode` tree (group
equipment-owning clusters under `Enterprise`; areas under `ClusterId`;
lines/equipment by FK). Equipment nodes get
`HasChildren = tagCount+vtagCount > 0` and a count badge.
2. **Expand equipment:** fires `LoadEquipmentTagsAsync` → spinner → child rows.
3. **Edit/Create/Delete:** modal on a cloned VM → service call → on success
the page **patches the in-memory node** (no full reload) and closes.
Create-child pre-fills the parent FK.
4. **Deploy semantics:** edits land in ConfigDb immediately (v2 live-edit)
but the running address space changes on the **next Deploy** — the page
shows a small "pending deployment" note.
## Deletions & exact blast radius
**Delete (11 files / their routes):** `ClusterUns`, `UnsAreaEdit`,
`UnsLineEdit`, `ClusterEquipment`, `EquipmentEdit`, `ImportEquipment`,
`ClusterTags`, `TagEdit`, `VirtualTags`, `VirtualTagEdit` — routes
`/clusters/{id}/uns`, `/uns/areas/*`, `/uns/lines/*`,
`/clusters/{id}/equipment*`, `/equipment/import`, `/clusters/{id}/tags*`,
`/virtual-tags*`.
**Edit (2 files):**
- `Components/Shared/ClusterNav.razor` — drop the **Equipment / UNS / Tags**
tabs (keep Overview, Namespaces, Drivers, ACLs, Audit, Redundancy).
- `Components/Layout/MainLayout.razor` — add a top-level **"UNS"** nav item
(`/uns`) under Navigation; remove **"Virtual tags"** from Scripting.
**Reuse, don't delete:** the per-driver tag `EditTemplate`s and the
`EquipmentEdit` / `VirtualTagEdit` form markup (extracted into the modals).
**Explicitly out of scope (do NOT touch):** the runtime VirtualTag engine,
`Phase7*`, `DeploymentArtifact`, `ConfigComposer`, runtime actors, and the
Configuration entities/migrations. The broad grep hits on "VirtualTag" /
"Equipment" / "tags" in those projects are backend; the model is frozen per
decision #1.
## Error handling, concurrency, validation, authz
- **Concurrency:** `RowVersion` optimistic; `DbUpdateConcurrencyException`
→ inline "changed elsewhere, reload" on that node (the F15 pattern).
- **Validation** (modal blocks Save with a message): Name
`^[a-z0-9-]{1,32}$`; Equipment `MachineCode` uniqueness; Tag
`(EquipmentId,Name)` uniqueness; VirtualTag trigger rule
(`ChangeTriggered OR TimerIntervalMs≥50`).
- **Delete guards:** area-with-lines / line-with-equipment /
equipment-with-tags blocked with a message (matches the deleted pages).
- **Cluster-reassignment guard (decision #122):** changing an area's
served-by cluster, or an equipment's driver, is validated so a
driver-bound equipment's driver cluster still matches its area's cluster
— prevents the cross-cluster orphan the scoping feature warns about.
- **Authz/render:** `[Authorize]` + `InteractiveServer`, identical to the
pages being replaced.
## Testing (no bUnit, per project convention)
- **`IUnsTreeService` unit tests (in-memory EF):** `LoadStructureAsync`
builds the correct hierarchy + accurate counts; lazy tag load; every CRUD
path incl. concurrency conflict, delete guards, and the #122
reassignment validation.
- **VM-helper unit tests (`AdminUI.Tests`):** flat-rows→`UnsNode` tree
assembly, badge counts, the global filter predicate.
- **Gate:** `dotnet build` clean + `dotnet test` green + manual `/run` in
docker-dev — browse `/uns`, create area→line→equipment→tag→vtag, Deploy,
confirm on `:4840`.
## Out of scope / follow-ups
- Reorganizing the data model (Enterprise/Site as first-class tables);
decoupling UNS from clusters. (Decision #1 keeps the model.)
- Drag-and-drop reparenting between nodes (edit-modal reassignment only).
- Per-driver specialized tag editors beyond the generic JSON fallback may
land incrementally after the first cut.
- A flat global VirtualTag list (removed; reinstate later if wanted).