diff --git a/docs/plans/2026-05-11-deployment-topology-page-design.md b/docs/plans/2026-05-11-deployment-topology-page-design.md new file mode 100644 index 0000000..0109178 --- /dev/null +++ b/docs/plans/2026-05-11-deployment-topology-page-design.md @@ -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 `` 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. diff --git a/docs/requirements/Component-TreeView.md b/docs/requirements/Component-TreeView.md index 7efb214..1d20b45 100644 --- a/docs/requirements/Component-TreeView.md +++ b/docs/requirements/Component-TreeView.md @@ -704,34 +704,52 @@ record TestNode(string Key, string Label, List Children); ## Page Integration Notes -### 1. Instances Page (`/deployment/instances` — Instances.razor) +### 1. Topology Page (`/deployment/topology` — Topology.razor) -**Current state:** Flat table with filters (Site, Template, Status, Search), pagination, and 6 inline action buttons per row (Deploy, Disable/Enable, Configure, Diff, Delete). ~490 lines. +The Topology page is the single home for Site → Area → Instance hierarchy management. It replaces the former `/deployment/instances` page (the legacy URL is retained as a secondary `@page` directive on `Topology.razor` so existing bookmarks resolve) and the former `/admin/areas*` admin pages. -**Change to:** -- Replace the `` with a `` showing Site → Area → Sub Area → Instance hierarchy. -- **Keep the existing filter bar** (Site, Template, Status, Search). Filters control which tree roots and leaves are shown: - - Site filter: pass only the matching site root to `Items`. - - Template/Status/Search filters: filter at the instance (leaf) level. Branch nodes with no matching descendants should be pruned from the tree. Build a helper method (`BuildFilteredTree()`) that walks the hierarchy bottom-up, keeping only branches that contain at least one matching instance. -- **Remove the table, pagination, and Actions column.** The tree replaces all of this. -- **Move all 6 action buttons into the `ContextMenu` fragment**, shown only for instance nodes: - - Deploy/Redeploy, Disable/Enable (conditional on state), Configure, Diff, Delete (with divider). - - Site and Area nodes get no context menu (browser default). -- **Node content per type:** - - Site nodes: `SiteName` - - Area nodes: `AreaName` - - Instance nodes: `UniqueName` + status badge + staleness badge -- **Tree model:** Build in `LoadDataAsync` — load sites, areas (recursive via `ParentAreaId`), instances. Group instances by `SiteId` + `AreaId`. Instances with `AreaId == null` attach directly under their site. Wrap in a uniform `TreeNode` record. -- **StorageKey:** `"instances-tree"` -- **Selection:** Enable selection. Clicking an instance could show a detail panel or simply highlight it for context menu use. +**Scope:** +- Structural management of areas (create, rename inline, move, delete) and instance placement (move to area). +- Instance lifecycle: Deploy/Redeploy, Enable/Disable, Configure, Diff, Delete via per-node context menu. +- Search-only filter row (single text input) — dims non-matching rows, preserves tree shape, no collapse. -**Files to modify:** -- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor` — replace table with TreeView, add tree model building, move actions to context menu, keep filter bar. +**TreeView wiring:** +- `Items` = list of Site root nodes built from `_sites`, `_allAreas`, and `_allInstances`. +- `KeySelector` returns prefixed keys (`s:{id}`, `a:{id}`, `i:{id}`). +- `StorageKey` = `"topology-tree"` for expansion state. +- A separate `topology-tree-selected` sessionStorage key persists the selected node across navigation. +- `Selectable` = true; selection does not navigate (instance configure goes through the context menu). +- Empty containers always rendered (so they can be drop/move targets). -**Removed code:** -- Pagination logic (`_currentPage`, `_totalPages`, `_pagedInstances`, `GoToPage`) -- Actions column markup -- `
` / `` / `` structure +**Glyphs (V1–V7 visual guide):** +- Site: `bi-building` +- Area: `bi-diagram-3` +- Instance: `bi-box` + state badge + Stale/Current badge when deployed. + +**Context menus:** +- **Site:** Add Area, Create Instance here. +- **Area:** Add Sub-area, Create Instance here, Move to Area…, Rename… (also F2 / double-click inline), Delete. +- **Instance:** Deploy/Redeploy, Enable/Disable (state-dependent), Configure, Diff, Move to Area…, Delete. Instance rename is intentionally absent (see "Instance rename" below). + +**Inline rename:** Area rows only. F2 or double-click swaps the label for an input bound to a local buffer. Enter commits via `AreaService.UpdateAreaAsync`; Escape cancels; server validation errors stay surfaced inline. + +**Search behavior:** Single text input above the tree. While text is present, any row whose label does not match (case-insensitive substring) and whose subtree contains no match is rendered at `opacity: 0.4`. The tree shape stays intact. + +**Top-of-page buttons:** `+ Area` (opens `CreateAreaDialog` with site picker), `+ Instance` (navigates to `/deployment/instances/create` with no preselection), `Refresh`, `Expand`, `Collapse`. + +**Files added:** +- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor` +- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor` +- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor` +- `src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor` + +**Files removed:** +- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor` +- `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor` (and AreaAdd / AreaEdit / AreaDelete) + +**Backend addition:** `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` adds area re-parenting (cycle prevention, same-site, name collision at new parent). Pairs with the existing `InstanceService.AssignToAreaAsync`. + +**Instance rename:** Out of scope for this page. `InstanceService` does not currently support renaming an instance (`UniqueName` is also the site-side `InstanceActor` identity and appears in deployment records). A separate design pass is required if rename is wanted. --- @@ -761,35 +779,7 @@ record TestNode(string Key, string Label, List Children); --- -### 3. Areas Page (`/admin/areas` — Areas.razor) - -**Current state:** Two-panel layout. Left panel: site list (`list-group`). Right panel: manually indented flat tree of areas for the selected site, with `[+]`/`-` indicators, inline Edit/Delete buttons, and an add/edit form. Custom `BuildFlatTree()` / `AddChildren()` methods, `AreaTreeNode` record, manual `padding-left` indentation. ~293 lines. - -**Change to:** -- **Keep the two-panel layout** (site list on left, area tree on right). -- Replace the custom flat-tree rendering in the right panel with a `` component. -- **Site selection stays as-is** (left panel `list-group` click sets `_selectedSiteId`). This acts as the external filter — the TreeView receives only the selected site's areas as `Items`. -- **Move Edit and Delete into the `ContextMenu` fragment** for area nodes: - - Edit → loads area into the add/edit form (same as current behavior) - - Delete → shows confirm dialog (with child check, same as current) -- **Node content:** `AreaName` — optionally show instance count if available. -- **Tree model:** For the selected site, load root areas (`ParentAreaId == null`), use `ChildrenSelector` to return child areas. The `Area` entity already has `Children` collection, so it can be used directly as `TItem` without a wrapper record — `ChildrenSelector = a => a.Children.ToList()`, `HasChildrenSelector = a => a.Children.Any()`, `KeySelector = a => a.Id`. -- **Keep the add/edit form** at the top of the right panel (above the tree). The "Parent Area" dropdown stays. -- **StorageKey:** `"areas-tree"` - -**Files to modify:** -- `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor` — replace custom flat-tree rendering with TreeView, remove `BuildFlatTree()`, `AddChildren()`, `AreaTreeNode` record, manual indentation CSS. - -**Removed code:** -- `BuildFlatTree()` method -- `AddChildren()` recursive helper -- `AreaTreeNode` record -- Manual `padding-left` indentation -- Custom `[+]`/`-` toggle rendering -- Inline Edit/Delete buttons in the tree rows - ## Interactions -- **DataTable**: The tree replaces flat tables on the three pages listed above. Other pages that don't need hierarchy continue using DataTable. -- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`. -- **Areas.razor**: The simplest integration — `Area` entity used directly as `TItem`, no wrapper needed. +- **DataTable**: The tree replaces flat tables on the Topology and Data Connections pages. Other pages that don't need hierarchy continue using DataTable. +- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`. Back-nav returns to `/deployment/topology`. diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 570b02d..5bceae1 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -42,9 +42,6 @@ - @@ -53,7 +50,7 @@