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:
Joseph Doherty
2026-05-11 22:03:55 -04:00
parent b2eddd9713
commit f3386d0278
18 changed files with 1857 additions and 1122 deletions

View 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 V1V7 visual identity as the templates page.
This page **replaces** both `/deployment/instances` (current read-mostly tree) and `/admin/areas*` (current flat-list CRUD for areas).
## Decisions
| Question | Decision |
|---|---|
| Page identity | Replace both `/deployment/instances` and `/admin/areas*` with one new page |
| Route | `/deployment/topology` |
| Empty containers | Always shown (so they're valid move/create targets) |
| Instance configuration | Stays on dedicated `/deployment/instances/{id}/configure` page |
| Filters | Search-only (single input above the tree) |
| Search semantics | Dim non-matches (50% opacity), preserve tree shape |
| Single-click behavior | Select-only; nothing navigates |
| Rename UX | Inline (F2 / double-click) for areas only. Instance rename is out of scope (see "Instance rename" below). |
| Site-node menu | Add Area, Create Instance here |
| Area-node menu | Add Sub-area, Create Instance here, Move to Area…, Rename…, Delete |
| Instance-node menu | Deploy/Redeploy, Enable/Disable, Configure, Diff, Move to Area…, Delete |
| Delete-area cascade | Keep server semantics — block on any non-empty subtree |
| Top-of-page buttons | Create Area, Create Instance, Refresh |
| Move structural scope | Same-site only (instance↔area, area↔area). Cross-site moves out of scope. |
| Backend area re-parenting | New `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` |
| State persistence | Expanded nodes + selected key, both in sessionStorage |
| Glyphs | Site `bi-building`, Area `bi-diagram-3`, Instance `bi-box` |
## Layout
```
┌─────────────────────────────────────────────────────────────┐
│ Topology │
├─────────────────────────────────────────────────────────────┤
│ [Search box ............................. ] │
│ [Create Area] [Create Instance] [Refresh] │
├─────────────────────────────────────────────────────────────┤
│ ▾ 🏢 Plant-A │
│ ▾ ▦ Line-1 │
│ ▸ ▦ Station-3 │
│ □ Pump-001 [Enabled] [Current] │
│ □ Pump-002 [Disabled] │
│ ▾ ▦ Line-2 │
│ □ Conveyor-01 [NotDeployed] │
│ ▾ 🏢 Plant-B │
│ ▸ ▦ (empty area, still shown) │
└─────────────────────────────────────────────────────────────┘
```
## Visual identity
Follows the existing `Component-TreeView.md` V1V7 guide. Glyphs adopted:
| Node | Glyph | Color hook |
|---|---|---|
| Site | `bi-building` | default |
| Area | `bi-diagram-3` | default |
| Instance | `bi-box` | default; state badge to the right |
Instance state badges (kept from current page):
| State | Badge |
|---|---|
| Enabled | `bg-success` |
| Disabled | `bg-secondary` |
| NotDeployed | `bg-light text-dark` |
| Stale (deployed but template revision drifted) | `bg-warning text-dark` |
| Current | `bg-light text-dark` |
Search dimming: non-matches receive `opacity: 0.4`. Matches keep full opacity. Tree shape is preserved; ancestors of matches are auto-expanded on first keystroke.
## Context menus
### Site
- **Add Area** → opens "Create Area" dialog with this site pre-selected (parent = root)
- **Create Instance here** → navigates `/deployment/instances/create?siteId={id}`
### Area
- **Add Sub-area** → "Create Area" dialog with this area pre-selected as parent
- **Create Instance here** → navigates `/deployment/instances/create?siteId={siteId}&areaId={id}`
- **Move to Area…** → opens `MoveAreaDialog`. Destination list = areas in the same site, excluding self and descendants. Plus "(root of site)" option.
- divider
- **Rename…** → opens `RenameAreaDialog` (also reachable via F2 / double-click for inline edit)
- **Delete** → calls `DeleteAreaAsync`; server rejects if non-empty, error surfaced via toast
### Instance
- **Deploy** / **Redeploy** (label depends on `IsStale`)
- **Enable** / **Disable** (state-dependent)
- **Configure** → navigates `/deployment/instances/{id}/configure`
- **Diff** → opens the existing diff modal (ported from current Instances page)
- **Move to Area…** → opens `MoveInstanceDialog`. Destination list = areas in the same site + "(no area, site root)".
- divider
- **Delete**
## Inline rename
Applies to **Area rows only**. Instance rows do not support rename on this page (see "Instance rename" below).
- `F2` or double-click on the label of an Area row replaces the label span with an `<input>` bound to a local edit buffer.
- `Enter` commits via `AreaService.UpdateAreaAsync(areaId, name, user)`.
- `Escape` cancels.
- On commit failure (e.g., name collision at the same level), the toast shows the server error and the input stays open with the bad value highlighted.
## Instance rename
**Out of scope for this page.** `InstanceService` currently has no rename method. Adding one is non-trivial:
- `Instance.UniqueName` is also the identity of the site-side `InstanceActor` (Akka actor name).
- It appears in deployment records, audit history, and deploy paths.
- Renaming a deployed instance would require coordinated site-side actor stop/restart, deployment-record rebinding, and potentially redeployment.
This warrants its own design pass. For now: an instance row's label is read-only on the topology page. If a rename is needed, the user can delete + recreate (with the limitation that deployment history is lost).
The Area-rename context-menu item ("Rename…") is **not** added to the instance menu.
## Backend changes
### `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` — NEW
Parallel to `InstanceService.AssignToAreaAsync`. Validates:
1. Area exists.
2. `newParentAreaId` is null OR refers to an area in the **same site** as the area being moved.
3. `newParentAreaId != areaId` (not self).
4. The new parent is not a descendant of the area being moved (cycle prevention) — reuse the existing descendant-walking helper that `DeleteAreaAsync` uses.
5. No sibling area at the new level has the same name (case-insensitive).
On success: updates `ParentAreaId`, persists, audits as `"Move"` on entity `"Area"`.
`UpdateAreaAsync` stays name-only.
### `Templates.razor` parent-immutability pattern is **not** repeated here
Areas can be moved freely (subject to validation). Templates are different because re-parenting changes inheritance semantics; areas are pure organizational containers.
### No change to:
- `InstanceService.AssignToAreaAsync` (already supports re-parenting; will be called by `MoveInstanceDialog`)
- `AreaService.DeleteAreaAsync` (keep current block-on-non-empty semantics)
- `AreaService.UpdateAreaAsync` (stays name-only)
- `InstanceService` lifecycle methods (already used by current Instances page)
### CLI / ManagementService parity (optional follow-up)
- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.
- Add CLI: `cli area move --id X --parent-id Y --username … --password …` (omit `--parent-id` to move to site root).
Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
## Routes affected
| Route | Before | After |
|---|---|---|
| `/deployment/topology` | — | **NEW** (this page — canonical route) |
| `/deployment/instances` | tree + lifecycle page | **secondary `@page` directive on `Topology.razor`** — old bookmarks continue to work. NavMenu and all internal back-navs retarget to `/deployment/topology`. |
| `/admin/areas` | flat list | **removed** |
| `/admin/areas/add` | dialog page | **removed** (Create Area dialog lives on topology page) |
| `/admin/areas/edit/{id}` | edit page | **removed** (rename via inline / context menu) |
| `/admin/areas/delete/{id}` | confirm page | **removed** (confirm via shared `ConfirmDialog`) |
| `/deployment/instances/create` | unchanged | accepts new `?siteId=` and `?areaId=` query params for preselection |
| `/deployment/instances/{id}/configure` | unchanged | unchanged |
The admin nav entry for "Areas" gets removed; "Topology" goes under the Deployment nav group.
## Files to add
```
src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor (~500 lines)
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor (~50 lines)
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor (~55 lines)
src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor (~60 lines)
src/ScadaLink.CentralUI/Components/Pages/Deployment/RenameAreaDialog.razor (~45 lines) (optional if inline-only)
```
## Files to modify
```
src/ScadaLink.TemplateEngine/Services/AreaService.cs (+ MoveAreaAsync, ~40 lines)
src/ScadaLink.Commons/Interfaces/... (interface for AreaService if exposed)
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
(+ SiteId, AreaId query-param SupplyParameterFromQuery;
retarget back-nav to /deployment/topology — 3 sites)
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
(retarget back-nav to /deployment/topology — 1 site)
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor (replace 'Instances' nav with 'Topology' at /deployment/topology;
remove 'Areas' nav under Admin)
tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
(update InlineData: 'Instances' → 'Topology', '/deployment/instances' → '/deployment/topology')
docs/requirements/Component-TreeView.md (rewrite §1 'Instances Page' → 'Topology Page' with new route;
remove §3 'Areas Page')
```
Note: `CLAUDE.md` does **not** reference `/deployment/instances` today, so no edit required there.
## Files to remove
```
src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor (replaced by Topology.razor; old route preserved as secondary @page)
src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaAdd.razor
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaEdit.razor
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaDelete.razor
tests/ScadaLink.CentralUI.Tests/InstancesPageTests.cs (if it exists)
tests/ScadaLink.CentralUI.Tests/AreaPageTests.cs (if it exists)
```
Verified there are no other references to `/admin/areas*` in CLI, ManagementService, requirement docs (other than `Component-TreeView.md` §3, which is updated above), or tests.
## State persistence
- `topology-tree` (sessionStorage) — expansion state (Set of node keys), already supported by `TreeView.StorageKey`.
- `topology-tree-selected` (sessionStorage) — selected node key. New; the `TreeView` already exposes `SelectedKey` two-way binding, but the page is responsible for persisting it. Pattern: write in `SelectedKeyChanged`, read on `OnAfterRenderAsync` after data load.
## Tests
### Unit (`tests/ScadaLink.TemplateEngine.Tests/AreaServiceTests.cs`)
- `MoveArea_ToOtherArea_Succeeds`
- `MoveArea_ToSiteRoot_Succeeds` (newParentAreaId = null)
- `MoveArea_ToSelf_Fails`
- `MoveArea_ToDescendant_FailsWithCycleError`
- `MoveArea_DifferentSite_Fails`
- `MoveArea_NameCollidesAtNewParent_Fails`
- `MoveArea_NameUniqueAtNewParent_Succeeds`
- `MoveArea_AuditLogged`
### bUnit (`tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs`)
- `Renders_EmptyState_WhenNoSites`
- `Renders_EmptySite_WhenSiteHasNoAreasOrInstances` (empty containers visible)
- `Renders_SiteAreaInstance_Nesting`
- `Search_DimsNonMatches_PreservesShape`
- `F2_OnAreaRow_EntersRenameMode`
- `F2_OnInstanceRow_DoesNothing` (rename out of scope)
- `EscapeDuringInlineRename_Cancels`
- `ContextMenu_AreaMove_OpensDialogWithCycleFreeOptions`
- `ContextMenu_InstanceMove_OpensDialogWithSameSiteAreasOnly`
- `ContextMenu_SiteCreateInstance_NavigatesWithSiteIdQuery`
- `LegacyInstancesRoute_RoutesToTopologyPage` (visiting `/deployment/instances` resolves to the same component)
### Removal cleanup
- Drop `InstancesPageTests` and any `AreaPageTests` along with the source files.
## Edge cases
- **Two sites with the same area name at root** — fine. Same-site uniqueness is the rule; areas in different sites are independent.
- **Move an area while it has an instance assigned at its root** — allowed. The instance keeps the same `AreaId`; the area's new parent doesn't affect it.
- **Site with no areas, just root instances** — instance rows render directly under the site node.
- **Concurrent rename of a node by another user** — last-write-wins (consistent with template policy).
- **Search match inside a collapsed branch** — auto-expand the ancestor chain so the highlighted match is visible.
- **Network failure during inline rename** — leave the input open with the pending value; show the error in a toast; user can retry or Escape.
- **Deleting an area, then immediately Ctrl+Z** — not supported (no undo); destructive actions are confirmed via `ConfirmDialog` and audited.
## Out of scope
- Cross-site moves (would need new `Instance.SiteId` rebinding semantics, deployment-record handling, name-collision check at new site).
- Drag-and-drop reordering of areas (no ordinal column today; arbitrary alpha-sort).
- Bulk operations (select multiple instances and move/deploy together).
- Search across templates / sites / instances from the same input (the search is scoped to this page's tree).
- **Instance rename.** No `RenameInstanceAsync` in `InstanceService` today; adding one requires a separate design pass (site-side actor identity, deployment-record rebinding, audit history continuity). Users wanting to rename should delete + recreate.
## Out-of-band consistency tasks
When this lands, the following docs need a touch-up:
- `README.md` — component table; verify no reference to the removed Instances/Areas pages remains.
- `docs/requirements/Component-CentralUI.md` (or the routing section if one exists) — route table.
- `src/ScadaLink.CLI/README.md` — if existing CLI examples reference `area` subcommands, align with the optional CLI `area move` addition.
Confirmed clean (no edit needed):
- `CLAUDE.md` does not reference `/deployment/instances` or `/admin/areas` today.

View File

@@ -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 (V1V7 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.

View File

@@ -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>

View File

@@ -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">&larr; 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;
}
}

View File

@@ -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">&larr; 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);
}
}

View File

@@ -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">&larr; 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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()));
}
}

View File

@@ -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">&larr; Back to Instances</button> <button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; 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()
{ {

View File

@@ -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">&larr; Back</a> <a href="/deployment/topology" class="btn btn-outline-secondary btn-sm me-3">&larr; 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()
{ {

View File

@@ -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;
}
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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";
}
}

View File

@@ -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>

View File

@@ -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)
{ {

View 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);
}
}

View File

@@ -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()
{ {