From 3361eac6d8c555c5df4d8c56e6b0a172740bbb57 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 8 Jun 2026 12:02:18 -0400 Subject: [PATCH] docs(uns): design for global UNS management tree-table --- ...2026-06-08-global-uns-management-design.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/plans/2026-06-08-global-uns-management-design.md diff --git a/docs/plans/2026-06-08-global-uns-management-design.md b/docs/plans/2026-06-08-global-uns-management-design.md new file mode 100644 index 00000000..d85e6afd --- /dev/null +++ b/docs/plans/2026-06-08-global-uns-management-design.md @@ -0,0 +1,180 @@ +# 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 3–6 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' + `` 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/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).