Files
scadalink-design/docs/plans/2026-05-11-deployment-topology-page-design.md
Joseph Doherty f3386d0278 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.
2026-05-11 22:03:55 -04:00

15 KiB
Raw Blame History

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)

  • 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.