docs(uns): design for global UNS management tree-table
This commit is contained in:
@@ -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'
|
||||
`<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).
|
||||
Reference in New Issue
Block a user