8 Commits

Author SHA1 Message Date
Joseph Doherty f3386d0278 feat(ui/deployment): consolidate sites/areas/instances into Topology page
Single /deployment/topology page replaces /deployment/instances (legacy URL
preserved as a secondary @page directive) and the /admin/areas* CRUD pages.
TreeView with Site → Area → Instance, V1–V7 visual guide (bi-building /
bi-diagram-3 / bi-box), always-visible empty containers, search dim, F2
inline area rename, and right-click context menus per node kind (Add Area,
Move to Area…, lifecycle actions, etc.).

Adds AreaService.MoveAreaAsync with cycle prevention, same-site enforcement,
and name-collision check at the new parent. Instance rename intentionally
out of scope — UniqueName is the site-side actor identity, requires its own
design pass.
2026-05-11 22:03:55 -04:00
Joseph Doherty b2eddd9713 feat(ui/templates): derived-template action and slimmer composition row
Right-click a template now offers "New Derived Template" — opens
TemplateCreate with the parent pre-selected via a new ?parentId query
parameter. Composition rows in the tree drop the trailing
"→ TargetName" muted text; the kind glyph plus the instance name carry
enough meaning, and the composed template is one click away from the
row's right-click menu.
2026-05-11 21:29:32 -04:00
Joseph Doherty b4cb7e6f5f feat(templates): lock ParentTemplateId after creation
Template inheritance is set once at create time and immutable on update.
UpdateTemplateAsync now returns "Parent template cannot be changed after
creation." when the caller sends a parent that differs from the stored
value — server-side enforcement covers UI, ManagementService, and CLI.
TemplateEdit renders the parent as static plaintext rather than an
editable dropdown; TemplateCreate's parent picker is unchanged.
2026-05-11 21:29:21 -04:00
Joseph Doherty 8e388a89c5 feat(ui/templates): adopt TreeView design guide; split editor to /design/templates/{id}
Templates page is now a tree-only browser; editing happens on a dedicated
TemplateEdit page. Drag-drop is replaced by context-menu Move-to-Folder.
TreeView gains Bootstrap Icons (chevron + per-kind glyphs), ancestor guide
lines, defined hover/selected/focus tokens, and Escape-dismisses-menu per
the new Visual Design Guide (V1-V7) in Component-TreeView.md.
2026-05-11 20:52:34 -04:00
Joseph Doherty f3b33e7e1d fix(ui/treeview): union sessionStorage keys instead of overwriting
The previous fix tried to defer page-side RevealNode to the second
render so TreeView's async sessionStorage load could finish first. In
practice Blazor Server didn't always fire a second OnAfterRenderAsync
on the page after the deep-link load, so the reveal never ran.

Real fix: change TreeView's storage-load to UNION the restored keys
with whatever's already in _expandedKeys, instead of REPLACING. That
way the page can call RevealNode whenever it wants and the storage
restore can't clobber the reveal regardless of completion order. The
page-side guard simplifies back to a one-shot reveal on first render.

Semantic note: if a deep-link reveal expands an ancestor that the user
had previously collapsed, the deep link wins. Intentional — the URL
expresses the navigation intent.
2026-05-11 12:42:38 -04:00
Joseph Doherty d8e6f44616 fix(ui/templates): defer deep-link reveal until TreeView restores sessionStorage
Both page.OnAfterRenderAsync(firstRender=true) and
TreeView.OnAfterRenderAsync(firstRender=true) ran concurrently:
- Page called RevealNode → added ancestor keys to _expandedKeys
- TreeView awaited treeviewStorage.load → replaced _expandedKeys with
  the persisted set (often empty if user collapsed before navigating)

Whichever JS interop completed second won. When TreeView won, the deep-link
reveal silently lost. Gate the reveal on firstRender==false so it runs
strictly after TreeView's restore is done.
2026-05-11 12:39:21 -04:00
Joseph Doherty ca164dca03 fix(ui/templates): stop drop propagation on folder nodes
Without stopPropagation, dropping a template onto a folder fires both
OnDrop(folder) and OnDropOnRoot via event bubbling. The two async handlers
race on the same scoped DbContext, which is not thread-safe — the second
throws ObjectDisposedException and tears down the Blazor circuit. Surfaced
during browser smoke testing via JS-dispatched DragEvent sequence.
2026-05-11 12:28:05 -04:00
Joseph Doherty acead212b2 fix(ui/templates): dereference string params with @ and stack toolbar below title
Smoke testing revealed two issues introduced by the modal extraction commit:

1. ErrorMessage / InitialName / TemplateName parameters on the dialog
   components were passed as bare strings (e.g. ErrorMessage="_newFolderError")
   instead of dereferenced C# expressions (ErrorMessage="@_newFolderError").
   Razor treats unquoted-but-not-@-prefixed values to string parameters as
   string literals — so the error block rendered the literal field name in
   red whenever the modal opened. Non-string parameters (int/IEnumerable)
   were fine since Razor treats those as C# expressions by default.

2. The Templates header + 4-button toolbar shared one flex row, but at
   col-md-4 / col-lg-3 width the buttons overflowed into the right-column
   empty-state area. Stack title above a full-width btn-group instead.
