Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3386d0278 | |||
| b2eddd9713 | |||
| b4cb7e6f5f | |||
| 8e388a89c5 | |||
| f3b33e7e1d | |||
| d8e6f44616 | |||
| ca164dca03 | |||
| acead212b2 |
@@ -0,0 +1,266 @@
|
||||
# Deployment Topology Page — Design
|
||||
|
||||
A single page under `/deployment` that owns the Site → Area → Instance hierarchy: structural management (create, rename, move, delete) and instance lifecycle (deploy, enable/disable, configure, diff), built on the existing `TreeView` component with the same V1–V7 visual identity as the templates page.
|
||||
|
||||
This page **replaces** both `/deployment/instances` (current read-mostly tree) and `/admin/areas*` (current flat-list CRUD for areas).
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Page identity | Replace both `/deployment/instances` and `/admin/areas*` with one new page |
|
||||
| Route | `/deployment/topology` |
|
||||
| Empty containers | Always shown (so they're valid move/create targets) |
|
||||
| Instance configuration | Stays on dedicated `/deployment/instances/{id}/configure` page |
|
||||
| Filters | Search-only (single input above the tree) |
|
||||
| Search semantics | Dim non-matches (50% opacity), preserve tree shape |
|
||||
| Single-click behavior | Select-only; nothing navigates |
|
||||
| Rename UX | Inline (F2 / double-click) for areas only. Instance rename is out of scope (see "Instance rename" below). |
|
||||
| Site-node menu | Add Area, Create Instance here |
|
||||
| Area-node menu | Add Sub-area, Create Instance here, Move to Area…, Rename…, Delete |
|
||||
| Instance-node menu | Deploy/Redeploy, Enable/Disable, Configure, Diff, Move to Area…, Delete |
|
||||
| Delete-area cascade | Keep server semantics — block on any non-empty subtree |
|
||||
| Top-of-page buttons | Create Area, Create Instance, Refresh |
|
||||
| Move structural scope | Same-site only (instance↔area, area↔area). Cross-site moves out of scope. |
|
||||
| Backend area re-parenting | New `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` |
|
||||
| State persistence | Expanded nodes + selected key, both in sessionStorage |
|
||||
| Glyphs | Site `bi-building`, Area `bi-diagram-3`, Instance `bi-box` |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Topology │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [Search box ............................. ] │
|
||||
│ [Create Area] [Create Instance] [Refresh] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ▾ 🏢 Plant-A │
|
||||
│ ▾ ▦ Line-1 │
|
||||
│ ▸ ▦ Station-3 │
|
||||
│ □ Pump-001 [Enabled] [Current] │
|
||||
│ □ Pump-002 [Disabled] │
|
||||
│ ▾ ▦ Line-2 │
|
||||
│ □ Conveyor-01 [NotDeployed] │
|
||||
│ ▾ 🏢 Plant-B │
|
||||
│ ▸ ▦ (empty area, still shown) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Visual identity
|
||||
|
||||
Follows the existing `Component-TreeView.md` V1–V7 guide. Glyphs adopted:
|
||||
|
||||
| Node | Glyph | Color hook |
|
||||
|---|---|---|
|
||||
| Site | `bi-building` | default |
|
||||
| Area | `bi-diagram-3` | default |
|
||||
| Instance | `bi-box` | default; state badge to the right |
|
||||
|
||||
Instance state badges (kept from current page):
|
||||
|
||||
| State | Badge |
|
||||
|---|---|
|
||||
| Enabled | `bg-success` |
|
||||
| Disabled | `bg-secondary` |
|
||||
| NotDeployed | `bg-light text-dark` |
|
||||
| Stale (deployed but template revision drifted) | `bg-warning text-dark` |
|
||||
| Current | `bg-light text-dark` |
|
||||
|
||||
Search dimming: non-matches receive `opacity: 0.4`. Matches keep full opacity. Tree shape is preserved; ancestors of matches are auto-expanded on first keystroke.
|
||||
|
||||
## Context menus
|
||||
|
||||
### Site
|
||||
- **Add Area** → opens "Create Area" dialog with this site pre-selected (parent = root)
|
||||
- **Create Instance here** → navigates `/deployment/instances/create?siteId={id}`
|
||||
|
||||
### Area
|
||||
- **Add Sub-area** → "Create Area" dialog with this area pre-selected as parent
|
||||
- **Create Instance here** → navigates `/deployment/instances/create?siteId={siteId}&areaId={id}`
|
||||
- **Move to Area…** → opens `MoveAreaDialog`. Destination list = areas in the same site, excluding self and descendants. Plus "(root of site)" option.
|
||||
- divider
|
||||
- **Rename…** → opens `RenameAreaDialog` (also reachable via F2 / double-click for inline edit)
|
||||
- **Delete** → calls `DeleteAreaAsync`; server rejects if non-empty, error surfaced via toast
|
||||
|
||||
### Instance
|
||||
- **Deploy** / **Redeploy** (label depends on `IsStale`)
|
||||
- **Enable** / **Disable** (state-dependent)
|
||||
- **Configure** → navigates `/deployment/instances/{id}/configure`
|
||||
- **Diff** → opens the existing diff modal (ported from current Instances page)
|
||||
- **Move to Area…** → opens `MoveInstanceDialog`. Destination list = areas in the same site + "(no area, site root)".
|
||||
- divider
|
||||
- **Delete**
|
||||
|
||||
## Inline rename
|
||||
|
||||
Applies to **Area rows only**. Instance rows do not support rename on this page (see "Instance rename" below).
|
||||
|
||||
- `F2` or double-click on the label of an Area row replaces the label span with an `<input>` bound to a local edit buffer.
|
||||
- `Enter` commits via `AreaService.UpdateAreaAsync(areaId, name, user)`.
|
||||
- `Escape` cancels.
|
||||
- On commit failure (e.g., name collision at the same level), the toast shows the server error and the input stays open with the bad value highlighted.
|
||||
|
||||
## Instance rename
|
||||
|
||||
**Out of scope for this page.** `InstanceService` currently has no rename method. Adding one is non-trivial:
|
||||
|
||||
- `Instance.UniqueName` is also the identity of the site-side `InstanceActor` (Akka actor name).
|
||||
- It appears in deployment records, audit history, and deploy paths.
|
||||
- Renaming a deployed instance would require coordinated site-side actor stop/restart, deployment-record rebinding, and potentially redeployment.
|
||||
|
||||
This warrants its own design pass. For now: an instance row's label is read-only on the topology page. If a rename is needed, the user can delete + recreate (with the limitation that deployment history is lost).
|
||||
|
||||
The Area-rename context-menu item ("Rename…") is **not** added to the instance menu.
|
||||
|
||||
## Backend changes
|
||||
|
||||
### `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` — NEW
|
||||
|
||||
Parallel to `InstanceService.AssignToAreaAsync`. Validates:
|
||||
|
||||
1. Area exists.
|
||||
2. `newParentAreaId` is null OR refers to an area in the **same site** as the area being moved.
|
||||
3. `newParentAreaId != areaId` (not self).
|
||||
4. The new parent is not a descendant of the area being moved (cycle prevention) — reuse the existing descendant-walking helper that `DeleteAreaAsync` uses.
|
||||
5. No sibling area at the new level has the same name (case-insensitive).
|
||||
|
||||
On success: updates `ParentAreaId`, persists, audits as `"Move"` on entity `"Area"`.
|
||||
|
||||
`UpdateAreaAsync` stays name-only.
|
||||
|
||||
### `Templates.razor` parent-immutability pattern is **not** repeated here
|
||||
Areas can be moved freely (subject to validation). Templates are different because re-parenting changes inheritance semantics; areas are pure organizational containers.
|
||||
|
||||
### No change to:
|
||||
- `InstanceService.AssignToAreaAsync` (already supports re-parenting; will be called by `MoveInstanceDialog`)
|
||||
- `AreaService.DeleteAreaAsync` (keep current block-on-non-empty semantics)
|
||||
- `AreaService.UpdateAreaAsync` (stays name-only)
|
||||
- `InstanceService` lifecycle methods (already used by current Instances page)
|
||||
|
||||
### CLI / ManagementService parity (optional follow-up)
|
||||
- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.
|
||||
- Add CLI: `cli area move --id X --parent-id Y --username … --password …` (omit `--parent-id` to move to site root).
|
||||
|
||||
Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
|
||||
|
||||
## Routes affected
|
||||
|
||||
| Route | Before | After |
|
||||
|---|---|---|
|
||||
| `/deployment/topology` | — | **NEW** (this page — canonical route) |
|
||||
| `/deployment/instances` | tree + lifecycle page | **secondary `@page` directive on `Topology.razor`** — old bookmarks continue to work. NavMenu and all internal back-navs retarget to `/deployment/topology`. |
|
||||
| `/admin/areas` | flat list | **removed** |
|
||||
| `/admin/areas/add` | dialog page | **removed** (Create Area dialog lives on topology page) |
|
||||
| `/admin/areas/edit/{id}` | edit page | **removed** (rename via inline / context menu) |
|
||||
| `/admin/areas/delete/{id}` | confirm page | **removed** (confirm via shared `ConfirmDialog`) |
|
||||
| `/deployment/instances/create` | unchanged | accepts new `?siteId=` and `?areaId=` query params for preselection |
|
||||
| `/deployment/instances/{id}/configure` | unchanged | unchanged |
|
||||
|
||||
The admin nav entry for "Areas" gets removed; "Topology" goes under the Deployment nav group.
|
||||
|
||||
## Files to add
|
||||
|
||||
```
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor (~500 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor (~50 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor (~55 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor (~60 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/RenameAreaDialog.razor (~45 lines) (optional if inline-only)
|
||||
```
|
||||
|
||||
## Files to modify
|
||||
|
||||
```
|
||||
src/ScadaLink.TemplateEngine/Services/AreaService.cs (+ MoveAreaAsync, ~40 lines)
|
||||
src/ScadaLink.Commons/Interfaces/... (interface for AreaService if exposed)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
|
||||
(+ SiteId, AreaId query-param SupplyParameterFromQuery;
|
||||
retarget back-nav to /deployment/topology — 3 sites)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
|
||||
(retarget back-nav to /deployment/topology — 1 site)
|
||||
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor (replace 'Instances' nav with 'Topology' at /deployment/topology;
|
||||
remove 'Areas' nav under Admin)
|
||||
tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
|
||||
(update InlineData: 'Instances' → 'Topology', '/deployment/instances' → '/deployment/topology')
|
||||
docs/requirements/Component-TreeView.md (rewrite §1 'Instances Page' → 'Topology Page' with new route;
|
||||
remove §3 'Areas Page')
|
||||
```
|
||||
|
||||
Note: `CLAUDE.md` does **not** reference `/deployment/instances` today, so no edit required there.
|
||||
|
||||
## Files to remove
|
||||
|
||||
```
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor (replaced by Topology.razor; old route preserved as secondary @page)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaAdd.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaEdit.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaDelete.razor
|
||||
tests/ScadaLink.CentralUI.Tests/InstancesPageTests.cs (if it exists)
|
||||
tests/ScadaLink.CentralUI.Tests/AreaPageTests.cs (if it exists)
|
||||
```
|
||||
|
||||
Verified there are no other references to `/admin/areas*` in CLI, ManagementService, requirement docs (other than `Component-TreeView.md` §3, which is updated above), or tests.
|
||||
|
||||
## State persistence
|
||||
|
||||
- `topology-tree` (sessionStorage) — expansion state (Set of node keys), already supported by `TreeView.StorageKey`.
|
||||
- `topology-tree-selected` (sessionStorage) — selected node key. New; the `TreeView` already exposes `SelectedKey` two-way binding, but the page is responsible for persisting it. Pattern: write in `SelectedKeyChanged`, read on `OnAfterRenderAsync` after data load.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit (`tests/ScadaLink.TemplateEngine.Tests/AreaServiceTests.cs`)
|
||||
- `MoveArea_ToOtherArea_Succeeds`
|
||||
- `MoveArea_ToSiteRoot_Succeeds` (newParentAreaId = null)
|
||||
- `MoveArea_ToSelf_Fails`
|
||||
- `MoveArea_ToDescendant_FailsWithCycleError`
|
||||
- `MoveArea_DifferentSite_Fails`
|
||||
- `MoveArea_NameCollidesAtNewParent_Fails`
|
||||
- `MoveArea_NameUniqueAtNewParent_Succeeds`
|
||||
- `MoveArea_AuditLogged`
|
||||
|
||||
### bUnit (`tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs`)
|
||||
- `Renders_EmptyState_WhenNoSites`
|
||||
- `Renders_EmptySite_WhenSiteHasNoAreasOrInstances` (empty containers visible)
|
||||
- `Renders_SiteAreaInstance_Nesting`
|
||||
- `Search_DimsNonMatches_PreservesShape`
|
||||
- `F2_OnAreaRow_EntersRenameMode`
|
||||
- `F2_OnInstanceRow_DoesNothing` (rename out of scope)
|
||||
- `EscapeDuringInlineRename_Cancels`
|
||||
- `ContextMenu_AreaMove_OpensDialogWithCycleFreeOptions`
|
||||
- `ContextMenu_InstanceMove_OpensDialogWithSameSiteAreasOnly`
|
||||
- `ContextMenu_SiteCreateInstance_NavigatesWithSiteIdQuery`
|
||||
- `LegacyInstancesRoute_RoutesToTopologyPage` (visiting `/deployment/instances` resolves to the same component)
|
||||
|
||||
### Removal cleanup
|
||||
- Drop `InstancesPageTests` and any `AreaPageTests` along with the source files.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Two sites with the same area name at root** — fine. Same-site uniqueness is the rule; areas in different sites are independent.
|
||||
- **Move an area while it has an instance assigned at its root** — allowed. The instance keeps the same `AreaId`; the area's new parent doesn't affect it.
|
||||
- **Site with no areas, just root instances** — instance rows render directly under the site node.
|
||||
- **Concurrent rename of a node by another user** — last-write-wins (consistent with template policy).
|
||||
- **Search match inside a collapsed branch** — auto-expand the ancestor chain so the highlighted match is visible.
|
||||
- **Network failure during inline rename** — leave the input open with the pending value; show the error in a toast; user can retry or Escape.
|
||||
- **Deleting an area, then immediately Ctrl+Z** — not supported (no undo); destructive actions are confirmed via `ConfirmDialog` and audited.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Cross-site moves (would need new `Instance.SiteId` rebinding semantics, deployment-record handling, name-collision check at new site).
|
||||
- Drag-and-drop reordering of areas (no ordinal column today; arbitrary alpha-sort).
|
||||
- Bulk operations (select multiple instances and move/deploy together).
|
||||
- Search across templates / sites / instances from the same input (the search is scoped to this page's tree).
|
||||
- **Instance rename.** No `RenameInstanceAsync` in `InstanceService` today; adding one requires a separate design pass (site-side actor identity, deployment-record rebinding, audit history continuity). Users wanting to rename should delete + recreate.
|
||||
|
||||
## Out-of-band consistency tasks
|
||||
|
||||
When this lands, the following docs need a touch-up:
|
||||
|
||||
- `README.md` — component table; verify no reference to the removed Instances/Areas pages remains.
|
||||
- `docs/requirements/Component-CentralUI.md` (or the routing section if one exists) — route table.
|
||||
- `src/ScadaLink.CLI/README.md` — if existing CLI examples reference `area` subcommands, align with the optional CLI `area move` addition.
|
||||
|
||||
Confirmed clean (no edit needed):
|
||||
- `CLAUDE.md` does not reference `/deployment/instances` or `/admin/areas` today.
|
||||
@@ -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 (~25–33% 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: ~25–33% (`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 25–33% 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.
|
||||
|
||||
@@ -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** (V1–V7). 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 V5–V6; `/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 V1–V6.
|
||||
|
||||
| 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 (V1–V7 visual guide):**
|
||||
- Site: `bi-building`
|
||||
- Area: `bi-diagram-3`
|
||||
- Instance: `bi-box` + state badge + Stale/Current badge when deployed.
|
||||
|
||||
**Context menus:**
|
||||
- **Site:** Add Area, Create Instance here.
|
||||
- **Area:** Add Sub-area, Create Instance here, Move to Area…, Rename… (also F2 / double-click inline), Delete.
|
||||
- **Instance:** Deploy/Redeploy, Enable/Disable (state-dependent), Configure, Diff, Move to Area…, Delete. Instance rename is intentionally absent (see "Instance rename" below).
|
||||
|
||||
**Inline rename:** Area rows only. F2 or double-click swaps the label for an input bound to a local buffer. Enter commits via `AreaService.UpdateAreaAsync`; Escape cancels; server validation errors stay surfaced inline.
|
||||
|
||||
**Search behavior:** Single text input above the tree. While text is present, any row whose label does not match (case-insensitive substring) and whose subtree contains no match is rendered at `opacity: 0.4`. The tree shape stays intact.
|
||||
|
||||
**Top-of-page buttons:** `+ Area` (opens `CreateAreaDialog` with site picker), `+ Instance` (navigates to `/deployment/instances/create` with no preselection), `Refresh`, `Expand`, `Collapse`.
|
||||
|
||||
**Files added:**
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor`
|
||||
|
||||
**Files removed:**
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor` (and AreaAdd / AreaEdit / AreaDelete)
|
||||
|
||||
**Backend addition:** `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` adds area re-parenting (cycle prevention, same-site, name collision at new parent). Pairs with the existing `InstanceService.AssignToAreaAsync`.
|
||||
|
||||
**Instance rename:** Out of scope for this page. `InstanceService` does not currently support renaming an instance (`UniqueName` is also the site-side `InstanceActor` identity and appears in deployment records). A separate design pass is required if rename is wanted.
|
||||
|
||||
---
|
||||
|
||||
@@ -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">← Back</a>
|
||||
<h4 class="mb-0">Add Area</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Site</label>
|
||||
<input type="text" class="form-control form-control-sm" value="@_siteName" readonly />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parent Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
||||
<option value="0">(Root level)</option>
|
||||
@foreach (var area in _areas)
|
||||
{
|
||||
<option value="@area.Id">@GetAreaPath(area)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_name" placeholder="Area name" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery] public int SiteId { get; set; }
|
||||
[SupplyParameterFromQuery] public int ParentAreaId { get; set; }
|
||||
|
||||
private string _siteName = string.Empty;
|
||||
private List<Area> _areas = new();
|
||||
private int _parentAreaId;
|
||||
private string _name = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private bool _loading = true;
|
||||
private bool _saving;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.Id == SiteId);
|
||||
_siteName = site?.Name ?? $"Site #{SiteId}";
|
||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(SiteId)).ToList();
|
||||
_parentAreaId = ParentAreaId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private string GetAreaPath(Area area)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var current = area;
|
||||
while (current != null)
|
||||
{
|
||||
parts.Insert(0, current.Name);
|
||||
current = current.ParentAreaId.HasValue
|
||||
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
|
||||
: null;
|
||||
}
|
||||
return string.Join(" / ", parts);
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
||||
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var area = new Area(_name.Trim())
|
||||
{
|
||||
SiteId = SiteId,
|
||||
ParentAreaId = _parentAreaId == 0 ? null : _parentAreaId
|
||||
};
|
||||
await TemplateEngineRepository.AddAreaAsync(area);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/areas");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
@page "/admin/areas/{Id:int}/delete"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<h4 class="mb-0">Delete Area</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card mb-3" style="max-width: 700px;">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
You are about to delete area <strong>@_area!.Name</strong>.
|
||||
@if (_hasBlockingInstances)
|
||||
{
|
||||
<span class="text-danger">This area (or its children) has instances assigned. Remove or reassign instances before deleting.</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
<h6 class="mt-3">Area hierarchy with assigned instances:</h6>
|
||||
|
||||
<TreeView TItem="DeleteTreeNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key"
|
||||
InitiallyExpanded="_ => true">
|
||||
<NodeContent Context="node">
|
||||
@if (node.Kind == DeleteNodeKind.Area)
|
||||
{
|
||||
<span class="@(node.HasInstances ? "text-danger fw-semibold" : "")">@node.Label</span>
|
||||
@if (node.HasInstances)
|
||||
{
|
||||
<span class="badge bg-danger ms-1">@node.InstanceCount instance(s)</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">@node.Label</span>
|
||||
}
|
||||
</NodeContent>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No child areas.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
|
||||
<div class="mt-3">
|
||||
@if (_hasBlockingInstances)
|
||||
{
|
||||
<button class="btn btn-danger btn-sm" disabled>Delete (blocked)</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-danger btn-sm" @onclick="Delete" disabled="@_deleting">Delete Area</button>
|
||||
}
|
||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm ms-2">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
record DeleteTreeNode(string Key, string Label, DeleteNodeKind Kind, List<DeleteTreeNode> Children,
|
||||
bool HasInstances = false, int InstanceCount = 0);
|
||||
enum DeleteNodeKind { Area, Instance }
|
||||
|
||||
private Area? _area;
|
||||
private List<DeleteTreeNode> _treeRoots = new();
|
||||
private bool _hasBlockingInstances;
|
||||
private bool _loading = true;
|
||||
private bool _deleting;
|
||||
private string? _errorMessage;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
||||
if (_area == null)
|
||||
{
|
||||
_errorMessage = $"Area #{Id} not found.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all areas for this site to build hierarchy
|
||||
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_area.SiteId)).ToList();
|
||||
var allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
|
||||
.Where(i => i.SiteId == _area.SiteId)
|
||||
.ToList();
|
||||
|
||||
var rootNode = BuildDeleteTree(_area, allAreas, allInstances);
|
||||
_treeRoots = new List<DeleteTreeNode> { rootNode };
|
||||
_hasBlockingInstances = HasAnyInstances(rootNode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private DeleteTreeNode BuildDeleteTree(Area area, List<Area> allAreas, List<Instance> allInstances)
|
||||
{
|
||||
var children = new List<DeleteTreeNode>();
|
||||
|
||||
// Add child areas recursively
|
||||
var childAreas = allAreas.Where(a => a.ParentAreaId == area.Id).OrderBy(a => a.Name);
|
||||
foreach (var child in childAreas)
|
||||
{
|
||||
children.Add(BuildDeleteTree(child, allAreas, allInstances));
|
||||
}
|
||||
|
||||
// Add instances assigned to this area
|
||||
var areaInstances = allInstances.Where(i => i.AreaId == area.Id).OrderBy(i => i.UniqueName);
|
||||
foreach (var inst in areaInstances)
|
||||
{
|
||||
children.Add(new DeleteTreeNode(
|
||||
Key: $"inst-{inst.Id}",
|
||||
Label: inst.UniqueName,
|
||||
Kind: DeleteNodeKind.Instance,
|
||||
Children: new()));
|
||||
}
|
||||
|
||||
var instanceCount = areaInstances.Count();
|
||||
return new DeleteTreeNode(
|
||||
Key: $"area-{area.Id}",
|
||||
Label: area.Name,
|
||||
Kind: DeleteNodeKind.Area,
|
||||
Children: children,
|
||||
HasInstances: instanceCount > 0,
|
||||
InstanceCount: instanceCount);
|
||||
}
|
||||
|
||||
private bool HasAnyInstances(DeleteTreeNode node)
|
||||
{
|
||||
if (node.Kind == DeleteNodeKind.Instance) return true;
|
||||
return node.Children.Any(HasAnyInstances);
|
||||
}
|
||||
|
||||
private async Task Delete()
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Permanently delete area '{_area!.Name}' and all its child areas?",
|
||||
"Confirm Delete");
|
||||
if (!confirmed) return;
|
||||
|
||||
_deleting = true;
|
||||
try
|
||||
{
|
||||
// Delete child areas bottom-up (deepest first)
|
||||
await DeleteAreaRecursive(_area!);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/areas");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
_deleting = false;
|
||||
}
|
||||
|
||||
private async Task DeleteAreaRecursive(Area area)
|
||||
{
|
||||
// Load fresh children in case the collection wasn't populated
|
||||
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(area.SiteId)).ToList();
|
||||
var children = allAreas.Where(a => a.ParentAreaId == area.Id).ToList();
|
||||
foreach (var child in children)
|
||||
{
|
||||
await DeleteAreaRecursive(child);
|
||||
}
|
||||
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
@page "/admin/areas/{Id:int}/edit"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<h4 class="mb-0">Edit Area</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_name" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private Area? _area;
|
||||
private string _name = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private bool _loading = true;
|
||||
private bool _saving;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
||||
if (_area == null)
|
||||
{
|
||||
_errorMessage = $"Area #{Id} not found.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_name = _area.Name;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load area: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
||||
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
_area!.Name = _name.Trim();
|
||||
await TemplateEngineRepository.UpdateAreaAsync(_area);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/areas");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
@page "/admin/areas"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Area Management</h4>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key"
|
||||
StorageKey="areas-tree">
|
||||
<NodeContent Context="node">
|
||||
@if (node.Kind == AreaNodeKind.Site)
|
||||
{
|
||||
<span class="fw-semibold">@node.Label</span>
|
||||
<span class="badge bg-secondary ms-1">@node.AreaCount</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@node.Label</span>
|
||||
}
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@if (node.Kind == AreaNodeKind.Site)
|
||||
{
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}")'>
|
||||
Add Area
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}&parentAreaId={node.Area!.Id}")'>
|
||||
Add Child Area
|
||||
</button>
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/edit")'>
|
||||
Edit Area
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/delete")'>
|
||||
Delete Area
|
||||
</button>
|
||||
}
|
||||
</ContextMenu>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No sites configured.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
record AreaTreeNode(string Key, string Label, AreaNodeKind Kind, List<AreaTreeNode> Children,
|
||||
int SiteId, Area? Area = null, int AreaCount = 0);
|
||||
enum AreaNodeKind { Site, Area }
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private List<AreaTreeNode> _treeRoots = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_treeRoots = new();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id)).ToList();
|
||||
var rootAreas = areas.Where(a => a.ParentAreaId == null).OrderBy(a => a.Name);
|
||||
var children = rootAreas.Select(a => BuildAreaNode(a, site.Id)).ToList();
|
||||
_treeRoots.Add(new AreaTreeNode(
|
||||
Key: $"site-{site.Id}",
|
||||
Label: site.Name,
|
||||
Kind: AreaNodeKind.Site,
|
||||
Children: children,
|
||||
SiteId: site.Id,
|
||||
AreaCount: areas.Count));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private AreaTreeNode BuildAreaNode(Area area, int siteId)
|
||||
{
|
||||
var children = area.Children
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => BuildAreaNode(c, siteId))
|
||||
.ToList();
|
||||
return new AreaTreeNode(
|
||||
Key: $"area-{area.Id}",
|
||||
Label: area.Name,
|
||||
Kind: AreaNodeKind.Area,
|
||||
Children: children,
|
||||
SiteId: siteId,
|
||||
Area: area);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">New Area</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (RequireSitePicker)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_siteId">
|
||||
<option value="0">(Select a site)</option>
|
||||
@foreach (var opt in SiteOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Parent area</label>
|
||||
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
||||
<option value="0">(Site root)</option>
|
||||
@foreach (var opt in ParentOptions.Where(o => SelectedSiteMatches(o)))
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted small mb-2">
|
||||
@ContextLabel
|
||||
</div>
|
||||
}
|
||||
<label class="form-label small">Name</label>
|
||||
<input class="form-control form-control-sm" placeholder="Area name" @bind="_name" />
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public bool RequireSitePicker { get; set; }
|
||||
[Parameter] public string ContextLabel { get; set; } = string.Empty;
|
||||
[Parameter] public int? SiteId { get; set; }
|
||||
[Parameter] public int? ParentAreaId { get; set; }
|
||||
[Parameter] public IEnumerable<(int Id, string Label)> SiteOptions { get; set; } = Array.Empty<(int, string)>();
|
||||
[Parameter] public IEnumerable<(int Id, string Label, int SiteId)> ParentOptions { get; set; } = Array.Empty<(int, string, int)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int SiteId, int? ParentAreaId, string Name)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private string _name = string.Empty;
|
||||
private int _siteId;
|
||||
private int _parentAreaId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_name = string.Empty;
|
||||
_siteId = SiteId ?? 0;
|
||||
_parentAreaId = ParentAreaId ?? 0;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private bool SelectedSiteMatches((int Id, string Label, int SiteId) opt) =>
|
||||
_siteId == 0 || opt.SiteId == _siteId;
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
var effectiveSite = RequireSitePicker ? _siteId : (SiteId ?? 0);
|
||||
var effectiveParent = RequireSitePicker
|
||||
? (_parentAreaId == 0 ? (int?)null : _parentAreaId)
|
||||
: ParentAreaId;
|
||||
await OnSubmit.InvokeAsync((effectiveSite, effectiveParent, _name.Trim()));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Instances</button>
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Topology</button>
|
||||
<h4 class="mb-0">Configure Instance</h4>
|
||||
</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">← Back</a>
|
||||
<a href="/deployment/topology" class="btn btn-outline-secondary btn-sm me-3">← 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">← 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
+15
-18
@@ -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" (V1–V7). */
|
||||
|
||||
/* 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);
|
||||
}
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user