Two verified-absent parity gaps between the service layer and the CLI / ManagementActor command surface, both left as follow-ups by the 2026-05-11 design plans. (1) area move. AreaService.MoveAreaAsync had existed since the deployment topology page shipped but was reachable only from the Blazor UI. Adds MoveAreaCommand(AreaId, NewParentAreaId?) to Commons, a ManagementActor dispatch arm delegating straight to AreaService.MoveAreaAsync (not-found / self-parent / descendant-cycle / cross-site / name-collision all surface as the standard curated ManagementCommandException failure response; the service writes its own "Move" audit row), and the CLI verb `site area move --id [--parent-id]`. Omitting --parent-id moves the area to the site root, matching the command's nullable NewParentAreaId. The command carries the SAME any-of [Designer, Deployer] gate as CreateArea/UpdateArea/DeleteArea (arch-review C6): re-parenting is the same structural authoring act, exposed on the same two surfaces. Placed under the existing `site area` group rather than a new top-level `area` group, alongside its create/update/delete siblings. (2) template folder verbs. The five folder management commands have been handled by ManagementActor since the folder-hierarchy plan, but the promised CLI surface was never written. Adds `template folder list|create|rename|move|reorder|delete` mapping 1:1 onto ListTemplateFolders / CreateTemplateFolder / RenameTemplateFolder / MoveTemplateFolder / ReorderTemplateFolder / DeleteTemplateFolder. --parent-id is omitted to target the tree root; --direction takes the lowercase literals up/down, validated at parse time by AcceptOnlyFromAmong (same case-sensitive contract as the audit --channel/--kind/--status options). Follow-on updates: the frozen authorization matrix gains its MoveArea entry (reflection-driven, so a missing entry would have failed CI); CommandTreeTests pins both new verb sets plus the omit-parent-id-means-root parse behaviour and registry round-trips; ManagementActorTests covers the MoveArea role gate and the delegate-to-service success/root/cycle/not-found paths; the CLI README and Component-ManagementService.md document the new surface (the latter also gained the previously-undocumented ReorderTemplateFolder); both plan docs' follow-up lines are marked done.
16 KiB
Deployment Topology Page — Design
A single page under /deployment that owns the Site → Area → Instance hierarchy: structural management (create, rename, move, delete) and instance lifecycle (deploy, enable/disable, configure, diff), built on the existing TreeView component with the same V1–V7 visual identity as the templates page.
This page replaces both /deployment/instances (current read-mostly tree) and /admin/areas* (current flat-list CRUD for areas).
Decisions
| Question | Decision |
|---|---|
| Page identity | Replace both /deployment/instances and /admin/areas* with one new page |
| Route | /deployment/topology |
| Empty containers | Always shown (so they're valid move/create targets) |
| Instance configuration | Stays on dedicated /deployment/instances/{id}/configure page |
| Filters | Search-only (single input above the tree) |
| Search semantics | Dim non-matches (50% opacity), preserve tree shape |
| Single-click behavior | Select-only; nothing navigates |
| Rename UX | Inline (F2 / double-click) for areas only. Instance rename is out of scope (see "Instance rename" below). |
| Site-node menu | Add Area, Create Instance here |
| Area-node menu | Add Sub-area, Create Instance here, Move to Area…, Rename…, Delete |
| Instance-node menu | Deploy/Redeploy, Enable/Disable, Configure, Diff, Move to Area…, Delete |
| Delete-area cascade | Keep server semantics — block on any non-empty subtree |
| Top-of-page buttons | Create Area, Create Instance, Refresh |
| Move structural scope | Same-site only (instance↔area, area↔area). Cross-site moves out of scope. |
| Backend area re-parenting | New AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user) |
| State persistence | Expanded nodes + selected key, both in sessionStorage |
| Glyphs | Site bi-building, Area bi-diagram-3, Instance bi-box |
Layout
┌─────────────────────────────────────────────────────────────┐
│ Topology │
├─────────────────────────────────────────────────────────────┤
│ [Search box ............................. ] │
│ [Create Area] [Create Instance] [Refresh] │
├─────────────────────────────────────────────────────────────┤
│ ▾ 🏢 Plant-A │
│ ▾ ▦ Line-1 │
│ ▸ ▦ Station-3 │
│ □ Pump-001 [Enabled] [Current] │
│ □ Pump-002 [Disabled] │
│ ▾ ▦ Line-2 │
│ □ Conveyor-01 [NotDeployed] │
│ ▾ 🏢 Plant-B │
│ ▸ ▦ (empty area, still shown) │
└─────────────────────────────────────────────────────────────┘
Visual identity
Follows the existing Component-TreeView.md V1–V7 guide. Glyphs adopted:
| Node | Glyph | Color hook |
|---|---|---|
| Site | bi-building |
default |
| Area | bi-diagram-3 |
default |
| Instance | bi-box |
default; state badge to the right |
Instance state badges (kept from current page):
| State | Badge |
|---|---|
| Enabled | bg-success |
| Disabled | bg-secondary |
| NotDeployed | bg-light text-dark |
| Stale (deployed but template revision drifted) | bg-warning text-dark |
| Current | bg-light text-dark |
Search dimming: non-matches receive opacity: 0.4. Matches keep full opacity. Tree shape is preserved; ancestors of matches are auto-expanded on first keystroke.
Context menus
Site
- Add Area → opens "Create Area" dialog with this site pre-selected (parent = root)
- Create Instance here → navigates
/deployment/instances/create?siteId={id}
Area
- Add Sub-area → "Create Area" dialog with this area pre-selected as parent
- Create Instance here → navigates
/deployment/instances/create?siteId={siteId}&areaId={id} - Move to Area… → opens
MoveAreaDialog. Destination list = areas in the same site, excluding self and descendants. Plus "(root of site)" option. - divider
- Rename… → opens
RenameAreaDialog(also reachable via F2 / double-click for inline edit) - Delete → calls
DeleteAreaAsync; server rejects if non-empty, error surfaced via toast
Instance
- Deploy / Redeploy (label depends on
IsStale) - Enable / Disable (state-dependent)
- Configure → navigates
/deployment/instances/{id}/configure - Diff → opens the existing diff modal (ported from current Instances page)
- Move to Area… → opens
MoveInstanceDialog. Destination list = areas in the same site + "(no area, site root)". - divider
- Delete
Inline rename
Applies to Area rows only. Instance rows do not support rename on this page (see "Instance rename" below).
F2or double-click on the label of an Area row replaces the label span with an<input>bound to a local edit buffer.Entercommits viaAreaService.UpdateAreaAsync(areaId, name, user).Escapecancels.- On commit failure (e.g., name collision at the same level), the toast shows the server error and the input stays open with the bad value highlighted.
Instance rename
Out of scope for this page. InstanceService currently has no rename method. Adding one is non-trivial:
Instance.UniqueNameis also the identity of the site-sideInstanceActor(Akka actor name).- It appears in deployment records, audit history, and deploy paths.
- Renaming a deployed instance would require coordinated site-side actor stop/restart, deployment-record rebinding, and potentially redeployment.
This warrants its own design pass. For now: an instance row's label is read-only on the topology page. If a rename is needed, the user can delete + recreate (with the limitation that deployment history is lost).
The Area-rename context-menu item ("Rename…") is not added to the instance menu.
Backend changes
AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user) — NEW
Parallel to InstanceService.AssignToAreaAsync. Validates:
- Area exists.
newParentAreaIdis null OR refers to an area in the same site as the area being moved.newParentAreaId != areaId(not self).- The new parent is not a descendant of the area being moved (cycle prevention) — reuse the existing descendant-walking helper that
DeleteAreaAsyncuses. - No sibling area at the new level has the same name (case-insensitive).
On success: updates ParentAreaId, persists, audits as "Move" on entity "Area".
UpdateAreaAsync stays name-only.
Templates.razor parent-immutability pattern is not repeated here
Areas can be moved freely (subject to validation). Templates are different because re-parenting changes inheritance semantics; areas are pure organizational containers.
No change to:
InstanceService.AssignToAreaAsync(already supports re-parenting; will be called byMoveInstanceDialog)AreaService.DeleteAreaAsync(keep current block-on-non-empty semantics)AreaService.UpdateAreaAsync(stays name-only)InstanceServicelifecycle methods (already used by current Instances page)
CLI / ManagementService parity (optional follow-up) — DONE 2026-08-01
AddShipped:MoveAreaCommandmessage +ManagementServicehandler that wrapsMoveAreaAsync.MoveAreaCommand(int AreaId, int? NewParentAreaId)inCommons/Messages/Management/SiteCommands.cs, dispatched byManagementActor.HandleMoveArea(delegates toAreaService.MoveAreaAsync; failures surface as the standard curated failure response), gated any-of[Designer, Deployer]like the other area mutations.Add CLI:Shipped ascli area move --id X --parent-id Y …(omit--parent-idto move to site root).scadabridge site area move --id X [--parent-id Y]— the area verbs live under the existingsite areagroup, not at the CLI root, so the verb was placed alongsidesite area create|update|deleterather than introducing a second top-level spelling.
Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
Routes affected
| Route | Before | After |
|---|---|---|
/deployment/topology |
— | NEW (this page — canonical route) |
/deployment/instances |
tree + lifecycle page | secondary @page directive on Topology.razor — old bookmarks continue to work. NavMenu and all internal back-navs retarget to /deployment/topology. |
/admin/areas |
flat list | removed |
/admin/areas/add |
dialog page | removed (Create Area dialog lives on topology page) |
/admin/areas/edit/{id} |
edit page | removed (rename via inline / context menu) |
/admin/areas/delete/{id} |
confirm page | removed (confirm via shared ConfirmDialog) |
/deployment/instances/create |
unchanged | accepts new ?siteId= and ?areaId= query params for preselection |
/deployment/instances/{id}/configure |
unchanged | unchanged |
The admin nav entry for "Areas" gets removed; "Topology" goes under the Deployment nav group.
Files to add
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor (~500 lines)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor (~50 lines)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor (~55 lines)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor (~60 lines)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/RenameAreaDialog.razor (~45 lines) (optional if inline-only)
Files to modify
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/AreaService.cs (+ MoveAreaAsync, ~40 lines)
src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/... (interface for AreaService if exposed)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
(+ SiteId, AreaId query-param SupplyParameterFromQuery;
retarget back-nav to /deployment/topology — 3 sites)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
(retarget back-nav to /deployment/topology — 1 site)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor (replace 'Instances' nav with 'Topology' at /deployment/topology;
remove 'Areas' nav under Admin)
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/NavigationTests.cs
(update InlineData: 'Instances' → 'Topology', '/deployment/instances' → '/deployment/topology')
docs/requirements/Component-TreeView.md (rewrite §1 'Instances Page' → 'Topology Page' with new route;
remove §3 'Areas Page')
Note: CLAUDE.md does not reference /deployment/instances today, so no edit required there.
Files to remove
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Instances.razor (replaced by Topology.razor; old route preserved as secondary @page)
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Areas.razor
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/AreaAdd.razor
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/AreaEdit.razor
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/AreaDelete.razor
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/InstancesPageTests.cs (if it exists)
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/AreaPageTests.cs (if it exists)
Verified there are no other references to /admin/areas* in CLI, ManagementService, requirement docs (other than Component-TreeView.md §3, which is updated above), or tests.
State persistence
topology-tree(sessionStorage) — expansion state (Set of node keys), already supported byTreeView.StorageKey.topology-tree-selected(sessionStorage) — selected node key. New; theTreeViewalready exposesSelectedKeytwo-way binding, but the page is responsible for persisting it. Pattern: write inSelectedKeyChanged, read onOnAfterRenderAsyncafter data load.
Tests
Unit (tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AreaServiceTests.cs)
MoveArea_ToOtherArea_SucceedsMoveArea_ToSiteRoot_Succeeds(newParentAreaId = null)MoveArea_ToSelf_FailsMoveArea_ToDescendant_FailsWithCycleErrorMoveArea_DifferentSite_FailsMoveArea_NameCollidesAtNewParent_FailsMoveArea_NameUniqueAtNewParent_SucceedsMoveArea_AuditLogged
bUnit (tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs)
Renders_EmptyState_WhenNoSitesRenders_EmptySite_WhenSiteHasNoAreasOrInstances(empty containers visible)Renders_SiteAreaInstance_NestingSearch_DimsNonMatches_PreservesShapeF2_OnAreaRow_EntersRenameModeF2_OnInstanceRow_DoesNothing(rename out of scope)EscapeDuringInlineRename_CancelsContextMenu_AreaMove_OpensDialogWithCycleFreeOptionsContextMenu_InstanceMove_OpensDialogWithSameSiteAreasOnlyContextMenu_SiteCreateInstance_NavigatesWithSiteIdQueryLegacyInstancesRoute_RoutesToTopologyPage(visiting/deployment/instancesresolves to the same component)
Removal cleanup
- Drop
InstancesPageTestsand anyAreaPageTestsalong with the source files.
Edge cases
- Two sites with the same area name at root — fine. Same-site uniqueness is the rule; areas in different sites are independent.
- Move an area while it has an instance assigned at its root — allowed. The instance keeps the same
AreaId; the area's new parent doesn't affect it. - Site with no areas, just root instances — instance rows render directly under the site node.
- Concurrent rename of a node by another user — last-write-wins (consistent with template policy).
- Search match inside a collapsed branch — auto-expand the ancestor chain so the highlighted match is visible.
- Network failure during inline rename — leave the input open with the pending value; show the error in a toast; user can retry or Escape.
- Deleting an area, then immediately Ctrl+Z — not supported (no undo); destructive actions are confirmed via
ConfirmDialogand audited.
Out of scope
- Cross-site moves (would need new
Instance.SiteIdrebinding semantics, deployment-record handling, name-collision check at new site). - Drag-and-drop reordering of areas (no ordinal column today; arbitrary alpha-sort).
- Bulk operations (select multiple instances and move/deploy together).
- Search across templates / sites / instances from the same input (the search is scoped to this page's tree).
- Instance rename. No
RenameInstanceAsyncinInstanceServicetoday; adding one requires a separate design pass (site-side actor identity, deployment-record rebinding, audit history continuity). Users wanting to rename should delete + recreate.
Out-of-band consistency tasks
When this lands, the following docs need a touch-up:
README.md— component table; verify no reference to the removed Instances/Areas pages remains.docs/requirements/Component-CentralUI.md(or the routing section if one exists) — route table.src/ZB.MOM.WW.ScadaBridge.CLI/README.md— if existing CLI examples referenceareasubcommands, align with the optional CLIarea moveaddition.
Confirmed clean (no edit needed):
CLAUDE.mddoes not reference/deployment/instancesor/admin/areastoday.