2026-05-11 12:20:40 -04:00
33 changed files with 5417 additions and 2290 deletions
@@ -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.
@@ -6,7 +6,7 @@
## Goal
Replace the current single-list view at `/design/templates` with a tree-organized authoring surface modeled on the Wonderware ArchestrA Template Toolbox. Users organize templates into nested folders, see composition children inline under their owning template, and edit templates in a persistent split-pane layout.
Replace the current single-list view at `/design/templates` with a tree-organized browser modeled on the Wonderware ArchestrA Template Toolbox. Users organize templates into nested folders, see composition children inline under their owning template, and navigate to a dedicated edit page (`/design/templates/{id}`) when authoring a specific template. The tree page itself does not host the editor.
## Reference
@@ -22,13 +22,14 @@ Inheritance is **not** rendered as tree nesting in the image, and it is not rend
| Decision | Choice |
|---|---|
| Inheritance in tree | Not shown as nesting; shown as text on the template node label. |
| Inheritance in tree | Not shown as nesting; **not shown on the node label either** (label is name only). Inheritance is visible in the TemplateEdit page when a template is selected. |
| Folder model | New `TemplateFolder` entity with self-referencing `ParentFolderId`. `Template.FolderId` nullable. |
| Reorganization UX | Context menus + native HTML5 drag-drop. |
| Reorganization UX | **Right-click context menus only** (no drag-drop). Modal dialog pickers for move targets. |
| Composition rendering | Read-only leaves with navigation; right-click → Open composed template / Remove composition. |
| Root-level templates | Allowed (`FolderId` nullable). Existing templates migrate with `FolderId = null`. |
| Folder delete with contents | Blocked; structured error lists child counts. |
| Page layout | Persistent split pane: tree on left (~2533% width), template detail/editor on right. |
| Page layout | **Tree browser only** — no split-pane editor. Selecting a template navigates to `/design/templates/{id}` (TemplateEdit page); creating navigates to `/design/templates/create`. |
| Tree node visuals | Per `Component-TreeView.md` Visual Design Guide V7: Bootstrap Icons (`bi-folder` / `bi-folder2-open` / `bi-file-earmark-text` / `bi-arrow-return-right`), name-only labels (no count/inherit badges on template nodes; composition rows also name-only — the glyph signals the kind), folder child-count pill. |
## Data model
@@ -138,72 +139,65 @@ private record TmplNode(
| `KeySelector` | `n => (object)n.Key` |
| `StorageKey` | `"templates-tree"` (preserved from current usage) |
| `Selectable` | `true` |
| `SelectedKeyChanged` | dispatch on key prefix: `t:`load template into right pane; `f:` → no-op; `c:`reveal + select composed template |
| `SelectedKeyChanged` | dispatch on key prefix: `t:``NavigationManager.NavigateTo($"/design/templates/{id}")` (TemplateEdit page); `f:` → no-op; `c:``NavigateTo` the composed template's edit page |
**Inline node labels:**
- Folder: glyph + name + child-count badge.
- Template: `<strong>$Name</strong>` + optional "inherits $Parent" muted text + existing attr/alarm/script/comp count badges.
- Composition: `<span>InstanceName</span>` + muted `→ $ComposedTemplateName`.
**Inline node labels** (see `Component-TreeView.md` V7 for the canonical recipe):
- Folder: `<i class="bi bi-folder">` (closed) or `<i class="bi bi-folder2-open">` (expanded) + name (semibold when has children) + count-pill badge of direct children.
- Template: `<i class="bi bi-file-earmark-text">` + `$Name` (semibold when has compositions). **No** inheritance hint, **no** attr/alarm/script count, **no** composition count on the node.
- Composition: `<i class="bi bi-arrow-return-right">` + composition instance name only. The composed template name is intentionally omitted from the tree — open the owning template's edit page to see/manage compositions.
**Search/filter:** out of scope for v1; the underlying component supports external filtering (per `Component-TreeView.md` R8) so it can be added later without component changes.
## Page layout
Two-column split inside the existing page container:
`/design/templates` is a **single-column tree browser** — no inline editor, no split pane.
```
+-------------------------------+----------------------------------------+
| Templates | $TestMachine |
| [+Folder][+Template][Expand] | inherits $gMachine |
| | [Properties] [Validate] |
| ▶ 📁 _Default Templates | |
| ▼ 📁 Dev | Tabs: Attributes | Alarms | Scripts |
| $TestMachine | | Compositions |
| DelmiaReceiver → ... | ... |
| MESReceiver → ... | |
| $TestObject | |
| ▶ 📁 System | |
| $UnfiledTemplate | |
+-------------------------------+----------------------------------------+
+--------------------------------------------+
| Templates |
| [+Folder] [+Template] [Expand] [Collapse] |
| |
| ▶ 📁 _Default Templates |
| ▼ 📂 Dev |
| 📄 $TestMachine |
| DelmiaReceiver |
| MESReceiver |
| 📄 $TestObject |
| ▶ 📁 System |
| 📄 $UnfiledTemplate |
+--------------------------------------------+
```
- Left column: ~2533% (`col-md-4 col-lg-3`), scrollable (`max-height: calc(100vh - 160px); overflow-y: auto`).
- Right column: existing template properties card, validation block, four-tab editor (Attributes / Alarms / Scripts / Compositions) lifted unchanged into `RenderTemplateDetail()`.
- "Back to List" button is removed — the tree is always visible.
- Empty state in the right column when nothing is selected.
- URL contract preserved: `/design/templates/{id}` selects + reveals the template on load via `TreeView.RevealNode("t:" + id, select: true)`.
- Tree scrollable region: `max-height: calc(100vh - 160px); overflow-y: auto`. The 2533% sidebar width constraint is removed; the tree uses the page's main container width.
- Selecting a template node navigates to `/design/templates/{id}` (TemplateEdit page).
- Selecting a composition node navigates to the composed template's edit page.
- Selecting a folder node is a no-op (still allowed; expansion and context-menu still work).
- Creating a template: toolbar "+ Template" button (or folder context-menu "New Template") navigates to `/design/templates/create?folderId={id}`. After successful create, the create page navigates to `/design/templates/{newId}`.
- URL contract for deep links: `/design/templates/{id}` resolves to the TemplateEdit page directly — the browser doesn't need to be on the tree page first.
## Context menus
Per-node-kind `ContextMenu` fragment driven by `node.Kind`:
The context menu is the **only** reorganization mechanism. Per-node-kind `ContextMenu` fragment driven by `node.Kind`:
**Folder:** New Folder · New Template · Rename · Delete
**Template:** Edit · Move to Folder… (modal with folder-only mini-tree, "(Root)" option) · Delete
**Folder:** New Folder · New Template · Rename · Move to Folder… · Delete
**Template:** Edit · Move to Folder… · Delete
**Composition:** Open composed template · Remove composition
The "New Template" modal collects name + description and creates with `FolderId = thisFolder.Id`. Root-level "+Folder" and "+Template" buttons live in the tree-sidebar toolbar above the tree.
- **Move to Folder…** opens a modal (`MoveFolderDialog` / `MoveTemplateDialog`) with a flat folder picker. The list includes "(Root)" as the first entry. For folder-move, the dialog client-side prunes the folder being moved and its descendants from the candidate list to prevent obvious cycles; the server still validates (authoritative). For template-move, all folders are valid targets.
- **Edit** on a template navigates to `/design/templates/{id}` (TemplateEdit page) — equivalent to clicking the node, kept in the menu for discoverability.
- Root-level "+ Folder" and "+ Template" buttons live in the toolbar above the tree.
## Drag-drop
Native HTML5 drag-drop, no library.
**Draggability:** folders and templates are `draggable="true"`. Composition nodes are not draggable.
**Drop targets:** folder nodes and the root sidebar wrapper. Template/composition nodes are not drop targets in v1.
**Payload:** `_dragPayload = (kind, id)` held in component state on `@ondragstart`.
**Visual feedback:** CSS `drag-over` class toggled via `@ondragenter` / `@ondragleave`; compositions dimmed to 0.5 opacity while drag in progress.
**Server-side validation (authoritative):**
**Server-side validation (authoritative)**:
- Folder onto descendant → reject (cycle).
- Folder onto itself → no-op.
- Drop on a composition → ignored.
- Template-onto-template → **ignored** (no sibling reordering, no surprising "drop into parent's folder").
- Folder onto itself → no-op (client prunes).
- Template-onto-template → not a valid target (templates aren't shown in the folder picker).
## Edge cases
- Deep-link route reveals ancestors via `RevealNode`.
- Deep-link route `/design/templates/{id}` resolves directly to the TemplateEdit page; the tree page is not involved. If the user navigates back, the tree's sessionStorage-persisted expansion state is restored.
- Stale `f:{id}` keys in `sessionStorage` after folder delete are harmless (ignored on next render).
- Selected template moved to another folder → tree rebuilds; selection preserved by stable key.
- Selected template deleted → right pane clears to empty state.
- Template deleted from the TemplateEdit page → page navigates back to `/design/templates`; the tree rebuilds without the deleted node.
- Last-write-wins on concurrent folder edits, matching existing template policy.
- Tree fully rebuilt on every CRUD; expected scale (dozens to low hundreds) makes this trivially cheap.
@@ -226,14 +220,16 @@ Native HTML5 drag-drop, no library.
**bUnit (`tests/ScadaLink.CentralUI.Tests/`):**
- Tree renders folders / templates / compositions in correct nesting.
- Empty state when no selection.
- Selecting a template node loads the detail pane.
- Selecting a composition reveals + selects the composed template.
- Right-click menus differ by node kind.
- Folder-delete-non-empty surfaces structured error toast.
- Deep link selects + reveals.
- Empty state when no roots exist (no folders, no root templates).
- Selecting a template node invokes `NavigationManager.NavigateTo($"/design/templates/{id}")`.
- Selecting a composition node invokes `NavigateTo` for the composed template's edit page.
- Selecting a folder node is a no-op (no navigation).
- Right-click menus differ by node kind (Folder / Template / Composition each have distinct items).
- Folder context menu includes "Move to Folder…"; the dialog excludes the folder being moved and its descendants from candidates.
- Folder-delete-non-empty surfaces a structured error toast.
- Bootstrap Icons render in the glyph slot for each node kind (`bi-folder` / `bi-folder2-open` / `bi-file-earmark-text` / `bi-arrow-return-right`).
**Manual smoke (per `CLAUDE.md`):** nested folder creation, drag-drop reorg, cycle rejection, refresh persistence, composition navigation.
**Manual smoke (per `CLAUDE.md`):** nested folder creation, context-menu reorg (folder + template Move-to-Folder dialogs), cycle rejection, refresh persistence, composition navigation, navigation from tree to TemplateEdit and back.
## Documentation updates
@@ -247,6 +243,6 @@ Native HTML5 drag-drop, no library.
- Tree search / filter input (component already supports it; add when needed).
- CLI commands for folder operations (message contracts make this trivial later).
- Sibling reorder via drag-drop (sort stays alphabetical).
- Sibling reorder (sort stays alphabetical).
- Root context menu (right-click in empty tree area).
- Bootstrap Icons CDN — current pages don't use it, so this design uses Unicode glyphs.
- (Removed from out-of-scope.) Bootstrap Icons are now adopted (static files at `wwwroot/lib/bootstrap-icons/`) — see `Component-TreeView.md` V4.
+224 -65
View File
@@ -64,10 +64,9 @@ The `NodeContent` fragment receives the `TItem` and is responsible for rendering
### R4 — Indentation and Visual Structure
- Each depth level is indented by a fixed amount (default 24px, configurable via `IndentPx` parameter).
- Vertical guide lines connect parent to children at each depth level (thin left-border or CSS pseudo-element).
- The toggle icon is inline with the node content, left-aligned at the current depth.
- Leaf nodes align with sibling branch labels (the content starts at the same horizontal position, with empty space where the toggle would be).
The component renders the structural chrome: indent gutters per depth, the toggle slot, and ancestor guide lines. Leaf nodes render an empty toggle placeholder so labels align across siblings.
The exact tokens (indent unit, toggle glyph, guide-line treatment) are specified in **V2** of the Visual Design Guide.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -132,12 +131,11 @@ This keeps filter logic in the page (domain-specific) while the component handle
### R9 — Styling
- Uses Bootstrap 5 utility classes only (no third-party frameworks).
- No hardcoded colors — uses standard Bootstrap text/background utilities.
- Toggle icons: Unicode characters (`+` / ``) in a `<span>` with `cursor: pointer`, or a small SVG chevron. No icon library dependency.
- Compact row height for dense data (matching `table-sm` density).
- Hover effect on rows: subtle background highlight (`bg-light` or similar).
- CSS scoped to the component via Blazor CSS isolation (`TreeView.razor.css`).
- Uses Bootstrap 5 utility classes and CSS variables. No third-party Blazor component frameworks.
- Adds one icon-library dependency: **Bootstrap Icons** (static files at `wwwroot/lib/bootstrap-icons/`). Distribution rules in **V4** of the Visual Design Guide.
- Hardcoded colors are forbidden; use Bootstrap utility classes (`bg-primary bg-opacity-10`, `text-muted`) or CSS variables (`var(--bs-tertiary-bg)`, `var(--bs-border-color)`).
- Component-local CSS lives in `TreeView.razor.css` (Blazor CSS isolation).
- All visual tokens (row density, indent, state visuals, glyphs, labels, badges) are specified in the **Visual Design Guide** (V1V7). This requirement is non-normative summary; the Guide is authoritative.
### R10 — No Internal Scrolling
@@ -296,7 +294,178 @@ Future enhancement. Single selection (R5) covers current needs. A future version
- Shift+click for range select, Ctrl+click for toggle
- Use case: bulk operations (select multiple instances → deploy/disable all)
## Component API Summary
## Visual Design Guide
This section is the canonical visual specification for the TreeView. It is normative: any change to the chrome (row layout, indentation, glyphs, state visuals, badge styling) must update this section. Consumers' `NodeContent` fragments follow the label and badge recipes in V5V6; `/design/templates` is the worked example in V7.
R4 and R9 above describe *that* the component renders structural chrome and uses Bootstrap utilities. This section says *exactly how*.
### V1 — Density & Row Anatomy
Each `<li role="treeitem">` renders one row. The row is a flexbox so trailing meta can right-align cleanly and the entire row width is a hover/selected/drop-target surface.
**Row container** (replaces today's `.tv-row` styling):
```html
<div class="tv-row d-flex align-items-center"
style="gap:.25rem; padding:.25rem .5rem; padding-left: calc(.5rem + var(--tv-indent, 0px));">
<span class="tv-toggle">…chevron or placeholder…</span>
<span class="tv-glyph">…Bootstrap Icon or placeholder…</span>
<span class="tv-label">…primary + secondary…</span>
<span class="tv-meta ms-auto">…badges…</span>
</div>
```
| Token | Value | Notes |
|---|---|---|
| Row vertical padding | `py-1` (0.25rem top/bottom) | Yields ~32px row height at base font-size + line-height 1.5. |
| Row horizontal padding | `px-2` (0.5rem left/right) | Selected/hover background spans full row including this padding. |
| Inter-slot gap | `gap: .25rem` | Between toggle, glyph, label. The meta slot is offset by `margin-left: auto`. |
| Font size | inherits (1rem base) | Compact pages may opt into `small` per-page, not at the component level. |
| Line height | inherits (1.5) | Aligns the chevron, glyph, and label baselines correctly. |
| Toggle slot width | 20px (`width: 1.25rem`) | Always present, even on leaves (which render an empty placeholder). |
| Glyph slot width | 20px (`width: 1.25rem`) | Always present; consumer may render an empty span to preserve alignment. |
| Label slot | `flex: 1 1 auto; min-width: 0;` | `min-width: 0` is required for ellipsis truncation to work in a flex child. |
| Meta slot | `margin-left: auto;` | Pushes badges to the right edge of the row. |
**Hit semantics**:
- The full row (`tv-row`) is the surface for hover, selected, focus-visible, and drop-target backgrounds.
- Click-to-select fires only on the **label slot** (preserves R5: toggle clicks do not select).
- The toggle slot's invisible tap target is enlarged by negative margins inside the 20px slot so it remains a comfortable 24×24px target.
### V2 — Depth, Indent & Guide Lines
| Token | Value |
|---|---|
| Indent per depth | 24px (`IndentPx` default, unchanged) |
| Toggle glyph (collapsed) | `<i class="bi bi-chevron-right">` |
| Toggle glyph (expanded) | `<i class="bi bi-chevron-down">` (or `bi-chevron-right` rotated 90° via CSS) |
| Guide line color | `var(--bs-border-color)` |
| Guide line width | 1px |
| Guide line style | solid, vertical-only (no horizontal stubs) |
| Guide line position | one line per ancestor depth, drawn down the indent column (left edge of each 24px indent slot) |
| Guide lines enabled | `ShowGuideLines` parameter (default true) |
| Leaf alignment | identical depth gutter as siblings; the toggle slot renders an empty placeholder so glyphs and labels align across leaves and branches |
Implementation note: guide lines are drawn by repeating a `linear-gradient` background or by stacking `border-left` on indent spacers — both are pure CSS, no extra DOM. The current `tv-guides` class is the hook.
### V3 — State Visuals
States compose: focus rings layer on top of hover/selected; drop-target overrides hover and selected. All states paint the full row width (V1).
| State | Visual | Implementation |
|---|---|---|
| Default | none | — |
| Hover | full-row tint | `background: var(--bs-tertiary-bg);` on `:hover` of `.tv-row` |
| Focus-visible | inset 2px primary ring | `box-shadow: inset 0 0 0 2px var(--bs-primary);` on `:focus-visible` |
| Selected | full-row primary tint | `class="bg-primary bg-opacity-10"` (existing `SelectedCssClass` default, unchanged) |
| Selected + hover | selected tint persists; hover does not deepen | hover background applies only when not selected (`:hover:not(.bg-primary)`) |
| Selected + focus | tint + ring both visible | focus ring layers via box-shadow |
| Drop-target (valid) | `bg-info bg-opacity-25` | overrides hover/selected backgrounds; opt-in per consumer |
| Drop-target (invalid) | cursor `not-allowed`, no tint change | absence of valid-tint is the cue |
| Dragging source | `opacity: 0.5` | applied to the row currently being dragged |
| Dimmed (non-droppable while a drag is in progress) | `opacity: 0.5` | applied to nodes the consumer marks as unsuitable drop targets |
Drag-drop is **not** part of the TreeView component's intrinsic behavior — it is opt-in per consuming page. The drag-related state visuals (drop-target, dragging, dimmed) are documented here so consumers that *do* implement DnD share the same visual language. The `/design/templates` page (V7) explicitly does **not** use drag-drop; reorganization happens via the right-click context menu.
### V4 — Glyph & Icon System
**Distribution**: Bootstrap Icons ships as static files under `src/ScadaLink.CentralUI/wwwroot/lib/bootstrap-icons/` (`bootstrap-icons.css` + `fonts/*.woff2`). Referenced once from `MainLayout.razor`:
```html
<link rel="stylesheet" href="~/lib/bootstrap-icons/bootstrap-icons.css" />
```
No CDN dependency — works on air-gapped industrial deployments. Version pinned in the file path or filename.
**Rules**:
- Glyphs are inline `<i class="bi bi-…"></i>` elements inside the 20px glyph slot.
- Branches render an **open/closed pair**: a `closed` glyph when collapsed, an `open` glyph when expanded (consumer chooses both via `NodeContent`). The chevron toggle reinforces the same state.
- Leaves render a single static glyph or no glyph (empty span preserves alignment).
- **Color**: glyphs inherit `color` from their row. Default is body text; consumers may apply `text-muted` for de-emphasis. Kind is communicated by *shape*, not by color, to keep the palette available for status badges.
- **Size**: glyphs render at `1em` (inherits row font-size). No fixed pixel size.
### V5 — Label Recipe & Typography
The label slot contains, in order: **[primary] [secondary modifiers]**. Trailing meta lives in the separate `.tv-meta` slot (V1).
| Element | Style |
|---|---|
| Primary label (branches) | `class="fw-semibold"` |
| Primary label (leaves) | normal weight |
| Secondary modifiers | `class="text-muted small ms-1"` |
| Overflow handling | `.tv-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }` |
| Tooltip | `title` attribute on the primary label span, set to the full name on every row (cheap, helps when the row is narrower than the name) |
**Rule of thumb**: font-weight tracks *has children*, not *kind*. A folder with no children renders regular weight; a leaf-template promoted to a branch by adding compositions becomes semibold automatically.
### V6 — Badge Taxonomy
Three semantic badge roles. The meta slot holds **at most two** badges per row. All badges live in `.tv-meta`, right-aligned (V1).
| Role | Purpose | Markup | Examples |
|---|---|---|---|
| Count | numeric child aggregation | `<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@N</span>` | folder child count; area instance count |
| Status | semantic state | `<span class="badge bg-{success\|warning\|danger\|info}">@Label</span>` | Enabled / Disabled / Stale / Error |
| Kind | category / type tag | same filled semantic style, used sparingly | Protocol (OPC UA), Source (Inherited) |
**Rules**:
- Counts represent **direct children only**. Never transitive descendants.
- A count of 0 **renders nothing** — no badge at all.
- Status uses Bootstrap semantic colors; do not introduce custom palettes.
- The component does not enforce the 2-badge cap; it is a documented convention. PR review should catch violations.
### V7 — Worked Example: `/design/templates`
**Page model**: the templates page is a **tree browser only**. Selecting a template in the tree navigates to a dedicated edit page (`/design/templates/{id}`); creating a template navigates to `/design/templates/create`. No split-pane editor. Reorganization (move folder, move template) happens exclusively through the **right-click context menu** with modal dialog pickers — there is no drag-and-drop on this page.
Three node kinds; concrete recipes following V1V6.
| Kind | Glyph (collapsed) | Glyph (expanded) | Primary | Secondary | Badges |
|---|---|---|---|---|---|
| Folder | `bi-folder` | `bi-folder2-open` | folder name (semibold when has children, regular otherwise) | — | count of direct children (subtle pill), only if ≥ 1 |
| Template | `bi-file-earmark-text` | same (templates with compositions still use the same glyph — chevron carries state) | `$Name` (semibold when has compositions, regular otherwise) | — | none |
| Composition | `bi-arrow-return-right` | n/a (leaf, no expanded state) | composition instance name (regular weight) | — | none |
**`NodeContent` fragment** for the templates page (replaces the current `RenderNodeLabel` in `Templates.razor`):
```razor
@switch (node.Kind)
{
case TmplNodeKind.Folder:
var folderOpen = _tree.IsExpanded(node.Key);
<span class="tv-glyph"><i class="bi @(folderOpen ? "bi-folder2-open" : "bi-folder")"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label">@node.Label</span>
@if (node.Children.Count > 0)
{
<span class="tv-meta ms-auto">
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
</span>
}
break;
case TmplNodeKind.Template:
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label">@node.Label</span>
break;
case TmplNodeKind.Composition:
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
<span class="tv-label" title="@node.Label">@node.Label</span>
break;
}
```
**Locked subtractions from the previous design**:
- Template node "inherits $Parent" muted text — **removed**. Inheritance is shown in the right pane only.
- Template node "X attr, Y alm, Z scr" compound badge — **removed**.
- Template node "N comp" accent badge — **removed**.
These subtractions are deliberate: templates are leaves-from-the-tree's-perspective (their inner attributes/alarms/scripts are not tree-navigable), so the tree row should carry only what's needed to identify and pick the template. All counts and inheritance information live in the right detail pane.
```csharp
@typeparam TItem
@@ -535,34 +704,52 @@ record TestNode(string Key, string Label, List<TestNode> Children);
## Page Integration Notes
### 1. Instances Page (`/deployment/instances` — Instances.razor)
### 1. Topology Page (`/deployment/topology` — Topology.razor)
**Current state:** Flat table with filters (Site, Template, Status, Search), pagination, and 6 inline action buttons per row (Deploy, Disable/Enable, Configure, Diff, Delete). ~490 lines.
The Topology page is the single home for Site → Area → Instance hierarchy management. It replaces the former `/deployment/instances` page (the legacy URL is retained as a secondary `@page` directive on `Topology.razor` so existing bookmarks resolve) and the former `/admin/areas*` admin pages.
**Change to:**
- Replace the `<table>` with a `<TreeView>` showing Site → Area → Sub Area → Instance hierarchy.
- **Keep the existing filter bar** (Site, Template, Status, Search). Filters control which tree roots and leaves are shown:
- Site filter: pass only the matching site root to `Items`.
- Template/Status/Search filters: filter at the instance (leaf) level. Branch nodes with no matching descendants should be pruned from the tree. Build a helper method (`BuildFilteredTree()`) that walks the hierarchy bottom-up, keeping only branches that contain at least one matching instance.
- **Remove the table, pagination, and Actions column.** The tree replaces all of this.
- **Move all 6 action buttons into the `ContextMenu` fragment**, shown only for instance nodes:
- Deploy/Redeploy, Disable/Enable (conditional on state), Configure, Diff, Delete (with divider).
- Site and Area nodes get no context menu (browser default).
- **Node content per type:**
- Site nodes: `<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.
**Scope:**
- Structural management of areas (create, rename inline, move, delete) and instance placement (move to area).
- Instance lifecycle: Deploy/Redeploy, Enable/Disable, Configure, Diff, Delete via per-node context menu.
- Search-only filter row (single text input) — dims non-matching rows, preserves tree shape, no collapse.
**Files to modify:**
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor` — replace table with TreeView, add tree model building, move actions to context menu, keep filter bar.
**TreeView wiring:**
- `Items` = list of Site root nodes built from `_sites`, `_allAreas`, and `_allInstances`.
- `KeySelector` returns prefixed keys (`s:{id}`, `a:{id}`, `i:{id}`).
- `StorageKey` = `"topology-tree"` for expansion state.
- A separate `topology-tree-selected` sessionStorage key persists the selected node across navigation.
- `Selectable` = true; selection does not navigate (instance configure goes through the context menu).
- Empty containers always rendered (so they can be drop/move targets).
**Removed code:**
- Pagination logic (`_currentPage`, `_totalPages`, `_pagedInstances`, `GoToPage`)
- Actions column markup
- `<table>` / `<thead>` / `<tbody>` structure
**Glyphs (V1V7 visual guide):**
- Site: `bi-building`
- Area: `bi-diagram-3`
- Instance: `bi-box` + state badge + Stale/Current badge when deployed.
**Context menus:**
- **Site:** Add Area, Create Instance here.
- **Area:** Add Sub-area, Create Instance here, Move to Area…, Rename… (also F2 / double-click inline), Delete.
- **Instance:** Deploy/Redeploy, Enable/Disable (state-dependent), Configure, Diff, Move to Area…, Delete. Instance rename is intentionally absent (see "Instance rename" below).
**Inline rename:** Area rows only. F2 or double-click swaps the label for an input bound to a local buffer. Enter commits via `AreaService.UpdateAreaAsync`; Escape cancels; server validation errors stay surfaced inline.
**Search behavior:** Single text input above the tree. While text is present, any row whose label does not match (case-insensitive substring) and whose subtree contains no match is rendered at `opacity: 0.4`. The tree shape stays intact.
**Top-of-page buttons:** `+ Area` (opens `CreateAreaDialog` with site picker), `+ Instance` (navigates to `/deployment/instances/create` with no preselection), `Refresh`, `Expand`, `Collapse`.
**Files added:**
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor`
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor`
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor`
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor`
**Files removed:**
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor`
- `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor` (and AreaAdd / AreaEdit / AreaDelete)
**Backend addition:** `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` adds area re-parenting (cycle prevention, same-site, name collision at new parent). Pairs with the existing `InstanceService.AssignToAreaAsync`.
**Instance rename:** Out of scope for this page. `InstanceService` does not currently support renaming an instance (`UniqueName` is also the site-side `InstanceActor` identity and appears in deployment records). A separate design pass is required if rename is wanted.
---
@@ -592,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
- **DataTable**: The tree replaces flat tables on the three pages listed above. Other pages that don't need hierarchy continue using DataTable.
- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`.
- **Areas.razor**: The simplest integration — `Area` entity used directly as `TItem`, no wrapper needed.
- **DataTable**: The tree replaces flat tables on the Topology and Data Connections pages. Other pages that don't need hierarchy continue using DataTable.
- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`. Back-nav returns to `/deployment/topology`.
@@ -42,9 +42,6 @@
<li class="nav-item">
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/admin/areas">Areas</NavLink>
</li>
</Authorized>
</AuthorizeView>
@@ -53,7 +50,7 @@
<Authorized Context="deploymentContext">
<li class="nav-section-header">Deployment</li>
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/instances">Instances</NavLink>
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
@@ -1,127 +0,0 @@
@page "/admin/areas/add"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">&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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -1,133 +0,0 @@
@page "/admin/areas"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISiteRepository SiteRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<h4 class="mb-3">Area Management</h4>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
StorageKey="areas-tree">
<NodeContent Context="node">
@if (node.Kind == AreaNodeKind.Site)
{
<span class="fw-semibold">@node.Label</span>
<span class="badge bg-secondary ms-1">@node.AreaCount</span>
}
else
{
<span>@node.Label</span>
}
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == AreaNodeKind.Site)
{
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}")'>
Add Area
</button>
}
else
{
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}&parentAreaId={node.Area!.Id}")'>
Add Child Area
</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/edit")'>
Edit Area
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/delete")'>
Delete Area
</button>
}
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No sites configured.</span>
</EmptyContent>
</TreeView>
}
</div>
@code {
record AreaTreeNode(string Key, string Label, AreaNodeKind Kind, List<AreaTreeNode> Children,
int SiteId, Area? Area = null, int AreaCount = 0);
enum AreaNodeKind { Site, Area }
private List<Site> _sites = new();
private List<AreaTreeNode> _treeRoots = new();
private bool _loading = true;
private string? _errorMessage;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_treeRoots = new();
foreach (var site in _sites)
{
var areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id)).ToList();
var rootAreas = areas.Where(a => a.ParentAreaId == null).OrderBy(a => a.Name);
var children = rootAreas.Select(a => BuildAreaNode(a, site.Id)).ToList();
_treeRoots.Add(new AreaTreeNode(
Key: $"site-{site.Id}",
Label: site.Name,
Kind: AreaNodeKind.Site,
Children: children,
SiteId: site.Id,
AreaCount: areas.Count));
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load data: {ex.Message}";
}
_loading = false;
}
private AreaTreeNode BuildAreaNode(Area area, int siteId)
{
var children = area.Children
.OrderBy(c => c.Name)
.Select(c => BuildAreaNode(c, siteId))
.ToList();
return new AreaTreeNode(
Key: $"area-{area.Id}",
Label: area.Name,
Kind: AreaNodeKind.Area,
Children: children,
SiteId: siteId,
Area: area);
}
}
@@ -0,0 +1,94 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Area</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (RequireSitePicker)
{
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_siteId">
<option value="0">(Select a site)</option>
@foreach (var opt in SiteOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Parent area</label>
<select class="form-select form-select-sm" @bind="_parentAreaId">
<option value="0">(Site root)</option>
@foreach (var opt in ParentOptions.Where(o => SelectedSiteMatches(o)))
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
</div>
}
else
{
<div class="text-muted small mb-2">
@ContextLabel
</div>
}
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" placeholder="Area name" @bind="_name" />
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public bool RequireSitePicker { get; set; }
[Parameter] public string ContextLabel { get; set; } = string.Empty;
[Parameter] public int? SiteId { get; set; }
[Parameter] public int? ParentAreaId { get; set; }
[Parameter] public IEnumerable<(int Id, string Label)> SiteOptions { get; set; } = Array.Empty<(int, string)>();
[Parameter] public IEnumerable<(int Id, string Label, int SiteId)> ParentOptions { get; set; } = Array.Empty<(int, string, int)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int SiteId, int? ParentAreaId, string Name)> OnSubmit { get; set; }
private bool _wasVisible;
private string _name = string.Empty;
private int _siteId;
private int _parentAreaId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_name = string.Empty;
_siteId = SiteId ?? 0;
_parentAreaId = ParentAreaId ?? 0;
}
_wasVisible = IsVisible;
}
private bool SelectedSiteMatches((int Id, string Label, int SiteId) opt) =>
_siteId == 0 || opt.SiteId == _siteId;
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit()
{
var effectiveSite = RequireSitePicker ? _siteId : (SiteId ?? 0);
var effectiveParent = RequireSitePicker
? (_parentAreaId == 0 ? (int?)null : _parentAreaId)
: ParentAreaId;
await OnSubmit.InvokeAsync((effectiveSite, effectiveParent, _name.Trim()));
}
}
@@ -15,7 +15,7 @@
<div class="container-fluid mt-3">
<div class="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>
</div>
@@ -257,7 +257,7 @@
_loading = false;
}
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
private async Task<string> GetCurrentUserAsync()
{
@@ -14,7 +14,7 @@
<div class="container-fluid mt-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>
</div>
@@ -74,6 +74,9 @@
</div>
@code {
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? AreaId { get; set; }
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
@@ -98,6 +101,15 @@
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_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)
{
@@ -120,7 +132,7 @@
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/deployment/instances");
NavigationManager.NavigateTo("/deployment/topology");
}
else
{
@@ -133,7 +145,7 @@
}
}
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
private async Task<string> GetCurrentUserAsync()
{
@@ -1,504 +0,0 @@
@page "/deployment/instances"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Entities.Deployment
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.DeploymentManager
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject DeploymentService DeploymentService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Instances</h4>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>Create Instance</button>
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@* Filters *@
<div class="row mb-3 g-2">
<div class="col-md-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_filterSiteId" @bind:after="ApplyFilters">
<option value="0">All Sites</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_filterTemplateId" @bind:after="ApplyFilters">
<option value="0">All Templates</option>
@foreach (var tmpl in _templates)
{
<option value="@tmpl.Id">@tmpl.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Status</label>
<select class="form-select form-select-sm" @bind="_filterStatus" @bind:after="ApplyFilters">
<option value="">All Statuses</option>
<option value="NotDeployed">Not Deployed</option>
<option value="Enabled">Enabled</option>
<option value="Disabled">Disabled</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Search</label>
<input type="text" class="form-control form-control-sm" placeholder="Instance name..."
@bind="_filterSearch" @bind:event="oninput" @bind:after="ApplyFilters" />
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">Refresh</button>
</div>
</div>
<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
StorageKey="instances-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="key => { _selectedKey = key; }">
<NodeContent Context="node">
@switch (node.Kind)
{
case InstanceNodeKind.Site:
<span class="fw-semibold">@node.Label</span>
break;
case InstanceNodeKind.Area:
<span class="text-secondary">@node.Label</span>
break;
case InstanceNodeKind.Instance:
<span>@node.Label</span>
<span class="badge @GetStateBadge(node.Instance!.State) ms-1">@node.Instance!.State</span>
@if (node.Instance!.State != InstanceState.NotDeployed)
{
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
@(node.IsStale ? "Stale" : "Current")
</span>
}
break;
}
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == InstanceNodeKind.Instance)
{
var inst = node.Instance!;
var isStale = node.IsStale;
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
@if (inst.State == InstanceState.Enabled)
{
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
disabled="@_actionInProgress">Disable</button>
}
else if (inst.State == InstanceState.Disabled)
{
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
disabled="@_actionInProgress">Enable</button>
}
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
Configure
</button>
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
disabled="@_actionInProgress">Delete</button>
}
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No instances match the current filters.</span>
</EmptyContent>
</TreeView>
<div class="text-muted small mt-2">
@_filteredInstances.Count instance(s) total
</div>
@* Diff Modal *@
@if (_showDiffModal)
{
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
</div>
<div class="modal-body">
@if (_diffLoading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_diffError != null)
{
<div class="alert alert-danger">@_diffError</div>
}
else if (_diffResult != null)
{
<div class="mb-2">
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
</span>
<span class="text-muted small ms-2">
Deployed: @_diffResult.DeployedRevisionHash[..8]
| Current: @_diffResult.CurrentRevisionHash[..8]
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
</span>
</div>
@if (!_diffResult.IsStale)
{
<p class="text-muted">No differences between deployed and current configuration.</p>
}
else
{
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
}
}
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
</div>
</div>
</div>
</div>
}
}
</div>
@code {
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
record InstanceTreeNode(string Key, string Label, InstanceNodeKind Kind,
List<InstanceTreeNode> Children, Instance? Instance = null,
bool IsStale = false);
enum InstanceNodeKind { Site, Area, Instance }
private List<Instance> _allInstances = new();
private List<Instance> _filteredInstances = new();
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
private Dictionary<int, bool> _stalenessMap = new();
private bool _loading = true;
private string? _errorMessage;
private bool _actionInProgress;
private int _filterSiteId;
private int _filterTemplateId;
private string _filterStatus = string.Empty;
private string _filterSearch = string.Empty;
private List<InstanceTreeNode> _treeRoots = new();
private TreeView<InstanceTreeNode> _instanceTree = default!;
private object? _selectedKey;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
// Load areas for display
_allAreas.Clear();
foreach (var site in _sites)
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
// Check staleness for deployed instances
_stalenessMap.Clear();
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
{
try
{
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
}
catch
{
_stalenessMap[inst.Id] = false;
}
}
ApplyFilters();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load instances: {ex.Message}";
}
_loading = false;
}
private void ApplyFilters()
{
_filteredInstances = _allInstances.Where(i =>
{
if (_filterSiteId > 0 && i.SiteId != _filterSiteId) return false;
if (_filterTemplateId > 0 && i.TemplateId != _filterTemplateId) return false;
if (!string.IsNullOrEmpty(_filterStatus) && i.State.ToString() != _filterStatus) return false;
if (!string.IsNullOrWhiteSpace(_filterSearch) &&
!i.UniqueName.Contains(_filterSearch, StringComparison.OrdinalIgnoreCase)) return false;
return true;
}).OrderBy(i => i.UniqueName).ToList();
BuildTree();
}
private void BuildTree()
{
_treeRoots = _sites.Select(site =>
{
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();
var areaNodes = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
var unassigned = siteInstances
.Where(i => i.AreaId == null)
.Select(MakeInstanceNode)
.ToList();
var children = areaNodes.Concat(unassigned).ToList();
return new InstanceTreeNode(
Key: $"site-{site.Id}",
Label: site.Name,
Kind: InstanceNodeKind.Site,
Children: children);
})
.Where(s => s.Children.Count > 0)
.ToList();
}
private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
List<Instance> instances, int? parentId)
{
return allAreas
.Where(a => a.ParentAreaId == parentId)
.Select(area =>
{
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
var areaInstances = instances
.Where(i => i.AreaId == area.Id)
.Select(MakeInstanceNode)
.ToList();
var children = childAreas.Concat(areaInstances).ToList();
return new InstanceTreeNode(
Key: $"area-{area.Id}",
Label: area.Name,
Kind: InstanceNodeKind.Area,
Children: children);
})
.Where(a => a.Children.Count > 0)
.ToList();
}
private InstanceTreeNode MakeInstanceNode(Instance inst) => new(
Key: $"inst-{inst.Id}",
Label: inst.UniqueName,
Kind: InstanceNodeKind.Instance,
Children: new(),
Instance: inst,
IsStale: _stalenessMap.GetValueOrDefault(inst.Id));
private string GetTemplateName(int templateId) =>
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";
private string GetSiteName(int siteId) =>
_sites.FirstOrDefault(s => s.Id == siteId)?.Name ?? $"#{siteId}";
private string GetAreaName(int areaId) =>
_allAreas.FirstOrDefault(a => a.Id == areaId)?.Name ?? $"#{areaId}";
private static string GetStateBadge(InstanceState state) => state switch
{
InstanceState.Enabled => "bg-success",
InstanceState.Disabled => "bg-secondary",
InstanceState.NotDeployed => "bg-light text-dark",
_ => "bg-secondary"
};
private async Task EnableInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Enable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Enable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DisableInstance(Instance inst)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
"Disable Instance");
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Disable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Disable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DeployInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Deploy failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Deploy failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DeleteInstance(Instance inst)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
"Delete Instance");
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Delete failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
_actionInProgress = false;
}
// Diff state
private bool _showDiffModal;
private bool _diffLoading;
private string? _diffError;
private string _diffInstanceName = string.Empty;
private DeploymentComparisonResult? _diffResult;
private async Task ShowDiff(Instance inst)
{
_showDiffModal = true;
_diffLoading = true;
_diffError = null;
_diffResult = null;
_diffInstanceName = inst.UniqueName;
try
{
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
if (result.IsSuccess)
{
_diffResult = result.Value;
}
else
{
_diffError = result.Error;
}
}
catch (Exception ex)
{
_diffError = $"Failed to load diff: {ex.Message}";
}
_diffLoading = false;
}
}
@@ -0,0 +1,53 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move area '@AreaName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetParentId">
@foreach (var opt in ParentOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int AreaId { get; set; }
[Parameter] public string AreaName { get; set; } = string.Empty;
[Parameter] public int? CurrentParentId { get; set; }
[Parameter] public IEnumerable<(int? Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int AreaId, int? NewParentId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetParentId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_targetParentId = CurrentParentId;
}
_wasVisible = IsVisible;
}
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit() => await OnSubmit.InvokeAsync((AreaId, _targetParentId));
}
@@ -0,0 +1,53 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@InstanceName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetAreaId">
@foreach (var opt in AreaOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int InstanceId { get; set; }
[Parameter] public string InstanceName { get; set; } = string.Empty;
[Parameter] public int? CurrentAreaId { get; set; }
[Parameter] public IEnumerable<(int? Id, string Label)> AreaOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int InstanceId, int? NewAreaId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetAreaId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_targetAreaId = CurrentAreaId;
}
_wasVisible = IsVisible;
}
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit() => await OnSubmit.InvokeAsync((InstanceId, _targetAreaId));
}
@@ -0,0 +1,878 @@
@page "/deployment/topology"
@page "/deployment/instances"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Entities.Deployment
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.DeploymentManager
@using ScadaLink.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject DeploymentService DeploymentService
@inject AreaService AreaService
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
<CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog"
RequireSitePicker="_createAreaRequireSitePicker"
ContextLabel="@_createAreaContextLabel"
SiteId="_createAreaSiteId"
ParentAreaId="_createAreaParentId"
SiteOptions="EnumerateSiteOptions()"
ParentOptions="EnumerateAreaOptionsForCreate()"
ErrorMessage="@_createAreaError"
OnSubmit="SubmitCreateArea" />
<MoveAreaDialog @bind-IsVisible="_showMoveAreaDialog"
AreaId="_moveAreaId"
AreaName="@_moveAreaName"
CurrentParentId="_moveAreaCurrentParentId"
ParentOptions="EnumerateAreaParentOptionsExcluding(_moveAreaId, _moveAreaSiteId)"
ErrorMessage="@_moveAreaError"
OnSubmit="SubmitMoveArea" />
<MoveInstanceDialog @bind-IsVisible="_showMoveInstanceDialog"
InstanceId="_moveInstanceId"
InstanceName="@_moveInstanceName"
CurrentAreaId="_moveInstanceCurrentAreaId"
AreaOptions="EnumerateAreaOptionsForSite(_moveInstanceSiteId)"
ErrorMessage="@_moveInstanceError"
OnSubmit="SubmitMoveInstance" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<h6 class="mb-2">Topology</h6>
<div class="d-flex align-items-center mb-2 gap-2">
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
placeholder="Search sites, areas, instances..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
<button class="btn btn-outline-secondary"
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
<button class="btn btn-outline-secondary" @onclick="LoadDataAsync">Refresh</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree?.ExpandAll()">Expand</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree?.CollapseAll()">Collapse</button>
</div>
</div>
<div style="max-height: calc(100vh - 180px); overflow-y: auto; padding: 4px;">
<TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="topology-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
<ContextMenu Context="node">
@RenderNodeContextMenu(node)
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
</EmptyContent>
</TreeView>
</div>
<div class="text-muted small mt-2">
@_allInstances.Count instance(s) across @_sites.Count site(s).
</div>
@* Diff Modal — ported from Instances.razor *@
@if (_showDiffModal)
{
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
</div>
<div class="modal-body">
@if (_diffLoading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_diffError != null)
{
<div class="alert alert-danger">@_diffError</div>
}
else if (_diffResult != null)
{
<div class="mb-2">
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
</span>
<span class="text-muted small ms-2">
Deployed: @_diffResult.DeployedRevisionHash[..8]
| Current: @_diffResult.CurrentRevisionHash[..8]
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
</span>
</div>
@if (!_diffResult.IsStale)
{
<p class="text-muted">No differences between deployed and current configuration.</p>
}
else
{
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
}
}
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
</div>
</div>
</div>
</div>
}
}
</div>
@code {
// ---- Data ----
private List<Instance> _allInstances = new();
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
private Dictionary<int, bool> _stalenessMap = new();
private bool _loading = true;
private string? _errorMessage;
private bool _actionInProgress;
private string _searchText = string.Empty;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
private TreeView<TopoNode> _tree = default!;
private object? _selectedKey;
private const string SelectedKeyStorage = "topology-tree-selected";
// ---- Tree model ----
private enum TopoNodeKind { Site, Area, Instance }
private record TopoNode(
string Key,
TopoNodeKind Kind,
int EntityId,
int SiteId,
string Label,
Site? Site,
Area? Area,
Instance? Instance,
bool IsStale,
bool MatchesSearch,
List<TopoNode> Children);
private List<TopoNode> _treeRoots = new();
// ---- Inline rename ----
private string? _renamingKey;
private string _renameBuffer = string.Empty;
private string? _renameError;
// ---- Lifecycle ----
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var stored = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", SelectedKeyStorage);
if (!string.IsNullOrEmpty(stored))
{
_selectedKey = stored;
StateHasChanged();
}
}
catch { /* no JS interop available (prerender, tests) — ignore */ }
}
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_allAreas.Clear();
foreach (var site in _sites)
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
_stalenessMap.Clear();
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
{
try
{
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
}
catch
{
_stalenessMap[inst.Id] = false;
}
}
BuildTree();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load topology: {ex.Message}";
}
_loading = false;
}
private void OnSearchChanged()
{
BuildTree();
}
private bool NodeMatchesSearch(string label)
{
if (string.IsNullOrWhiteSpace(_searchText)) return true;
return label.Contains(_searchText, StringComparison.OrdinalIgnoreCase);
}
private void BuildTree()
{
_treeRoots = _sites
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(site =>
{
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
var siteInstances = _allInstances.Where(i => i.SiteId == site.Id).ToList();
var areaChildren = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
var rootInstances = siteInstances
.Where(i => i.AreaId == null)
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(MakeInstanceNode)
.ToList();
var children = areaChildren.Concat(rootInstances).ToList();
return new TopoNode(
Key: $"s:{site.Id}",
Kind: TopoNodeKind.Site,
EntityId: site.Id,
SiteId: site.Id,
Label: site.Name,
Site: site,
Area: null,
Instance: null,
IsStale: false,
MatchesSearch: NodeMatchesSearch(site.Name),
Children: children);
})
.ToList();
}
private List<TopoNode> BuildAreaNodes(List<Area> allAreas, List<Instance> instances, int? parentId)
{
return allAreas
.Where(a => a.ParentAreaId == parentId)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.Select(area =>
{
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
var areaInstances = instances
.Where(i => i.AreaId == area.Id)
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(MakeInstanceNode)
.ToList();
var children = childAreas.Concat(areaInstances).ToList();
return new TopoNode(
Key: $"a:{area.Id}",
Kind: TopoNodeKind.Area,
EntityId: area.Id,
SiteId: area.SiteId,
Label: area.Name,
Site: null,
Area: area,
Instance: null,
IsStale: false,
MatchesSearch: NodeMatchesSearch(area.Name),
Children: children);
})
.ToList();
}
private TopoNode MakeInstanceNode(Instance inst) => new(
Key: $"i:{inst.Id}",
Kind: TopoNodeKind.Instance,
EntityId: inst.Id,
SiteId: inst.SiteId,
Label: inst.UniqueName,
Site: null,
Area: null,
Instance: inst,
IsStale: _stalenessMap.GetValueOrDefault(inst.Id),
MatchesSearch: NodeMatchesSearch(inst.UniqueName),
Children: new List<TopoNode>());
// ---- Rendering ----
private RenderFragment RenderNodeLabel(TopoNode node) => __builder =>
{
var dim = !string.IsNullOrWhiteSpace(_searchText) && !SubtreeContainsMatch(node);
var labelStyle = dim ? "opacity: 0.4;" : null;
switch (node.Kind)
{
case TopoNodeKind.Site:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
break;
case TopoNodeKind.Area:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-diagram-3"></i></span>
@if (_renamingKey == node.Key)
{
<input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;"
@ref="_renameInput"
@bind="_renameBuffer"
@onkeydown="(e) => OnRenameKeyDown(e, node)"
@onblur="() => CancelRename()" />
@if (!string.IsNullOrEmpty(_renameError))
{
<span class="text-danger small ms-2">@_renameError</span>
}
}
else
{
<span class="tv-label" style="@labelStyle" title="@node.Label"
@ondblclick="() => BeginRename(node)">@node.Label</span>
}
break;
case TopoNodeKind.Instance:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-box"></i></span>
<span class="tv-label" style="@labelStyle" title="@node.Label">@node.Label</span>
<span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span>
@if (node.Instance!.State != InstanceState.NotDeployed)
{
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1" style="@labelStyle">
@(node.IsStale ? "Stale" : "Current")
</span>
}
break;
}
};
private static bool SubtreeContainsMatch(TopoNode node)
{
if (node.MatchesSearch) return true;
return node.Children.Any(SubtreeContainsMatch);
}
private RenderFragment RenderNodeContextMenu(TopoNode node) => __builder =>
{
switch (node.Kind)
{
case TopoNodeKind.Site:
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForSite(node.EntityId)">Add Area</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.EntityId}")'>
Create Instance here
</button>
break;
case TopoNodeKind.Area:
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForArea(node.SiteId, node.EntityId, node.Label)">Add Sub-area</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.SiteId}&areaId={node.EntityId}")'>
Create Instance here
</button>
<button class="dropdown-item" @onclick="() => OpenMoveAreaDialog(node)">Move to Area…</button>
<button class="dropdown-item" @onclick="() => BeginRename(node)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteArea(node)" disabled="@_actionInProgress">Delete</button>
break;
case TopoNodeKind.Instance:
var inst = node.Instance!;
var isStale = node.IsStale;
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
@if (inst.State == InstanceState.Enabled)
{
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
disabled="@_actionInProgress">Disable</button>
}
else if (inst.State == InstanceState.Disabled)
{
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
disabled="@_actionInProgress">Enable</button>
}
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
Configure
</button>
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<button class="dropdown-item" @onclick="() => OpenMoveInstanceDialog(node)">Move to Area…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
disabled="@_actionInProgress">Delete</button>
break;
}
};
private static string GetStateBadge(InstanceState state) => state switch
{
InstanceState.Enabled => "bg-success",
InstanceState.Disabled => "bg-secondary",
InstanceState.NotDeployed => "bg-light text-dark",
_ => "bg-secondary"
};
// ---- Selection ----
private async Task OnTreeNodeSelected(object? key)
{
_selectedKey = key;
try
{
await JSRuntime.InvokeVoidAsync("treeviewStorage.save", SelectedKeyStorage,
key?.ToString() ?? string.Empty);
}
catch { /* ignore */ }
}
// ---- Inline rename ----
private ElementReference _renameInput;
private void BeginRename(TopoNode node)
{
if (node.Kind != TopoNodeKind.Area) return;
_renamingKey = node.Key;
_renameBuffer = node.Label;
_renameError = null;
}
private void CancelRename()
{
_renamingKey = null;
_renameBuffer = string.Empty;
_renameError = null;
}
private async Task OnRenameKeyDown(KeyboardEventArgs e, TopoNode node)
{
if (e.Key == "Escape")
{
CancelRename();
}
else if (e.Key == "Enter")
{
await CommitRename(node);
}
}
private async Task CommitRename(TopoNode node)
{
if (string.IsNullOrWhiteSpace(_renameBuffer) || _renameBuffer.Trim() == node.Label)
{
CancelRename();
return;
}
var user = await GetCurrentUserAsync();
var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
if (result.IsSuccess)
{
CancelRename();
_toast.ShowSuccess($"Area renamed to '{result.Value.Name}'.");
await LoadDataAsync();
}
else
{
_renameError = result.Error;
}
}
// ---- Create-area dialog ----
private bool _showCreateAreaDialog;
private bool _createAreaRequireSitePicker;
private string _createAreaContextLabel = string.Empty;
private int? _createAreaSiteId;
private int? _createAreaParentId;
private string? _createAreaError;
private void OpenCreateAreaDialogRoot()
{
_createAreaRequireSitePicker = true;
_createAreaContextLabel = string.Empty;
_createAreaSiteId = null;
_createAreaParentId = null;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private void OpenCreateAreaDialogForSite(int siteId)
{
var site = _sites.FirstOrDefault(s => s.Id == siteId);
_createAreaRequireSitePicker = false;
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} (root)";
_createAreaSiteId = siteId;
_createAreaParentId = null;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private void OpenCreateAreaDialogForArea(int siteId, int parentAreaId, string parentLabel)
{
var site = _sites.FirstOrDefault(s => s.Id == siteId);
_createAreaRequireSitePicker = false;
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} → Parent: {parentLabel}";
_createAreaSiteId = siteId;
_createAreaParentId = parentAreaId;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private async Task SubmitCreateArea((int SiteId, int? ParentAreaId, string Name) req)
{
_createAreaError = null;
if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }
var user = await GetCurrentUserAsync();
var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
if (result.IsSuccess)
{
_showCreateAreaDialog = false;
_toast.ShowSuccess($"Area '{result.Value.Name}' created.");
await LoadDataAsync();
}
else
{
_createAreaError = result.Error;
}
}
// ---- Move-area dialog ----
private bool _showMoveAreaDialog;
private int _moveAreaId;
private string _moveAreaName = string.Empty;
private int _moveAreaSiteId;
private int? _moveAreaCurrentParentId;
private string? _moveAreaError;
private void OpenMoveAreaDialog(TopoNode node)
{
_moveAreaId = node.EntityId;
_moveAreaName = node.Label;
_moveAreaSiteId = node.SiteId;
_moveAreaCurrentParentId = node.Area?.ParentAreaId;
_moveAreaError = null;
_showMoveAreaDialog = true;
}
private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
{
_moveAreaError = null;
var user = await GetCurrentUserAsync();
var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
if (result.IsSuccess)
{
_showMoveAreaDialog = false;
_toast.ShowSuccess($"Area '{_moveAreaName}' moved.");
await LoadDataAsync();
}
else
{
_moveAreaError = result.Error;
}
}
// ---- Move-instance dialog ----
private bool _showMoveInstanceDialog;
private int _moveInstanceId;
private string _moveInstanceName = string.Empty;
private int _moveInstanceSiteId;
private int? _moveInstanceCurrentAreaId;
private string? _moveInstanceError;
private void OpenMoveInstanceDialog(TopoNode node)
{
var inst = node.Instance!;
_moveInstanceId = inst.Id;
_moveInstanceName = inst.UniqueName;
_moveInstanceSiteId = inst.SiteId;
_moveInstanceCurrentAreaId = inst.AreaId;
_moveInstanceError = null;
_showMoveInstanceDialog = true;
}
private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
{
_moveInstanceError = null;
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
if (result.IsSuccess)
{
_showMoveInstanceDialog = false;
_toast.ShowSuccess($"Instance '{_moveInstanceName}' moved.");
await LoadDataAsync();
}
else
{
_moveInstanceError = result.Error;
}
}
// ---- Area & instance deletion ----
private async Task DeleteArea(TopoNode node)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete area '{node.Label}'? This will fail if it has sub-areas or assigned instances.",
"Delete Area");
if (!confirmed) return;
_actionInProgress = true;
var user = await GetCurrentUserAsync();
var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Area '{node.Label}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError(result.Error);
}
_actionInProgress = false;
}
private async Task DeleteInstance(Instance inst)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
"Delete Instance");
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Delete failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
_actionInProgress = false;
}
// ---- Lifecycle actions ----
private async Task EnableInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Enable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Enable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DisableInstance(Instance inst)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
"Disable Instance");
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Disable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Disable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DeployInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Deploy failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Deploy failed: {ex.Message}");
}
_actionInProgress = false;
}
// ---- Diff modal ----
private bool _showDiffModal;
private bool _diffLoading;
private string? _diffError;
private string _diffInstanceName = string.Empty;
private DeploymentComparisonResult? _diffResult;
private async Task ShowDiff(Instance inst)
{
_showDiffModal = true;
_diffLoading = true;
_diffError = null;
_diffResult = null;
_diffInstanceName = inst.UniqueName;
try
{
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
if (result.IsSuccess)
{
_diffResult = result.Value;
}
else
{
_diffError = result.Error;
}
}
catch (Exception ex)
{
_diffError = $"Failed to load diff: {ex.Message}";
}
_diffLoading = false;
}
// ---- Dropdown option helpers ----
private IEnumerable<(int Id, string Label)> EnumerateSiteOptions()
{
foreach (var s in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
yield return (s.Id, s.Name);
}
private IEnumerable<(int Id, string Label, int SiteId)> EnumerateAreaOptionsForCreate()
{
foreach (var a in WalkAllSiteAreas())
yield return a;
}
private IEnumerable<(int Id, string Label, int SiteId)> WalkAllSiteAreas()
{
foreach (var site in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
foreach (var entry in WalkSiteHierarchy(site.Id, parentId: null, depth: 0, excludeAreaId: null))
yield return entry;
}
}
private IEnumerable<(int? Id, string Label)> EnumerateAreaOptionsForSite(int siteId)
{
yield return ((int?)null, "(No area — site root)");
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: null))
yield return ((int?)entry.Id, entry.Label);
}
private IEnumerable<(int? Id, string Label)> EnumerateAreaParentOptionsExcluding(int areaId, int siteId)
{
yield return ((int?)null, "(Site root)");
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: areaId))
yield return ((int?)entry.Id, entry.Label);
}
private IEnumerable<(int Id, string Label, int SiteId)> WalkSiteHierarchy(int siteId, int? parentId, int depth, int? excludeAreaId)
{
var levelAreas = _allAreas
.Where(a => a.SiteId == siteId && a.ParentAreaId == parentId)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase);
foreach (var a in levelAreas)
{
if (excludeAreaId.HasValue && a.Id == excludeAreaId.Value) continue;
yield return (a.Id, new string(' ', depth * 2) + a.Name, siteId);
foreach (var sub in WalkSiteHierarchy(siteId, a.Id, depth + 1, excludeAreaId))
yield return sub;
}
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
}
@@ -56,6 +56,9 @@
</div>
@code {
[SupplyParameterFromQuery] public int? FolderId { get; set; }
[SupplyParameterFromQuery] public int? ParentId { get; set; }
private List<Template> _templates = new();
private bool _loading = true;
@@ -69,6 +72,10 @@
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
if (ParentId is int pid && _templates.Any(t => t.Id == pid))
{
_createParentId = pid;
}
}
catch (Exception ex)
{
@@ -87,11 +94,12 @@
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, user);
_createParentId == 0 ? null : _createParentId, user,
folderId: FolderId);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/templates");
NavigationManager.NavigateTo($"/design/templates/{result.Value.Id}");
}
else
{
@@ -0,0 +1,858 @@
@page "/design/templates/{Id:int}"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Services
@using ScadaLink.TemplateEngine.Validation
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">&larr; Templates</button>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_loadError != null)
{
<div class="alert alert-danger">@_loadError</div>
}
else if (_selectedTemplate == null)
{
<div class="alert alert-warning">Template not found.</div>
}
else
{
@RenderTemplateDetail()
}
</div>
@code {
[Parameter] public int Id { get; set; }
private List<Template> _templates = new();
private Template? _selectedTemplate;
private List<TemplateAttribute> _attributes = new();
private List<TemplateAlarm> _alarms = new();
private List<TemplateScript> _scripts = new();
private List<TemplateComposition> _compositions = new();
private bool _loading = true;
private string? _loadError;
private string _activeTab = "attributes";
// Edit properties
private string _editName = string.Empty;
private string? _editDescription;
private int _editParentId;
// Validation
private bool _validating;
private Commons.Types.Flattening.ValidationResult? _validationResult;
// Member add forms
private bool _showAttrForm;
private string _attrName = string.Empty;
private string? _attrValue;
private DataType _attrDataType;
private bool _attrIsLocked;
private string? _attrDataSourceRef;
private string? _attrFormError;
private bool _showAlarmForm;
private string _alarmName = string.Empty;
private int _alarmPriority;
private AlarmTriggerType _alarmTriggerType;
private string? _alarmTriggerConfig;
private bool _alarmIsLocked;
private string? _alarmFormError;
private bool _showScriptForm;
private string _scriptName = string.Empty;
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
private string? _scriptTriggerConfig;
private bool _scriptIsLocked;
private string? _scriptFormError;
private bool _showCompForm;
private int _compComposedTemplateId;
private string _compInstanceName = string.Empty;
private string? _compFormError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnParametersSetAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_loadError = null;
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_selectedTemplate = await TemplateEngineRepository.GetTemplateWithChildrenAsync(Id)
?? _templates.FirstOrDefault(t => t.Id == Id);
if (_selectedTemplate == null) { _loading = false; return; }
_editName = _selectedTemplate.Name;
_editDescription = _selectedTemplate.Description;
_editParentId = _selectedTemplate.ParentTemplateId ?? 0;
_attributes = (await TemplateEngineRepository.GetAttributesByTemplateIdAsync(Id)).ToList();
_alarms = (await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(Id)).ToList();
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(Id)).ToList();
_validationResult = null;
}
catch (Exception ex)
{
_loadError = $"Failed to load template: {ex.Message}";
}
_loading = false;
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/templates");
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
private RenderFragment RenderTemplateDetail() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="d-inline mb-0">@_selectedTemplate!.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
<button class="btn btn-outline-danger btn-sm" @onclick="DeleteTemplate">Delete</button>
</div>
</div>
@* Validation results *@
@if (_validationResult != null)
{
<div class="mb-3">
@if (_validationResult.Errors.Count > 0)
{
<div class="alert alert-danger py-2">
<strong>Validation Errors (@_validationResult.Errors.Count)</strong>
<ul class="mb-0 small">
@foreach (var err in _validationResult.Errors)
{
<li>[@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")</li>
}
</ul>
</div>
}
@if (_validationResult.Warnings.Count > 0)
{
<div class="alert alert-warning py-2">
<strong>Warnings (@_validationResult.Warnings.Count)</strong>
<ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings)
{
<li>[@warn.Category] @warn.Message</li>
}
</ul>
</div>
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
<div class="alert alert-success py-2">Validation passed with no errors or warnings.</div>
}
</div>
}
@* Template info edit *@
<div class="card mb-3">
<div class="card-header">Template Properties</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_editName" />
</div>
<div class="col-md-4">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_editDescription" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<div class="form-control-plaintext form-control-sm">
@(_selectedTemplate.ParentTemplateId is int pid
? _templates.FirstOrDefault(t => t.Id == pid)?.Name ?? $"#{pid}"
: "(none)")
</div>
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
</div>
</div>
</div>
</div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")" @onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")" @onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")" @onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")" @onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
{
@RenderAttributesTab()
}
else if (_activeTab == "alarms")
{
@RenderAlarmsTab()
}
else if (_activeTab == "scripts")
{
@RenderScriptsTab()
}
else if (_activeTab == "compositions")
{
@RenderCompositionsTab()
}
};
private async Task DeleteTemplate()
{
if (_selectedTemplate == null) return;
var confirmed = await _confirmDialog.ShowAsync(
$"Delete template '{_selectedTemplate.Name}'? This will fail if instances or child templates reference it.",
"Delete Template");
if (!confirmed) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteTemplateAsync(_selectedTemplate.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Template '{_selectedTemplate.Name}' deleted.");
NavigationManager.NavigateTo("/design/templates");
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
private async Task UpdateTemplateProperties()
{
if (_selectedTemplate == null) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.UpdateTemplateAsync(
_selectedTemplate.Id, _editName.Trim(), _editDescription?.Trim(),
_editParentId == 0 ? null : _editParentId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess("Template properties updated.");
_selectedTemplate = result.Value;
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Update failed: {ex.Message}");
}
}
private async Task RunValidation()
{
if (_selectedTemplate == null) return;
_validating = true;
_validationResult = null;
try
{
var validationService = new ValidationService();
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
{
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
TemplateId = _selectedTemplate.Id,
Attributes = _attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
IsLocked = a.IsLocked,
DataSourceReference = a.DataSourceReference
}).ToList(),
Alarms = _alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm
{
CanonicalName = a.Name,
PriorityLevel = a.PriorityLevel,
IsLocked = a.IsLocked,
TriggerType = a.TriggerType.ToString(),
TriggerConfiguration = a.TriggerConfiguration
}).ToList(),
Scripts = _scripts.Select(s => new Commons.Types.Flattening.ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList()
};
_validationResult = validationService.Validate(flatConfig);
var collisions = await TemplateService.DetectCollisionsAsync(_selectedTemplate.Id);
if (collisions.Count > 0)
{
var collisionErrors = collisions.Select(c =>
Commons.Types.Flattening.ValidationEntry.Error(
Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray();
var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors };
_validationResult = Commons.Types.Flattening.ValidationResult.Merge(_validationResult, collisionResult);
}
}
catch (Exception ex)
{
_toast.ShowError($"Validation error: {ex.Message}");
}
_validating = false;
}
// ---- Attributes Tab ----
private RenderFragment RenderAttributesTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Attributes</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button>
</div>
@if (_showAttrForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_attrName" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Type</label>
<select class="form-select form-select-sm" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Value</label>
<input type="text" class="form-control form-control-sm" @bind="_attrValue" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Source Ref</label>
<input type="text" class="form-control form-control-sm" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label small" for="attrLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAttribute">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAttrForm = false">Cancel</button>
</div>
</div>
@if (_attrFormError != null) { <div class="text-danger small mt-1">@_attrFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Type</th>
<th>Value</th>
<th>Data Source</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _attributes)
{
<tr>
<td>@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small">@(attr.Value ?? "—")</td>
<td class="small text-muted">@(attr.DataSourceReference ?? "—")</td>
<td>
@if (attr.IsLocked)
{
<span class="badge bg-danger" title="Locked">L</span>
}
else
{
<span class="badge bg-light text-dark" title="Unlocked">U</span>
}
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAttribute(attr)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Alarms</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button>
</div>
@if (_showAlarmForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<select class="form-select form-select-sm" @bind="_alarmTriggerType">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{
<option value="@tt">@tt</option>
}
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Priority</label>
<input type="number" class="form-control form-control-sm" @bind="_alarmPriority" min="0" max="1000" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label small" for="alarmLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAlarm">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAlarmForm = false">Cancel</button>
</div>
</div>
@if (_alarmFormError != null) { <div class="text-danger small mt-1">@_alarmFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Priority</th>
<th>Config</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _alarms)
{
<tr>
<td>@alarm.Name</td>
<td><span class="badge bg-light text-dark">@alarm.TriggerType</span></td>
<td>@alarm.PriorityLevel</td>
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
<td>
@if (alarm.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAlarm(alarm)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Scripts</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptIsLocked = false; }">Add Script</button>
</div>
@if (_showScriptForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label small" for="scriptLocked">Locked</label>
</div>
</div>
</div>
<div class="mt-2">
<label class="form-label small">Code</label>
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_scriptCode"
style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="AddScript">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showScriptForm = false">Cancel</button>
</div>
@if (_scriptFormError != null) { <div class="text-danger small mt-1">@_scriptFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Code (preview)</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var script in _scripts)
{
<tr>
<td>@script.Name</td>
<td class="small">@(script.TriggerType ?? "—")</td>
<td class="small text-muted text-truncate font-monospace" style="max-width: 300px;">@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "")</td>
<td>
@if (script.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Compositions Tab ----
private RenderFragment RenderCompositionsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Compositions</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showCompForm = true; _compFormError = null; _compInstanceName = string.Empty; _compComposedTemplateId = 0; }">Add Composition</button>
</div>
@if (_showCompForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_compInstanceName" />
</div>
<div class="col-md-4">
<label class="form-label small">Composed Template</label>
<select class="form-select form-select-sm" @bind="_compComposedTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates.Where(t => _selectedTemplate == null || t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddComposition">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCompForm = false">Cancel</button>
</div>
</div>
@if (_compFormError != null) { <div class="text-danger small mt-1">@_compFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Instance Name</th>
<th>Composed Template</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var comp in _compositions)
{
<tr>
<td><code>@comp.InstanceName</code></td>
<td>@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}")</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteComposition(comp)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- CRUD handlers ----
private async Task AddAttribute()
{
if (_selectedTemplate == null) return;
_attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
var attr = new TemplateAttribute(_attrName.Trim())
{
DataType = _attrDataType,
Value = _attrValue?.Trim(),
IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim()
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (result.IsSuccess)
{
_showAttrForm = false;
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
await LoadAsync();
}
else
{
_attrFormError = result.Error;
}
}
private async Task DeleteAttribute(TemplateAttribute attr)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete attribute '{attr.Name}'?", "Delete Attribute");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAttributeAsync(attr.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Attribute '{attr.Name}' deleted.");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task AddAlarm()
{
if (_selectedTemplate == null) return;
_alarmFormError = null;
if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; }
var alarm = new TemplateAlarm(_alarmName.Trim())
{
TriggerType = _alarmTriggerType,
PriorityLevel = _alarmPriority,
TriggerConfiguration = _alarmTriggerConfig?.Trim(),
IsLocked = _alarmIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (result.IsSuccess)
{
_showAlarmForm = false;
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
await LoadAsync();
}
else
{
_alarmFormError = result.Error;
}
}
private async Task DeleteAlarm(TemplateAlarm alarm)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete alarm '{alarm.Name}'?", "Delete Alarm");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAlarmAsync(alarm.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Alarm '{alarm.Name}' deleted.");
await LoadAsync();
}
else { _toast.ShowError(result.Error); }
}
private async Task AddScript()
{
if (_selectedTemplate == null) return;
_scriptFormError = null;
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code is required."; return; }
var script = new TemplateScript(_scriptName.Trim(), _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
IsLocked = _scriptIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (result.IsSuccess)
{
_showScriptForm = false;
_toast.ShowSuccess($"Script '{_scriptName}' added.");
await LoadAsync();
}
else
{
_scriptFormError = result.Error;
}
}
private async Task DeleteScript(TemplateScript script)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete script '{script.Name}'?", "Delete Script");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteScriptAsync(script.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
await LoadAsync();
}
else { _toast.ShowError(result.Error); }
}
private async Task AddComposition()
{
if (_selectedTemplate == null) return;
_compFormError = null;
if (string.IsNullOrWhiteSpace(_compInstanceName)) { _compFormError = "Instance name is required."; return; }
if (_compComposedTemplateId == 0) { _compFormError = "Select a template."; return; }
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddCompositionAsync(
_selectedTemplate.Id, _compComposedTemplateId, _compInstanceName.Trim(), user);
if (result.IsSuccess)
{
_showCompForm = false;
_toast.ShowSuccess($"Composition '{_compInstanceName}' added.");
await LoadAsync();
}
else
{
_compFormError = result.Error;
}
}
private async Task DeleteComposition(TemplateComposition comp)
{
var confirmed = await _confirmDialog.ShowAsync($"Remove composition '{comp.InstanceName}'?", "Delete Composition");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteCompositionAsync(comp.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Composition '{comp.InstanceName}' removed.");
await LoadAsync();
}
else { _toast.ShowError(result.Error); }
}
}
File diff suppressed because it is too large Load Diff
@@ -4,23 +4,21 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Template</h6>
<h6 class="modal-title">Move '@FolderName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="_name" />
</div>
<div class="mb-2">
<label class="form-label small">Description</label>
<input class="form-control form-control-sm" @bind="_description" />
</div>
<select class="form-select form-select-sm" @bind="_targetParentId">
@foreach (var opt in FolderOptions)
{
<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">Create</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
@@ -30,21 +28,20 @@
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int? FolderId { get; set; }
[Parameter] public int FolderId { get; set; }
[Parameter] public string FolderName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int? FolderId, string Name, string? Description)> OnSubmit { get; set; }
[Parameter] public EventCallback<(int FolderId, int? NewParentId)> OnSubmit { get; set; }
private bool _wasVisible;
private string _name = string.Empty;
private string? _description;
private int? _targetParentId;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_name = string.Empty;
_description = null;
_targetParentId = null;
}
_wasVisible = IsVisible;
}
@@ -56,6 +53,6 @@
private async Task Submit()
{
await OnSubmit.InvokeAsync((FolderId, _name.Trim(), _description?.Trim()));
await OnSubmit.InvokeAsync((FolderId, _targetParentId));
}
}
@@ -11,7 +11,7 @@
}
else
{
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")" style="list-style:none;padding-left:0;margin:0;">
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
@foreach (var item in _items)
{
RenderNode(item, 0);
@@ -22,7 +22,8 @@ else
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
{
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
<div class="dropdown-menu show" style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;">
<div class="dropdown-menu show" tabindex="-1" @ref="_contextMenuRef" @onkeydown="OnContextMenuKeyDown"
style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;outline:none;">
@ContextMenu(_contextMenuItem)
</div>
}
@@ -33,19 +34,21 @@ else
var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item);
var isExpanded = _expandedKeys.Contains(KeyStr(key));
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
<li role="treeitem" @key="key"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? "true" : null)">
<div class="tv-row @(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? SelectedCssClass : "")" style="padding-left: @(depth * IndentPx)px"
aria-selected="@(isSelected ? "true" : null)">
<div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
@if (isBranch)
{
<span class="tv-toggle" style="display:inline-block;width:1.2em;text-align:center;cursor:pointer;" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation>@(isExpanded ? "\u2212" : "+")</span>
<span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation><i class="bi bi-chevron-right"></i></span>
}
else
{
<span class="tv-spacer" style="display:inline-block;width:1.2em;"></span>
<span class="tv-spacer"></span>
}
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
@NodeContent(item)
@@ -53,7 +56,7 @@ else
</div>
@if (isBranch && isExpanded && children is { Count: > 0 })
{
<ul role="group" style="list-style:none;padding-left:0;margin:0;">
<ul role="group">
@foreach (var child in children)
{
RenderNode(child, depth + 1);
@@ -76,6 +79,8 @@ else
private double _contextMenuX;
private double _contextMenuY;
private bool _showContextMenu;
private bool _contextMenuNeedsFocus;
private ElementReference _contextMenuRef;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
@@ -117,6 +122,12 @@ else
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_contextMenuNeedsFocus && _showContextMenu)
{
_contextMenuNeedsFocus = false;
try { await _contextMenuRef.FocusAsync(); } catch { /* element may have been disposed if dismissed during render */ }
}
if (firstRender && StorageKey != null)
{
var json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
@@ -127,7 +138,10 @@ else
var keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
if (keys != null)
{
_expandedKeys = new HashSet<string>(keys);
// Union (don't replace): callers may have invoked RevealNode before
// this async storage load completed. Preserving those reveal-added
// keys ensures deep-link reveal isn't clobbered by the restore.
foreach (var k in keys) _expandedKeys.Add(k);
_initialExpansionApplied = true;
}
}
@@ -209,6 +223,7 @@ else
_contextMenuX = e.ClientX;
_contextMenuY = e.ClientY;
_showContextMenu = true;
_contextMenuNeedsFocus = true;
}
private void DismissContextMenu()
@@ -217,6 +232,17 @@ else
_contextMenuItem = default;
}
private void OnContextMenuKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
DismissContextMenu();
}
}
/// <summary>Whether the node with the given key is currently expanded.</summary>
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
/// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll()
{
@@ -0,0 +1,135 @@
/* TreeView component styling — see docs/requirements/Component-TreeView.md "Visual Design Guide" (V1V7). */
/* Root list — no list styling. */
.tv-root,
.tv-root ul {
list-style: none;
padding-left: 0;
margin: 0;
}
/* V1 — Row anatomy. Flex container; full-width hit surface; ~32px row. */
.tv-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
cursor: default;
border-radius: 0.25rem;
transition: background-color 0.08s linear;
}
/* V1 — slot widths. Toggle and glyph are always present so labels align across siblings. */
.tv-row .tv-toggle,
.tv-row .tv-spacer {
flex: 0 0 auto;
width: 1.25rem; /* 20px */
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--bs-secondary-color);
user-select: none;
}
.tv-row .tv-spacer {
cursor: default;
}
.tv-row .tv-content {
flex: 1 1 auto;
min-width: 0; /* required so child label can ellipsis-truncate inside a flex item */
display: flex;
align-items: center;
gap: 0.25rem;
}
/* V5 — primary label truncation. Consumers add their own bold class for branches. */
.tv-row .tv-label {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* V1 — meta slot right-aligns trailing badges/text against row edge. */
.tv-row .tv-meta {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex: 0 0 auto;
}
/* V4 — glyph slot. Bootstrap Icons render at 1em, inherit text color. */
.tv-row .tv-glyph {
flex: 0 0 auto;
width: 1.25rem; /* 20px, same slot size as toggle */
display: inline-flex;
align-items: center;
justify-content: center;
}
/* V3 — hover. Subtle gray wash; suppressed when row is selected. */
.tv-row:hover:not(.tv-selected) {
background-color: var(--bs-tertiary-bg);
}
/* V3 — selected. Bootstrap utility `bg-primary bg-opacity-10` is the SelectedCssClass default;
the .tv-selected hook is provided for consumers that prefer scoped styling. */
.tv-row.tv-selected {
background-color: rgba(var(--bs-primary-rgb), 0.1);
}
/* V3 — keyboard focus. Inset ring composes with hover/selected without layout shift. */
.tv-row:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--bs-primary);
}
/* V3 — drop-target (valid). Overrides hover/selected. */
.tv-row.tv-drop-target {
background-color: rgba(var(--bs-info-rgb), 0.25);
}
/* V3 — dimmed (e.g. non-droppable while a drag is in progress; reserved for future use). */
.tv-row.tv-dimmed {
opacity: 0.5;
}
/* V2 — guide lines. A pseudo-element overlays the row's depth gutter and draws
one vertical line per ancestor depth at 24px intervals. The `--tv-depth` variable
is set inline per row; lines never extend into the content area. */
.tv-guides .tv-row {
position: relative;
}
.tv-guides .tv-row::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: calc(var(--tv-depth, 0) * 1.5rem);
pointer-events: none;
background-image: linear-gradient(
to right,
transparent calc(0.625rem - 0.5px),
var(--bs-border-color) calc(0.625rem - 0.5px),
var(--bs-border-color) calc(0.625rem + 0.5px),
transparent calc(0.625rem + 0.5px)
);
background-size: 1.5rem 100%;
background-repeat: repeat-x;
}
/* Branch chevron rotates on expand via the aria-expanded attribute on the parent treeitem.
Consumers using `bi-chevron-right` get the down-rotation for free. */
.tv-row .tv-toggle .bi-chevron-right {
transition: transform 0.1s linear;
}
[role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right {
transform: rotate(90deg);
}
+2
View File
@@ -6,6 +6,8 @@
<base href="/" />
<title>ScadaLink</title>
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet" />
<link href="/ScadaLink.Host.styles.css" rel="stylesheet" />
<style>
.sidebar {
min-width: 220px;
File diff suppressed because it is too large Load Diff
@@ -96,6 +96,61 @@ public class AreaService
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>
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
/// </summary>
@@ -83,28 +83,16 @@ public class TemplateService
if (template == null)
return Result<Template>.Failure($"Template with ID {templateId} not found.");
// Validate parent change
if (parentTemplateId.HasValue && parentTemplateId.Value != (template.ParentTemplateId ?? 0))
// ParentTemplateId is immutable after creation — set once at create time.
// Reject any attempt to change it (null→value, value→null, or value→other).
if (parentTemplateId != template.ParentTemplateId)
{
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
if (parent == null)
return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
// Check inheritance acyclicity
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var cycleError = CycleDetector.DetectInheritanceCycle(templateId, parentTemplateId.Value, allTemplates);
if (cycleError != null)
return Result<Template>.Failure(cycleError);
// Check cross-graph cycle
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, parentTemplateId, null, allTemplates);
if (crossCycleError != null)
return Result<Template>.Failure(crossCycleError);
return Result<Template>.Failure(
"Parent template cannot be changed after creation.");
}
template.Name = name;
template.Description = description;
template.ParentTemplateId = parentTemplateId;
// Check for naming collisions after the change
var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
@@ -45,7 +45,7 @@ public class NavigationTests
}
[Theory]
[InlineData("Instances", "/deployment/instances")]
[InlineData("Topology", "/deployment/topology")]
[InlineData("Deployments", "/deployment/deployments")]
public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
{
@@ -1,7 +1,6 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Templates;
@@ -117,44 +116,11 @@ public class TemplatesPageTests : BunitContext
ownerToggle!.Click();
Assert.Contains("DelmiaReceiver", cut.Markup);
Assert.Contains("→", cut.Markup);
// The composition glyph appears via Bootstrap Icons; the composed template name
// is intentionally not rendered on the tree (V7 spec).
Assert.Contains("bi-arrow-return-right", cut.Markup);
}
[Fact]
public async Task DragTemplateOntoRoot_CallsMoveTemplateAsync_WithNullFolderId()
{
// Arrange: one template currently parented to a folder; the test simulates
// the user dragging that template onto the root drop-zone, which should
// result in TemplateService.MoveTemplateAsync(..., newFolderId: null) being
// invoked. We keep the template at the root in the rendered tree (FolderId
// null) so it renders without needing an expand-click; the drag payload only
// cares about the in-memory id captured by OnDragStart, not the visual
// parent.
var template = new Template("RootDragTarget") { Id = 5, FolderId = null };
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
_repo.GetTemplateByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Template?>(template));
var cut = Render<TemplatesPage>();
// Act: fire ondragstart on the template's draggable <div> (RenderNodeLabel
// emits draggable="true" for template/folder nodes), then ondrop on the
// root wrapper marked with the test-affordance class added to the page.
var templateNode = cut.Find("div[draggable='true']");
await templateNode.TriggerEventAsync("ondragstart", new DragEventArgs());
var rootDropZone = cut.Find("div.templates-root-dropzone");
await rootDropZone.TriggerEventAsync("ondrop", new DragEventArgs());
// Assert: TemplateService.MoveTemplateAsync delegates to the repository's
// UpdateTemplateAsync with FolderId set to null.
await _repo.Received(1).UpdateTemplateAsync(
Arg.Is<Template>(t => t.Id == 5 && t.FolderId == null),
Arg.Any<CancellationToken>());
}
}
internal sealed class TestAuthStateProvider : AuthenticationStateProvider
@@ -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);
}
}
@@ -127,7 +127,8 @@ public class TreeViewTests : BunitContext
Assert.Equal("false", alphaLi.GetAttribute("aria-expanded"));
var toggle = alphaLi.QuerySelector(".tv-toggle");
Assert.NotNull(toggle);
Assert.Equal("+", toggle!.TextContent);
// V2 spec: toggle uses Bootstrap Icons chevron-right; CSS rotates on aria-expanded.
Assert.NotNull(toggle!.QuerySelector("i.bi.bi-chevron-right"));
}
[Fact]
@@ -610,6 +611,43 @@ public class TreeViewTests : BunitContext
Assert.Equal("Alpha", btn!.TextContent);
}
[Fact]
public void ContextMenu_EscapeKeyDismissesMenu()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
Assert.NotNull(cut.Find(".dropdown-menu"));
// Press Escape on the menu — R15 spec requires it to dismiss.
var menu = cut.Find(".dropdown-menu");
menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" });
Assert.Empty(cut.FindAll(".dropdown-menu"));
}
[Fact]
public void ContextMenu_NonEscapeKey_DoesNotDismiss()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
var menu = cut.Find(".dropdown-menu");
menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "ArrowDown" });
Assert.NotEmpty(cut.FindAll(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{
@@ -116,6 +116,157 @@ public class AreaServiceTests
_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]
public async Task DeleteArea_InstancesInDescendants_Blocked()
{
@@ -468,35 +468,50 @@ public class TemplateServiceTests
// ========================================================================
[Fact]
public async Task UpdateTemplate_InheritanceCycle_Fails()
public async Task UpdateTemplate_ChangeParent_Fails()
{
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
var parentA = new Template("A") { Id = 1 };
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(templateB);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { templateA, templateB });
// Try to make A inherit from B (B already inherits from A) => cycle
var result = await _service.UpdateTemplateAsync(1, "A", null, 2, "admin");
// Attempt to re-parent Child from A (id=1) to B (id=3).
var result = await _service.UpdateTemplateAsync(2, "Child", null, 3, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task UpdateTemplate_SelfInheritance_Fails()
public async Task UpdateTemplate_ClearParent_Fails()
{
var template = new Template("Self") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
var result = await _service.UpdateTemplateAsync(1, "Self", null, 1, "admin");
// Attempt to clear the parent.
var result = await _service.UpdateTemplateAsync(2, "Child", null, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("itself", result.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task UpdateTemplate_SameParent_Succeeds()
{
var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "old" };
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
// Idempotent pass — same parent value sent on update should succeed and apply name/description changes.
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", "new", 1, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("ChildRenamed", result.Value.Name);
Assert.Equal("new", result.Value.Description);
Assert.Equal(1, result.Value.ParentTemplateId);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]