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.
|
||||||
@@ -704,34 +704,52 @@ record TestNode(string Key, string Label, List<TestNode> Children);
|
|||||||
|
|
||||||
## Page Integration Notes
|
## 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:**
|
**Scope:**
|
||||||
- Replace the `<table>` with a `<TreeView>` showing Site → Area → Sub Area → Instance hierarchy.
|
- Structural management of areas (create, rename inline, move, delete) and instance placement (move to area).
|
||||||
- **Keep the existing filter bar** (Site, Template, Status, Search). Filters control which tree roots and leaves are shown:
|
- Instance lifecycle: Deploy/Redeploy, Enable/Disable, Configure, Diff, Delete via per-node context menu.
|
||||||
- Site filter: pass only the matching site root to `Items`.
|
- Search-only filter row (single text input) — dims non-matching rows, preserves tree shape, no collapse.
|
||||||
- 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: `<span class="fw-semibold">SiteName</span>`
|
|
||||||
- Area nodes: `<span class="text-secondary">AreaName</span>`
|
|
||||||
- Instance nodes: `<span>UniqueName</span>` + 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.
|
|
||||||
|
|
||||||
**Files to modify:**
|
**TreeView wiring:**
|
||||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor` — replace table with TreeView, add tree model building, move actions to context menu, keep filter bar.
|
- `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:**
|
**Glyphs (V1–V7 visual guide):**
|
||||||
- Pagination logic (`_currentPage`, `_totalPages`, `_pagedInstances`, `GoToPage`)
|
- Site: `bi-building`
|
||||||
- Actions column markup
|
- Area: `bi-diagram-3`
|
||||||
- `<table>` / `<thead>` / `<tbody>` structure
|
- 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<TestNode> 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 `<TreeView>` 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:** `<span>AreaName</span>` — 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
|
## Interactions
|
||||||
|
|
||||||
- **DataTable**: The tree replaces flat tables on the three pages listed above. Other pages that don't need hierarchy continue using DataTable.
|
- **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`.
|
- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`. Back-nav returns to `/deployment/topology`.
|
||||||
- **Areas.razor**: The simplest integration — `Area` entity used directly as `TItem`, no wrapper needed.
|
|
||||||
|
|||||||
@@ -42,9 +42,6 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/admin/areas">Areas</NavLink>
|
|
||||||
</li>
|
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@@ -53,7 +50,7 @@
|
|||||||
<Authorized Context="deploymentContext">
|
<Authorized Context="deploymentContext">
|
||||||
<li class="nav-section-header">Deployment</li>
|
<li class="nav-section-header">Deployment</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/deployment/instances">Instances</NavLink>
|
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
@page "/admin/areas/add"
|
|
||||||
@using ScadaLink.Security
|
|
||||||
@using ScadaLink.Commons.Entities.Instances
|
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
||||||
@inject ISiteRepository SiteRepository
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
|
||||||
<h4 class="mb-0">Add Area</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
|
||||||
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<LoadingSpinner IsLoading="true" />
|
|
||||||
}
|
|
||||||
else if (_errorMessage != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">@_errorMessage</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="card" style="max-width: 500px;">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small">Site</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" value="@_siteName" readonly />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small">Parent Area</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
|
||||||
<option value="0">(Root level)</option>
|
|
||||||
@foreach (var area in _areas)
|
|
||||||
{
|
|
||||||
<option value="@area.Id">@GetAreaPath(area)</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small">Name</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_name" placeholder="Area name" />
|
|
||||||
</div>
|
|
||||||
@if (_formError != null)
|
|
||||||
{
|
|
||||||
<div class="text-danger small mb-2">@_formError</div>
|
|
||||||
}
|
|
||||||
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[SupplyParameterFromQuery] public int SiteId { get; set; }
|
|
||||||
[SupplyParameterFromQuery] public int ParentAreaId { get; set; }
|
|
||||||
|
|
||||||
private string _siteName = string.Empty;
|
|
||||||
private List<Area> _areas = new();
|
|
||||||
private int _parentAreaId;
|
|
||||||
private string _name = string.Empty;
|
|
||||||
private string? _formError;
|
|
||||||
private string? _errorMessage;
|
|
||||||
private bool _loading = true;
|
|
||||||
private bool _saving;
|
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.Id == SiteId);
|
|
||||||
_siteName = site?.Name ?? $"Site #{SiteId}";
|
|
||||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(SiteId)).ToList();
|
|
||||||
_parentAreaId = ParentAreaId;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Failed to load: {ex.Message}";
|
|
||||||
}
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetAreaPath(Area area)
|
|
||||||
{
|
|
||||||
var parts = new List<string>();
|
|
||||||
var current = area;
|
|
||||||
while (current != null)
|
|
||||||
{
|
|
||||||
parts.Insert(0, current.Name);
|
|
||||||
current = current.ParentAreaId.HasValue
|
|
||||||
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
return string.Join(" / ", parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Save()
|
|
||||||
{
|
|
||||||
_formError = null;
|
|
||||||
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
|
||||||
|
|
||||||
_saving = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var area = new Area(_name.Trim())
|
|
||||||
{
|
|
||||||
SiteId = SiteId,
|
|
||||||
ParentAreaId = _parentAreaId == 0 ? null : _parentAreaId
|
|
||||||
};
|
|
||||||
await TemplateEngineRepository.AddAreaAsync(area);
|
|
||||||
await TemplateEngineRepository.SaveChangesAsync();
|
|
||||||
NavigationManager.NavigateTo("/admin/areas");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_formError = $"Save failed: {ex.Message}";
|
|
||||||
}
|
|
||||||
_saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
@page "/admin/areas/{Id:int}/delete"
|
|
||||||
@using ScadaLink.Security
|
|
||||||
@using ScadaLink.Commons.Entities.Instances
|
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
||||||
@inject ISiteRepository SiteRepository
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
|
||||||
<h4 class="mb-0">Delete Area</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
|
||||||
<ConfirmDialog @ref="_confirmDialog" />
|
|
||||||
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<LoadingSpinner IsLoading="true" />
|
|
||||||
}
|
|
||||||
else if (_errorMessage != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">@_errorMessage</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="card mb-3" style="max-width: 700px;">
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
You are about to delete area <strong>@_area!.Name</strong>.
|
|
||||||
@if (_hasBlockingInstances)
|
|
||||||
{
|
|
||||||
<span class="text-danger">This area (or its children) has instances assigned. Remove or reassign instances before deleting.</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h6 class="mt-3">Area hierarchy with assigned instances:</h6>
|
|
||||||
|
|
||||||
<TreeView TItem="DeleteTreeNode" Items="_treeRoots"
|
|
||||||
ChildrenSelector="n => n.Children"
|
|
||||||
HasChildrenSelector="n => n.Children.Count > 0"
|
|
||||||
KeySelector="n => n.Key"
|
|
||||||
InitiallyExpanded="_ => true">
|
|
||||||
<NodeContent Context="node">
|
|
||||||
@if (node.Kind == DeleteNodeKind.Area)
|
|
||||||
{
|
|
||||||
<span class="@(node.HasInstances ? "text-danger fw-semibold" : "")">@node.Label</span>
|
|
||||||
@if (node.HasInstances)
|
|
||||||
{
|
|
||||||
<span class="badge bg-danger ms-1">@node.InstanceCount instance(s)</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="text-muted small">@node.Label</span>
|
|
||||||
}
|
|
||||||
</NodeContent>
|
|
||||||
<EmptyContent>
|
|
||||||
<span class="text-muted fst-italic">No child areas.</span>
|
|
||||||
</EmptyContent>
|
|
||||||
</TreeView>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
@if (_hasBlockingInstances)
|
|
||||||
{
|
|
||||||
<button class="btn btn-danger btn-sm" disabled>Delete (blocked)</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-danger btn-sm" @onclick="Delete" disabled="@_deleting">Delete Area</button>
|
|
||||||
}
|
|
||||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm ms-2">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public int Id { get; set; }
|
|
||||||
|
|
||||||
record DeleteTreeNode(string Key, string Label, DeleteNodeKind Kind, List<DeleteTreeNode> Children,
|
|
||||||
bool HasInstances = false, int InstanceCount = 0);
|
|
||||||
enum DeleteNodeKind { Area, Instance }
|
|
||||||
|
|
||||||
private Area? _area;
|
|
||||||
private List<DeleteTreeNode> _treeRoots = new();
|
|
||||||
private bool _hasBlockingInstances;
|
|
||||||
private bool _loading = true;
|
|
||||||
private bool _deleting;
|
|
||||||
private string? _errorMessage;
|
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
|
||||||
if (_area == null)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Area #{Id} not found.";
|
|
||||||
_loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all areas for this site to build hierarchy
|
|
||||||
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_area.SiteId)).ToList();
|
|
||||||
var allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
|
|
||||||
.Where(i => i.SiteId == _area.SiteId)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var rootNode = BuildDeleteTree(_area, allAreas, allInstances);
|
|
||||||
_treeRoots = new List<DeleteTreeNode> { rootNode };
|
|
||||||
_hasBlockingInstances = HasAnyInstances(rootNode);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Failed to load: {ex.Message}";
|
|
||||||
}
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DeleteTreeNode BuildDeleteTree(Area area, List<Area> allAreas, List<Instance> allInstances)
|
|
||||||
{
|
|
||||||
var children = new List<DeleteTreeNode>();
|
|
||||||
|
|
||||||
// Add child areas recursively
|
|
||||||
var childAreas = allAreas.Where(a => a.ParentAreaId == area.Id).OrderBy(a => a.Name);
|
|
||||||
foreach (var child in childAreas)
|
|
||||||
{
|
|
||||||
children.Add(BuildDeleteTree(child, allAreas, allInstances));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add instances assigned to this area
|
|
||||||
var areaInstances = allInstances.Where(i => i.AreaId == area.Id).OrderBy(i => i.UniqueName);
|
|
||||||
foreach (var inst in areaInstances)
|
|
||||||
{
|
|
||||||
children.Add(new DeleteTreeNode(
|
|
||||||
Key: $"inst-{inst.Id}",
|
|
||||||
Label: inst.UniqueName,
|
|
||||||
Kind: DeleteNodeKind.Instance,
|
|
||||||
Children: new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
var instanceCount = areaInstances.Count();
|
|
||||||
return new DeleteTreeNode(
|
|
||||||
Key: $"area-{area.Id}",
|
|
||||||
Label: area.Name,
|
|
||||||
Kind: DeleteNodeKind.Area,
|
|
||||||
Children: children,
|
|
||||||
HasInstances: instanceCount > 0,
|
|
||||||
InstanceCount: instanceCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool HasAnyInstances(DeleteTreeNode node)
|
|
||||||
{
|
|
||||||
if (node.Kind == DeleteNodeKind.Instance) return true;
|
|
||||||
return node.Children.Any(HasAnyInstances);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Delete()
|
|
||||||
{
|
|
||||||
var confirmed = await _confirmDialog.ShowAsync(
|
|
||||||
$"Permanently delete area '{_area!.Name}' and all its child areas?",
|
|
||||||
"Confirm Delete");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
_deleting = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Delete child areas bottom-up (deepest first)
|
|
||||||
await DeleteAreaRecursive(_area!);
|
|
||||||
await TemplateEngineRepository.SaveChangesAsync();
|
|
||||||
NavigationManager.NavigateTo("/admin/areas");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
_deleting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAreaRecursive(Area area)
|
|
||||||
{
|
|
||||||
// Load fresh children in case the collection wasn't populated
|
|
||||||
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(area.SiteId)).ToList();
|
|
||||||
var children = allAreas.Where(a => a.ParentAreaId == area.Id).ToList();
|
|
||||||
foreach (var child in children)
|
|
||||||
{
|
|
||||||
await DeleteAreaRecursive(child);
|
|
||||||
}
|
|
||||||
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
@page "/admin/areas/{Id:int}/edit"
|
|
||||||
@using ScadaLink.Security
|
|
||||||
@using ScadaLink.Commons.Entities.Instances
|
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
|
||||||
<h4 class="mb-0">Edit Area</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
|
||||||
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<LoadingSpinner IsLoading="true" />
|
|
||||||
}
|
|
||||||
else if (_errorMessage != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">@_errorMessage</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="card" style="max-width: 500px;">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small">Name</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_name" />
|
|
||||||
</div>
|
|
||||||
@if (_formError != null)
|
|
||||||
{
|
|
||||||
<div class="text-danger small mb-2">@_formError</div>
|
|
||||||
}
|
|
||||||
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public int Id { get; set; }
|
|
||||||
|
|
||||||
private Area? _area;
|
|
||||||
private string _name = string.Empty;
|
|
||||||
private string? _formError;
|
|
||||||
private string? _errorMessage;
|
|
||||||
private bool _loading = true;
|
|
||||||
private bool _saving;
|
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
|
||||||
if (_area == null)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Area #{Id} not found.";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_name = _area.Name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Failed to load area: {ex.Message}";
|
|
||||||
}
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Save()
|
|
||||||
{
|
|
||||||
_formError = null;
|
|
||||||
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
|
||||||
|
|
||||||
_saving = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_area!.Name = _name.Trim();
|
|
||||||
await TemplateEngineRepository.UpdateAreaAsync(_area);
|
|
||||||
await TemplateEngineRepository.SaveChangesAsync();
|
|
||||||
NavigationManager.NavigateTo("/admin/areas");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_formError = $"Save failed: {ex.Message}";
|
|
||||||
}
|
|
||||||
_saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
@page "/admin/areas"
|
|
||||||
@using ScadaLink.Security
|
|
||||||
@using ScadaLink.Commons.Entities.Instances
|
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
||||||
@inject ISiteRepository SiteRepository
|
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
|
||||||
<h4 class="mb-3">Area Management</h4>
|
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
|
||||||
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<LoadingSpinner IsLoading="true" />
|
|
||||||
}
|
|
||||||
else if (_errorMessage != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">@_errorMessage</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
|
|
||||||
ChildrenSelector="n => n.Children"
|
|
||||||
HasChildrenSelector="n => n.Children.Count > 0"
|
|
||||||
KeySelector="n => n.Key"
|
|
||||||
StorageKey="areas-tree">
|
|
||||||
<NodeContent Context="node">
|
|
||||||
@if (node.Kind == AreaNodeKind.Site)
|
|
||||||
{
|
|
||||||
<span class="fw-semibold">@node.Label</span>
|
|
||||||
<span class="badge bg-secondary ms-1">@node.AreaCount</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span>@node.Label</span>
|
|
||||||
}
|
|
||||||
</NodeContent>
|
|
||||||
<ContextMenu Context="node">
|
|
||||||
@if (node.Kind == AreaNodeKind.Site)
|
|
||||||
{
|
|
||||||
<button class="dropdown-item"
|
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}")'>
|
|
||||||
Add Area
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="dropdown-item"
|
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}&parentAreaId={node.Area!.Id}")'>
|
|
||||||
Add Child Area
|
|
||||||
</button>
|
|
||||||
<button class="dropdown-item"
|
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/edit")'>
|
|
||||||
Edit Area
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<button class="dropdown-item text-danger"
|
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/delete")'>
|
|
||||||
Delete Area
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</ContextMenu>
|
|
||||||
<EmptyContent>
|
|
||||||
<span class="text-muted fst-italic">No sites configured.</span>
|
|
||||||
</EmptyContent>
|
|
||||||
</TreeView>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
record AreaTreeNode(string Key, string Label, AreaNodeKind Kind, List<AreaTreeNode> Children,
|
|
||||||
int SiteId, Area? Area = null, int AreaCount = 0);
|
|
||||||
enum AreaNodeKind { Site, Area }
|
|
||||||
|
|
||||||
private List<Site> _sites = new();
|
|
||||||
private List<AreaTreeNode> _treeRoots = new();
|
|
||||||
private bool _loading = true;
|
|
||||||
private string? _errorMessage;
|
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await LoadDataAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadDataAsync()
|
|
||||||
{
|
|
||||||
_loading = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
|
||||||
_treeRoots = new();
|
|
||||||
foreach (var site in _sites)
|
|
||||||
{
|
|
||||||
var areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id)).ToList();
|
|
||||||
var rootAreas = areas.Where(a => a.ParentAreaId == null).OrderBy(a => a.Name);
|
|
||||||
var children = rootAreas.Select(a => BuildAreaNode(a, site.Id)).ToList();
|
|
||||||
_treeRoots.Add(new AreaTreeNode(
|
|
||||||
Key: $"site-{site.Id}",
|
|
||||||
Label: site.Name,
|
|
||||||
Kind: AreaNodeKind.Site,
|
|
||||||
Children: children,
|
|
||||||
SiteId: site.Id,
|
|
||||||
AreaCount: areas.Count));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Failed to load data: {ex.Message}";
|
|
||||||
}
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AreaTreeNode BuildAreaNode(Area area, int siteId)
|
|
||||||
{
|
|
||||||
var children = area.Children
|
|
||||||
.OrderBy(c => c.Name)
|
|
||||||
.Select(c => BuildAreaNode(c, siteId))
|
|
||||||
.ToList();
|
|
||||||
return new AreaTreeNode(
|
|
||||||
Key: $"area-{area.Id}",
|
|
||||||
Label: area.Name,
|
|
||||||
Kind: AreaNodeKind.Area,
|
|
||||||
Children: children,
|
|
||||||
SiteId: siteId,
|
|
||||||
Area: area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@if (IsVisible)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">New Area</h6>
|
||||||
|
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (RequireSitePicker)
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Site</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="_siteId">
|
||||||
|
<option value="0">(Select a site)</option>
|
||||||
|
@foreach (var opt in SiteOptions)
|
||||||
|
{
|
||||||
|
<option value="@opt.Id">@opt.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Parent area</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
||||||
|
<option value="0">(Site root)</option>
|
||||||
|
@foreach (var opt in ParentOptions.Where(o => SelectedSiteMatches(o)))
|
||||||
|
{
|
||||||
|
<option value="@opt.Id">@opt.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="text-muted small mb-2">
|
||||||
|
@ContextLabel
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input class="form-control form-control-sm" placeholder="Area name" @bind="_name" />
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsVisible { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||||
|
[Parameter] public bool RequireSitePicker { get; set; }
|
||||||
|
[Parameter] public string ContextLabel { get; set; } = string.Empty;
|
||||||
|
[Parameter] public int? SiteId { get; set; }
|
||||||
|
[Parameter] public int? ParentAreaId { get; set; }
|
||||||
|
[Parameter] public IEnumerable<(int Id, string Label)> SiteOptions { get; set; } = Array.Empty<(int, string)>();
|
||||||
|
[Parameter] public IEnumerable<(int Id, string Label, int SiteId)> ParentOptions { get; set; } = Array.Empty<(int, string, int)>();
|
||||||
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
|
[Parameter] public EventCallback<(int SiteId, int? ParentAreaId, string Name)> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
private bool _wasVisible;
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private int _siteId;
|
||||||
|
private int _parentAreaId;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (IsVisible && !_wasVisible)
|
||||||
|
{
|
||||||
|
_name = string.Empty;
|
||||||
|
_siteId = SiteId ?? 0;
|
||||||
|
_parentAreaId = ParentAreaId ?? 0;
|
||||||
|
}
|
||||||
|
_wasVisible = IsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool SelectedSiteMatches((int Id, string Label, int SiteId) opt) =>
|
||||||
|
_siteId == 0 || opt.SiteId == _siteId;
|
||||||
|
|
||||||
|
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
var effectiveSite = RequireSitePicker ? _siteId : (SiteId ?? 0);
|
||||||
|
var effectiveParent = RequireSitePicker
|
||||||
|
? (_parentAreaId == 0 ? (int?)null : _parentAreaId)
|
||||||
|
: ParentAreaId;
|
||||||
|
await OnSubmit.InvokeAsync((effectiveSite, effectiveParent, _name.Trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Instances</button>
|
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Topology</button>
|
||||||
<h4 class="mb-0">Configure Instance</h4>
|
<h4 class="mb-0">Configure Instance</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
|
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
private async Task<string> GetCurrentUserAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<a href="/deployment/instances" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
<a href="/deployment/topology" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||||
<h4 class="mb-0">Create Instance</h4>
|
<h4 class="mb-0">Create Instance</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,6 +74,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[SupplyParameterFromQuery] public int? SiteId { get; set; }
|
||||||
|
[SupplyParameterFromQuery] public int? AreaId { get; set; }
|
||||||
|
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
private List<Template> _templates = new();
|
private List<Template> _templates = new();
|
||||||
private List<Area> _allAreas = new();
|
private List<Area> _allAreas = new();
|
||||||
@@ -98,6 +101,15 @@
|
|||||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
||||||
_allAreas.AddRange(areas);
|
_allAreas.AddRange(areas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (SiteId is int sid && _sites.Any(s => s.Id == sid))
|
||||||
|
{
|
||||||
|
_createSiteId = sid;
|
||||||
|
}
|
||||||
|
if (AreaId is int aid && _allAreas.Any(a => a.Id == aid && a.SiteId == _createSiteId))
|
||||||
|
{
|
||||||
|
_createAreaId = aid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -120,7 +132,7 @@
|
|||||||
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
|
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
NavigationManager.NavigateTo("/deployment/instances");
|
NavigationManager.NavigateTo("/deployment/topology");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -133,7 +145,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
|
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||||
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
private async Task<string> GetCurrentUserAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,504 +0,0 @@
|
|||||||
@page "/deployment/instances"
|
|
||||||
@using ScadaLink.Security
|
|
||||||
@using ScadaLink.Commons.Entities.Instances
|
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
|
||||||
@using ScadaLink.Commons.Entities.Templates
|
|
||||||
@using ScadaLink.Commons.Entities.Deployment
|
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
|
||||||
@using ScadaLink.Commons.Types.Enums
|
|
||||||
@using ScadaLink.DeploymentManager
|
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
||||||
@inject ISiteRepository SiteRepository
|
|
||||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
|
||||||
@inject DeploymentService DeploymentService
|
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h4 class="mb-0">Instances</h4>
|
|
||||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>Create Instance</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
|
||||||
<ConfirmDialog @ref="_confirmDialog" />
|
|
||||||
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<LoadingSpinner IsLoading="true" />
|
|
||||||
}
|
|
||||||
else if (_errorMessage != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">@_errorMessage</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@* Filters *@
|
|
||||||
<div class="row mb-3 g-2">
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Site</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="_filterSiteId" @bind:after="ApplyFilters">
|
|
||||||
<option value="0">All Sites</option>
|
|
||||||
@foreach (var site in _sites)
|
|
||||||
{
|
|
||||||
<option value="@site.Id">@site.Name</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Template</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="_filterTemplateId" @bind:after="ApplyFilters">
|
|
||||||
<option value="0">All Templates</option>
|
|
||||||
@foreach (var tmpl in _templates)
|
|
||||||
{
|
|
||||||
<option value="@tmpl.Id">@tmpl.Name</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Status</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="_filterStatus" @bind:after="ApplyFilters">
|
|
||||||
<option value="">All Statuses</option>
|
|
||||||
<option value="NotDeployed">Not Deployed</option>
|
|
||||||
<option value="Enabled">Enabled</option>
|
|
||||||
<option value="Disabled">Disabled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small">Search</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" placeholder="Instance name..."
|
|
||||||
@bind="_filterSearch" @bind:event="oninput" @bind:after="ApplyFilters" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 d-flex align-items-end">
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
|
|
||||||
ChildrenSelector="n => n.Children"
|
|
||||||
HasChildrenSelector="n => n.Children.Count > 0"
|
|
||||||
KeySelector="n => n.Key"
|
|
||||||
StorageKey="instances-tree"
|
|
||||||
Selectable="true"
|
|
||||||
SelectedKey="_selectedKey"
|
|
||||||
SelectedKeyChanged="key => { _selectedKey = key; }">
|
|
||||||
<NodeContent Context="node">
|
|
||||||
@switch (node.Kind)
|
|
||||||
{
|
|
||||||
case InstanceNodeKind.Site:
|
|
||||||
<span class="fw-semibold">@node.Label</span>
|
|
||||||
break;
|
|
||||||
case InstanceNodeKind.Area:
|
|
||||||
<span class="text-secondary">@node.Label</span>
|
|
||||||
break;
|
|
||||||
case InstanceNodeKind.Instance:
|
|
||||||
<span>@node.Label</span>
|
|
||||||
<span class="badge @GetStateBadge(node.Instance!.State) ms-1">@node.Instance!.State</span>
|
|
||||||
@if (node.Instance!.State != InstanceState.NotDeployed)
|
|
||||||
{
|
|
||||||
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
|
|
||||||
@(node.IsStale ? "Stale" : "Current")
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
</NodeContent>
|
|
||||||
<ContextMenu Context="node">
|
|
||||||
@if (node.Kind == InstanceNodeKind.Instance)
|
|
||||||
{
|
|
||||||
var inst = node.Instance!;
|
|
||||||
var isStale = node.IsStale;
|
|
||||||
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
|
|
||||||
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
|
|
||||||
@if (inst.State == InstanceState.Enabled)
|
|
||||||
{
|
|
||||||
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
|
|
||||||
disabled="@_actionInProgress">Disable</button>
|
|
||||||
}
|
|
||||||
else if (inst.State == InstanceState.Disabled)
|
|
||||||
{
|
|
||||||
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
|
|
||||||
disabled="@_actionInProgress">Enable</button>
|
|
||||||
}
|
|
||||||
<button class="dropdown-item"
|
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
|
|
||||||
Configure
|
|
||||||
</button>
|
|
||||||
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
|
|
||||||
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
|
|
||||||
disabled="@_actionInProgress">Delete</button>
|
|
||||||
}
|
|
||||||
</ContextMenu>
|
|
||||||
<EmptyContent>
|
|
||||||
<span class="text-muted fst-italic">No instances match the current filters.</span>
|
|
||||||
</EmptyContent>
|
|
||||||
</TreeView>
|
|
||||||
|
|
||||||
<div class="text-muted small mt-2">
|
|
||||||
@_filteredInstances.Count instance(s) total
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Diff Modal *@
|
|
||||||
@if (_showDiffModal)
|
|
||||||
{
|
|
||||||
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
|
|
||||||
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (_diffLoading)
|
|
||||||
{
|
|
||||||
<LoadingSpinner IsLoading="true" />
|
|
||||||
}
|
|
||||||
else if (_diffError != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">@_diffError</div>
|
|
||||||
}
|
|
||||||
else if (_diffResult != null)
|
|
||||||
{
|
|
||||||
<div class="mb-2">
|
|
||||||
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
|
|
||||||
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
|
|
||||||
</span>
|
|
||||||
<span class="text-muted small ms-2">
|
|
||||||
Deployed: @_diffResult.DeployedRevisionHash[..8]
|
|
||||||
| Current: @_diffResult.CurrentRevisionHash[..8]
|
|
||||||
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@if (!_diffResult.IsStale)
|
|
||||||
{
|
|
||||||
<p class="text-muted">No differences between deployed and current configuration.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private async Task<string> GetCurrentUserAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
record InstanceTreeNode(string Key, string Label, InstanceNodeKind Kind,
|
|
||||||
List<InstanceTreeNode> Children, Instance? Instance = null,
|
|
||||||
bool IsStale = false);
|
|
||||||
enum InstanceNodeKind { Site, Area, Instance }
|
|
||||||
|
|
||||||
private List<Instance> _allInstances = new();
|
|
||||||
private List<Instance> _filteredInstances = new();
|
|
||||||
private List<Site> _sites = new();
|
|
||||||
private List<Template> _templates = new();
|
|
||||||
private List<Area> _allAreas = new();
|
|
||||||
private Dictionary<int, bool> _stalenessMap = new();
|
|
||||||
private bool _loading = true;
|
|
||||||
private string? _errorMessage;
|
|
||||||
private bool _actionInProgress;
|
|
||||||
|
|
||||||
private int _filterSiteId;
|
|
||||||
private int _filterTemplateId;
|
|
||||||
private string _filterStatus = string.Empty;
|
|
||||||
private string _filterSearch = string.Empty;
|
|
||||||
|
|
||||||
private List<InstanceTreeNode> _treeRoots = new();
|
|
||||||
private TreeView<InstanceTreeNode> _instanceTree = default!;
|
|
||||||
private object? _selectedKey;
|
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await LoadDataAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadDataAsync()
|
|
||||||
{
|
|
||||||
_loading = true;
|
|
||||||
_errorMessage = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
|
||||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
|
||||||
|
|
||||||
// Load areas for display
|
|
||||||
_allAreas.Clear();
|
|
||||||
foreach (var site in _sites)
|
|
||||||
{
|
|
||||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
|
||||||
_allAreas.AddRange(areas);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check staleness for deployed instances
|
|
||||||
_stalenessMap.Clear();
|
|
||||||
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
|
||||||
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
_stalenessMap[inst.Id] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyFilters();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_errorMessage = $"Failed to load instances: {ex.Message}";
|
|
||||||
}
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyFilters()
|
|
||||||
{
|
|
||||||
_filteredInstances = _allInstances.Where(i =>
|
|
||||||
{
|
|
||||||
if (_filterSiteId > 0 && i.SiteId != _filterSiteId) return false;
|
|
||||||
if (_filterTemplateId > 0 && i.TemplateId != _filterTemplateId) return false;
|
|
||||||
if (!string.IsNullOrEmpty(_filterStatus) && i.State.ToString() != _filterStatus) return false;
|
|
||||||
if (!string.IsNullOrWhiteSpace(_filterSearch) &&
|
|
||||||
!i.UniqueName.Contains(_filterSearch, StringComparison.OrdinalIgnoreCase)) return false;
|
|
||||||
return true;
|
|
||||||
}).OrderBy(i => i.UniqueName).ToList();
|
|
||||||
|
|
||||||
BuildTree();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildTree()
|
|
||||||
{
|
|
||||||
_treeRoots = _sites.Select(site =>
|
|
||||||
{
|
|
||||||
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
|
|
||||||
var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();
|
|
||||||
|
|
||||||
var areaNodes = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
|
|
||||||
|
|
||||||
var unassigned = siteInstances
|
|
||||||
.Where(i => i.AreaId == null)
|
|
||||||
.Select(MakeInstanceNode)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var children = areaNodes.Concat(unassigned).ToList();
|
|
||||||
|
|
||||||
return new InstanceTreeNode(
|
|
||||||
Key: $"site-{site.Id}",
|
|
||||||
Label: site.Name,
|
|
||||||
Kind: InstanceNodeKind.Site,
|
|
||||||
Children: children);
|
|
||||||
})
|
|
||||||
.Where(s => s.Children.Count > 0)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
|
|
||||||
List<Instance> instances, int? parentId)
|
|
||||||
{
|
|
||||||
return allAreas
|
|
||||||
.Where(a => a.ParentAreaId == parentId)
|
|
||||||
.Select(area =>
|
|
||||||
{
|
|
||||||
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
|
|
||||||
var areaInstances = instances
|
|
||||||
.Where(i => i.AreaId == area.Id)
|
|
||||||
.Select(MakeInstanceNode)
|
|
||||||
.ToList();
|
|
||||||
var children = childAreas.Concat(areaInstances).ToList();
|
|
||||||
return new InstanceTreeNode(
|
|
||||||
Key: $"area-{area.Id}",
|
|
||||||
Label: area.Name,
|
|
||||||
Kind: InstanceNodeKind.Area,
|
|
||||||
Children: children);
|
|
||||||
})
|
|
||||||
.Where(a => a.Children.Count > 0)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private InstanceTreeNode MakeInstanceNode(Instance inst) => new(
|
|
||||||
Key: $"inst-{inst.Id}",
|
|
||||||
Label: inst.UniqueName,
|
|
||||||
Kind: InstanceNodeKind.Instance,
|
|
||||||
Children: new(),
|
|
||||||
Instance: inst,
|
|
||||||
IsStale: _stalenessMap.GetValueOrDefault(inst.Id));
|
|
||||||
|
|
||||||
private string GetTemplateName(int templateId) =>
|
|
||||||
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";
|
|
||||||
|
|
||||||
private string GetSiteName(int siteId) =>
|
|
||||||
_sites.FirstOrDefault(s => s.Id == siteId)?.Name ?? $"#{siteId}";
|
|
||||||
|
|
||||||
private string GetAreaName(int areaId) =>
|
|
||||||
_allAreas.FirstOrDefault(a => a.Id == areaId)?.Name ?? $"#{areaId}";
|
|
||||||
|
|
||||||
private static string GetStateBadge(InstanceState state) => state switch
|
|
||||||
{
|
|
||||||
InstanceState.Enabled => "bg-success",
|
|
||||||
InstanceState.Disabled => "bg-secondary",
|
|
||||||
InstanceState.NotDeployed => "bg-light text-dark",
|
|
||||||
_ => "bg-secondary"
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task EnableInstance(Instance inst)
|
|
||||||
{
|
|
||||||
_actionInProgress = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var user = await GetCurrentUserAsync();
|
|
||||||
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
|
|
||||||
await LoadDataAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Enable failed: {result.Error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Enable failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
_actionInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DisableInstance(Instance inst)
|
|
||||||
{
|
|
||||||
var confirmed = await _confirmDialog.ShowAsync(
|
|
||||||
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
|
||||||
"Disable Instance");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
_actionInProgress = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var user = await GetCurrentUserAsync();
|
|
||||||
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
|
|
||||||
await LoadDataAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Disable failed: {result.Error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Disable failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
_actionInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeployInstance(Instance inst)
|
|
||||||
{
|
|
||||||
_actionInProgress = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var user = await GetCurrentUserAsync();
|
|
||||||
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
|
|
||||||
await LoadDataAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Deploy failed: {result.Error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Deploy failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
_actionInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteInstance(Instance inst)
|
|
||||||
{
|
|
||||||
var confirmed = await _confirmDialog.ShowAsync(
|
|
||||||
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
|
|
||||||
"Delete Instance");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
_actionInProgress = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var user = await GetCurrentUserAsync();
|
|
||||||
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
|
|
||||||
await LoadDataAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Delete failed: {result.Error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
_actionInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diff state
|
|
||||||
private bool _showDiffModal;
|
|
||||||
private bool _diffLoading;
|
|
||||||
private string? _diffError;
|
|
||||||
private string _diffInstanceName = string.Empty;
|
|
||||||
private DeploymentComparisonResult? _diffResult;
|
|
||||||
|
|
||||||
private async Task ShowDiff(Instance inst)
|
|
||||||
{
|
|
||||||
_showDiffModal = true;
|
|
||||||
_diffLoading = true;
|
|
||||||
_diffError = null;
|
|
||||||
_diffResult = null;
|
|
||||||
_diffInstanceName = inst.UniqueName;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
|
||||||
_diffResult = result.Value;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_diffError = result.Error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_diffError = $"Failed to load diff: {ex.Message}";
|
|
||||||
}
|
|
||||||
_diffLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@if (IsVisible)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">Move area '@AreaName' to…</h6>
|
||||||
|
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<select class="form-select form-select-sm" @bind="_targetParentId">
|
||||||
|
@foreach (var opt in ParentOptions)
|
||||||
|
{
|
||||||
|
<option value="@opt.Id">@opt.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsVisible { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||||
|
[Parameter] public int AreaId { get; set; }
|
||||||
|
[Parameter] public string AreaName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public int? CurrentParentId { get; set; }
|
||||||
|
[Parameter] public IEnumerable<(int? Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||||
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
|
[Parameter] public EventCallback<(int AreaId, int? NewParentId)> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
private bool _wasVisible;
|
||||||
|
private int? _targetParentId;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (IsVisible && !_wasVisible)
|
||||||
|
{
|
||||||
|
_targetParentId = CurrentParentId;
|
||||||
|
}
|
||||||
|
_wasVisible = IsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||||
|
|
||||||
|
private async Task Submit() => await OnSubmit.InvokeAsync((AreaId, _targetParentId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@if (IsVisible)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">Move '@InstanceName' to…</h6>
|
||||||
|
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<select class="form-select form-select-sm" @bind="_targetAreaId">
|
||||||
|
@foreach (var opt in AreaOptions)
|
||||||
|
{
|
||||||
|
<option value="@opt.Id">@opt.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsVisible { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||||
|
[Parameter] public int InstanceId { get; set; }
|
||||||
|
[Parameter] public string InstanceName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public int? CurrentAreaId { get; set; }
|
||||||
|
[Parameter] public IEnumerable<(int? Id, string Label)> AreaOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||||
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
|
[Parameter] public EventCallback<(int InstanceId, int? NewAreaId)> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
private bool _wasVisible;
|
||||||
|
private int? _targetAreaId;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (IsVisible && !_wasVisible)
|
||||||
|
{
|
||||||
|
_targetAreaId = CurrentAreaId;
|
||||||
|
}
|
||||||
|
_wasVisible = IsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||||
|
|
||||||
|
private async Task Submit() => await OnSubmit.InvokeAsync((InstanceId, _targetAreaId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
@page "/deployment/topology"
|
||||||
|
@page "/deployment/instances"
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@using ScadaLink.Commons.Entities.Instances
|
||||||
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
|
@using ScadaLink.Commons.Entities.Templates
|
||||||
|
@using ScadaLink.Commons.Entities.Deployment
|
||||||
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@using ScadaLink.Commons.Types.Enums
|
||||||
|
@using ScadaLink.DeploymentManager
|
||||||
|
@using ScadaLink.TemplateEngine.Services
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||||
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||||
|
@inject DeploymentService DeploymentService
|
||||||
|
@inject AreaService AreaService
|
||||||
|
@inject InstanceService InstanceService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<ToastNotification @ref="_toast" />
|
||||||
|
<ConfirmDialog @ref="_confirmDialog" />
|
||||||
|
|
||||||
|
<CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog"
|
||||||
|
RequireSitePicker="_createAreaRequireSitePicker"
|
||||||
|
ContextLabel="@_createAreaContextLabel"
|
||||||
|
SiteId="_createAreaSiteId"
|
||||||
|
ParentAreaId="_createAreaParentId"
|
||||||
|
SiteOptions="EnumerateSiteOptions()"
|
||||||
|
ParentOptions="EnumerateAreaOptionsForCreate()"
|
||||||
|
ErrorMessage="@_createAreaError"
|
||||||
|
OnSubmit="SubmitCreateArea" />
|
||||||
|
|
||||||
|
<MoveAreaDialog @bind-IsVisible="_showMoveAreaDialog"
|
||||||
|
AreaId="_moveAreaId"
|
||||||
|
AreaName="@_moveAreaName"
|
||||||
|
CurrentParentId="_moveAreaCurrentParentId"
|
||||||
|
ParentOptions="EnumerateAreaParentOptionsExcluding(_moveAreaId, _moveAreaSiteId)"
|
||||||
|
ErrorMessage="@_moveAreaError"
|
||||||
|
OnSubmit="SubmitMoveArea" />
|
||||||
|
|
||||||
|
<MoveInstanceDialog @bind-IsVisible="_showMoveInstanceDialog"
|
||||||
|
InstanceId="_moveInstanceId"
|
||||||
|
InstanceName="@_moveInstanceName"
|
||||||
|
CurrentAreaId="_moveInstanceCurrentAreaId"
|
||||||
|
AreaOptions="EnumerateAreaOptionsForSite(_moveInstanceSiteId)"
|
||||||
|
ErrorMessage="@_moveInstanceError"
|
||||||
|
OnSubmit="SubmitMoveInstance" />
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<LoadingSpinner IsLoading="true" />
|
||||||
|
}
|
||||||
|
else if (_errorMessage != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h6 class="mb-2">Topology</h6>
|
||||||
|
<div class="d-flex align-items-center mb-2 gap-2">
|
||||||
|
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
|
||||||
|
placeholder="Search sites, areas, instances..."
|
||||||
|
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
|
||||||
|
<button class="btn btn-outline-secondary"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="LoadDataAsync">Refresh</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="() => _tree?.ExpandAll()">Expand</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="() => _tree?.CollapseAll()">Collapse</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="max-height: calc(100vh - 180px); overflow-y: auto; padding: 4px;">
|
||||||
|
<TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots"
|
||||||
|
ChildrenSelector="n => n.Children"
|
||||||
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
|
KeySelector="n => (object)n.Key"
|
||||||
|
StorageKey="topology-tree"
|
||||||
|
Selectable="true"
|
||||||
|
SelectedKey="_selectedKey"
|
||||||
|
SelectedKeyChanged="OnTreeNodeSelected">
|
||||||
|
<NodeContent Context="node">
|
||||||
|
@RenderNodeLabel(node)
|
||||||
|
</NodeContent>
|
||||||
|
<ContextMenu Context="node">
|
||||||
|
@RenderNodeContextMenu(node)
|
||||||
|
</ContextMenu>
|
||||||
|
<EmptyContent>
|
||||||
|
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
|
||||||
|
</EmptyContent>
|
||||||
|
</TreeView>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
|
@_allInstances.Count instance(s) across @_sites.Count site(s).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Diff Modal — ported from Instances.razor *@
|
||||||
|
@if (_showDiffModal)
|
||||||
|
{
|
||||||
|
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_diffLoading)
|
||||||
|
{
|
||||||
|
<LoadingSpinner IsLoading="true" />
|
||||||
|
}
|
||||||
|
else if (_diffError != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_diffError</div>
|
||||||
|
}
|
||||||
|
else if (_diffResult != null)
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
|
||||||
|
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small ms-2">
|
||||||
|
Deployed: @_diffResult.DeployedRevisionHash[..8]
|
||||||
|
| Current: @_diffResult.CurrentRevisionHash[..8]
|
||||||
|
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!_diffResult.IsStale)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No differences between deployed and current configuration.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ---- Data ----
|
||||||
|
private List<Instance> _allInstances = new();
|
||||||
|
private List<Site> _sites = new();
|
||||||
|
private List<Template> _templates = new();
|
||||||
|
private List<Area> _allAreas = new();
|
||||||
|
private Dictionary<int, bool> _stalenessMap = new();
|
||||||
|
|
||||||
|
private bool _loading = true;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private bool _actionInProgress;
|
||||||
|
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
|
||||||
|
private ToastNotification _toast = default!;
|
||||||
|
private ConfirmDialog _confirmDialog = default!;
|
||||||
|
|
||||||
|
private TreeView<TopoNode> _tree = default!;
|
||||||
|
private object? _selectedKey;
|
||||||
|
private const string SelectedKeyStorage = "topology-tree-selected";
|
||||||
|
|
||||||
|
// ---- Tree model ----
|
||||||
|
private enum TopoNodeKind { Site, Area, Instance }
|
||||||
|
|
||||||
|
private record TopoNode(
|
||||||
|
string Key,
|
||||||
|
TopoNodeKind Kind,
|
||||||
|
int EntityId,
|
||||||
|
int SiteId,
|
||||||
|
string Label,
|
||||||
|
Site? Site,
|
||||||
|
Area? Area,
|
||||||
|
Instance? Instance,
|
||||||
|
bool IsStale,
|
||||||
|
bool MatchesSearch,
|
||||||
|
List<TopoNode> Children);
|
||||||
|
|
||||||
|
private List<TopoNode> _treeRoots = new();
|
||||||
|
|
||||||
|
// ---- Inline rename ----
|
||||||
|
private string? _renamingKey;
|
||||||
|
private string _renameBuffer = string.Empty;
|
||||||
|
private string? _renameError;
|
||||||
|
|
||||||
|
// ---- Lifecycle ----
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stored = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", SelectedKeyStorage);
|
||||||
|
if (!string.IsNullOrEmpty(stored))
|
||||||
|
{
|
||||||
|
_selectedKey = stored;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* no JS interop available (prerender, tests) — ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
||||||
|
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||||
|
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||||
|
|
||||||
|
_allAreas.Clear();
|
||||||
|
foreach (var site in _sites)
|
||||||
|
{
|
||||||
|
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
||||||
|
_allAreas.AddRange(areas);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stalenessMap.Clear();
|
||||||
|
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
||||||
|
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_stalenessMap[inst.Id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildTree();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to load topology: {ex.Message}";
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSearchChanged()
|
||||||
|
{
|
||||||
|
BuildTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool NodeMatchesSearch(string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_searchText)) return true;
|
||||||
|
return label.Contains(_searchText, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildTree()
|
||||||
|
{
|
||||||
|
_treeRoots = _sites
|
||||||
|
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(site =>
|
||||||
|
{
|
||||||
|
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
|
||||||
|
var siteInstances = _allInstances.Where(i => i.SiteId == site.Id).ToList();
|
||||||
|
|
||||||
|
var areaChildren = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
|
||||||
|
var rootInstances = siteInstances
|
||||||
|
.Where(i => i.AreaId == null)
|
||||||
|
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(MakeInstanceNode)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var children = areaChildren.Concat(rootInstances).ToList();
|
||||||
|
|
||||||
|
return new TopoNode(
|
||||||
|
Key: $"s:{site.Id}",
|
||||||
|
Kind: TopoNodeKind.Site,
|
||||||
|
EntityId: site.Id,
|
||||||
|
SiteId: site.Id,
|
||||||
|
Label: site.Name,
|
||||||
|
Site: site,
|
||||||
|
Area: null,
|
||||||
|
Instance: null,
|
||||||
|
IsStale: false,
|
||||||
|
MatchesSearch: NodeMatchesSearch(site.Name),
|
||||||
|
Children: children);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TopoNode> BuildAreaNodes(List<Area> allAreas, List<Instance> instances, int? parentId)
|
||||||
|
{
|
||||||
|
return allAreas
|
||||||
|
.Where(a => a.ParentAreaId == parentId)
|
||||||
|
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(area =>
|
||||||
|
{
|
||||||
|
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
|
||||||
|
var areaInstances = instances
|
||||||
|
.Where(i => i.AreaId == area.Id)
|
||||||
|
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(MakeInstanceNode)
|
||||||
|
.ToList();
|
||||||
|
var children = childAreas.Concat(areaInstances).ToList();
|
||||||
|
return new TopoNode(
|
||||||
|
Key: $"a:{area.Id}",
|
||||||
|
Kind: TopoNodeKind.Area,
|
||||||
|
EntityId: area.Id,
|
||||||
|
SiteId: area.SiteId,
|
||||||
|
Label: area.Name,
|
||||||
|
Site: null,
|
||||||
|
Area: area,
|
||||||
|
Instance: null,
|
||||||
|
IsStale: false,
|
||||||
|
MatchesSearch: NodeMatchesSearch(area.Name),
|
||||||
|
Children: children);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TopoNode MakeInstanceNode(Instance inst) => new(
|
||||||
|
Key: $"i:{inst.Id}",
|
||||||
|
Kind: TopoNodeKind.Instance,
|
||||||
|
EntityId: inst.Id,
|
||||||
|
SiteId: inst.SiteId,
|
||||||
|
Label: inst.UniqueName,
|
||||||
|
Site: null,
|
||||||
|
Area: null,
|
||||||
|
Instance: inst,
|
||||||
|
IsStale: _stalenessMap.GetValueOrDefault(inst.Id),
|
||||||
|
MatchesSearch: NodeMatchesSearch(inst.UniqueName),
|
||||||
|
Children: new List<TopoNode>());
|
||||||
|
|
||||||
|
// ---- Rendering ----
|
||||||
|
private RenderFragment RenderNodeLabel(TopoNode node) => __builder =>
|
||||||
|
{
|
||||||
|
var dim = !string.IsNullOrWhiteSpace(_searchText) && !SubtreeContainsMatch(node);
|
||||||
|
var labelStyle = dim ? "opacity: 0.4;" : null;
|
||||||
|
|
||||||
|
switch (node.Kind)
|
||||||
|
{
|
||||||
|
case TopoNodeKind.Site:
|
||||||
|
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
|
||||||
|
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TopoNodeKind.Area:
|
||||||
|
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-diagram-3"></i></span>
|
||||||
|
@if (_renamingKey == node.Key)
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;"
|
||||||
|
@ref="_renameInput"
|
||||||
|
@bind="_renameBuffer"
|
||||||
|
@onkeydown="(e) => OnRenameKeyDown(e, node)"
|
||||||
|
@onblur="() => CancelRename()" />
|
||||||
|
@if (!string.IsNullOrEmpty(_renameError))
|
||||||
|
{
|
||||||
|
<span class="text-danger small ms-2">@_renameError</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="tv-label" style="@labelStyle" title="@node.Label"
|
||||||
|
@ondblclick="() => BeginRename(node)">@node.Label</span>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TopoNodeKind.Instance:
|
||||||
|
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-box"></i></span>
|
||||||
|
<span class="tv-label" style="@labelStyle" title="@node.Label">@node.Label</span>
|
||||||
|
<span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span>
|
||||||
|
@if (node.Instance!.State != InstanceState.NotDeployed)
|
||||||
|
{
|
||||||
|
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1" style="@labelStyle">
|
||||||
|
@(node.IsStale ? "Stale" : "Current")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool SubtreeContainsMatch(TopoNode node)
|
||||||
|
{
|
||||||
|
if (node.MatchesSearch) return true;
|
||||||
|
return node.Children.Any(SubtreeContainsMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderFragment RenderNodeContextMenu(TopoNode node) => __builder =>
|
||||||
|
{
|
||||||
|
switch (node.Kind)
|
||||||
|
{
|
||||||
|
case TopoNodeKind.Site:
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForSite(node.EntityId)">Add Area</button>
|
||||||
|
<button class="dropdown-item"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.EntityId}")'>
|
||||||
|
Create Instance here
|
||||||
|
</button>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TopoNodeKind.Area:
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForArea(node.SiteId, node.EntityId, node.Label)">Add Sub-area</button>
|
||||||
|
<button class="dropdown-item"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.SiteId}&areaId={node.EntityId}")'>
|
||||||
|
Create Instance here
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenMoveAreaDialog(node)">Move to Area…</button>
|
||||||
|
<button class="dropdown-item" @onclick="() => BeginRename(node)">Rename…</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item text-danger" @onclick="() => DeleteArea(node)" disabled="@_actionInProgress">Delete</button>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TopoNodeKind.Instance:
|
||||||
|
var inst = node.Instance!;
|
||||||
|
var isStale = node.IsStale;
|
||||||
|
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
|
||||||
|
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
|
||||||
|
@if (inst.State == InstanceState.Enabled)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
|
||||||
|
disabled="@_actionInProgress">Disable</button>
|
||||||
|
}
|
||||||
|
else if (inst.State == InstanceState.Disabled)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
|
||||||
|
disabled="@_actionInProgress">Enable</button>
|
||||||
|
}
|
||||||
|
<button class="dropdown-item"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
|
||||||
|
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenMoveInstanceDialog(node)">Move to Area…</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
|
||||||
|
disabled="@_actionInProgress">Delete</button>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetStateBadge(InstanceState state) => state switch
|
||||||
|
{
|
||||||
|
InstanceState.Enabled => "bg-success",
|
||||||
|
InstanceState.Disabled => "bg-secondary",
|
||||||
|
InstanceState.NotDeployed => "bg-light text-dark",
|
||||||
|
_ => "bg-secondary"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Selection ----
|
||||||
|
private async Task OnTreeNodeSelected(object? key)
|
||||||
|
{
|
||||||
|
_selectedKey = key;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("treeviewStorage.save", SelectedKeyStorage,
|
||||||
|
key?.ToString() ?? string.Empty);
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Inline rename ----
|
||||||
|
private ElementReference _renameInput;
|
||||||
|
|
||||||
|
private void BeginRename(TopoNode node)
|
||||||
|
{
|
||||||
|
if (node.Kind != TopoNodeKind.Area) return;
|
||||||
|
_renamingKey = node.Key;
|
||||||
|
_renameBuffer = node.Label;
|
||||||
|
_renameError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelRename()
|
||||||
|
{
|
||||||
|
_renamingKey = null;
|
||||||
|
_renameBuffer = string.Empty;
|
||||||
|
_renameError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnRenameKeyDown(KeyboardEventArgs e, TopoNode node)
|
||||||
|
{
|
||||||
|
if (e.Key == "Escape")
|
||||||
|
{
|
||||||
|
CancelRename();
|
||||||
|
}
|
||||||
|
else if (e.Key == "Enter")
|
||||||
|
{
|
||||||
|
await CommitRename(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CommitRename(TopoNode node)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_renameBuffer) || _renameBuffer.Trim() == node.Label)
|
||||||
|
{
|
||||||
|
CancelRename();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
CancelRename();
|
||||||
|
_toast.ShowSuccess($"Area renamed to '{result.Value.Name}'.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_renameError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Create-area dialog ----
|
||||||
|
private bool _showCreateAreaDialog;
|
||||||
|
private bool _createAreaRequireSitePicker;
|
||||||
|
private string _createAreaContextLabel = string.Empty;
|
||||||
|
private int? _createAreaSiteId;
|
||||||
|
private int? _createAreaParentId;
|
||||||
|
private string? _createAreaError;
|
||||||
|
|
||||||
|
private void OpenCreateAreaDialogRoot()
|
||||||
|
{
|
||||||
|
_createAreaRequireSitePicker = true;
|
||||||
|
_createAreaContextLabel = string.Empty;
|
||||||
|
_createAreaSiteId = null;
|
||||||
|
_createAreaParentId = null;
|
||||||
|
_createAreaError = null;
|
||||||
|
_showCreateAreaDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateAreaDialogForSite(int siteId)
|
||||||
|
{
|
||||||
|
var site = _sites.FirstOrDefault(s => s.Id == siteId);
|
||||||
|
_createAreaRequireSitePicker = false;
|
||||||
|
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} (root)";
|
||||||
|
_createAreaSiteId = siteId;
|
||||||
|
_createAreaParentId = null;
|
||||||
|
_createAreaError = null;
|
||||||
|
_showCreateAreaDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenCreateAreaDialogForArea(int siteId, int parentAreaId, string parentLabel)
|
||||||
|
{
|
||||||
|
var site = _sites.FirstOrDefault(s => s.Id == siteId);
|
||||||
|
_createAreaRequireSitePicker = false;
|
||||||
|
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} → Parent: {parentLabel}";
|
||||||
|
_createAreaSiteId = siteId;
|
||||||
|
_createAreaParentId = parentAreaId;
|
||||||
|
_createAreaError = null;
|
||||||
|
_showCreateAreaDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitCreateArea((int SiteId, int? ParentAreaId, string Name) req)
|
||||||
|
{
|
||||||
|
_createAreaError = null;
|
||||||
|
if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_showCreateAreaDialog = false;
|
||||||
|
_toast.ShowSuccess($"Area '{result.Value.Name}' created.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_createAreaError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Move-area dialog ----
|
||||||
|
private bool _showMoveAreaDialog;
|
||||||
|
private int _moveAreaId;
|
||||||
|
private string _moveAreaName = string.Empty;
|
||||||
|
private int _moveAreaSiteId;
|
||||||
|
private int? _moveAreaCurrentParentId;
|
||||||
|
private string? _moveAreaError;
|
||||||
|
|
||||||
|
private void OpenMoveAreaDialog(TopoNode node)
|
||||||
|
{
|
||||||
|
_moveAreaId = node.EntityId;
|
||||||
|
_moveAreaName = node.Label;
|
||||||
|
_moveAreaSiteId = node.SiteId;
|
||||||
|
_moveAreaCurrentParentId = node.Area?.ParentAreaId;
|
||||||
|
_moveAreaError = null;
|
||||||
|
_showMoveAreaDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
|
||||||
|
{
|
||||||
|
_moveAreaError = null;
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_showMoveAreaDialog = false;
|
||||||
|
_toast.ShowSuccess($"Area '{_moveAreaName}' moved.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_moveAreaError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Move-instance dialog ----
|
||||||
|
private bool _showMoveInstanceDialog;
|
||||||
|
private int _moveInstanceId;
|
||||||
|
private string _moveInstanceName = string.Empty;
|
||||||
|
private int _moveInstanceSiteId;
|
||||||
|
private int? _moveInstanceCurrentAreaId;
|
||||||
|
private string? _moveInstanceError;
|
||||||
|
|
||||||
|
private void OpenMoveInstanceDialog(TopoNode node)
|
||||||
|
{
|
||||||
|
var inst = node.Instance!;
|
||||||
|
_moveInstanceId = inst.Id;
|
||||||
|
_moveInstanceName = inst.UniqueName;
|
||||||
|
_moveInstanceSiteId = inst.SiteId;
|
||||||
|
_moveInstanceCurrentAreaId = inst.AreaId;
|
||||||
|
_moveInstanceError = null;
|
||||||
|
_showMoveInstanceDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
|
||||||
|
{
|
||||||
|
_moveInstanceError = null;
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_showMoveInstanceDialog = false;
|
||||||
|
_toast.ShowSuccess($"Instance '{_moveInstanceName}' moved.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_moveInstanceError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Area & instance deletion ----
|
||||||
|
private async Task DeleteArea(TopoNode node)
|
||||||
|
{
|
||||||
|
var confirmed = await _confirmDialog.ShowAsync(
|
||||||
|
$"Delete area '{node.Label}'? This will fail if it has sub-areas or assigned instances.",
|
||||||
|
"Delete Area");
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
_actionInProgress = true;
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Area '{node.Label}' deleted.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError(result.Error);
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteInstance(Instance inst)
|
||||||
|
{
|
||||||
|
var confirmed = await _confirmDialog.ShowAsync(
|
||||||
|
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
|
||||||
|
"Delete Instance");
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
_actionInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Delete failed: {result.Error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lifecycle actions ----
|
||||||
|
private async Task EnableInstance(Instance inst)
|
||||||
|
{
|
||||||
|
_actionInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Enable failed: {result.Error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Enable failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisableInstance(Instance inst)
|
||||||
|
{
|
||||||
|
var confirmed = await _confirmDialog.ShowAsync(
|
||||||
|
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
||||||
|
"Disable Instance");
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
_actionInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Disable failed: {result.Error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Disable failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeployInstance(Instance inst)
|
||||||
|
{
|
||||||
|
_actionInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Deploy failed: {result.Error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Deploy failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Diff modal ----
|
||||||
|
private bool _showDiffModal;
|
||||||
|
private bool _diffLoading;
|
||||||
|
private string? _diffError;
|
||||||
|
private string _diffInstanceName = string.Empty;
|
||||||
|
private DeploymentComparisonResult? _diffResult;
|
||||||
|
|
||||||
|
private async Task ShowDiff(Instance inst)
|
||||||
|
{
|
||||||
|
_showDiffModal = true;
|
||||||
|
_diffLoading = true;
|
||||||
|
_diffError = null;
|
||||||
|
_diffResult = null;
|
||||||
|
_diffInstanceName = inst.UniqueName;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_diffResult = result.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_diffError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_diffError = $"Failed to load diff: {ex.Message}";
|
||||||
|
}
|
||||||
|
_diffLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Dropdown option helpers ----
|
||||||
|
private IEnumerable<(int Id, string Label)> EnumerateSiteOptions()
|
||||||
|
{
|
||||||
|
foreach (var s in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
yield return (s.Id, s.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<(int Id, string Label, int SiteId)> EnumerateAreaOptionsForCreate()
|
||||||
|
{
|
||||||
|
foreach (var a in WalkAllSiteAreas())
|
||||||
|
yield return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<(int Id, string Label, int SiteId)> WalkAllSiteAreas()
|
||||||
|
{
|
||||||
|
foreach (var site in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var entry in WalkSiteHierarchy(site.Id, parentId: null, depth: 0, excludeAreaId: null))
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<(int? Id, string Label)> EnumerateAreaOptionsForSite(int siteId)
|
||||||
|
{
|
||||||
|
yield return ((int?)null, "(No area — site root)");
|
||||||
|
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: null))
|
||||||
|
yield return ((int?)entry.Id, entry.Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<(int? Id, string Label)> EnumerateAreaParentOptionsExcluding(int areaId, int siteId)
|
||||||
|
{
|
||||||
|
yield return ((int?)null, "(Site root)");
|
||||||
|
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: areaId))
|
||||||
|
yield return ((int?)entry.Id, entry.Label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<(int Id, string Label, int SiteId)> WalkSiteHierarchy(int siteId, int? parentId, int depth, int? excludeAreaId)
|
||||||
|
{
|
||||||
|
var levelAreas = _allAreas
|
||||||
|
.Where(a => a.SiteId == siteId && a.ParentAreaId == parentId)
|
||||||
|
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var a in levelAreas)
|
||||||
|
{
|
||||||
|
if (excludeAreaId.HasValue && a.Id == excludeAreaId.Value) continue;
|
||||||
|
yield return (a.Id, new string(' ', depth * 2) + a.Name, siteId);
|
||||||
|
foreach (var sub in WalkSiteHierarchy(siteId, a.Id, depth + 1, excludeAreaId))
|
||||||
|
yield return sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetCurrentUserAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,61 @@ public class AreaService
|
|||||||
return Result<Area>.Success(area);
|
return Result<Area>.Success(area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-parents an area within its site. `newParentAreaId == null` moves the area to the site root.
|
||||||
|
/// Rejects: self-parent, descendant-parent (cycle), cross-site parent, name collision at new level.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<Area>> MoveAreaAsync(
|
||||||
|
int areaId, int? newParentAreaId, string user,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||||
|
if (area == null)
|
||||||
|
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||||
|
|
||||||
|
if (newParentAreaId == areaId)
|
||||||
|
return Result<Area>.Failure("An area cannot be its own parent.");
|
||||||
|
|
||||||
|
if (newParentAreaId.HasValue)
|
||||||
|
{
|
||||||
|
var newParent = await _repository.GetAreaByIdAsync(newParentAreaId.Value, cancellationToken);
|
||||||
|
if (newParent == null)
|
||||||
|
return Result<Area>.Failure($"Target parent area with ID {newParentAreaId.Value} not found.");
|
||||||
|
if (newParent.SiteId != area.SiteId)
|
||||||
|
return Result<Area>.Failure("Areas can only be moved within the same site.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||||
|
|
||||||
|
// Cycle prevention: the new parent must not be a descendant of the area being moved.
|
||||||
|
if (newParentAreaId.HasValue)
|
||||||
|
{
|
||||||
|
var descendants = GetDescendantAreaIds(areaId, siblings);
|
||||||
|
if (descendants.Contains(newParentAreaId.Value))
|
||||||
|
return Result<Area>.Failure(
|
||||||
|
$"Cannot move area '{area.Name}' under one of its own descendants.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newParentAreaId == area.ParentAreaId)
|
||||||
|
return Result<Area>.Success(area);
|
||||||
|
|
||||||
|
var collision = siblings.FirstOrDefault(a =>
|
||||||
|
a.Id != areaId &&
|
||||||
|
a.ParentAreaId == newParentAreaId &&
|
||||||
|
string.Equals(a.Name, area.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (collision != null)
|
||||||
|
return Result<Area>.Failure(
|
||||||
|
$"An area named '{area.Name}' already exists at the target level.");
|
||||||
|
|
||||||
|
area.ParentAreaId = newParentAreaId;
|
||||||
|
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _auditService.LogAsync(user, "Move", "Area", area.Id.ToString(), area.Name, area, cancellationToken);
|
||||||
|
|
||||||
|
return Result<Area>.Success(area);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
|
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class NavigationTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Instances", "/deployment/instances")]
|
[InlineData("Topology", "/deployment/topology")]
|
||||||
[InlineData("Deployments", "/deployment/deployments")]
|
[InlineData("Deployments", "/deployment/deployments")]
|
||||||
public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
|
||||||
{
|
{
|
||||||
|
|||||||
244
tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs
Normal file
244
tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Instances;
|
||||||
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Communication;
|
||||||
|
using ScadaLink.DeploymentManager;
|
||||||
|
using ScadaLink.TemplateEngine.Services;
|
||||||
|
using TopologyPage = ScadaLink.CentralUI.Components.Pages.Deployment.Topology;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit rendering tests for the Topology page (Site → Area → Instance tree).
|
||||||
|
/// Focuses on the behavior that's specific to this page:
|
||||||
|
/// always-visible empty containers, search dimming, F2 area rename, and the
|
||||||
|
/// move-dialog destination lists.
|
||||||
|
/// </summary>
|
||||||
|
public class TopologyPageTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
|
||||||
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||||
|
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||||
|
private readonly IAuditService _audit = Substitute.For<IAuditService>();
|
||||||
|
|
||||||
|
public TopologyPageTests()
|
||||||
|
{
|
||||||
|
Services.AddSingleton(_repo);
|
||||||
|
Services.AddSingleton(_siteRepo);
|
||||||
|
Services.AddSingleton(_deployRepo);
|
||||||
|
Services.AddSingleton(_pipeline);
|
||||||
|
Services.AddSingleton(_audit);
|
||||||
|
|
||||||
|
// DeploymentService has non-mockable concrete deps; instantiate them directly.
|
||||||
|
var comms = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
Services.AddSingleton(comms);
|
||||||
|
|
||||||
|
Services.AddSingleton(new OperationLockManager());
|
||||||
|
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
|
||||||
|
{
|
||||||
|
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
||||||
|
}));
|
||||||
|
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
|
||||||
|
Services.AddScoped<DeploymentService>();
|
||||||
|
Services.AddScoped<AreaService>();
|
||||||
|
Services.AddScoped<InstanceService>();
|
||||||
|
|
||||||
|
AddTestAuth();
|
||||||
|
|
||||||
|
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
|
||||||
|
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||||
|
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddTestAuth()
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim("Username", "tester"),
|
||||||
|
new Claim(ClaimTypes.Role, "Deployment")
|
||||||
|
};
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SeedRepos(
|
||||||
|
IEnumerable<Site>? sites = null,
|
||||||
|
IEnumerable<Template>? templates = null,
|
||||||
|
IEnumerable<Instance>? instances = null,
|
||||||
|
Dictionary<int, IReadOnlyList<Area>>? areasBySite = null)
|
||||||
|
{
|
||||||
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites?.ToList() ?? new List<Site>()));
|
||||||
|
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates?.ToList() ?? new List<Template>()));
|
||||||
|
_repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances?.ToList() ?? new List<Instance>()));
|
||||||
|
|
||||||
|
areasBySite ??= new();
|
||||||
|
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(call =>
|
||||||
|
{
|
||||||
|
var sid = call.Arg<int>();
|
||||||
|
return Task.FromResult(areasBySite.TryGetValue(sid, out var list)
|
||||||
|
? list
|
||||||
|
: (IReadOnlyList<Area>)new List<Area>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renders_EmptyState_WhenNoSites()
|
||||||
|
{
|
||||||
|
SeedRepos();
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
|
||||||
|
Assert.Contains("No sites configured", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renders_EmptySite_AsTopLevelNode()
|
||||||
|
{
|
||||||
|
// An always-show-empty container is a hard requirement: a site with nothing
|
||||||
|
// under it must still appear so users can move/create into it.
|
||||||
|
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
|
||||||
|
Assert.Contains("Plant-A", cut.Markup);
|
||||||
|
Assert.Contains("bi-building", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent<TopologyPage> cut, string label) =>
|
||||||
|
cut.FindAll(".tv-row")
|
||||||
|
.FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
|
||||||
|
?.QuerySelector(".tv-toggle");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renders_SiteAreaInstance_Nesting()
|
||||||
|
{
|
||||||
|
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
|
||||||
|
{
|
||||||
|
[1] = new List<Area>
|
||||||
|
{
|
||||||
|
new("Line-1") { Id = 10, SiteId = 1, ParentAreaId = null }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SeedRepos(
|
||||||
|
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||||
|
instances: new[]
|
||||||
|
{
|
||||||
|
new Instance("Pump-001") { Id = 100, SiteId = 1, AreaId = 10, State = InstanceState.NotDeployed }
|
||||||
|
},
|
||||||
|
areasBySite: areasBySite);
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
|
||||||
|
// Expand the site, then the area, to render the instance leaf. The
|
||||||
|
// helper scopes by the row's own label so we don't match outer rows
|
||||||
|
// whose TextContent transitively contains the inner label.
|
||||||
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||||
|
FindToggleForLabel(cut, "Line-1")!.Click();
|
||||||
|
|
||||||
|
Assert.Contains("Pump-001", cut.Markup);
|
||||||
|
Assert.Contains("bi-diagram-3", cut.Markup);
|
||||||
|
Assert.Contains("bi-box", cut.Markup);
|
||||||
|
Assert.Contains("NotDeployed", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Search_DimsNonMatches_PreservesShape()
|
||||||
|
{
|
||||||
|
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
|
||||||
|
{
|
||||||
|
[1] = new List<Area>
|
||||||
|
{
|
||||||
|
new("Line-1") { Id = 10, SiteId = 1 },
|
||||||
|
new("Boilers") { Id = 11, SiteId = 1 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SeedRepos(
|
||||||
|
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||||
|
areasBySite: areasBySite);
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||||
|
|
||||||
|
var search = cut.Find("input[type='text']");
|
||||||
|
search.Input("Line");
|
||||||
|
|
||||||
|
// Both areas remain in the DOM (shape preserved). 'Boilers' gets the dim style.
|
||||||
|
Assert.Contains("Line-1", cut.Markup);
|
||||||
|
Assert.Contains("Boilers", cut.Markup);
|
||||||
|
var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']");
|
||||||
|
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoubleClick_OnAreaLabel_EntersRenameMode()
|
||||||
|
{
|
||||||
|
var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
|
||||||
|
{
|
||||||
|
[1] = new List<Area> { new("Line-1") { Id = 10, SiteId = 1 } }
|
||||||
|
};
|
||||||
|
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||||
|
|
||||||
|
var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1");
|
||||||
|
areaLabel.DoubleClick();
|
||||||
|
|
||||||
|
// Inline rename input replaces the label.
|
||||||
|
Assert.NotNull(cut.Find("input.form-control-sm.d-inline-block"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstanceRows_DoNotHaveDoubleClickRename()
|
||||||
|
{
|
||||||
|
// Instance rename is out of scope; the label should not have @ondblclick wired.
|
||||||
|
// bUnit throws MissingEventHandlerException when dispatching to an element
|
||||||
|
// that has no handler — that's the assertion: the dblclick event is not bound.
|
||||||
|
SeedRepos(
|
||||||
|
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
||||||
|
instances: new[]
|
||||||
|
{
|
||||||
|
new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.NotDeployed }
|
||||||
|
});
|
||||||
|
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||||
|
|
||||||
|
var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
|
||||||
|
Assert.Throws<Bunit.MissingEventHandlerException>(() => instanceLabel.DoubleClick());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyInstancesRoute_IsDeclaredOnTopologyPage()
|
||||||
|
{
|
||||||
|
// Old bookmarks to /deployment/instances must still resolve. Reflection
|
||||||
|
// check: the Topology component carries both @page directives.
|
||||||
|
var pageAttrs = typeof(TopologyPage).GetCustomAttributes(
|
||||||
|
typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
|
||||||
|
.Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
|
||||||
|
.Select(a => a.Template)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Contains("/deployment/topology", pageAttrs);
|
||||||
|
Assert.Contains("/deployment/instances", pageAttrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,157 @@ public class AreaServiceTests
|
|||||||
_repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
_repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_ToOtherArea_Succeeds()
|
||||||
|
{
|
||||||
|
// Move 'Leaf' from under 'A' to under 'B'.
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new("A") { Id = 1, SiteId = 1 },
|
||||||
|
new("B") { Id = 2, SiteId = 1 },
|
||||||
|
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
|
||||||
|
});
|
||||||
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(3, 2, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.ParentAreaId);
|
||||||
|
_repoMock.Verify(r => r.UpdateAreaAsync(It.Is<Area>(a => a.Id == 3 && a.ParentAreaId == 2), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_auditMock.Verify(a => a.LogAsync("admin", "Move", "Area", "3", "Leaf", It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_ToSiteRoot_Succeeds()
|
||||||
|
{
|
||||||
|
// Move 'Leaf' from under 'A' to site root (null parent).
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new("A") { Id = 1, SiteId = 1 },
|
||||||
|
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
|
||||||
|
});
|
||||||
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(3, null, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Null(result.Value.ParentAreaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_ToSelf_Fails()
|
||||||
|
{
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(1, 1, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("its own parent", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_ToDescendant_FailsWithCycleError()
|
||||||
|
{
|
||||||
|
// Tree: 1 -> 2 -> 3. Try to move 1 under 3.
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Root") { Id = 1, SiteId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 });
|
||||||
|
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new("Root") { Id = 1, SiteId = 1 },
|
||||||
|
new("Mid") { Id = 2, SiteId = 1, ParentAreaId = 1 },
|
||||||
|
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(1, 3, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("descendants", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_DifferentSite_Fails()
|
||||||
|
{
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(99, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Foreign") { Id = 99, SiteId = 2 });
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(1, 99, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("same site", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_NameCollidesAtNewParent_Fails()
|
||||||
|
{
|
||||||
|
// 'Leaf' under parent 1; a sibling 'Leaf' already exists under parent 2.
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new("A") { Id = 1, SiteId = 1 },
|
||||||
|
new("B") { Id = 2, SiteId = 1 },
|
||||||
|
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 },
|
||||||
|
new("Leaf") { Id = 4, SiteId = 1, ParentAreaId = 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(3, 2, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("already exists", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_SameParent_NoOpSuccess()
|
||||||
|
{
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Area>
|
||||||
|
{
|
||||||
|
new("A") { Id = 1, SiteId = 1 },
|
||||||
|
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(3, 1, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
_repoMock.Verify(r => r.UpdateAreaAsync(It.IsAny<Area>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
_auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), "Move", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveArea_TargetParentMissing_Fails()
|
||||||
|
{
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetAreaByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((Area?)null);
|
||||||
|
|
||||||
|
var result = await _sut.MoveAreaAsync(3, 999, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("not found", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteArea_InstancesInDescendants_Blocked()
|
public async Task DeleteArea_InstancesInDescendants_Blocked()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user