feat(ui/deployment): consolidate sites/areas/instances into Topology page
Single /deployment/topology page replaces /deployment/instances (legacy URL preserved as a secondary @page directive) and the /admin/areas* CRUD pages. TreeView with Site → Area → Instance, V1–V7 visual guide (bi-building / bi-diagram-3 / bi-box), always-visible empty containers, search dim, F2 inline area rename, and right-click context menus per node kind (Add Area, Move to Area…, lifecycle actions, etc.). Adds AreaService.MoveAreaAsync with cycle prevention, same-site enforcement, and name-collision check at the new parent. Instance rename intentionally out of scope — UniqueName is the site-side actor identity, requires its own design pass.
This commit is contained in:
266
docs/plans/2026-05-11-deployment-topology-page-design.md
Normal file
266
docs/plans/2026-05-11-deployment-topology-page-design.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 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).
|
||||
|
||||
- `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)
|
||||
- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.
|
||||
- Add CLI: `cli area move --id X --parent-id Y --username … --password …` (omit `--parent-id` to move to site root).
|
||||
|
||||
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/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor (~500 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor (~50 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor (~55 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor (~60 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/RenameAreaDialog.razor (~45 lines) (optional if inline-only)
|
||||
```
|
||||
|
||||
## Files to modify
|
||||
|
||||
```
|
||||
src/ScadaLink.TemplateEngine/Services/AreaService.cs (+ MoveAreaAsync, ~40 lines)
|
||||
src/ScadaLink.Commons/Interfaces/... (interface for AreaService if exposed)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
|
||||
(+ SiteId, AreaId query-param SupplyParameterFromQuery;
|
||||
retarget back-nav to /deployment/topology — 3 sites)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
|
||||
(retarget back-nav to /deployment/topology — 1 site)
|
||||
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor (replace 'Instances' nav with 'Topology' at /deployment/topology;
|
||||
remove 'Areas' nav under Admin)
|
||||
tests/ScadaLink.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/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor (replaced by Topology.razor; old route preserved as secondary @page)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaAdd.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaEdit.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaDelete.razor
|
||||
tests/ScadaLink.CentralUI.Tests/InstancesPageTests.cs (if it exists)
|
||||
tests/ScadaLink.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/ScadaLink.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/ScadaLink.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/ScadaLink.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.
|
||||
Reference in New Issue
Block a user