9.2 KiB
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
- Scope of "global" = UI only; data model unchanged.
UnsArea.ClusterIdstays as the "served-by cluster" assignment. Per-ClusterId scoping (decision #122) and the deployment pipeline are not touched. - Editing model = tree is navigational; editing pops a modal per row.
Reuses the existing Bootstrap-modal pattern (
CollectionEditor/DriverTagPicker). - 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.
- Tree depth = 6 levels, down to Tags + VirtualTags. Each Equipment
expands to its equipment-bound Tags and VirtualTags, editable via modal.
The standalone
/virtual-tagslist is folded in and removed. - 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.ClusterIdin the area modal, which moves the branch. No new concept, no migration. - Cluster/Enterprise/Site creation stays on the
/clusterspages. 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 onDriverBrowseTree: chevrons (▼/▶),padding-left:{depth*18}pxindent, per-row action icons,.chipcount badges. Drives off aUnsNodeVM withKind ∈ {Enterprise, Cluster, Area, Line, Equipment, Tag, VirtualTag}; switches onKindfor icons/actions. EmitsOnAddChild / 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, mirroringDriverEditRouter: a_tagEditorMapkeyed by the equipment's driver type dispatches to a per-driver tag field set. Each driver's existing tagEditTemplate(today inline inModbusDriverPageetc.) is extracted to a sharedDrivers/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, + twoGROUP BY EquipmentIdcount 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 carriesRowVersionand enforces the guard rules below.
Data flow
- Load: page →
LoadStructureAsync()→ assembleUnsNodetree (group equipment-owning clusters underEnterprise; areas underClusterId; lines/equipment by FK). Equipment nodes getHasChildren = tagCount+vtagCount > 0and a count badge. - Expand equipment: fires
LoadEquipmentTagsAsync→ spinner → child rows. - 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.
- 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 EditTemplates 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:
RowVersionoptimistic;DbUpdateConcurrencyException→ inline "changed elsewhere, reload" on that node (the F15 pattern). - Validation (modal blocks Save with a message): Name
^[a-z0-9-]{1,32}$; EquipmentMachineCodeuniqueness; 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)
IUnsTreeServiceunit tests (in-memory EF):LoadStructureAsyncbuilds 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→UnsNodetree assembly, badge counts, the global filter predicate. - Gate:
dotnet buildclean +dotnet testgreen + manual/runin 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).