# 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 `` 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.