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.
15 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)
- Add
MoveAreaCommandmessage +ManagementServicehandler that wrapsMoveAreaAsync. - Add CLI:
cli area move --id X --parent-id Y --username … --password …(omit--parent-idto 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 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/ScadaLink.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/ScadaLink.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/ScadaLink.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.