Files
ScadaBridge/docs/plans/2026-05-11-deployment-topology-page-design.md
T
Joseph Doherty 88638d774a feat(cli,management): close area-move and template-folder CLI parity gaps
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.
2026-08-01 11:15:24 -04:00

267 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 V1V7 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` V1V7 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).
- `F2` or double-click on the label of an Area row replaces the label span with an `<input>` bound to a local edit buffer.
- `Enter` commits via `AreaService.UpdateAreaAsync(areaId, name, user)`.
- `Escape` cancels.
- 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.UniqueName` is also the identity of the site-side `InstanceActor` (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:
1. Area exists.
2. `newParentAreaId` is null OR refers to an area in the **same site** as the area being moved.
3. `newParentAreaId != areaId` (not self).
4. The new parent is not a descendant of the area being moved (cycle prevention) — reuse the existing descendant-walking helper that `DeleteAreaAsync` uses.
5. 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 by `MoveInstanceDialog`)
- `AreaService.DeleteAreaAsync` (keep current block-on-non-empty semantics)
- `AreaService.UpdateAreaAsync` (stays name-only)
- `InstanceService` lifecycle methods (already used by current Instances page)
### CLI / ManagementService parity (optional follow-up) — **DONE 2026-08-01**
- ~~Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.~~ Shipped: `MoveAreaCommand(int AreaId, int? NewParentAreaId)` in `Commons/Messages/Management/SiteCommands.cs`, dispatched by `ManagementActor.HandleMoveArea` (delegates to `AreaService.MoveAreaAsync`; failures surface as the standard curated failure response), gated any-of `[Designer, Deployer]` like the other area mutations.
- ~~Add CLI: `cli area move --id X --parent-id Y …` (omit `--parent-id` to move to site root).~~ Shipped as **`scadabridge site area move --id X [--parent-id Y]`** — the area verbs live under the existing `site area` group, not at the CLI root, so the verb was placed alongside `site area create|update|delete` rather 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 by `TreeView.StorageKey`.
- `topology-tree-selected` (sessionStorage) — selected node key. New; the `TreeView` already exposes `SelectedKey` two-way binding, but the page is responsible for persisting it. Pattern: write in `SelectedKeyChanged`, read on `OnAfterRenderAsync` after data load.
## Tests
### Unit (`tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AreaServiceTests.cs`)
- `MoveArea_ToOtherArea_Succeeds`
- `MoveArea_ToSiteRoot_Succeeds` (newParentAreaId = null)
- `MoveArea_ToSelf_Fails`
- `MoveArea_ToDescendant_FailsWithCycleError`
- `MoveArea_DifferentSite_Fails`
- `MoveArea_NameCollidesAtNewParent_Fails`
- `MoveArea_NameUniqueAtNewParent_Succeeds`
- `MoveArea_AuditLogged`
### bUnit (`tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs`)
- `Renders_EmptyState_WhenNoSites`
- `Renders_EmptySite_WhenSiteHasNoAreasOrInstances` (empty containers visible)
- `Renders_SiteAreaInstance_Nesting`
- `Search_DimsNonMatches_PreservesShape`
- `F2_OnAreaRow_EntersRenameMode`
- `F2_OnInstanceRow_DoesNothing` (rename out of scope)
- `EscapeDuringInlineRename_Cancels`
- `ContextMenu_AreaMove_OpensDialogWithCycleFreeOptions`
- `ContextMenu_InstanceMove_OpensDialogWithSameSiteAreasOnly`
- `ContextMenu_SiteCreateInstance_NavigatesWithSiteIdQuery`
- `LegacyInstancesRoute_RoutesToTopologyPage` (visiting `/deployment/instances` resolves to the same component)
### Removal cleanup
- Drop `InstancesPageTests` and any `AreaPageTests` along 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 `ConfirmDialog` and audited.
## Out of scope
- Cross-site moves (would need new `Instance.SiteId` rebinding 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 `RenameInstanceAsync` in `InstanceService` today; 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 reference `area` subcommands, align with the optional CLI `area move` addition.
Confirmed clean (no edit needed):
- `CLAUDE.md` does not reference `/deployment/instances` or `/admin/areas` today.