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

9.2 KiB
Raw Blame History

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.razordriver-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 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: 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).