29 Commits

Author SHA1 Message Date
Joseph Doherty 8ba64b1d99 fix(uns): enforce #122 on line reparent across clusters (final review)
v2-ci / build (push) Failing after 4m38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-08 14:12:46 -04:00
Joseph Doherty 1bb7482c3a feat(uns): remove per-cluster UNS/Equipment/Tags + standalone virtual-tag pages
Deletes the 10 Razor pages superseded by the global /uns tree (Tasks 12–16):
ClusterUns, UnsAreaEdit, UnsLineEdit, ClusterEquipment, EquipmentEdit,
ImportEquipment, ClusterTags, TagEdit, VirtualTags, VirtualTagEdit.
No dangling references found; build is clean.
2026-06-08 14:02:32 -04:00
Joseph Doherty 983d30cb15 fix(uns): guard import save + comma-limitation hint + reset-on-open (review) 2026-06-08 14:00:26 -04:00
Joseph Doherty 7db9a24403 feat(uns): equipment CSV import folded into the tree toolbar 2026-06-08 13:56:01 -04:00
Joseph Doherty c0346f14ce feat(uns): tag + virtual-tag modals wired into the tree 2026-06-08 13:47:34 -04:00
Joseph Doherty d637b834b9 fix(uns): reject equipment bind to non-existent driver + modal-xl (review) 2026-06-08 13:38:33 -04:00
Joseph Doherty 2beaa43d60 feat(uns): equipment modal wired into the tree 2026-06-08 13:31:14 -04:00
Joseph Doherty 0abd1d8fc2 fix(uns): delete-confirm reports not-available instead of false success for unwired kinds (review) 2026-06-08 13:25:15 -04:00
Joseph Doherty a4a9dc912a feat(uns): area + line modals wired into the tree 2026-06-08 13:20:25 -04:00
Joseph Doherty 307cec5a3d test(uns): cover no-script + update-duplicate-name virtual-tag guards (review) 2026-06-08 13:15:10 -04:00
Joseph Doherty d8fba02a5e feat(uns): equipment-bound virtual-tag CRUD 2026-06-08 13:11:12 -04:00
Joseph Doherty 77024f87da fix(uns): reject tag create on non-existent equipment + narrow JSON catch (review) 2026-06-08 13:06:45 -04:00
Joseph Doherty 5a392c5db0 feat(uns): equipment-bound tag CRUD with namespace + cluster guards 2026-06-08 13:00:26 -04:00
Joseph Doherty ab0ff8aedf fix(uns): reject driver-bind on unresolvable line + enforce MachineCode uniqueness on update (review) 2026-06-08 12:55:36 -04:00
Joseph Doherty 2836a0704b feat(uns): equipment CRUD with #122 driver-cluster guard 2026-06-08 12:47:19 -04:00
Joseph Doherty 8b1d3de806 feat(uns): add global UNS nav item, drop per-cluster UNS/Equipment/Tags tabs 2026-06-08 12:45:18 -04:00
Joseph Doherty ace366ebcf test(uns): cover #122 allow-when-driver-already-in-target-cluster (review) 2026-06-08 12:42:13 -04:00
Joseph Doherty 4a32edef1a fix(uns): re-entrancy guard + clear stale error + PageTitle on GlobalUns (review) 2026-06-08 12:39:23 -04:00
Joseph Doherty 47b1d2259f feat(uns): area + line CRUD with #122 reassignment guard 2026-06-08 12:35:58 -04:00
Joseph Doherty c9f59e4bd2 feat(uns): GlobalUns page with browsable tree 2026-06-08 12:34:37 -04:00
Joseph Doherty b33cf1c80d feat(uns): lazy per-equipment tag + virtual-tag load
Add LoadEquipmentChildrenAsync to IUnsTreeService and UnsTreeService; returns
Tag nodes (ordered by Name) then VirtualTag nodes (ordered by Name) as leaf
nodes with ChildCount=0, HasLazyChildren=false, keys tag:{id}/vtag:{id}.
2026-06-08 12:29:52 -04:00
Joseph Doherty c264441b74 refactor(uns): clarify service lifetime doc + defensive vtag-count null filter (review) 2026-06-08 12:27:29 -04:00
Joseph Doherty 2c0297c1af fix(uns): @key node rows for stable Blazor diffing (review) 2026-06-08 12:27:19 -04:00
Joseph Doherty cec670f0c8 feat(uns): IUnsTreeService structural load + DI registration 2026-06-08 12:23:00 -04:00
Joseph Doherty 0f286a70b8 feat(uns): recursive UnsTree renderer 2026-06-08 12:21:38 -04:00
Joseph Doherty 3e8941bce4 docs(uns): clarify HasLazyChildren + cluster EntityId, add tie-break test (review I1/I2/M2) 2026-06-08 12:18:37 -04:00
Joseph Doherty d9082e22e3 feat(uns): UnsNode VM + pure tree-assembly helper 2026-06-08 12:14:49 -04:00
Joseph Doherty 944732e500 docs(uns): implementation plan + task graph for global UNS management 2026-06-08 12:11:40 -04:00
Joseph Doherty 3361eac6d8 docs(uns): design for global UNS management tree-table 2026-06-08 12:02:18 -04:00
42 changed files with 7105 additions and 1874 deletions
@@ -0,0 +1,180 @@
# Global UNS Management — Design
**Date:** 2026-06-08
**Branch:** `feat/global-uns-management`
**Status:** Approved (design); pending implementation plan
## Goal
Replace the per-cluster UNS/Equipment/Tags management UI with a single
**global master tree-table** that spans every cluster, presenting all
layers of the Unified Namespace — Enterprise → Site/Cluster → Area → Line
→ Equipment → Tags/VirtualTags — and lets an operator create, edit, and
delete every editable layer from one surface, styled with the
`ZB.MOM.WW.Theme` library.
## Decisions locked during brainstorming
1. **Scope of "global" = UI only; data model unchanged.** `UnsArea.ClusterId`
stays as the "served-by cluster" assignment. Per-ClusterId scoping
(decision #122) and the deployment pipeline are **not touched**.
2. **Editing model = tree is navigational; editing pops a modal per row.**
Reuses the existing Bootstrap-modal pattern (`CollectionEditor` /
`DriverTagPicker`).
3. **Old pages = replace and remove.** The global tree is the only UNS
management surface; the per-cluster UNS + Equipment + Tags tabs and the
standalone area/line/equipment/tag/virtual-tag edit pages are deleted,
their forms folded into modals.
4. **Tree depth = 6 levels, down to Tags + VirtualTags.** Each Equipment
expands to its equipment-bound Tags and VirtualTags, editable via modal.
The standalone `/virtual-tags` list is folded in and removed.
5. **Loading strategy = hybrid.** Eager-load the bounded structural levels
(Enterprise → Cluster → Area → Line → Equipment with count badges);
**lazy-load** the Tag/VirtualTag children per equipment on first expand.
## Architecture
### Tree shape — faithful to the existing data model
`Enterprise` and `Site` are columns on `ServerCluster` (no tables of their
own), so the top levels are **derived, read-only groupings**; editable UNS
entities start at Area.
| Level | Source | Behaviour in tree |
|---|---|---|
| 1 · Enterprise | distinct `ServerCluster.Enterprise` | read-only group header |
| 2 · Site / Cluster | `ServerCluster` row, keyed by `ClusterId`, labelled by `Site` + cluster name | read-only header; "⚙ settings" links to existing `/clusters/{id}` |
| 3 · Area | `UnsArea` (`ClusterId` FK) | CRUD modal; created under a cluster (sets `ClusterId`) |
| 4 · Line | `UnsLine` (`UnsAreaId` FK) | CRUD modal |
| 5 · Equipment | `Equipment` (`UnsLineId` FK) | CRUD modal (full form) |
| 6 · Tag / VirtualTag | `Tag.EquipmentId` / `VirtualTag.EquipmentId` | CRUD modals; Tag modal dispatches by the equipment's driver type |
Consequences of staying faithful to the model:
- **"Served-by cluster" = which cluster node an area lives under.**
Reassigning is editing `UnsArea.ClusterId` in the area modal, which moves
the branch. No new concept, no migration.
- **Cluster/Enterprise/Site creation stays on the `/clusters` pages.** The
tree manages levels 36 only.
- **Galaxy / SystemPlatform tags (`Tag.EquipmentId = NULL`)** hang off a
driver's folder path and are auto-materialised from the Galaxy browse, so
they are *not* equipment-scoped and do **not** appear in the equipment
tree. They remain on the Galaxy driver page (the Drivers tab is kept).
Removing the Tags tab loses only a flat read-only listing of those
auto-generated rows.
## Components (AdminUI only — runtime/Phase7/engine untouched)
**Page**
- `Components/Pages/Uns/GlobalUns.razor``@page "/uns"`, `[Authorize]`,
`@rendermode InteractiveServer`. Toolbar (global text filter,
expand/collapse-all, "Import equipment CSV"), owns modal state, renders
the tree. Optional `?cluster=` / `?equipment=` query auto-expands a node.
**Tree renderer**
- `Components/Shared/Uns/UnsTree.razor` — recursive renderer modeled on
`DriverBrowseTree`: chevrons (▼/▶), `padding-left:{depth*18}px` indent,
per-row action icons, `.chip` count badges. Drives off a `UnsNode` VM
with `Kind ∈ {Enterprise, Cluster, Area, Line, Equipment, Tag,
VirtualTag}`; switches on `Kind` for icons/actions. Emits
`OnAddChild / OnEdit / OnDelete / OnToggleExpand`. Equipment lazy-loads
Tag/VirtualTag children on first expand.
**Modal editors** (`.modal.fade.show` + backdrop; edit a cloned VM, commit
on Save — the `CollectionEditor` pattern):
- `Uns/AreaModal.razor`, `Uns/LineModal.razor`, `Uns/EquipmentModal.razor`,
`Uns/VirtualTagModal.razor` — folded from the deleted edit pages'
`<EditForm>` markup (forms preserved, rehosted in modals).
- `Uns/TagModal.razor`**driver-typed**, mirroring `DriverEditRouter`: a
`_tagEditorMap` keyed by the equipment's driver type dispatches to a
per-driver tag field set. Each driver's existing tag `EditTemplate`
(today inline in `ModbusDriverPage` etc.) is extracted to a shared
`Drivers/TagEditors/<Driver>TagEditor.razor`, with a **generic
raw-`TagConfig`-JSON editor as the guaranteed fallback** so every driver
type is editable from day one; specialized editors land incrementally.
**Service (thin, testable — replaces inline-EF-in-razor)**
- `IUnsTreeService` + impl:
- `LoadStructureAsync()` → whole structural tree in **6 batched queries**
(clusters, areas, lines, equipment, + two `GROUP BY EquipmentId` count
queries) — flat in count regardless of fleet size, no N+1.
- `LoadEquipmentTagsAsync(equipmentId)` → tags + virtual tags for one
equipment (the lazy leaf load).
- `Create/Update/Delete{Area,Line,Equipment,Tag,VirtualTag}Async(...)`
each carries `RowVersion` and enforces the guard rules below.
## Data flow
1. **Load:** page → `LoadStructureAsync()` → assemble `UnsNode` tree (group
equipment-owning clusters under `Enterprise`; areas under `ClusterId`;
lines/equipment by FK). Equipment nodes get
`HasChildren = tagCount+vtagCount > 0` and a count badge.
2. **Expand equipment:** fires `LoadEquipmentTagsAsync` → spinner → child rows.
3. **Edit/Create/Delete:** modal on a cloned VM → service call → on success
the page **patches the in-memory node** (no full reload) and closes.
Create-child pre-fills the parent FK.
4. **Deploy semantics:** edits land in ConfigDb immediately (v2 live-edit)
but the running address space changes on the **next Deploy** — the page
shows a small "pending deployment" note.
## Deletions & exact blast radius
**Delete (11 files / their routes):** `ClusterUns`, `UnsAreaEdit`,
`UnsLineEdit`, `ClusterEquipment`, `EquipmentEdit`, `ImportEquipment`,
`ClusterTags`, `TagEdit`, `VirtualTags`, `VirtualTagEdit` — routes
`/clusters/{id}/uns`, `/uns/areas/*`, `/uns/lines/*`,
`/clusters/{id}/equipment*`, `/equipment/import`, `/clusters/{id}/tags*`,
`/virtual-tags*`.
**Edit (2 files):**
- `Components/Shared/ClusterNav.razor` — drop the **Equipment / UNS / Tags**
tabs (keep Overview, Namespaces, Drivers, ACLs, Audit, Redundancy).
- `Components/Layout/MainLayout.razor` — add a top-level **"UNS"** nav item
(`/uns`) under Navigation; remove **"Virtual tags"** from Scripting.
**Reuse, don't delete:** the per-driver tag `EditTemplate`s and the
`EquipmentEdit` / `VirtualTagEdit` form markup (extracted into the modals).
**Explicitly out of scope (do NOT touch):** the runtime VirtualTag engine,
`Phase7*`, `DeploymentArtifact`, `ConfigComposer`, runtime actors, and the
Configuration entities/migrations. The broad grep hits on "VirtualTag" /
"Equipment" / "tags" in those projects are backend; the model is frozen per
decision #1.
## Error handling, concurrency, validation, authz
- **Concurrency:** `RowVersion` optimistic; `DbUpdateConcurrencyException`
→ inline "changed elsewhere, reload" on that node (the F15 pattern).
- **Validation** (modal blocks Save with a message): Name
`^[a-z0-9-]{1,32}$`; Equipment `MachineCode` uniqueness; Tag
`(EquipmentId,Name)` uniqueness; VirtualTag trigger rule
(`ChangeTriggered OR TimerIntervalMs≥50`).
- **Delete guards:** area-with-lines / line-with-equipment /
equipment-with-tags blocked with a message (matches the deleted pages).
- **Cluster-reassignment guard (decision #122):** changing an area's
served-by cluster, or an equipment's driver, is validated so a
driver-bound equipment's driver cluster still matches its area's cluster
— prevents the cross-cluster orphan the scoping feature warns about.
- **Authz/render:** `[Authorize]` + `InteractiveServer`, identical to the
pages being replaced.
## Testing (no bUnit, per project convention)
- **`IUnsTreeService` unit tests (in-memory EF):** `LoadStructureAsync`
builds the correct hierarchy + accurate counts; lazy tag load; every CRUD
path incl. concurrency conflict, delete guards, and the #122
reassignment validation.
- **VM-helper unit tests (`AdminUI.Tests`):** flat-rows→`UnsNode` tree
assembly, badge counts, the global filter predicate.
- **Gate:** `dotnet build` clean + `dotnet test` green + manual `/run` in
docker-dev — browse `/uns`, create area→line→equipment→tag→vtag, Deploy,
confirm on `:4840`.
## Out of scope / follow-ups
- Reorganizing the data model (Enterprise/Site as first-class tables);
decoupling UNS from clusters. (Decision #1 keeps the model.)
- Drag-and-drop reparenting between nodes (edit-modal reassignment only).
- Per-driver specialized tag editors beyond the generic JSON fallback may
land incrementally after the first cut.
- A flat global VirtualTag list (removed; reinstate later if wanted).
@@ -0,0 +1,622 @@
# Global UNS Management Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Replace the per-cluster UNS/Equipment/Tags AdminUI pages with one global master tree-table at `/uns` (Enterprise → Site/Cluster → Area → Line → Equipment → Tag/VirtualTag), editing every layer via modals backed by a thin, unit-tested `IUnsTreeService`.
**Architecture:** UI-only change; the data model is frozen (no migrations). A new `IUnsTreeService` (AdminUI) owns all data access — a 6-batched-query structural load, lazy per-equipment tag load, and CRUD with `RowVersion` concurrency + the decision-#122 same-cluster guards. A recursive `UnsTree.razor` renders a `UnsNode` view-model; `GlobalUns.razor` hosts the tree + Bootstrap-modal editors folded from the deleted edit pages. The runtime/Phase7/VirtualTag **engine and the Configuration entities are NOT touched.**
**Tech Stack:** .NET 10, Blazor Server (`InteractiveServer`), EF Core (SQL Server prod / InMemory tests), `ZB.MOM.WW.Theme`, xUnit + Shouldly.
---
## Scope notes (read before starting)
1. **TagModal = the generic `TagEdit.razor` form.** The per-driver tag editors
(`ModbusTagRow` etc.) edit the *driver config blob*, a different model from the
`Tag` table. The tree's Tag level shows equipment-bound `Tag` rows, whose canonical
editor is `TagEdit.razor` (raw `TagConfig` JSON textarea). Driver-typed specialized
tag field-sets are **deferred** (follow-up `F-uns-1`), consistent with the design's
"generic editor guaranteed, specialized incremental" clause.
2. **No bUnit.** Logic lives in `IUnsTreeService` + pure VM helpers, unit-tested with
in-memory EF and Shouldly. Razor components are verified by build + manual `/run`.
3. **Concurrency caveat:** the EF **InMemory** provider does not enforce `rowversion`,
so `DbUpdateConcurrencyException` paths are implemented (pass `RowVersion` as
`OriginalValue`, catch the exception) but verified at runtime, not in unit tests —
matching the existing codebase (which has zero concurrency-exception tests).
4. **Decision #122 (same-cluster invariant) is enforced in the service:** an equipment's
driver must live in the same cluster as the equipment's line→area; an area cannot be
reassigned to a cluster where its driver-bound equipment would be orphaned; a tag's
driver must be in the equipment's cluster and in an Equipment-kind namespace.
5. **Hard git rules:** never `git add .` (stage by path); never stage `sql_login.txt` or
`src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API key into a new
tracked file; never force-push or skip hooks. Branch: `feat/global-uns-management`.
6. **Deletions happen LAST** (Task 17), after the modals have folded in the reference
forms — don't delete a page whose form you still need to read.
## Reference: source forms to fold (already on disk)
| Concern | Source page (delete in Task 17) | Service method it becomes |
|---|---|---|
| Area CRUD | `Components/Pages/Clusters/UnsAreaEdit.razor` | `*AreaAsync` |
| Line CRUD | `Components/Pages/Clusters/UnsLineEdit.razor` | `*LineAsync` |
| Equipment CRUD | `Components/Pages/Clusters/EquipmentEdit.razor` | `*EquipmentAsync` |
| Tag CRUD | `Components/Pages/Clusters/TagEdit.razor` | `*TagAsync` |
| VirtualTag CRUD | `Components/Pages/VirtualTagEdit.razor` | `*VirtualTagAsync` |
| CSV import | `Components/Pages/Clusters/ImportEquipment.razor` | reused in Task 15 |
DI registration goes in `EndpointRouteBuilderExtensions.cs` (the `AddAdminUI` method,
after the `IBrowserSessionService` line). Test project:
`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` (has `InternalsVisibleTo` from AdminUI;
**add** `Microsoft.EntityFrameworkCore.InMemory` to its csproj in Task 2).
---
## Task 1: UnsNode view-model + tree-assembly helper
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (foundation)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs`
Define the VM the renderer consumes and a **pure** assembly function (no EF) so it is
trivially unit-testable.
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
public enum UnsNodeKind { Enterprise, Cluster, Area, Line, Equipment, Tag, VirtualTag }
public sealed class UnsNode
{
public required UnsNodeKind Kind { get; init; }
public required string Key { get; init; } // stable per-node id, unique in tree
public required string DisplayName { get; init; }
public string? ClusterId { get; init; } // owning cluster (Area/Line/Equipment)
public string? EntityId { get; init; } // UnsAreaId/UnsLineId/EquipmentId/TagId/VirtualTagId
public int ChildCount { get; set; } // for badge; equipment = tag+vtag count
public bool HasLazyChildren { get; init; } // equipment with ChildCount > 0
public List<UnsNode> Children { get; } = new();
// runtime UI state (not persisted)
public bool Expanded { get; set; }
public bool Loaded { get; set; }
public bool Loading { get; set; }
public string? Error { get; set; }
}
// Flat rows the structural query returns (kept tiny + provider-agnostic).
public readonly record struct ClusterRow(string ClusterId, string Enterprise, string Site, string Name);
public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string Name);
public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name);
public readonly record struct EquipmentRow(string EquipmentId, string UnsLineId, string MachineCode, string Name, int TagCount, int VirtualTagCount);
public static class UnsTreeAssembly
{
// Pure: builds Enterprise→Cluster→Area→Line→Equipment with counts.
public static IReadOnlyList<UnsNode> Build(
IReadOnlyList<ClusterRow> clusters,
IReadOnlyList<AreaRow> areas,
IReadOnlyList<LineRow> lines,
IReadOnlyList<EquipmentRow> equipment) { /* group + nest, see steps */ }
}
```
**Step 1 — failing tests** (`UnsTreeAssemblyTests`):
- `Build_groups_clusters_under_enterprise` — two clusters sharing `Enterprise="zb"` produce one Enterprise node with two Cluster children.
- `Build_nests_area_line_equipment_under_owning_cluster` — an area's branch lands under its `ClusterId` cluster node; line under area; equipment under line.
- `Build_sets_equipment_child_count_and_lazy_flag` — equipment with `TagCount=2,VirtualTagCount=1` gets `ChildCount==3` and `HasLazyChildren==true`; an equipment with zero gets `HasLazyChildren==false`.
- `Build_includes_clusters_with_no_areas` — a cluster with no areas still appears (so you can add areas under it).
- `Build_orders_deterministically` — enterprises, clusters, areas, lines, equipment each ordered by name/id.
Use Shouldly. Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter UnsTreeAssemblyTests` → FAIL (type missing).
**Step 2 — implement** `UnsNode.cs` (enum + VM + rows + `UnsTreeAssembly.Build`). Keys:
`ent:{enterprise}`, `clu:{clusterId}`, `area:{unsAreaId}`, `line:{unsLineId}`,
`eq:{equipmentId}`. Equipment `ChildCount = TagCount + VirtualTagCount`,
`HasLazyChildren = ChildCount > 0`.
**Step 3 — run tests** → PASS.
**Step 4 — commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs
git commit -m "feat(uns): UnsNode VM + pure tree-assembly helper"
```
---
## Task 2: IUnsTreeService + LoadStructureAsync + DI
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocks all service CRUD)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `AddScoped<IUnsTreeService, UnsTreeService>()` after the `IBrowserSessionService` registration in `AddAdminUI`)
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (add `<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs`
- Test (shared helper): `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs`
`UnsTreeService` ctor takes `IDbContextFactory<OtOpcUaConfigDbContext>`.
```csharp
public interface IUnsTreeService
{
Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default);
// (lazy load + CRUD added in later tasks — keep the interface growing per task)
}
```
`LoadStructureAsync` runs **6 queries** (all `AsNoTracking`):
clusters, areas, lines, equipment, and two count maps —
`Tags.Where(t => t.EquipmentId != null).GroupBy(t => t.EquipmentId).Select(g => new { g.Key, C = g.Count() })`
and the equivalent for `VirtualTags` grouped by `EquipmentId`. Project to the `*Row`
records, join counts onto equipment, call `UnsTreeAssembly.Build`.
**Step 1 — test DB helper** `UnsTreeTestDb.cs`: a static `Create()`
`new OtOpcUaConfigDbContext(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase($"uns-{Guid.NewGuid():N}").Options)`
plus a `Seed(...)` helper that inserts a small fixture (1 enterprise, 2 clusters, areas,
lines, equipment, a couple tags + vtags). Mirror `ClusterAuditQueryTests` setup.
**Step 2 — failing tests** (`UnsTreeServiceStructureTests`, construct service over a seeded
InMemory db):
- `LoadStructure_builds_full_hierarchy` — node kinds/keys match the seed.
- `LoadStructure_counts_tags_and_vtags_per_equipment` — badge counts correct.
- `LoadStructure_includes_empty_clusters`.
Run: `dotnet test ...AdminUI.Tests --filter UnsTreeServiceStructure` → FAIL (no InMemory pkg / no service).
**Step 3 — implement** csproj pkg ref, interface, service `LoadStructureAsync`, DI line.
**Step 4 — run tests** → PASS. Also `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` clean.
**Step 5 — commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs
git commit -m "feat(uns): IUnsTreeService structural load + DI registration"
```
---
## Task 3: LoadEquipmentTagsAsync (lazy leaf load)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 10 (different files)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs`
Add `Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default)`
returning Tag nodes (`Kind=Tag`, key `tag:{TagId}`) for `Tag.EquipmentId == equipmentId`
ordered by `Name`, followed by VirtualTag nodes (`Kind=VirtualTag`, key `vtag:{VirtualTagId}`)
for `VirtualTag.EquipmentId == equipmentId`. DisplayName: tag → `Name` (`DataType`), vtag → `Name` (VirtualTag).
**Steps:** failing test (`returns_tags_then_vtags_for_equipment`, `empty_for_equipment_with_none`) → implement → pass → commit.
```bash
git commit -m "feat(uns): lazy per-equipment tag + virtual-tag load"
```
---
## Task 4: Area + Line CRUD in service (+ #122 area-reassignment guard)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11 (different files)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs`
Add result type `public readonly record struct UnsMutationResult(bool Ok, string? Error);`
and methods (translate `UnsAreaEdit`/`UnsLineEdit` logic verbatim, returning the `_error`
strings as `UnsMutationResult.Error` instead of navigating):
```csharp
Task<UnsMutationResult> CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default);
Task<UnsMutationResult> UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default);
Task<UnsMutationResult> UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
```
Guards (carry over from the pages + add #122):
- Create: duplicate-id check (`AnyAsync`).
- Update/Delete: set `db.Entry(e).Property(x => x.RowVersion).OriginalValue = rowVersion`, catch `DbUpdateConcurrencyException``Error = "changed elsewhere…"`.
- Delete Area: catch FK failure → `"…lines still reference this area — remove them first."` Delete Line → `"…equipment still references this line…"`.
- **#122 area reassignment** (`UpdateAreaAsync` when `newClusterId` differs from current):
collect equipment under the area (`lines of area → equipment of lines`) that are
driver-bound; if any such equipment's `DriverInstance.ClusterId != newClusterId`, return
`Error = "Cannot move area to '{newClusterId}': equipment '{id}' is bound to a driver in another cluster (decision #122). Re-home or unbind it first."`
**Step 1 — failing tests:** create/update/delete happy paths; duplicate-id error;
area-reassignment blocked when a driver-bound equipment would orphan; reassignment allowed
when equipment is driver-less. **Step 2** implement. **Step 3** pass. **Step 4** commit:
```bash
git commit -m "feat(uns): area + line CRUD with #122 reassignment guard"
```
---
## Task 5: Equipment CRUD in service
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs`
Translate `EquipmentEdit` logic. Create generates `EquipmentId = $"EQ-{Guid.NewGuid().ToString("N")[..12]}"`
+ `EquipmentUuid`. Use a parameter object to avoid a 16-arg signature:
```csharp
public sealed record EquipmentInput(string Name, string MachineCode, string UnsLineId, string? DriverInstanceId,
string? ZTag, string? SAPID, string? Manufacturer, string? Model, string? SerialNumber, string? HardwareRevision,
string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation, string? ManufacturerUri,
string? DeviceManualUri, bool Enabled);
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
```
Guards: `UnsLineId` required; MachineCode fleet-unique on create; delete FK failure →
`"…tags or virtual tags reference this equipment…"`. **#122 driver-cluster check** on
create + update: if `DriverInstanceId` set, resolve the equipment's cluster via
`UnsLine(input.UnsLineId).UnsAreaId → UnsArea.ClusterId` and require
`DriverInstance(input.DriverInstanceId).ClusterId == that cluster`, else
`Error = "Driver '{id}' is in cluster '{dc}' but the line is in cluster '{lc}' (decision #122)."`
**Steps:** failing tests (create generates id; machinecode-dup blocked; #122 driver mismatch
blocked; driver-less allowed; update/delete) → implement → pass → commit:
```bash
git commit -m "feat(uns): equipment CRUD with #122 driver-cluster guard"
```
---
## Task 6: Tag CRUD in service
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagTests.cs`
Tree tags are always equipment-bound. Provide candidate drivers for the modal +
CRUD:
```csharp
// Drivers eligible for an equipment's tags: same cluster AND Equipment-kind namespace.
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
public sealed record TagInput(string TagId, string Name, string DriverInstanceId, string DataType,
TagAccessLevel AccessLevel, bool WriteIdempotent, string? PollGroupId, string TagConfig);
Task<UnsMutationResult> CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
```
Set `Tag.EquipmentId = equipmentId`, `FolderPath = null` (equipment-bound). Guards:
duplicate TagId; `TagConfig` must parse as JSON (`JsonDocument.Parse`, else
`"TagConfig is not valid JSON."`); driver must be in the equipment's cluster and in an
Equipment-kind namespace (resolve via `DriverInstance.NamespaceId → Namespace.Kind`);
`(EquipmentId,Name)` uniqueness (rely on DB unique index + catch, or pre-check).
**Steps:** failing tests (create equipment-bound tag; invalid-json blocked;
driver-not-in-cluster blocked; SystemPlatform-namespace driver blocked; duplicate name
blocked; update/delete) → implement → pass → commit:
```bash
git commit -m "feat(uns): equipment-bound tag CRUD with namespace + cluster guards"
```
---
## Task 7: VirtualTag CRUD in service
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 10, Task 11
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs`
Translate `VirtualTagEdit`. Provide `LoadScriptsAsync()` (id + display) for the modal.
```csharp
Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default);
public sealed record VirtualTagInput(string VirtualTagId, string Name, string DataType, string ScriptId,
bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled);
Task<UnsMutationResult> CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
```
Guards: duplicate id; trigger rule (`ChangeTriggered || TimerIntervalMs is not null`, and
if timer set it must be `>= 50`); set `EquipmentId = equipmentId`.
**Steps:** failing tests (create; trigger-rule blocked when neither set; timer<50 blocked;
update/delete) → implement → pass → commit:
```bash
git commit -m "feat(uns): equipment-bound virtual-tag CRUD"
```
---
## Task 8: (reserved — merged into Task 7)
_No-op placeholder so downstream task numbers stay stable. Skip._
---
## Task 10: UnsTree.razor recursive renderer (read-only)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4, 5, 6, 7 (service files, disjoint)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor`
Model on `Components/Shared/Drivers/DriverBrowseTree.razor`: a `RenderNode(UnsNode, depth)`
`RenderFragment` recursing via `@RenderNode(child, depth+1)`, `padding-left:{depth*18}px`,
chevron `▼/▶` button for nodes with children (structural children OR `HasLazyChildren`),
`.chip` badge showing `ChildCount`. Per-`Kind` action buttons (✎ edit / add child / 🗑
delete) rendered as `btn btn-sm btn-link`. Parameters:
```csharp
[Parameter, EditorRequired] public IReadOnlyList<UnsNode> Roots { get; set; } = default!;
[Parameter] public EventCallback<UnsNode> OnAddChild { get; set; }
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
[Parameter] public EventCallback<UnsNode> OnDelete { get; set; }
[Parameter] public EventCallback<UnsNode> OnToggleExpand { get; set; } // page handles lazy load
[Parameter] public string? Filter { get; set; } // case-insensitive DisplayName contains
```
Action visibility by `Kind`: Enterprise → none; Cluster → "+ Area" + "⚙ settings" link to
`/clusters/{ClusterId}`; Area → edit/delete + "+ Line"; Line → edit/delete + "+ Equipment";
Equipment → edit/delete + "+ Tag" + "+ Virtual tag"; Tag/VirtualTag → edit/delete.
Spinner row when `node.Loading`; error row when `node.Error != null`.
**Verification:** `dotnet build` clean (no test — pure markup). **Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor
git commit -m "feat(uns): recursive UnsTree renderer"
```
---
## Task 11: GlobalUns.razor page (browse-only)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 2, 3, 10)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
`@page "/uns"`, `@attribute [Authorize]`, `@rendermode RenderMode.InteractiveServer`,
`@inject IUnsTreeService Svc`. Toolbar (`.panel-head`): title, global filter `<input>`
(bound to a `_filter` field passed to `UnsTree.Filter`), expand/collapse-all, an "Import
equipment CSV" button (wired in Task 15), and a "Changes apply on next deployment" note.
`OnInitializedAsync``Svc.LoadStructureAsync()``_roots`. `OnToggleExpand` handler:
toggle `node.Expanded`; if equipment + `!node.Loaded` → set `Loading`, call
`Svc.LoadEquipmentChildrenAsync`, populate `node.Children`, `Loaded=true`, `StateHasChanged`.
No modals yet (added in 1214). Render `<UnsTree Roots="_roots" .../>`.
**Verification:** `dotnet build` clean; manual `/run` → browse `/uns`, expand to tags.
**Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor
git commit -m "feat(uns): GlobalUns page with browsable tree"
```
---
## Task 12: Area + Line modals, wired
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AreaModal.razor`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/LineModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
Each modal: Bootstrap `.modal.fade.show` + `.modal-backdrop` (copy the chrome from
`CollectionEditor.razor`), an `EditForm` with the fields from `UnsAreaEdit`/`UnsLineEdit`
(Area: read-only `UnsAreaId` on edit, Name, served-by **Cluster** `<select>`, Notes;
Line: read-only `UnsLineId` on edit, parent-Area `<select>`, Name, Notes), a `Visible`
parameter, and `OnSaved`/`OnCancel` callbacks. The modal calls the matching
`Svc.*AreaAsync/*LineAsync`, shows `UnsMutationResult.Error` inline, and on success invokes
`OnSaved`. In `GlobalUns`: handle `OnAddChild`/`OnEdit`/`OnDelete` for Cluster/Area/Line
kinds → open the right modal seeded with parent context; on `OnSaved` reload structure
(`LoadStructureAsync`) — simplest correct refresh — and close.
**Verification:** build clean; manual `/run`: add area under a cluster, add line under area,
edit + delete both; confirm #122 block when moving an area with driver-bound equipment.
**Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AreaModal.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/LineModal.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor
git commit -m "feat(uns): area + line modals wired into the tree"
```
---
## Task 13: Equipment modal, wired
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
Fold the full `EquipmentEdit` form (Identity + OPC 40010 sections) into a `modal-xl`.
Driver `<select>` candidates = drivers in the equipment's cluster (add
`Svc.LoadDriversForClusterAsync(clusterId)` if not already available; the equipment's
cluster comes from the parent Line node's `ClusterId`). Create uses the parent Line node;
calls `Svc.CreateEquipmentAsync/UpdateEquipmentAsync/DeleteEquipmentAsync`. Wire add (under
Line) / edit / delete for Equipment kind in `GlobalUns`; reload on save.
**Verification:** build clean; manual `/run`: create equipment under a line, edit identity +
40010 fields, delete; confirm #122 driver-cluster block.
**Commit:**
```bash
git commit -m "feat(uns): equipment modal wired into the tree"
```
---
## Task 14: Tag + VirtualTag modals, wired
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
**TagModal** folds the generic `TagEdit` form **minus** the FolderPath/SystemPlatform branch
(tree tags are always equipment-bound): TagId, Name, Driver `<select>` (from
`Svc.LoadTagDriversForEquipmentAsync`), DataType `<select>`, AccessLevel, WriteIdempotent,
PollGroupId, and the `TagConfig` JSON `<textarea>`. Per scope note 1, this is the generic
editor; driver-typed field-sets are follow-up `F-uns-1`.
**VirtualTagModal** folds `VirtualTagEdit` (Equipment is fixed to the parent node, so drop
the equipment `<select>`; keep Script `<select>` from `Svc.LoadScriptsAsync`, DataType,
ChangeTriggered, TimerIntervalMs, Historize, Enabled). Wire add (under Equipment) / edit /
delete for Tag and VirtualTag kinds; on save, lazy-reload just that equipment's children
(re-call `LoadEquipmentChildrenAsync` for the parent equipment) and the structure counts.
**Verification:** build clean; manual `/run`: add a tag + a virtual tag under an equipment,
edit + delete; confirm invalid-JSON and trigger-rule blocks.
**Commit:**
```bash
git commit -m "feat(uns): tag + virtual-tag modals wired into the tree"
```
---
## Task 15: Import equipment CSV toolbar action
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs`
Port `ImportEquipment.razor`'s parse + validate + insert into
`Svc.ImportEquipmentAsync(IEnumerable<EquipmentInput> rows, CancellationToken)` returning a
summary (`int Inserted, int Skipped, IReadOnlyList<string> Errors`). Keep its rules: required
Name/MachineCode/UnsLineId/DriverInstanceId columns, validate UnsLineId + DriverInstanceId
exist, skip MachineCode duplicates, auto-generate EquipmentId per row, enforce the #122
driver-cluster check. The modal hosts the textarea/file paste + a results panel.
**Steps:** failing service test (`Import_inserts_valid_skips_dup_machinecode`,
`Import_reports_unknown_line`) → implement service + modal + toolbar button → pass + build →
manual `/run` import → commit:
```bash
git commit -m "feat(uns): equipment CSV import folded into the tree toolbar"
```
---
## Task 16: Rewire navigation
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 17 prep (but edits different files — safe alongside)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor` (remove the `equipment`, `uns`, `tags` `<li>` tabs)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor` (add `<NavRailItem Href="/uns" Text="UNS" />` in the Navigation section after `Clusters`; remove the `<NavRailItem Href="/virtual-tags" Text="Virtual tags" />` from the Scripting section)
**Verification:** `dotnet build` clean. **Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor
git commit -m "feat(uns): add global UNS nav item, drop per-cluster UNS/Equipment/Tags tabs"
```
---
## Task 17: Delete the replaced pages
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (final cleanup)
**Files (delete):**
`Components/Pages/Clusters/ClusterUns.razor`, `UnsAreaEdit.razor`, `UnsLineEdit.razor`,
`ClusterEquipment.razor`, `EquipmentEdit.razor`, `ImportEquipment.razor`, `ClusterTags.razor`,
`TagEdit.razor` (all under `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/`),
and `Components/Pages/VirtualTags.razor`, `Components/Pages/VirtualTagEdit.razor`.
**Steps:**
1. `git rm` the 10 razor files above (the 11th surface, the `/virtual-tags` nav link, was
removed in Task 16).
2. `grep -rn "ClusterUns\|UnsAreaEdit\|UnsLineEdit\|ClusterEquipment\|EquipmentEdit\|ImportEquipment\|ClusterTags\|TagEdit\|/virtual-tags\|/uns/areas\|/uns/lines\|/equipment/new\|/tags/new" src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → expect **zero** dangling references (the only hits should be the new `/uns` components, which don't match these). Fix any stragglers.
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean (Razor route/component removal compiles).
4. Commit:
```bash
git add -A src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/VirtualTags.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/VirtualTagEdit.razor
git commit -m "feat(uns): remove per-cluster UNS/Equipment/Tags + standalone virtual-tag pages"
```
(Staging the specific paths above — NOT a bare `git add .`.)
---
## Task 18: Full verification gate
**Classification:** verification
**Estimated implement time:** ~5 min (+ docker)
**Parallelizable with:** none
**Steps:**
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean.
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` → green; then
`dotnet test ZB.MOM.WW.OtOpcUa.slnx` → green (confirm nothing else regressed).
3. docker-dev `/run`: `docker compose -f docker-dev/docker-compose.yml up -d --build`,
sign in, browse `/uns`, create area→line→equipment→tag→virtual-tag under MAIN, click
**Deploy current configuration**, then
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840 -r -d 6`
and confirm the new branch appears.
4. Confirm the deleted routes 404 and the per-cluster tabs are gone; confirm site clusters
still scope correctly (`:4842` unaffected).
No commit (verification only). On completion → finishing-a-development-branch.
---
## Out of scope / follow-ups
- **`F-uns-1`** — driver-typed specialized Tag field-sets in `TagModal` (bridging the
driver-config tag model to the `Tag` table). The generic JSON editor ships now.
- Reinstating a flat global VirtualTag list (removed; tree owns per-equipment vtags).
- Drag-and-drop reparenting (edit-modal reassignment only).
- Any change to the runtime/Phase7/VirtualTag engine or Configuration entities/migrations.
@@ -0,0 +1,22 @@
{
"planPath": "docs/plans/2026-06-08-global-uns-management.md",
"tasks": [
{"id": 117, "subject": "Task 1: UnsNode VM + tree-assembly helper", "status": "pending"},
{"id": 118, "subject": "Task 2: IUnsTreeService + LoadStructureAsync + DI", "status": "pending", "blockedBy": [117]},
{"id": 119, "subject": "Task 3: LoadEquipmentChildrenAsync (lazy tags)", "status": "pending", "blockedBy": [118]},
{"id": 120, "subject": "Task 4: Area + Line CRUD in service (#122 guard)", "status": "pending", "blockedBy": [118]},
{"id": 121, "subject": "Task 5: Equipment CRUD in service", "status": "pending", "blockedBy": [120]},
{"id": 122, "subject": "Task 6: Tag CRUD in service", "status": "pending", "blockedBy": [121]},
{"id": 123, "subject": "Task 7: VirtualTag CRUD in service", "status": "pending", "blockedBy": [122]},
{"id": 124, "subject": "Task 10: UnsTree.razor recursive renderer", "status": "pending", "blockedBy": [117]},
{"id": 125, "subject": "Task 11: GlobalUns.razor page (browse-only)", "status": "pending", "blockedBy": [118, 119, 124]},
{"id": 126, "subject": "Task 12: Area + Line modals, wired", "status": "pending", "blockedBy": [120, 125]},
{"id": 127, "subject": "Task 13: Equipment modal, wired", "status": "pending", "blockedBy": [121, 126]},
{"id": 128, "subject": "Task 14: Tag + VirtualTag modals, wired", "status": "pending", "blockedBy": [122, 123, 127]},
{"id": 129, "subject": "Task 15: Import equipment CSV toolbar action", "status": "pending", "blockedBy": [121, 128]},
{"id": 130, "subject": "Task 16: Rewire navigation", "status": "pending", "blockedBy": [125]},
{"id": 131, "subject": "Task 17: Delete the replaced pages", "status": "pending", "blockedBy": [126, 127, 128, 129, 130]},
{"id": 132, "subject": "Task 18: Full verification gate", "status": "pending", "blockedBy": [131]}
],
"lastUpdated": "2026-06-08"
}
@@ -15,12 +15,12 @@
<NavRailItem Href="/fleet" Text="Fleet status" />
<NavRailItem Href="/hosts" Text="Host status" />
<NavRailItem Href="/clusters" Text="Clusters" />
<NavRailItem Href="/uns" Text="UNS" />
<NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" />
</NavRailSection>
<NavRailSection Title="Scripting" Key="scripting">
<NavRailItem Href="/virtual-tags" Text="Virtual tags" />
<NavRailItem Href="/scripted-alarms" Text="Scripted alarms" />
<NavRailItem Href="/scripts" Text="Scripts" />
<NavRailItem Href="/script-log" Text="Script log" />
@@ -1,99 +0,0 @@
@page "/clusters/{ClusterId}/equipment"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Equipment &middot; <span class="mono">@ClusterId</span></h4>
<div class="d-flex gap-2">
<a href="/clusters/@ClusterId/equipment/import" class="btn btn-outline-primary btn-sm">Import CSV…</a>
<a href="/clusters/@ClusterId/equipment/new" class="btn btn-primary btn-sm">New equipment</a>
</div>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Equipment rows are scoped to a UNS line and optionally bound to a driver instance
(driver-less = VirtualTag-only). EquipmentId is
system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag
(ERP).
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count equipment row@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No equipment defined for this cluster.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>EquipmentId</th>
<th>Name</th>
<th>MachineCode</th>
<th>ZTag</th>
<th>Driver</th>
<th>UNS line</th>
<th>Identification</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var e in _rows)
{
<tr>
<td><span class="mono small">@e.EquipmentId</span></td>
<td>@e.Name</td>
<td><span class="mono">@e.MachineCode</span></td>
<td>@(e.ZTag ?? "—")</td>
<td><span class="mono small">@e.DriverInstanceId</span></td>
<td><span class="mono small">@e.UnsLineId</span></td>
<td class="text-muted small">
@if (!string.IsNullOrWhiteSpace(e.Manufacturer)) { <span>@e.Manufacturer</span> }
@if (!string.IsNullOrWhiteSpace(e.Model)) { <span class="ms-1">/ @e.Model</span> }
</td>
<td><a href="/clusters/@ClusterId/equipment/@e.EquipmentId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<Equipment>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var driversInCluster = db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId).Select(d => d.DriverInstanceId);
// Driver-less equipment (DriverInstanceId == null) has no DriverInstance FK.
// Scope it to this cluster via UnsLine → UnsArea.ClusterId instead.
var areaIds = db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId);
var linesInCluster = db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId)).Select(l => l.UnsLineId);
_rows = await db.Equipment.AsNoTracking()
.Where(e => driversInCluster.Contains(e.DriverInstanceId)
|| (e.DriverInstanceId == null && linesInCluster.Contains(e.UnsLineId)))
.OrderBy(e => e.Name)
.ToListAsync();
}
}
@@ -1,106 +0,0 @@
@page "/clusters/{ClusterId}/tags"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Tags &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/tags/new" class="btn btn-primary btn-sm">New tag</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="tags" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Tags are bound to a driver instance and (optionally) an equipment + poll group. The view
below shows the first @PageSize tags by Name.
</section>
<div class="d-flex align-items-center mb-3 gap-2 mt-3">
<input type="text" class="form-control form-control-sm" style="max-width:300px"
placeholder="Filter by name (substring)…"
@bind="_filter" @bind:event="oninput" />
<span class="text-muted small">
Showing @VisibleRows.Count of @_rows.Count
</span>
</div>
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Tags</div>
@if (VisibleRows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No tags match the current filter.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>TagId</th>
<th>Name</th>
<th>Driver</th>
<th>Equipment</th>
<th>Data type</th>
<th>Access</th>
<th>Folder</th>
<th>Poll group</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var t in VisibleRows)
{
<tr>
<td><span class="mono small">@t.TagId</span></td>
<td>@t.Name</td>
<td><span class="mono small">@t.DriverInstanceId</span></td>
<td>@(t.EquipmentId ?? "—")</td>
<td><span class="mono small">@t.DataType</span></td>
<td>@t.AccessLevel</td>
<td class="text-muted small">@(t.FolderPath ?? "")</td>
<td>@(t.PollGroupId ?? "—")</td>
<td><a href="/clusters/@ClusterId/tags/@t.TagId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private const int PageSize = 200;
[Parameter] public string ClusterId { get; set; } = "";
private List<Tag>? _rows;
private string _filter = "";
private List<Tag> VisibleRows => (_rows ?? new())
.Where(t => string.IsNullOrWhiteSpace(_filter)
|| t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase))
.Take(PageSize)
.ToList();
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
// Tags don't carry ClusterId; resolve via DriverInstance scoping.
var driverIds = db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.Select(d => d.DriverInstanceId);
_rows = await db.Tags.AsNoTracking()
.Where(t => driverIds.Contains(t.DriverInstanceId))
.OrderBy(t => t.Name)
.ToListAsync();
}
}
@@ -1,106 +0,0 @@
@page "/clusters/{ClusterId}/uns"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">UNS structure &middot; <span class="mono">@ClusterId</span></h4>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
@if (_areas is null || _lines is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
UNS levels: Enterprise (cluster) → Site (cluster) → Area → Line → Equipment. Areas and
lines are cluster-scoped; equipment hangs under a single line.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head d-flex align-items-center">
<span>Areas (level 3) &middot; @_areas.Count</span>
<a href="/clusters/@ClusterId/uns/areas/new" class="btn btn-sm btn-outline-primary ms-auto">New area</a>
</div>
@if (_areas.Count == 0)
{
<div style="padding:1rem" class="text-muted">No areas defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>UnsAreaId</th><th>Name</th><th>Notes</th><th></th></tr></thead>
<tbody>
@foreach (var a in _areas)
{
<tr>
<td><span class="mono">@a.UnsAreaId</span></td>
<td>@a.Name</td>
<td class="text-muted small">@(a.Notes ?? "")</td>
<td><a href="/clusters/@ClusterId/uns/areas/@a.UnsAreaId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head d-flex align-items-center">
<span>Lines (level 4) &middot; @_lines.Count</span>
<a href="/clusters/@ClusterId/uns/lines/new" class="btn btn-sm btn-outline-primary ms-auto">New line</a>
</div>
@if (_lines.Count == 0)
{
<div style="padding:1rem" class="text-muted">No lines defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>UnsLineId</th><th>Name</th><th>Area</th><th>Notes</th><th></th></tr></thead>
<tbody>
@foreach (var l in _lines)
{
<tr>
<td><span class="mono">@l.UnsLineId</span></td>
<td>@l.Name</td>
<td><span class="mono">@l.UnsAreaId</span></td>
<td class="text-muted small">@(l.Notes ?? "")</td>
<td><a href="/clusters/@ClusterId/uns/lines/@l.UnsLineId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<UnsArea>? _areas;
private List<UnsLine>? _lines;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_areas = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId)
.OrderBy(a => a.UnsAreaId)
.ToListAsync();
var areaIds = _areas.Select(a => a.UnsAreaId).ToList();
_lines = await db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId))
.OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId)
.ToListAsync();
}
}
@@ -1,309 +0,0 @@
@page "/clusters/{ClusterId}/equipment/new"
@page "/clusters/{ClusterId}/equipment/{EquipmentId}"
@* Equipment CRUD. EquipmentId is system-generated (decision #125) — operator picks Name +
MachineCode + UnsLine + Driver; the EquipmentId is derived from the EquipmentUuid on create.
OPC 40010 identification fields (Manufacturer, Model, etc.) are all optional. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New equipment" : "Edit equipment") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Equipment <span class="mono">@EquipmentId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="equipmentEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Identity</div>
<div style="padding:1rem">
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">EquipmentId</label>
<input class="form-control form-control-sm mono" value="@EquipmentId" disabled />
<div class="form-text">System-generated; never operator-edited.</div>
</div>
}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
placeholder="machine-01" />
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="machinecode">MachineCode</label>
<InputText id="machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
placeholder="machine_001" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="line">UNS line</label>
<InputSelect id="line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
<option value="">— pick a line —</option>
@foreach (var l in _lines)
{
<option value="@l.UnsLineId">@l.UnsAreaId / @l.UnsLineId &mdash; @l.Name</option>
}
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="driver">Driver instance</label>
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.DriverInstanceId &mdash; @d.Name (@d.DriverType)</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="ztag">ZTag (ERP)</label>
<InputText id="ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="sap">SAPID</label>
<InputText id="sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Surface in deployments</label>
</div>
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">OPC 40010 identification (optional)</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? EquipmentId { get; set; }
private bool IsNew => string.IsNullOrEmpty(EquipmentId);
private FormModel _form = new();
private Equipment? _existing;
private List<UnsLine> _lines = new();
private List<DriverInstance> _drivers = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var areaIds = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId).ToListAsync();
_lines = await db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId))
.OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId)
.ToListAsync();
_drivers = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.OrderBy(d => d.DriverInstanceId)
.ToListAsync();
if (!IsNew)
{
_existing = await db.Equipment.AsNoTracking()
.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (_existing is not null)
{
_form = new FormModel
{
Name = _existing.Name,
MachineCode = _existing.MachineCode,
UnsLineId = _existing.UnsLineId,
DriverInstanceId = _existing.DriverInstanceId,
ZTag = _existing.ZTag,
SAPID = _existing.SAPID,
Manufacturer = _existing.Manufacturer,
Model = _existing.Model,
SerialNumber = _existing.SerialNumber,
HardwareRevision = _existing.HardwareRevision,
SoftwareRevision = _existing.SoftwareRevision,
YearOfConstruction = _existing.YearOfConstruction,
AssetLocation = _existing.AssetLocation,
ManufacturerUri = _existing.ManufacturerUri,
DeviceManualUri = _existing.DeviceManualUri,
Enabled = _existing.Enabled,
RowVersion = _existing.RowVersion,
};
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
if (string.IsNullOrEmpty(_form.UnsLineId)) { _error = "Pick a UNS line."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
if (await db.Equipment.AnyAsync(e => e.MachineCode == _form.MachineCode))
{ _error = $"MachineCode '{_form.MachineCode}' already exists in this fleet."; return; }
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId,
UnsLineId = _form.UnsLineId,
Name = _form.Name,
MachineCode = _form.MachineCode,
ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag,
SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID,
Manufacturer = _form.Manufacturer,
Model = _form.Model,
SerialNumber = _form.SerialNumber,
HardwareRevision = _form.HardwareRevision,
SoftwareRevision = _form.SoftwareRevision,
YearOfConstruction = _form.YearOfConstruction,
AssetLocation = _form.AssetLocation,
ManufacturerUri = _form.ManufacturerUri,
DeviceManualUri = _form.DeviceManualUri,
Enabled = _form.Enabled,
});
}
else
{
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId;
entity.UnsLineId = _form.UnsLineId;
entity.Name = _form.Name;
entity.MachineCode = _form.MachineCode;
entity.ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag;
entity.SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID;
entity.Manufacturer = _form.Manufacturer;
entity.Model = _form.Model;
entity.SerialNumber = _form.SerialNumber;
entity.HardwareRevision = _form.HardwareRevision;
entity.SoftwareRevision = _form.SoftwareRevision;
entity.YearOfConstruction = _form.YearOfConstruction;
entity.AssetLocation = _form.AssetLocation;
entity.ManufacturerUri = _form.ManufacturerUri;
entity.DeviceManualUri = _form.DeviceManualUri;
entity.Enabled = _form.Enabled;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.Equipment.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were viewing it."; }
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first."; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public short? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
public bool Enabled { get; set; } = true;
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,260 +0,0 @@
@page "/clusters/{ClusterId}/equipment/import"
@* Bulk equipment import via pasted CSV. Header row required; columns:
Name, MachineCode, UnsLineId, DriverInstanceId, ZTag, SAPID, Manufacturer, Model
Empty optional columns parsed as null. EquipmentId is system-generated per row
(matches single-add path in EquipmentEdit.razor). *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Import equipment &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
<section class="panel notice rise" style="animation-delay:.02s">
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Bulk import requires a driver; driver-less (VirtualTag-only) equipment is created via the single-add form.
Each row inserts one Equipment with a freshly-generated EquipmentId. Existing rows are
detected by MachineCode and skipped (the importer is additive-only — no updates).
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">CSV</div>
<div style="padding:1rem">
<textarea class="form-control form-control-sm mono" rows="14"
@bind="_csv" @bind:event="oninput"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,line-3,drv-modbus-line3-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
@if (_preview is not null)
{
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Preview &middot; @_preview.Count row@(_preview.Count == 1 ? "" : "s") to import</div>
@if (_preview.Count > 0)
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>MachineCode</th>
<th>UNS line</th>
<th>Driver</th>
<th>ZTag</th>
<th>SAPID</th>
<th>Manufacturer</th>
<th>Model</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var p in _preview)
{
<tr>
<td>@p.Name</td>
<td><span class="mono">@p.MachineCode</span></td>
<td><span class="mono small">@p.UnsLineId</span></td>
<td><span class="mono small">@p.DriverInstanceId</span></td>
<td>@(p.ZTag ?? "")</td>
<td>@(p.SAPID ?? "")</td>
<td>@(p.Manufacturer ?? "")</td>
<td>@(p.Model ?? "")</td>
<td>
@if (p.IsSkipped) { <span class="chip chip-idle">skip — exists</span> }
else if (!string.IsNullOrEmpty(p.RowError)) { <span class="chip chip-alert">@p.RowError</span> }
else { <span class="chip chip-ok">ready</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-primary" @onclick="PreviewAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Preview
</button>
<button class="btn btn-primary" @onclick="ImportAsync"
disabled="@(_busy || _preview is null || _preview.All(p => p.IsSkipped || !string.IsNullOrEmpty(p.RowError)))">
Import @(_preview?.Count(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)) ?? 0) row(s)
</button>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary">Cancel</a>
</div>
@code {
[Parameter] public string ClusterId { get; set; } = "";
private string _csv = "";
private List<PreviewRow>? _preview;
private bool _busy;
private string? _error;
// Bulk import requires a DriverInstanceId by design — every CSV row must reference an existing driver.
// Driver-less equipment (DriverInstanceId == null) is not supported via bulk import;
// create it via the single-add editor (/clusters/{id}/equipment/new) or the SQL loader.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
private static readonly string[] OptionalColumns = ["ZTag", "SAPID", "Manufacturer", "Model"];
private async Task PreviewAsync()
{
_busy = true;
_error = null;
_preview = null;
try
{
var parsed = ParseCsv(_csv);
if (parsed is null) return;
await using var db = await DbFactory.CreateDbContextAsync();
var driversInCluster = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.Select(d => d.DriverInstanceId)
.ToListAsync();
var driverSet = driversInCluster.ToHashSet(StringComparer.Ordinal);
var areaIds = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId)
.Select(a => a.UnsAreaId).ToListAsync();
var validLines = await db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId))
.Select(l => l.UnsLineId).ToListAsync();
var lineSet = validLines.ToHashSet(StringComparer.Ordinal);
var existingMachineCodes = await db.Equipment.AsNoTracking()
.Select(e => e.MachineCode).ToListAsync();
var existingSet = existingMachineCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var row in parsed)
{
if (existingSet.Contains(row.MachineCode))
{
row.IsSkipped = true;
continue;
}
if (!driverSet.Contains(row.DriverInstanceId))
{
row.RowError = $"driver '{row.DriverInstanceId}' not in this cluster";
continue;
}
if (!lineSet.Contains(row.UnsLineId))
{
row.RowError = $"UNS line '{row.UnsLineId}' not in this cluster";
}
}
_preview = parsed;
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task ImportAsync()
{
if (_preview is null) return;
_busy = true;
_error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var added = 0;
foreach (var row in _preview.Where(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)))
{
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = row.DriverInstanceId,
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = row.ZTag,
SAPID = row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
Enabled = true,
});
added++;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private List<PreviewRow>? ParseCsv(string csv)
{
if (string.IsNullOrWhiteSpace(csv)) { _error = "CSV is empty."; return null; }
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) { _error = "Need a header row and at least one data row."; return null; }
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
for (var i = 0; i < RequiredColumns.Length; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
_error = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
return null;
}
}
var rows = new List<PreviewRow>();
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
{
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
if (parts.Length < RequiredColumns.Length)
{
rows.Add(new PreviewRow { RowError = $"too few columns (got {parts.Length}, need {RequiredColumns.Length})" });
continue;
}
rows.Add(new PreviewRow
{
Name = parts[0],
MachineCode = parts[1],
UnsLineId = parts[2],
DriverInstanceId = parts[3],
ZTag = NullIfEmpty(parts, 4),
SAPID = NullIfEmpty(parts, 5),
Manufacturer = NullIfEmpty(parts, 6),
Model = NullIfEmpty(parts, 7),
});
}
return rows;
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
private sealed class PreviewRow
{
public string Name { get; set; } = "";
public string MachineCode { get; set; } = "";
public string UnsLineId { get; set; } = "";
public string DriverInstanceId { get; set; } = "";
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public bool IsSkipped { get; set; }
public string? RowError { get; set; }
}
}
@@ -1,320 +0,0 @@
@page "/clusters/{ClusterId}/tags/new"
@page "/clusters/{ClusterId}/tags/{TagId}"
@* Tag CRUD. EquipmentId is required when the chosen driver's namespace is Equipment-kind,
forbidden when SystemPlatform-kind (decision #110); the form switches between
"pick equipment" and "FolderPath input" based on namespace kind. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New tag" : "Edit tag") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/tags" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="tags" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Tag <span class="mono">@TagId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="tagEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Identity</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tagId">TagId</label>
<InputText id="tagId" @bind-Value="_form.TagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="tag-line3-temp-01" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature setpoint" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="driver">Driver instance</label>
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.DriverInstanceId &mdash; @d.Name</option>
}
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="dtype">Data type</label>
<InputSelect id="dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
@{ var driverNamespace = ResolveDriverNamespace(_form.DriverInstanceId); }
@if (driverNamespace?.Kind == NamespaceKind.Equipment)
{
<div class="mb-3">
<label class="form-label" for="equipment">Equipment</label>
<InputSelect id="equipment" @bind-Value="_form.EquipmentId" class="form-select form-select-sm">
<option value="">— pick equipment —</option>
@foreach (var e in _equipment.Where(e => e.DriverInstanceId == _form.DriverInstanceId))
{
<option value="@e.EquipmentId">@e.MachineCode &mdash; @e.Name</option>
}
</InputSelect>
</div>
}
else if (driverNamespace?.Kind == NamespaceKind.SystemPlatform)
{
<div class="mb-3">
<label class="form-label" for="folder">FolderPath (SystemPlatform namespace)</label>
<InputText id="folder" @bind-Value="_form.FolderPath" class="form-control form-control-sm mono"
placeholder="GalaxyArea/Machine_001" />
<div class="form-text">Galaxy hierarchy preserved as v1 expressed it — no UNS rule.</div>
</div>
}
else if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="text-muted small mb-3">Pick a driver to see its namespace kind.</div>
}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="access">Access level</label>
<InputSelect id="access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes (decision #4445)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="pgroup">PollGroupId (optional)</label>
<InputText id="pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Tag config (JSON)</div>
<div style="padding:1rem">
<InputTextArea @bind-Value="_form.TagConfig" rows="8"
class="form-control form-control-sm mono"
placeholder='{ "register": 40001, "scale": 0.1 }' />
<div class="form-text">Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/tags" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? TagId { get; set; }
private bool IsNew => string.IsNullOrEmpty(TagId);
private FormModel _form = new();
private Tag? _existing;
private List<DriverInstance> _drivers = new();
private List<Equipment> _equipment = new();
private Dictionary<string, Namespace> _namespacesByDriverInstance = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_drivers = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.OrderBy(d => d.DriverInstanceId)
.ToListAsync();
var namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.ToListAsync();
var nsById = namespaces.ToDictionary(n => n.NamespaceId);
_namespacesByDriverInstance = _drivers.ToDictionary(
d => d.DriverInstanceId,
d => nsById.TryGetValue(d.NamespaceId, out var ns) ? ns : namespaces.First());
var driverIds = _drivers.Select(d => d.DriverInstanceId).ToHashSet();
_equipment = await db.Equipment.AsNoTracking()
.Where(e => e.DriverInstanceId != null && driverIds.Contains(e.DriverInstanceId))
.OrderBy(e => e.MachineCode)
.ToListAsync();
if (!IsNew)
{
_existing = await db.Tags.AsNoTracking()
.FirstOrDefaultAsync(t => t.TagId == TagId);
if (_existing is not null)
{
_form = new FormModel
{
TagId = _existing.TagId,
Name = _existing.Name,
DriverInstanceId = _existing.DriverInstanceId,
EquipmentId = _existing.EquipmentId,
FolderPath = _existing.FolderPath,
DataType = _existing.DataType,
AccessLevel = _existing.AccessLevel,
WriteIdempotent = _existing.WriteIdempotent,
PollGroupId = _existing.PollGroupId,
TagConfig = _existing.TagConfig,
RowVersion = _existing.RowVersion,
};
}
}
else
{
_form.DataType = "Float";
_form.AccessLevel = TagAccessLevel.Read;
_form.TagConfig = "{}";
}
_loaded = true;
}
private Namespace? ResolveDriverNamespace(string driverId) =>
string.IsNullOrEmpty(driverId) ? null
: _namespacesByDriverInstance.TryGetValue(driverId, out var ns) ? ns : null;
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver."; return; }
var ns = ResolveDriverNamespace(_form.DriverInstanceId);
if (ns?.Kind == NamespaceKind.Equipment && string.IsNullOrEmpty(_form.EquipmentId))
{ _error = "Driver lives in an Equipment-kind namespace — pick an equipment."; return; }
if (ns?.Kind == NamespaceKind.SystemPlatform && !string.IsNullOrEmpty(_form.EquipmentId))
{ _error = "Driver lives in a SystemPlatform namespace — EquipmentId must be empty (use FolderPath)."; return; }
try { using var _ = System.Text.Json.JsonDocument.Parse(_form.TagConfig); }
catch { _error = "TagConfig is not valid JSON."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.Tags.AnyAsync(t => t.TagId == _form.TagId))
{ _error = $"Tag '{_form.TagId}' already exists."; return; }
db.Tags.Add(new Tag
{
TagId = _form.TagId,
DriverInstanceId = _form.DriverInstanceId,
EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId,
Name = _form.Name,
FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath,
DataType = _form.DataType,
AccessLevel = _form.AccessLevel,
WriteIdempotent = _form.WriteIdempotent,
PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId,
TagConfig = _form.TagConfig,
});
}
else
{
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.DriverInstanceId = _form.DriverInstanceId;
entity.EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId;
entity.Name = _form.Name;
entity.FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath;
entity.DataType = _form.DataType;
entity.AccessLevel = _form.AccessLevel;
entity.WriteIdempotent = _form.WriteIdempotent;
entity.PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId;
entity.TagConfig = _form.TagConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/tags"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.Tags.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were viewing it."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string? EquipmentId { get; set; }
public string? FolderPath { get; set; }
[Required] public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,167 +0,0 @@
@page "/clusters/{ClusterId}/uns/areas/new"
@page "/clusters/{ClusterId}/uns/areas/{UnsAreaId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New UNS area" : "Edit UNS area") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Area <span class="mono">@UnsAreaId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="unsAreaEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">UNS area (level 3)</div>
<div style="padding:1rem">
<div class="mb-3">
<label class="form-label" for="aid">UnsAreaId</label>
<InputText id="aid" @bind-Value="_form.UnsAreaId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
</div>
<div class="mb-3">
<label class="form-label" for="notes">Notes</label>
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? UnsAreaId { get; set; }
private bool IsNew => string.IsNullOrEmpty(UnsAreaId);
private FormModel _form = new();
private UnsArea? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
if (!IsNew)
{
await using var db = await DbFactory.CreateDbContextAsync();
_existing = await db.UnsAreas.AsNoTracking()
.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
if (_existing is not null)
{
_form = new FormModel
{
UnsAreaId = _existing.UnsAreaId,
Name = _existing.Name,
Notes = _existing.Notes,
RowVersion = _existing.RowVersion,
};
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == _form.UnsAreaId))
{ _error = $"Area '{_form.UnsAreaId}' already exists."; return; }
db.UnsAreas.Add(new UnsArea
{
UnsAreaId = _form.UnsAreaId,
ClusterId = ClusterId,
Name = _form.Name,
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
});
}
else
{
var entity = await db.UnsAreas.FirstOrDefaultAsync(
a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _form.Name;
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were editing. Reload to see the latest values."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.UnsAreas.FirstOrDefaultAsync(
a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.UnsAreas.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were viewing it."; }
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first."; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsAreaId { get; set; } = "";
[Required] public string Name { get; set; } = "";
public string? Notes { get; set; }
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,187 +0,0 @@
@page "/clusters/{ClusterId}/uns/lines/new"
@page "/clusters/{ClusterId}/uns/lines/{UnsLineId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New UNS line" : "Edit UNS line") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Line <span class="mono">@UnsLineId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="unsLineEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">UNS line (level 4)</div>
<div style="padding:1rem">
<div class="mb-3">
<label class="form-label" for="lid">UnsLineId</label>
<InputText id="lid" @bind-Value="_form.UnsLineId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label" for="area">Parent area</label>
<InputSelect id="area" @bind-Value="_form.UnsAreaId" class="form-select form-select-sm">
@foreach (var area in _areas)
{
<option value="@area.UnsAreaId">@area.UnsAreaId &mdash; @area.Name</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
</div>
<div class="mb-3">
<label class="form-label" for="notes">Notes</label>
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? UnsLineId { get; set; }
private bool IsNew => string.IsNullOrEmpty(UnsLineId);
private FormModel _form = new();
private UnsLine? _existing;
private List<UnsArea> _areas = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_areas = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId)
.OrderBy(a => a.UnsAreaId)
.ToListAsync();
if (!IsNew)
{
_existing = await db.UnsLines.AsNoTracking()
.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
if (_existing is not null)
{
_form = new FormModel
{
UnsLineId = _existing.UnsLineId,
UnsAreaId = _existing.UnsAreaId,
Name = _existing.Name,
Notes = _existing.Notes,
RowVersion = _existing.RowVersion,
};
}
}
else
{
_form.UnsAreaId = _areas.FirstOrDefault()?.UnsAreaId ?? "";
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.UnsLines.AnyAsync(l => l.UnsLineId == _form.UnsLineId))
{ _error = $"Line '{_form.UnsLineId}' already exists."; return; }
db.UnsLines.Add(new UnsLine
{
UnsLineId = _form.UnsLineId,
UnsAreaId = _form.UnsAreaId,
Name = _form.Name,
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
});
}
else
{
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.UnsAreaId = _form.UnsAreaId;
entity.Name = _form.Name;
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.UnsLines.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were viewing it."; }
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first."; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsLineId { get; set; } = "";
[Required] public string UnsAreaId { get; set; } = "";
[Required] public string Name { get; set; } = "";
public string? Notes { get; set; }
public byte[] RowVersion { get; set; } = [];
}
}
@@ -0,0 +1,626 @@
@page "/uns"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
@inject IUnsTreeService Svc
<PageTitle>UNS</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">UNS</h4>
<span class="text-muted small">Changes apply on the next deployment.</span>
</div>
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Unified namespace</span>
<div class="d-flex align-items-center gap-2 flex-wrap">
<input type="text" class="form-control form-control-sm" style="max-width:240px"
placeholder="Filter by name (substring)…"
@bind="_filter" @bind:event="oninput" />
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ExpandAll">Expand all</button>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CollapseAll">Collapse all</button>
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenImportModal">Import equipment CSV</button>
</div>
</div>
@if (_loading)
{
<div style="padding:1rem" class="text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
</div>
}
else if (_roots.Count == 0)
{
<div style="padding:1rem" class="text-muted">No clusters yet.</div>
}
else
{
<div style="padding:.5rem 1rem">
<UnsTree Roots="_roots" Filter="_filter"
OnToggleExpand="ToggleAsync"
OnAddChild="HandleAddChild"
OnAddVirtualTag="HandleAddVirtualTag"
OnEdit="HandleEdit"
OnDelete="HandleDelete" />
</div>
}
</section>
<AreaModal Visible="_areaModalVisible"
IsNew="_areaModalIsNew"
ClusterId="_areaModalClusterId"
Existing="_areaModalExisting"
Clusters="ClusterOptions"
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
<LineModal Visible="_lineModalVisible"
IsNew="_lineModalIsNew"
UnsAreaId="_lineModalAreaId"
Existing="_lineModalExisting"
Areas="_lineModalAreaOptions"
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
<EquipmentModal Visible="_equipmentModalVisible"
IsNew="_equipmentModalIsNew"
UnsLineId="_equipmentModalLineId"
Existing="_equipmentModalExisting"
Lines="_equipmentModalLineOptions"
Drivers="_equipmentModalDriverOptions"
OnSaved="OnModalSavedAsync"
OnCancel="CloseModals" />
<TagModal Visible="_tagModalVisible"
IsNew="_tagModalIsNew"
EquipmentId="_tagModalEquipmentId"
Existing="_tagModalExisting"
Drivers="_tagModalDriverOptions"
OnSaved="OnEquipmentChildModalSavedAsync"
OnCancel="CloseModals" />
<VirtualTagModal Visible="_vtagModalVisible"
IsNew="_vtagModalIsNew"
EquipmentId="_vtagModalEquipmentId"
Existing="_vtagModalExisting"
Scripts="_vtagModalScriptOptions"
OnSaved="OnEquipmentChildModalSavedAsync"
OnCancel="CloseModals" />
<ImportEquipmentModal Visible="_importModalVisible"
OnImported="OnImportedAsync"
OnCancel="CloseModals" />
@if (_confirmNode is not null)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete @_confirmNode.Kind</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseModals"></button>
</div>
<div class="modal-body">
<p>Delete <span class="mono">@_confirmNode.DisplayName</span>? This cannot be undone.</p>
@if (!string.IsNullOrWhiteSpace(_confirmError))
{
<div class="text-danger small mt-2">@_confirmError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseModals" disabled="@_confirmBusy">Cancel</button>
<button type="button" class="btn btn-danger" @onclick="ConfirmDeleteAsync" disabled="@_confirmBusy">
@if (_confirmBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Delete
</button>
</div>
</div>
</div>
</div>
}
@code {
private IReadOnlyList<UnsNode> _roots = Array.Empty<UnsNode>();
private string? _filter;
private bool _loading = true;
// --- Area modal state ---
private bool _areaModalVisible;
private bool _areaModalIsNew;
private string? _areaModalClusterId;
private AreaEditDto? _areaModalExisting;
// --- Line modal state ---
private bool _lineModalVisible;
private bool _lineModalIsNew;
private string? _lineModalAreaId;
private LineEditDto? _lineModalExisting;
private IReadOnlyList<(string Id, string Display)> _lineModalAreaOptions = Array.Empty<(string, string)>();
// --- Equipment modal state ---
private bool _equipmentModalVisible;
private bool _equipmentModalIsNew;
private string? _equipmentModalLineId;
private EquipmentEditDto? _equipmentModalExisting;
private IReadOnlyList<(string Id, string Display)> _equipmentModalLineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _equipmentModalDriverOptions = Array.Empty<(string, string)>();
// --- Tag modal state ---
private bool _tagModalVisible;
private bool _tagModalIsNew;
private string? _tagModalEquipmentId;
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>();
// --- Virtual-tag modal state ---
private bool _vtagModalVisible;
private bool _vtagModalIsNew;
private string? _vtagModalEquipmentId;
private VirtualTagEditDto? _vtagModalExisting;
private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>();
// --- Import-equipment-CSV modal state ---
private bool _importModalVisible;
// --- Owning equipment to refresh in place after a tag/virtual-tag mutation ---
private string? _childRefreshEquipmentId;
// --- Delete-confirm state ---
private UnsNode? _confirmNode;
private bool _confirmBusy;
private string? _confirmError;
/// <summary>The served-by cluster options for the AreaModal, derived from the loaded tree.</summary>
private IReadOnlyList<(string Id, string Display)> ClusterOptions =>
_roots
.SelectMany(ent => ent.Children)
.Where(n => n.Kind == UnsNodeKind.Cluster && n.EntityId is not null)
.Select(n => (n.EntityId!, n.DisplayName))
.ToList();
protected override async Task OnInitializedAsync()
{
_roots = await Svc.LoadStructureAsync();
_loading = false;
}
/// <summary>Returns the <c>(Id, Display)</c> area options inside a single cluster, for the line picker.</summary>
private IReadOnlyList<(string Id, string Display)> AreaOptionsForCluster(string? clusterId) =>
_roots
.SelectMany(ent => ent.Children)
.Where(c => c.Kind == UnsNodeKind.Cluster && c.ClusterId == clusterId)
.SelectMany(c => c.Children)
.Where(a => a.Kind == UnsNodeKind.Area && a.EntityId is not null)
.Select(a => (a.EntityId!, a.DisplayName))
.ToList();
/// <summary>Returns the <c>(Id, Display)</c> line options inside a single cluster, for the equipment picker.</summary>
private IReadOnlyList<(string Id, string Display)> LinesForCluster(string? clusterId) =>
_roots
.SelectMany(ent => ent.Children)
.Where(c => c.Kind == UnsNodeKind.Cluster && c.ClusterId == clusterId)
.SelectMany(c => c.Children)
.Where(a => a.Kind == UnsNodeKind.Area)
.SelectMany(a => a.Children)
.Where(l => l.Kind == UnsNodeKind.Line && l.EntityId is not null)
.Select(l => (l.EntityId!, l.DisplayName))
.ToList();
/// <summary>
/// Toggles a node's expansion. For equipment nodes whose children have not yet
/// been loaded, lazily fetches the tag/virtual-tag leaves on first expand.
/// </summary>
private async Task ToggleAsync(UnsNode node)
{
if (node.Loading) return;
node.Expanded = !node.Expanded;
if (node.Kind == UnsNodeKind.Equipment && node.Expanded && !node.Loaded)
{
node.Error = null;
node.Loading = true;
StateHasChanged();
try
{
var kids = await Svc.LoadEquipmentChildrenAsync(node.EntityId!);
node.Children.Clear();
node.Children.AddRange(kids);
node.Loaded = true;
}
catch (Exception ex)
{
node.Error = ex.Message;
}
finally
{
node.Loading = false;
StateHasChanged();
}
}
}
/// <summary>
/// Opens the create modal for a node's primary child: a cluster gets a new area; an area gets a
/// new line scoped to its cluster; a line gets a new equipment scoped to its cluster; an equipment
/// gets a new tag scoped to its candidate drivers.
/// </summary>
private async Task HandleAddChild(UnsNode node)
{
CloseModals();
switch (node.Kind)
{
case UnsNodeKind.Cluster:
_areaModalIsNew = true;
_areaModalExisting = null;
_areaModalClusterId = node.ClusterId ?? node.EntityId;
_areaModalVisible = true;
break;
case UnsNodeKind.Area:
_lineModalIsNew = true;
_lineModalExisting = null;
_lineModalAreaId = node.EntityId;
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
_lineModalVisible = true;
break;
case UnsNodeKind.Line:
_equipmentModalIsNew = true;
_equipmentModalExisting = null;
_equipmentModalLineId = node.EntityId;
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
case UnsNodeKind.Equipment:
_tagModalIsNew = true;
_tagModalExisting = null;
_tagModalEquipmentId = node.EntityId;
_childRefreshEquipmentId = node.EntityId;
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(node.EntityId!);
_tagModalVisible = true;
break;
}
}
/// <summary>Opens the create modal for a new virtual tag scoped to the clicked equipment.</summary>
private async Task HandleAddVirtualTag(UnsNode node)
{
CloseModals();
_vtagModalIsNew = true;
_vtagModalExisting = null;
_vtagModalEquipmentId = node.EntityId;
_childRefreshEquipmentId = node.EntityId;
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
_vtagModalVisible = true;
}
/// <summary>
/// Opens the edit modal for the clicked node, loading the entity first to prefill the form and
/// capture its RowVersion. Tag/VirtualTag edits also stash the owning equipment id so a successful
/// save can refresh just that equipment's children in place.
/// </summary>
private async Task HandleEdit(UnsNode node)
{
CloseModals();
switch (node.Kind)
{
case UnsNodeKind.Area:
var area = await Svc.LoadAreaAsync(node.EntityId!);
if (area is null) { return; }
_areaModalIsNew = false;
_areaModalExisting = area;
_areaModalClusterId = area.ClusterId;
_areaModalVisible = true;
break;
case UnsNodeKind.Line:
var line = await Svc.LoadLineAsync(node.EntityId!);
if (line is null) { return; }
_lineModalIsNew = false;
_lineModalExisting = line;
_lineModalAreaId = line.UnsAreaId;
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
_lineModalVisible = true;
break;
case UnsNodeKind.Equipment:
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
if (equipment is null) { return; }
_equipmentModalIsNew = false;
_equipmentModalExisting = equipment;
_equipmentModalLineId = equipment.UnsLineId;
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
_equipmentModalVisible = true;
break;
case UnsNodeKind.Tag:
var tag = await Svc.LoadTagAsync(node.EntityId!);
if (tag is null) { return; }
_tagModalIsNew = false;
_tagModalExisting = tag;
_tagModalEquipmentId = tag.EquipmentId;
_childRefreshEquipmentId = tag.EquipmentId;
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(tag.EquipmentId);
_tagModalVisible = true;
break;
case UnsNodeKind.VirtualTag:
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
if (vtag is null) { return; }
_vtagModalIsNew = false;
_vtagModalExisting = vtag;
_vtagModalEquipmentId = vtag.EquipmentId;
_childRefreshEquipmentId = vtag.EquipmentId;
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
_vtagModalVisible = true;
break;
}
}
/// <summary>Opens the bulk equipment-CSV import modal.</summary>
private void OpenImportModal()
{
CloseModals();
_importModalVisible = true;
}
/// <summary>
/// Handles the import modal's close after a run. A bulk import can add equipment across many
/// lines and clusters, so the whole structural tree is reloaded rather than refreshed in place.
/// </summary>
private async Task OnImportedAsync()
{
_roots = await Svc.LoadStructureAsync();
CloseModals();
StateHasChanged();
}
/// <summary>Opens the delete-confirm modal for a node, stashing it as the pending target.</summary>
private void HandleDelete(UnsNode node)
{
CloseModals();
_confirmNode = node;
}
/// <summary>
/// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind.
/// Area/Line/Equipment trigger a full structural reload on success; Tag/VirtualTag refresh only the
/// owning equipment's children in place so the rest of the user's expansion is preserved.
/// </summary>
private async Task ConfirmDeleteAsync()
{
if (_confirmNode is null) { return; }
_confirmBusy = true;
_confirmError = null;
try
{
var node = _confirmNode;
UnsMutationResult result;
// Tag/VirtualTag deletes refresh just the owning equipment's children rather than the
// whole tree, so they're handled separately from the structural Area/Line/Equipment path.
switch (node.Kind)
{
case UnsNodeKind.Tag:
var tag = await Svc.LoadTagAsync(node.EntityId!);
if (tag is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteTagAsync(node.EntityId!, tag.RowVersion);
if (result.Ok)
{
await RefreshEquipmentChildrenAsync(tag.EquipmentId);
CloseModals();
StateHasChanged();
}
else
{
_confirmError = result.Error;
}
return;
case UnsNodeKind.VirtualTag:
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
if (vtag is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteVirtualTagAsync(node.EntityId!, vtag.RowVersion);
if (result.Ok)
{
await RefreshEquipmentChildrenAsync(vtag.EquipmentId);
CloseModals();
StateHasChanged();
}
else
{
_confirmError = result.Error;
}
return;
case UnsNodeKind.Area:
var area = await Svc.LoadAreaAsync(node.EntityId!);
if (area is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteAreaAsync(node.EntityId!, area.RowVersion);
break;
case UnsNodeKind.Line:
var line = await Svc.LoadLineAsync(node.EntityId!);
if (line is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteLineAsync(node.EntityId!, line.RowVersion);
break;
case UnsNodeKind.Equipment:
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
if (equipment is null) { await ReloadAndCloseAsync(); return; }
result = await Svc.DeleteEquipmentAsync(node.EntityId!, equipment.RowVersion);
break;
default:
// Enterprise/Cluster have no delete button, so this branch is unreachable in practice.
result = new UnsMutationResult(false, "Delete for this node kind is not yet available.");
break;
}
if (result.Ok)
{
await ReloadAndCloseAsync();
}
else
{
_confirmError = result.Error;
}
}
finally
{
_confirmBusy = false;
}
}
/// <summary>Reloads the tree after a successful modal save and closes any open modal.</summary>
private async Task OnModalSavedAsync()
{
_roots = await Svc.LoadStructureAsync();
CloseModals();
StateHasChanged();
}
/// <summary>
/// Handles a successful Tag/VirtualTag modal save by refreshing only the owning equipment's children
/// in place — never a full structural reload, which would collapse the user's expansion.
/// </summary>
private async Task OnEquipmentChildModalSavedAsync()
{
if (_childRefreshEquipmentId is not null)
{
await RefreshEquipmentChildrenAsync(_childRefreshEquipmentId);
}
CloseModals();
StateHasChanged();
}
/// <summary>
/// Reloads a single equipment node's tag/virtual-tag children in place, leaving the rest of the tree
/// (and the user's expansion) untouched. Falls back to a full structural reload only if the node
/// can no longer be found in the current tree.
/// </summary>
private async Task RefreshEquipmentChildrenAsync(string equipmentId)
{
var node = FindEquipmentNode(equipmentId);
if (node is null) { _roots = await Svc.LoadStructureAsync(); return; }
var kids = await Svc.LoadEquipmentChildrenAsync(equipmentId);
node.Children.Clear();
node.Children.AddRange(kids);
node.ChildCount = node.Children.Count;
node.Loaded = true;
node.Expanded = true;
}
/// <summary>Recursively walks the current tree for the Equipment node with the given id, or null.</summary>
private UnsNode? FindEquipmentNode(string equipmentId)
{
foreach (var root in _roots)
{
var found = FindEquipmentNode(root, equipmentId);
if (found is not null) { return found; }
}
return null;
}
private static UnsNode? FindEquipmentNode(UnsNode node, string equipmentId)
{
if (node.Kind == UnsNodeKind.Equipment && node.EntityId == equipmentId)
{
return node;
}
foreach (var child in node.Children)
{
var found = FindEquipmentNode(child, equipmentId);
if (found is not null) { return found; }
}
return null;
}
/// <summary>Reloads the tree after a successful delete and closes the confirm modal.</summary>
private async Task ReloadAndCloseAsync()
{
_roots = await Svc.LoadStructureAsync();
CloseModals();
StateHasChanged();
}
/// <summary>Closes every modal and clears its transient state.</summary>
private void CloseModals()
{
_areaModalVisible = false;
_areaModalExisting = null;
_lineModalVisible = false;
_lineModalExisting = null;
_lineModalAreaOptions = Array.Empty<(string, string)>();
_equipmentModalVisible = false;
_equipmentModalExisting = null;
_equipmentModalLineOptions = Array.Empty<(string, string)>();
_equipmentModalDriverOptions = Array.Empty<(string, string)>();
_tagModalVisible = false;
_tagModalExisting = null;
_tagModalEquipmentId = null;
_tagModalDriverOptions = Array.Empty<(string, string)>();
_vtagModalVisible = false;
_vtagModalExisting = null;
_vtagModalEquipmentId = null;
_vtagModalScriptOptions = Array.Empty<(string, string)>();
_importModalVisible = false;
_childRefreshEquipmentId = null;
_confirmNode = null;
_confirmError = null;
}
/// <summary>
/// Expands every structural node (Enterprise/Cluster/Area/Line). Equipment nodes
/// are intentionally left collapsed because expanding them would trigger lazy loads.
/// </summary>
private void ExpandAll()
{
foreach (var root in _roots)
{
ExpandStructural(root);
}
}
private static void ExpandStructural(UnsNode node)
{
if (node.Kind is UnsNodeKind.Enterprise or UnsNodeKind.Cluster
or UnsNodeKind.Area or UnsNodeKind.Line)
{
node.Expanded = true;
}
foreach (var child in node.Children)
{
ExpandStructural(child);
}
}
/// <summary>Collapses every node in the tree.</summary>
private void CollapseAll()
{
foreach (var root in _roots)
{
CollapseNode(root);
}
}
private static void CollapseNode(UnsNode node)
{
node.Expanded = false;
foreach (var child in node.Children)
{
CollapseNode(child);
}
}
}
@@ -1,231 +0,0 @@
@page "/virtual-tags/new"
@page "/virtual-tags/{VirtualTagId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New virtual tag" : "Edit virtual tag")</h4>
<a href="/virtual-tags" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise"><span class="mono">@VirtualTagId</span> not found.</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="vtagEdit">
<DataAnnotationsValidator />
<section class="panel rise">
<div class="panel-head">Identity</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">VirtualTagId</label>
<InputText @bind-Value="_form.VirtualTagId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Name</label>
<InputText @bind-Value="_form.Name" class="form-control form-control-sm" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Equipment</label>
<InputSelect @bind-Value="_form.EquipmentId" class="form-select form-select-sm">
<option value="">— pick equipment —</option>
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.MachineCode &mdash; @e.Name</option> }
</InputSelect>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">DataType</label>
<InputText @bind-Value="_form.DataType" class="form-control form-control-sm mono" placeholder="Double" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Script</label>
<InputSelect @bind-Value="_form.ScriptId" class="form-select form-select-sm">
<option value="">— pick script —</option>
@foreach (var s in _scripts) { <option value="@s.ScriptId">@s.Name (@s.Language)</option> }
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Change-triggered</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.ChangeTriggered" class="form-check-input" />
<label class="form-check-label">Re-evaluate on dependency change</label>
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">TimerIntervalMs (optional)</label>
<InputNumber @bind-Value="_form.TimerIntervalMs" class="form-control form-control-sm" />
<div class="form-text">Periodic re-evaluation. Null = change-trigger only.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Historize</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Historize" class="form-check-input" />
<label class="form-check-label">Send to Wonderware historian</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Spawn this virtual tag in deployments</label>
</div>
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
<a href="/virtual-tags" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div>
</EditForm>
}
@code {
[Parameter] public string? VirtualTagId { get; set; }
private bool IsNew => string.IsNullOrEmpty(VirtualTagId);
private FormModel _form = new();
private VirtualTag? _existing;
private List<Equipment> _equipment = new();
private List<Script> _scripts = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_equipment = await db.Equipment.AsNoTracking().OrderBy(e => e.MachineCode).ToListAsync();
_scripts = await db.Scripts.AsNoTracking().OrderBy(s => s.Name).ToListAsync();
if (!IsNew)
{
_existing = await db.VirtualTags.AsNoTracking().FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
if (_existing is not null)
{
_form = new FormModel
{
VirtualTagId = _existing.VirtualTagId,
Name = _existing.Name,
EquipmentId = _existing.EquipmentId,
DataType = _existing.DataType,
ScriptId = _existing.ScriptId,
ChangeTriggered = _existing.ChangeTriggered,
TimerIntervalMs = _existing.TimerIntervalMs,
Historize = _existing.Historize,
Enabled = _existing.Enabled,
RowVersion = _existing.RowVersion,
};
}
}
else
{
_form.DataType = "Double";
_form.ChangeTriggered = true;
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
if (string.IsNullOrEmpty(_form.EquipmentId)) { _error = "Pick equipment."; return; }
if (string.IsNullOrEmpty(_form.ScriptId)) { _error = "Pick a script."; return; }
if (!_form.ChangeTriggered && _form.TimerIntervalMs is null)
{ _error = "Pick at least one trigger — change or timer."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.VirtualTags.AnyAsync(v => v.VirtualTagId == _form.VirtualTagId))
{ _error = $"VirtualTag '{_form.VirtualTagId}' already exists."; return; }
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = _form.VirtualTagId,
EquipmentId = _form.EquipmentId,
Name = _form.Name,
DataType = _form.DataType,
ScriptId = _form.ScriptId,
ChangeTriggered = _form.ChangeTriggered,
TimerIntervalMs = _form.TimerIntervalMs,
Historize = _form.Historize,
Enabled = _form.Enabled,
});
}
else
{
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.EquipmentId = _form.EquipmentId;
entity.Name = _form.Name;
entity.DataType = _form.DataType;
entity.ScriptId = _form.ScriptId;
entity.ChangeTriggered = _form.ChangeTriggered;
entity.TimerIntervalMs = _form.TimerIntervalMs;
entity.Historize = _form.Historize;
entity.Enabled = _form.Enabled;
}
await db.SaveChangesAsync();
Nav.NavigateTo("/virtual-tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this virtual tag while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
if (entity is null) { Nav.NavigateTo("/virtual-tags"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.VirtualTags.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo("/virtual-tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this virtual tag while you were viewing it."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string VirtualTagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string EquipmentId { get; set; } = "";
[Required] public string DataType { get; set; } = "Double";
[Required] public string ScriptId { get; set; } = "";
public bool ChangeTriggered { get; set; } = true;
public int? TimerIntervalMs { get; set; }
public bool Historize { get; set; }
public bool Enabled { get; set; } = true;
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,85 +0,0 @@
@page "/virtual-tags"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Virtual tags</h4>
<a href="/virtual-tags/new" class="btn btn-primary btn-sm">New virtual tag</a>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Virtual tags evaluate a script per equipment instance and publish the result as an OPC UA
variable. ChangeTriggered = re-evaluate when any dependency changes; TimerIntervalMs
re-evaluates on a periodic timer.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count virtual tag@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No virtual tags defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>VirtualTagId</th>
<th>Name</th>
<th>Equipment</th>
<th>Data type</th>
<th>Script</th>
<th>Trigger</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var v in _rows)
{
<tr>
<td><span class="mono small">@v.VirtualTagId</span></td>
<td>@v.Name</td>
<td><span class="mono small">@v.EquipmentId</span></td>
<td><span class="mono small">@v.DataType</span></td>
<td><span class="mono small">@v.ScriptId</span></td>
<td>
@if (v.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
@if (v.TimerIntervalMs is int ms) { <span class="chip chip-idle">@(ms)ms</span> }
</td>
<td>
@if (v.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
<td><a href="/virtual-tags/@v.VirtualTagId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private List<VirtualTag>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.VirtualTags.AsNoTracking()
.OrderBy(v => v.Name)
.ToListAsync();
}
}
@@ -10,11 +10,8 @@
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li>
<li class="nav-item"><a class="nav-link @Active("equipment")" href="/clusters/@ClusterId/equipment">Equipment</a></li>
<li class="nav-item"><a class="nav-link @Active("uns")" href="/clusters/@ClusterId/uns">UNS</a></li>
<li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li>
<li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li>
<li class="nav-item"><a class="nav-link @Active("tags")" href="/clusters/@ClusterId/tags">Tags</a></li>
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
@@ -0,0 +1,145 @@
@* Create/edit modal for a UNS area, wired straight into IUnsTreeService. The host page owns
visibility and supplies the parent cluster (create) or the loaded AreaEditDto (edit) plus the
served-by cluster list. On a successful save it raises OnSaved so the host can reload the tree. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="areaModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New UNS area" : "Edit UNS area")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="area-id">UnsAreaId</label>
<InputText id="area-id" @bind-Value="_form.UnsAreaId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
<ValidationMessage For="@(() => _form.UnsAreaId)" />
</div>
<div class="mb-3">
<label class="form-label" for="area-name">Name</label>
<InputText id="area-name" @bind-Value="_form.Name" class="form-control form-control-sm" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
<div class="mb-3">
<label class="form-label" for="area-cluster">Served by cluster</label>
<InputSelect id="area-cluster" @bind-Value="_form.ClusterId" class="form-select form-select-sm">
@foreach (var (id, display) in Clusters)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.ClusterId)" />
</div>
<div class="mb-3">
<label class="form-label" for="area-notes">Notes</label>
<InputTextArea id="area-notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new area; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The parent cluster id used to default the served-by select on create.</summary>
[Parameter] public string? ClusterId { get; set; }
/// <summary>The area being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public AreaEditDto? Existing { get; set; }
/// <summary>The selectable served-by clusters as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Clusters { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel { ClusterId = ClusterId ?? "" };
}
else if (Existing is not null)
{
_form = new FormModel
{
UnsAreaId = Existing.UnsAreaId,
Name = Existing.Name,
Notes = Existing.Notes,
ClusterId = Existing.ClusterId,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var result = IsNew
? await Svc.CreateAreaAsync(_form.ClusterId, _form.UnsAreaId, _form.Name, _form.Notes)
: await Svc.UpdateAreaAsync(_form.UnsAreaId, _form.Name, _form.Notes, _form.ClusterId, Existing!.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsAreaId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string ClusterId { get; set; } = "";
public string? Notes { get; set; }
}
}
@@ -0,0 +1,252 @@
@* Create/edit modal for an equipment, wired straight into IUnsTreeService. The host page owns
visibility and supplies the parent line id (create) or the loaded EquipmentEditDto (edit), plus
the cluster-scoped UNS-line and driver lists. The EquipmentId is system-generated (decision #125)
so it is never an editable field — only shown read-only on edit. On a successful save it raises
OnSaved so the host can reload the tree. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New equipment" : "Edit equipment")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<h6 class="text-muted">Identity</h6>
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">EquipmentId</label>
<input class="form-control form-control-sm mono" value="@Existing?.EquipmentId" disabled />
<div class="form-text">System-generated; never operator-edited.</div>
</div>
}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-name">Name</label>
<InputText id="eq-name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
placeholder="machine-01" />
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
<ValidationMessage For="@(() => _form.Name)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-machinecode">MachineCode</label>
<InputText id="eq-machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
placeholder="machine_001" />
<ValidationMessage For="@(() => _form.MachineCode)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-line">UNS line</label>
<InputSelect id="eq-line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
<option value="">— pick a line —</option>
@foreach (var (id, display) in Lines)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.UnsLineId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-driver">Driver instance</label>
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var (id, display) in Drivers)
{
<option value="@id">@display</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
<InputText id="eq-ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-sap">SAPID</label>
<InputText id="eq-sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Surface in deployments</label>
</div>
</div>
<hr />
<h6 class="text-muted">OPC 40010 identification (optional)</h6>
<div class="row">
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
</div>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new equipment; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The parent line id used to default the UNS-line select on create.</summary>
[Parameter] public string? UnsLineId { get; set; }
/// <summary>The equipment being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public EquipmentEditDto? Existing { get; set; }
/// <summary>The selectable UNS lines — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Lines { get; set; } = Array.Empty<(string, string)>();
/// <summary>The selectable drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel { UnsLineId = UnsLineId ?? "" };
}
else if (Existing is not null)
{
_form = new FormModel
{
Name = Existing.Name,
MachineCode = Existing.MachineCode,
UnsLineId = Existing.UnsLineId,
DriverInstanceId = Existing.DriverInstanceId,
ZTag = Existing.ZTag,
SAPID = Existing.SAPID,
Manufacturer = Existing.Manufacturer,
Model = Existing.Model,
SerialNumber = Existing.SerialNumber,
HardwareRevision = Existing.HardwareRevision,
SoftwareRevision = Existing.SoftwareRevision,
YearOfConstruction = Existing.YearOfConstruction,
AssetLocation = Existing.AssetLocation,
ManufacturerUri = Existing.ManufacturerUri,
DeviceManualUri = Existing.DeviceManualUri,
Enabled = Existing.Enabled,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var input = new EquipmentInput(
_form.Name,
_form.MachineCode,
_form.UnsLineId,
_form.DriverInstanceId,
_form.ZTag,
_form.SAPID,
_form.Manufacturer,
_form.Model,
_form.SerialNumber,
_form.HardwareRevision,
_form.SoftwareRevision,
_form.YearOfConstruction,
_form.AssetLocation,
_form.ManufacturerUri,
_form.DeviceManualUri,
_form.Enabled);
var result = IsNew
? await Svc.CreateEquipmentAsync(input)
: await Svc.UpdateEquipmentAsync(Existing!.EquipmentId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public short? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
public bool Enabled { get; set; } = true;
}
}
@@ -0,0 +1,191 @@
@* Bulk equipment import modal wired straight into IUnsTreeService. Paste a CSV (header + rows),
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
so the page can reload the whole tree (an import can add equipment across many lines/clusters).
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId.
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import equipment CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
are detected by MachineCode and skipped (additive-only — no updates).
</p>
<textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
@if (!string.IsNullOrWhiteSpace(_parseError))
{
<div class="text-danger small mt-2">@_parseError</div>
}
@if (_result is not null)
{
<div class="alert alert-info mt-3 mb-0" role="alert">
<div>
<strong>Inserted:</strong> @_result.Inserted
&middot; <strong>Skipped (existing MachineCode):</strong> @_result.Skipped
&middot; <strong>Errors:</strong> @_result.Errors.Count
</div>
@if (_result.Errors.Count > 0)
{
<ul class="small mb-0 mt-2">
@foreach (var err in _result.Errors)
{
<li>@err</li>
}
</ul>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">
@(_result is null ? "Cancel" : "Close")
</button>
<button type="button" class="btn btn-primary" @onclick="ImportAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Import
</button>
</div>
</div>
</div>
</div>
}
@code {
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment).
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>
/// Raised when the user closes the modal after an import so the host can reload the tree and
/// dismiss the modal. Fired on close regardless of whether anything was inserted — closing always
/// returns control to the host.
/// </summary>
[Parameter] public EventCallback OnImported { get; set; }
/// <summary>Raised when the user cancels before importing so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private string _csv = "";
private bool _busy;
private string? _parseError;
private EquipmentImportResult? _result;
private bool _wasVisible;
protected override void OnParametersSet()
{
// Reset only on the false→true transition so that re-renders while open don't wipe state.
if (Visible && !_wasVisible)
{
_csv = "";
_parseError = null;
_result = null;
}
_wasVisible = Visible;
}
private async Task ImportAsync()
{
_busy = true;
_parseError = null;
_result = null;
try
{
var rows = ParseCsv(_csv);
if (rows is null) { return; }
_result = await Svc.ImportEquipmentAsync(rows);
}
finally
{
_busy = false;
}
}
/// <summary>
/// Closes the modal. Once an import has run (the user has seen the summary), closing raises
/// OnImported so the host reloads the tree; before any import it raises OnCancel.
/// </summary>
private Task CloseAsync() =>
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
/// <summary>
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
/// </summary>
private List<EquipmentInput>? ParseCsv(string csv)
{
if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
for (var i = 0; i < RequiredColumns.Length; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
_parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
return null;
}
}
var rows = new List<EquipmentInput>();
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
{
// NOTE: simple comma split — no RFC4180 quoting; values with commas are not supported.
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
if (parts.Length < RequiredColumns.Length)
{
_parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
return null;
}
rows.Add(new EquipmentInput(
Name: parts[0],
MachineCode: parts[1],
UnsLineId: parts[2],
DriverInstanceId: NullIfEmpty(parts, 3),
ZTag: NullIfEmpty(parts, 4),
SAPID: NullIfEmpty(parts, 5),
Manufacturer: NullIfEmpty(parts, 6),
Model: NullIfEmpty(parts, 7),
SerialNumber: null,
HardwareRevision: null,
SoftwareRevision: null,
YearOfConstruction: null,
AssetLocation: null,
ManufacturerUri: null,
DeviceManualUri: null,
Enabled: true));
}
return rows;
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
}
@@ -0,0 +1,146 @@
@* Create/edit modal for a UNS line, wired straight into IUnsTreeService. The host page owns
visibility and supplies the parent area (create) or the loaded LineEditDto (edit). The parent-area
list is SCOPED TO THE LINE'S CLUSTER by the host so an edit cannot move a line across clusters.
On a successful save it raises OnSaved so the host can reload the tree. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="lineModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New UNS line" : "Edit UNS line")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="line-id">UnsLineId</label>
<InputText id="line-id" @bind-Value="_form.UnsLineId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
<ValidationMessage For="@(() => _form.UnsLineId)" />
</div>
<div class="mb-3">
<label class="form-label" for="line-area">Parent area</label>
<InputSelect id="line-area" @bind-Value="_form.UnsAreaId" class="form-select form-select-sm">
@foreach (var (id, display) in Areas)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.UnsAreaId)" />
</div>
<div class="mb-3">
<label class="form-label" for="line-name">Name</label>
<InputText id="line-name" @bind-Value="_form.Name" class="form-control form-control-sm" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
<div class="mb-3">
<label class="form-label" for="line-notes">Notes</label>
<InputTextArea id="line-notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new line; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The parent area id used to default the parent-area select on create.</summary>
[Parameter] public string? UnsAreaId { get; set; }
/// <summary>The line being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public LineEditDto? Existing { get; set; }
/// <summary>The selectable parent areas — scoped to the line's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Areas { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel { UnsAreaId = UnsAreaId ?? "" };
}
else if (Existing is not null)
{
_form = new FormModel
{
UnsLineId = Existing.UnsLineId,
UnsAreaId = Existing.UnsAreaId,
Name = Existing.Name,
Notes = Existing.Notes,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var result = IsNew
? await Svc.CreateLineAsync(_form.UnsAreaId, _form.UnsLineId, _form.Name, _form.Notes)
: await Svc.UpdateLineAsync(_form.UnsLineId, _form.Name, _form.Notes, _form.UnsAreaId, Existing!.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsLineId { get; set; } = "";
[Required] public string UnsAreaId { get; set; } = "";
[Required] public string Name { get; set; } = "";
public string? Notes { get; set; }
}
}
@@ -0,0 +1,212 @@
@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page
owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus
the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so
the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
refresh the equipment's children in place. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="tagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New tag" : "Edit tag")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-id">TagId</label>
<InputText id="tag-id" @bind-Value="_form.TagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="tag-line3-temp-01" />
<ValidationMessage For="@(() => _form.TagId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-name">Name</label>
<InputText id="tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature setpoint" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-driver">Driver instance</label>
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var (id, display) in Drivers)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-dtype">Data type</label>
<InputSelect id="tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-access">Access level</label>
<InputSelect id="tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes (decision #4445)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="tag-pgroup">PollGroupId (optional)</label>
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label" for="tag-config">Tag config (JSON)</label>
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
class="form-control form-control-sm mono"
placeholder='{ "register": 40001, "scale": 0.1 }' />
<div class="form-text">Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.</div>
<ValidationMessage For="@(() => _form.TagConfig)" />
</div>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created tag binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel();
}
else if (Existing is not null)
{
_form = new FormModel
{
TagId = Existing.TagId,
Name = Existing.Name,
DriverInstanceId = Existing.DriverInstanceId,
DataType = Existing.DataType,
AccessLevel = Existing.AccessLevel,
WriteIdempotent = Existing.WriteIdempotent,
PollGroupId = Existing.PollGroupId,
TagConfig = Existing.TagConfig,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var input = new TagInput(
_form.TagId,
_form.Name,
_form.DriverInstanceId,
_form.DataType,
_form.AccessLevel,
_form.WriteIdempotent,
_form.PollGroupId,
_form.TagConfig);
var result = IsNew
? await Svc.CreateTagAsync(EquipmentId!, input)
: await Svc.UpdateTagAsync(Existing!.TagId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
}
}
@@ -0,0 +1,142 @@
@* Recursive, read-only UNS browse-tree renderer. Modelled on DriverBrowseTree:
per-node indent, expand chevron and Bootstrap/theme styling. This component
owns no state and calls no service — it reads node.Expanded/Loading/Error/
Children and raises callbacks; the host page owns expansion + lazy loading. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@foreach (var root in Roots)
{
@RenderNode(root, 0)
}
@code {
/// <summary>The top-level UNS nodes to render (typically the enterprise roots).</summary>
[Parameter, EditorRequired] public IReadOnlyList<UnsNode> Roots { get; set; } = default!;
/// <summary>Raised for the primary "add child" action of a node (e.g. "+ Area" on a cluster, "+ Tag" on equipment).</summary>
[Parameter] public EventCallback<UnsNode> OnAddChild { get; set; }
/// <summary>Raised for the equipment-only "+ Virtual tag" action, kept distinct from OnAddChild ("+ Tag").</summary>
[Parameter] public EventCallback<UnsNode> OnAddVirtualTag { get; set; }
/// <summary>Raised when the user edits a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
/// <summary>Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
[Parameter] public EventCallback<UnsNode> OnDelete { get; set; }
/// <summary>Raised when the user toggles a node's expansion; the host owns the resulting state + lazy load.</summary>
[Parameter] public EventCallback<UnsNode> OnToggleExpand { get; set; }
/// <summary>Optional case-insensitive substring filter applied to visible children by DisplayName.</summary>
[Parameter] public string? Filter { get; set; }
private RenderFragment RenderNode(UnsNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.Children.Count > 0 || node.HasLazyChildren;
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
{
<button type="button" class="btn btn-sm btn-link p-0"
@onclick="@(() => OnToggleExpand.InvokeAsync(node))" style="width:18px">
@(node.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
<span class="mono small">@node.DisplayName</span>
@if (node.ChildCount > 0)
{
<span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
}
@RenderActions(node)
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@node.Error
</div>
}
else if (node.Expanded)
{
@foreach (var child in FilterChildren(node))
{
@RenderNode(child, depth + 1)
}
}
};
private RenderFragment RenderActions(UnsNode node) => __builder =>
{
switch (node.Kind)
{
case UnsNodeKind.Enterprise:
// No actions on the enterprise root.
break;
case UnsNodeKind.Cluster:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Area</button>
<a class="btn btn-sm btn-link p-0 ms-2" href="@($"/clusters/{node.ClusterId}")">⚙ settings</a>
break;
case UnsNodeKind.Area:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Line</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
break;
case UnsNodeKind.Line:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Equipment</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
break;
case UnsNodeKind.Equipment:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Tag</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnAddVirtualTag.InvokeAsync(node))">+ Virtual tag</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
break;
case UnsNodeKind.Tag:
case UnsNodeKind.VirtualTag:
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
break;
}
};
private IEnumerable<UnsNode> FilterChildren(UnsNode node)
{
var f = Filter?.Trim();
if (string.IsNullOrEmpty(f))
{
return node.Children;
}
return node.Children.Where(c =>
c.DisplayName.Contains(f, StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,201 @@
@* Create/edit modal for an equipment-bound virtual tag, wired straight into IUnsTreeService. The host
page owns visibility and supplies the owning equipment id (create) or the loaded VirtualTagEditDto
(edit), plus the script list. Virtual tags are always scoped to an equipment (plan decision #2) and
that binding never moves, so this modal deliberately offers NO equipment selector — the owning
equipment is fixed. On a successful save it raises OnSaved so the host can refresh the equipment's
children in place. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="virtualTagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New virtual tag" : "Edit virtual tag")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-id">VirtualTagId</label>
<InputText id="vtag-id" @bind-Value="_form.VirtualTagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
<ValidationMessage For="@(() => _form.VirtualTagId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-name">Name</label>
<InputText id="vtag-name" @bind-Value="_form.Name" class="form-control form-control-sm" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-dtype">DataType</label>
<InputText id="vtag-dtype" @bind-Value="_form.DataType" class="form-control form-control-sm mono"
placeholder="Double" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="vtag-script">Script</label>
<InputSelect id="vtag-script" @bind-Value="_form.ScriptId" class="form-select form-select-sm">
<option value="">— pick script —</option>
@foreach (var (id, display) in Scripts)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.ScriptId)" />
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Change-triggered</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.ChangeTriggered" class="form-check-input" />
<label class="form-check-label">Re-evaluate on dependency change</label>
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="vtag-timer">TimerIntervalMs (optional)</label>
<InputNumber id="vtag-timer" @bind-Value="_form.TimerIntervalMs" class="form-control form-control-sm" />
<div class="form-text">Periodic re-evaluation. Null = change-trigger only.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Historize</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Historize" class="form-check-input" />
<label class="form-check-label">Send to Wonderware historian</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Spawn this virtual tag in deployments</label>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new virtual tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created virtual tag binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The virtual tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public VirtualTagEditDto? Existing { get; set; }
/// <summary>The selectable scripts as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Scripts { get; set; } = Array.Empty<(string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel();
}
else if (Existing is not null)
{
_form = new FormModel
{
VirtualTagId = Existing.VirtualTagId,
Name = Existing.Name,
DataType = Existing.DataType,
ScriptId = Existing.ScriptId,
ChangeTriggered = Existing.ChangeTriggered,
TimerIntervalMs = Existing.TimerIntervalMs,
Historize = Existing.Historize,
Enabled = Existing.Enabled,
};
}
_error = null;
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
var input = new VirtualTagInput(
_form.VirtualTagId,
_form.Name,
_form.DataType,
_form.ScriptId,
_form.ChangeTriggered,
_form.TimerIntervalMs,
_form.Historize,
_form.Enabled);
var result = IsNew
? await Svc.CreateVirtualTagAsync(EquipmentId!, input)
: await Svc.UpdateVirtualTagAsync(Existing!.VirtualTagId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string VirtualTagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
public string DataType { get; set; } = "Double";
[Required] public string ScriptId { get; set; } = "";
public bool ChangeTriggered { get; set; } = true;
public int? TimerIntervalMs { get; set; }
public bool Historize { get; set; }
public bool Enabled { get; set; } = true;
}
}
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
@@ -42,6 +43,7 @@ public static class EndpointRouteBuilderExtensions
services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for an equipment create or update,
/// so <see cref="IUnsTreeService.CreateEquipmentAsync"/> and
/// <see cref="IUnsTreeService.UpdateEquipmentAsync"/> avoid an unwieldy positional signature.
/// The <c>EquipmentId</c> and <c>EquipmentUuid</c> are system-generated (decision #125) and are
/// therefore not part of this input. Optional string fields that arrive whitespace-only are
/// collapsed to <c>null</c> by the service.
/// </summary>
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
/// <param name="Model">Optional OPC 40010 model designation.</param>
/// <param name="SerialNumber">Optional OPC 40010 serial number.</param>
/// <param name="HardwareRevision">Optional OPC 40010 hardware revision.</param>
/// <param name="SoftwareRevision">Optional OPC 40010 software revision.</param>
/// <param name="YearOfConstruction">Optional OPC 40010 year of construction.</param>
/// <param name="AssetLocation">Optional OPC 40010 asset location.</param>
/// <param name="ManufacturerUri">Optional OPC 40010 manufacturer URI.</param>
/// <param name="DeviceManualUri">Optional OPC 40010 device-manual URI.</param>
/// <param name="Enabled">Whether the equipment is surfaced in deployments.</param>
public sealed record EquipmentInput(
string Name,
string MachineCode,
string UnsLineId,
string? DriverInstanceId,
string? ZTag,
string? SAPID,
string? Manufacturer,
string? Model,
string? SerialNumber,
string? HardwareRevision,
string? SoftwareRevision,
short? YearOfConstruction,
string? AssetLocation,
string? ManufacturerUri,
string? DeviceManualUri,
bool Enabled);
@@ -0,0 +1,403 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// A UNS area projected for editing: its operator-editable fields plus the owning cluster and
/// the concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="UnsAreaId">The area's stable id (read-only on edit).</param>
/// <param name="Name">The area name.</param>
/// <param name="Notes">Optional notes; <c>null</c> when unset.</param>
/// <param name="ClusterId">The owning cluster id (the served-by selection).</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record AreaEditDto(string UnsAreaId, string Name, string? Notes, string ClusterId, byte[] RowVersion);
/// <summary>
/// A UNS line projected for editing: its operator-editable fields plus the parent area and the
/// concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="UnsLineId">The line's stable id (read-only on edit).</param>
/// <param name="UnsAreaId">The owning area id (the parent-area selection).</param>
/// <param name="Name">The line name.</param>
/// <param name="Notes">Optional notes; <c>null</c> when unset.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record LineEditDto(string UnsLineId, string UnsAreaId, string Name, string? Notes, byte[] RowVersion);
/// <summary>
/// An equipment projected for editing: its system-generated id, the operator-editable identity and
/// OPC 40010 identification fields, plus the concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="EquipmentId">The system-generated stable id (read-only — never operator-edited, decision #125).</param>
/// <param name="Name">UNS level-5 segment name.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">The owning line id (the UNS-line selection).</param>
/// <param name="DriverInstanceId">Optional driver binding; <c>null</c> when driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
/// <param name="Model">Optional OPC 40010 model designation.</param>
/// <param name="SerialNumber">Optional OPC 40010 serial number.</param>
/// <param name="HardwareRevision">Optional OPC 40010 hardware revision.</param>
/// <param name="SoftwareRevision">Optional OPC 40010 software revision.</param>
/// <param name="YearOfConstruction">Optional OPC 40010 year of construction.</param>
/// <param name="AssetLocation">Optional OPC 40010 asset location.</param>
/// <param name="ManufacturerUri">Optional OPC 40010 manufacturer URI.</param>
/// <param name="DeviceManualUri">Optional OPC 40010 device-manual URI.</param>
/// <param name="Enabled">Whether the equipment is surfaced in deployments.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record EquipmentEditDto(string EquipmentId, string Name, string MachineCode, string UnsLineId,
string? DriverInstanceId, string? ZTag, string? SAPID, string? Manufacturer, string? Model, string? SerialNumber,
string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation,
string? ManufacturerUri, string? DeviceManualUri, bool Enabled, byte[] RowVersion);
/// <summary>
/// An equipment-bound tag projected for editing: its operator-editable fields, the owning equipment
/// (so the host can scope the candidate-driver list and refresh the right node), plus the concurrency
/// token the edit modal must echo back on save. Tree tags are always equipment-bound (decision #110),
/// so <c>FolderPath</c> never surfaces here.
/// </summary>
/// <param name="TagId">The tag's stable id (read-only on edit).</param>
/// <param name="EquipmentId">The owning equipment id.</param>
/// <param name="Name">The tag name.</param>
/// <param name="DriverInstanceId">The bound driver id.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="AccessLevel">The tag-level access baseline.</param>
/// <param name="WriteIdempotent">Whether writes are safe to retry.</param>
/// <param name="PollGroupId">Optional poll-group key; <c>null</c> when unset.</param>
/// <param name="TagConfig">The schemaless per-driver-type JSON config.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record TagEditDto(string TagId, string EquipmentId, string Name, string DriverInstanceId, string DataType,
TagAccessLevel AccessLevel, bool WriteIdempotent, string? PollGroupId, string TagConfig, byte[] RowVersion);
/// <summary>
/// An equipment-bound virtual tag projected for editing: its operator-editable fields, the owning
/// equipment (so the host can refresh the right node), plus the concurrency token the edit modal must
/// echo back on save. Virtual tags are always scoped to an equipment (plan decision #2), so the modal
/// never offers an equipment-change control.
/// </summary>
/// <param name="VirtualTagId">The virtual tag's stable id (read-only on edit).</param>
/// <param name="EquipmentId">The owning equipment id.</param>
/// <param name="Name">The virtual-tag name.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="ScriptId">The bound script id.</param>
/// <param name="ChangeTriggered">Whether the tag re-evaluates on dependency change.</param>
/// <param name="TimerIntervalMs">Optional periodic re-evaluation cadence in ms; <c>null</c> when unset.</param>
/// <param name="Historize">Whether the tag's values are historized.</param>
/// <param name="Enabled">Whether the tag is spawned in deployments.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId, string Name, string DataType, string ScriptId,
bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled, byte[] RowVersion);
/// <summary>
/// The outcome of a bulk equipment CSV import: how many rows were inserted, how many were skipped
/// (existing MachineCode — the importer is additive-only, never an update), and a per-row error list
/// for rows that could not be inserted (unknown line, unknown driver, or a decision-#122 cluster
/// mismatch). Skipped rows never appear in <see cref="Errors"/>.
/// </summary>
/// <param name="Inserted">The count of new Equipment rows added.</param>
/// <param name="Skipped">The count of rows skipped because their MachineCode already exists.</param>
/// <param name="Errors">The human-readable error strings for rows that failed validation.</param>
public sealed record EquipmentImportResult(int Inserted, int Skipped, IReadOnlyList<string> Errors);
/// <summary>
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
/// Enterprise → Cluster → Area → Line → Equipment — from the config database.
/// Equipment children (tags/virtual tags) are summarised by count only and loaded
/// lazily by the renderer via <see cref="LoadEquipmentChildrenAsync"/>.
/// </summary>
public interface IUnsTreeService
{
/// <summary>
/// Loads the full structural tree. Empty clusters are retained so they remain
/// visible and editable. The returned nodes are detached view-models, safe to
/// hold and mutate UI state on after the underlying context is disposed.
/// </summary>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The enterprise root nodes, each populated down to equipment.</returns>
Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default);
/// <summary>
/// Lazily loads the Tag and VirtualTag leaf nodes for a single equipment node.
/// Tags are returned first (ordered by Name), followed by VirtualTags (ordered by Name).
/// Leaf nodes carry <c>ChildCount = 0</c> and <c>HasLazyChildren = false</c>.
/// </summary>
/// <param name="equipmentId">The equipment whose children to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>Tag nodes followed by VirtualTag nodes; empty if the equipment has none.</returns>
Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads a single UNS area projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="unsAreaId">The area to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The area's edit projection, or <c>null</c> when missing.</returns>
Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default);
/// <summary>
/// Loads a single UNS line projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="unsLineId">The line to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The line's edit projection, or <c>null</c> when missing.</returns>
Task<LineEditDto?> LoadLineAsync(string unsLineId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="equipmentId">The equipment to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's edit projection, or <c>null</c> when missing.</returns>
Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment-bound tag projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="tagId">The tag to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The tag's edit projection, or <c>null</c> when missing.</returns>
Task<TagEditDto?> LoadTagAsync(string tagId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment-bound virtual tag projected for editing, or <c>null</c> if it no longer
/// exists. Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="virtualTagId">The virtual tag to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The virtual tag's edit projection, or <c>null</c> when missing.</returns>
Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default);
/// <summary>
/// Loads every driver instance in a cluster (regardless of namespace kind) so the equipment modal
/// can offer the full cluster driver list for binding. Ordered by <c>DriverInstanceId</c>. Each is
/// projected to a <c>(DriverInstanceId, Display)</c> pair where <c>Display</c> is
/// <c>"{DriverInstanceId} — {Name} ({DriverType})"</c>.
/// </summary>
/// <param name="clusterId">The cluster whose drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster's drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(string clusterId, CancellationToken ct = default);
/// <summary>
/// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
/// Whitespace-only notes are stored as <c>null</c>.
/// </summary>
/// <param name="clusterId">The owning cluster.</param>
/// <param name="unsAreaId">The unique area id to create.</param>
/// <param name="name">The area name.</param>
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default);
/// <summary>
/// Updates a UNS area's name, notes, and owning cluster. When the cluster changes, the
/// decision-#122 reassignment guard blocks the move if any driver-bound equipment under the
/// area is bound to a driver in a different cluster than the target. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsArea.RowVersion"/>.
/// </summary>
/// <param name="unsAreaId">The area to update.</param>
/// <param name="name">The new name.</param>
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
/// <param name="newClusterId">The target cluster (may equal the current one).</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a UNS area. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because lines still reference the area surfaces a
/// guidance message.
/// </summary>
/// <param name="unsAreaId">The area to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new UNS line under an area. Fails if a line with the same id already exists.
/// Whitespace-only notes are stored as <c>null</c>.
/// </summary>
/// <param name="unsAreaId">The owning area.</param>
/// <param name="unsLineId">The unique line id to create.</param>
/// <param name="name">The line name.</param>
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default);
/// <summary>
/// Updates a UNS line's owning area, name, and notes. Uses last-write-wins optimistic
/// concurrency on <see cref="Configuration.Entities.UnsLine.RowVersion"/>.
/// </summary>
/// <param name="unsLineId">The line to update.</param>
/// <param name="name">The new name.</param>
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
/// <param name="newUnsAreaId">The target parent area.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a UNS line. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because equipment still references the line
/// surfaces a guidance message.
/// </summary>
/// <param name="unsLineId">The line to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
/// (decision #125: <c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
/// decision-#122 driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
/// collapse to <c>null</c>.
/// </summary>
/// <param name="input">The operator-editable equipment fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
/// a driver-bound row whose driver is in a different cluster than its line fails the decision-#122
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
/// the end.
/// </summary>
/// <param name="rows">The parsed equipment rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The insert/skip counts and the per-row error list.</returns>
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
/// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
/// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
/// </summary>
/// <param name="equipmentId">The equipment to update.</param>
/// <param name="input">The new operator-editable equipment fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes an equipment. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because tags or virtual tags still reference the
/// equipment surfaces a guidance message.
/// </summary>
/// <param name="equipmentId">The equipment to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads the drivers eligible to back a tag on the given equipment: drivers in the equipment's
/// cluster (<c>Equipment.UnsLine → UnsArea.ClusterId</c>) whose namespace is Equipment-kind
/// (decision #110 — tree tags are equipment-bound). Ordered by <c>DriverInstanceId</c>. Returns
/// an empty list when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
/// the tree only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
/// <c>TagConfig</c> JSON, an unknown driver, a driver whose namespace is not Equipment-kind, a
/// driver in a different cluster than the equipment (decision #122), or a name already used on
/// the equipment. Whitespace-only <c>PollGroupId</c> collapses to <c>null</c>.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable tag fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures.</returns>
Task<UnsMutationResult> CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default);
/// <summary>
/// Updates an equipment-bound tag's driver binding, name, data type, access level, write-retry
/// flag, poll group, and config. The owning <c>EquipmentId</c> and the <c>null</c>
/// <c>FolderPath</c> are preserved. Re-runs the JSON-validity, namespace-kind, and decision-#122
/// cluster guards against the tag's existing equipment, and enforces name uniqueness on that
/// equipment excluding this tag. Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The tag to update.</param>
/// <param name="input">The new operator-editable tag fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The tag to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads the scripts eligible to back a virtual tag, ordered by name. Each is projected to a
/// <c>(ScriptId, Display)</c> pair where <c>Display</c> is <c>"{Name} ({Language})"</c>.
/// </summary>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The scripts projected to <c>(ScriptId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound virtual tag (plan decision #2 — virtual tags are always scoped
/// to an equipment). Fails if the equipment does not exist, if no script is chosen, if neither a
/// change trigger nor a timer is set, if the timer is below the 50 ms minimum, on a duplicate
/// <c>VirtualTagId</c>, or on a name already used on the equipment.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable virtual-tag fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures.</returns>
Task<UnsMutationResult> CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default);
/// <summary>
/// Updates an equipment-bound virtual tag's name, data type, script binding, triggers, historize,
/// and enabled flags. The owning <c>EquipmentId</c> is preserved. Re-runs the script-chosen,
/// change-or-timer, and 50 ms timer-minimum guards, and enforces name uniqueness on the tag's
/// existing equipment excluding this virtual tag. Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
/// </summary>
/// <param name="virtualTagId">The virtual tag to update.</param>
/// <param name="input">The new operator-editable virtual-tag fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a virtual tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
/// </summary>
/// <param name="virtualTagId">The virtual tag to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
}
@@ -0,0 +1,28 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for an equipment-bound Tag create or
/// update via the UNS tree. Tree tags always bind to an equipment (<c>FolderPath</c> stays
/// <c>null</c>), so the SystemPlatform/FolderPath branch from the legacy cluster-scoped page does
/// not apply here. Optional string fields that arrive whitespace-only are collapsed to <c>null</c>
/// by the service.
/// </summary>
/// <param name="TagId">Stable unique tag id; only honoured on create (immutable thereafter).</param>
/// <param name="Name">Tag display name; unique within the owning equipment.</param>
/// <param name="DriverInstanceId">The bound driver; must resolve to an Equipment-kind namespace in the equipment's cluster.</param>
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
/// <param name="AccessLevel">Tag-level OPC UA access baseline.</param>
/// <param name="WriteIdempotent">Whether writes are safe to retry (decisions #4445).</param>
/// <param name="PollGroupId">Optional poll-group batching key; whitespace/empty collapses to <c>null</c>.</param>
/// <param name="TagConfig">Schemaless per-driver-type JSON config; validated for JSON well-formedness.</param>
public sealed record TagInput(
string TagId,
string Name,
string DriverInstanceId,
string DataType,
TagAccessLevel AccessLevel,
bool WriteIdempotent,
string? PollGroupId,
string TagConfig);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Outcome of a UNS structural mutation (create/update/delete of an area or line).
/// On success <see cref="Ok"/> is <c>true</c> and <see cref="Error"/> is <c>null</c>;
/// on a guarded or concurrency failure <see cref="Ok"/> is <c>false</c> and
/// <see cref="Error"/> carries the operator-facing message the caller should surface.
/// </summary>
/// <param name="Ok">Whether the mutation was applied.</param>
/// <param name="Error">The operator-facing failure message, or <c>null</c> on success.</param>
public readonly record struct UnsMutationResult(bool Ok, string? Error);
@@ -0,0 +1,247 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// The kind of node within the unified-namespace (UNS) browse tree. Determines
/// how the renderer styles the row and which entity (if any) it links to.
/// </summary>
public enum UnsNodeKind
{
Enterprise,
Cluster,
Area,
Line,
Equipment,
Tag,
VirtualTag,
}
/// <summary>
/// View-model for a single node in the UNS browse tree. Carries the stable
/// identity, display text and lazy-load metadata the renderer needs, plus
/// transient UI state (expansion/loading) that is never persisted.
/// </summary>
public sealed class UnsNode
{
/// <summary>The kind of node — drives styling and entity linking.</summary>
public required UnsNodeKind Kind { get; init; }
/// <summary>Stable per-node identifier, unique within the tree (e.g. <c>eq:{equipmentId}</c>).</summary>
public required string Key { get; init; }
/// <summary>Human-readable label shown in the tree.</summary>
public required string DisplayName { get; init; }
/// <summary>Owning cluster id for Area/Line/Equipment (and the cluster itself); null for Enterprise.</summary>
public string? ClusterId { get; init; }
/// <summary>This node's own logical entity id (UnsAreaId/UnsLineId/EquipmentId/TagId/VirtualTagId); null for Enterprise.</summary>
public string? EntityId { get; init; }
/// <summary>Badge count. For equipment this is tag + virtual-tag count; for container nodes it is the direct child count.</summary>
public int ChildCount { get; set; }
/// <summary>
/// Gets a value indicating whether this node has children that load lazily (equipment with tags/virtual tags).
/// Structural nodes (Cluster/Area/Line) always carry <c>false</c> even when they have eager children, so a
/// renderer must decide whether to show an expand chevron with <c>Children.Count &gt; 0 || HasLazyChildren</c>,
/// not on <c>HasLazyChildren</c> alone.
/// </summary>
public bool HasLazyChildren { get; init; }
/// <summary>Eagerly-materialised children. Empty for lazy-loaded equipment until expanded.</summary>
public List<UnsNode> Children { get; } = new();
// --- Runtime UI state (not persisted) ---
/// <summary>Whether the node is currently expanded in the UI.</summary>
public bool Expanded { get; set; }
/// <summary>Whether the node's lazy children have been loaded.</summary>
public bool Loaded { get; set; }
/// <summary>Whether a lazy-load is currently in flight for this node.</summary>
public bool Loading { get; set; }
/// <summary>Last load error message, if any.</summary>
public string? Error { get; set; }
}
/// <summary>Flat structural row describing a cluster and its enterprise/site placement.</summary>
public readonly record struct ClusterRow(string ClusterId, string Enterprise, string Site, string Name);
/// <summary>Flat structural row describing a UNS area and its owning cluster.</summary>
public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string Name);
/// <summary>Flat structural row describing a UNS line and its owning area.</summary>
public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name);
/// <summary>Flat structural row describing an equipment node, its owning line and its tag/virtual-tag counts.</summary>
public readonly record struct EquipmentRow(
string EquipmentId,
string UnsLineId,
string MachineCode,
string Name,
int TagCount,
int VirtualTagCount);
/// <summary>
/// Pure, EF-free assembly of the UNS browse tree from flat structural rows.
/// Builds Enterprise → Cluster → Area → Line → Equipment with deterministic
/// ordinal ordering and equipment lazy-load metadata.
/// </summary>
public static class UnsTreeAssembly
{
/// <summary>
/// Builds the Enterprise→Cluster→Area→Line→Equipment tree. Empty clusters
/// (and enterprises whose clusters have no areas) are retained. Ordering is
/// deterministic and ordinal at every level.
/// </summary>
public static IReadOnlyList<UnsNode> Build(
IReadOnlyList<ClusterRow> clusters,
IReadOnlyList<AreaRow> areas,
IReadOnlyList<LineRow> lines,
IReadOnlyList<EquipmentRow> equipment)
{
// Index children by their parent key for O(1) lookup during nesting.
var areasByCluster = areas
.GroupBy(a => a.ClusterId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var linesByArea = lines
.GroupBy(l => l.UnsAreaId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var equipmentByLine = equipment
.GroupBy(e => e.UnsLineId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
// Top level: distinct enterprises, ordered ordinally.
var enterprises = clusters
.GroupBy(c => c.Enterprise, StringComparer.Ordinal)
.OrderBy(g => g.Key, StringComparer.Ordinal)
.Select(entGroup =>
{
var clusterNodes = entGroup
.OrderBy(c => c.Name, StringComparer.Ordinal)
.ThenBy(c => c.ClusterId, StringComparer.Ordinal)
.Select(c => BuildCluster(c, areasByCluster, linesByArea, equipmentByLine))
.ToList();
var ent = new UnsNode
{
Kind = UnsNodeKind.Enterprise,
Key = $"ent:{entGroup.Key}",
DisplayName = entGroup.Key,
ClusterId = null,
EntityId = null,
HasLazyChildren = false,
};
ent.Children.AddRange(clusterNodes);
ent.ChildCount = ent.Children.Count;
return ent;
})
.ToList();
return enterprises;
}
private static UnsNode BuildCluster(
ClusterRow cluster,
IReadOnlyDictionary<string, List<AreaRow>> areasByCluster,
IReadOnlyDictionary<string, List<LineRow>> linesByArea,
IReadOnlyDictionary<string, List<EquipmentRow>> equipmentByLine)
{
var areaNodes = (areasByCluster.TryGetValue(cluster.ClusterId, out var clusterAreas)
? clusterAreas
: Enumerable.Empty<AreaRow>())
.OrderBy(a => a.Name, StringComparer.Ordinal)
.ThenBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => BuildArea(a, cluster.ClusterId, linesByArea, equipmentByLine))
.ToList();
var node = new UnsNode
{
Kind = UnsNodeKind.Cluster,
Key = $"clu:{cluster.ClusterId}",
DisplayName = string.IsNullOrEmpty(cluster.Site)
? cluster.Name
: $"{cluster.Site} ({cluster.Name})",
ClusterId = cluster.ClusterId,
EntityId = cluster.ClusterId, // Cluster's own logical id IS its ClusterId — EntityId mirrors it for uniform navigation.
HasLazyChildren = false,
};
node.Children.AddRange(areaNodes);
node.ChildCount = node.Children.Count;
return node;
}
private static UnsNode BuildArea(
AreaRow area,
string clusterId,
IReadOnlyDictionary<string, List<LineRow>> linesByArea,
IReadOnlyDictionary<string, List<EquipmentRow>> equipmentByLine)
{
var lineNodes = (linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)
? areaLines
: Enumerable.Empty<LineRow>())
.OrderBy(l => l.Name, StringComparer.Ordinal)
.ThenBy(l => l.UnsLineId, StringComparer.Ordinal)
.Select(l => BuildLine(l, clusterId, equipmentByLine))
.ToList();
var node = new UnsNode
{
Kind = UnsNodeKind.Area,
Key = $"area:{area.UnsAreaId}",
DisplayName = area.Name,
ClusterId = clusterId,
EntityId = area.UnsAreaId,
HasLazyChildren = false,
};
node.Children.AddRange(lineNodes);
node.ChildCount = node.Children.Count;
return node;
}
private static UnsNode BuildLine(
LineRow line,
string clusterId,
IReadOnlyDictionary<string, List<EquipmentRow>> equipmentByLine)
{
var equipmentNodes = (equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)
? lineEquipment
: Enumerable.Empty<EquipmentRow>())
.OrderBy(e => e.Name, StringComparer.Ordinal)
.ThenBy(e => e.EquipmentId, StringComparer.Ordinal)
.Select(e => BuildEquipment(e, clusterId))
.ToList();
var node = new UnsNode
{
Kind = UnsNodeKind.Line,
Key = $"line:{line.UnsLineId}",
DisplayName = line.Name,
ClusterId = clusterId,
EntityId = line.UnsLineId,
HasLazyChildren = false,
};
node.Children.AddRange(equipmentNodes);
node.ChildCount = node.Children.Count;
return node;
}
private static UnsNode BuildEquipment(EquipmentRow equipment, string clusterId)
{
var childCount = equipment.TagCount + equipment.VirtualTagCount;
return new UnsNode
{
Kind = UnsNodeKind.Equipment,
Key = $"eq:{equipment.EquipmentId}",
DisplayName = equipment.Name,
ClusterId = clusterId,
EntityId = equipment.EquipmentId,
ChildCount = childCount,
HasLazyChildren = childCount > 0,
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for an equipment-bound VirtualTag create
/// or update via the UNS tree. Virtual tags are always scoped to an equipment (plan decision #2), so
/// the owning <c>EquipmentId</c> is supplied separately and never changes on update. A virtual tag
/// must have at least one evaluation trigger: <see cref="ChangeTriggered"/> or a non-null
/// <see cref="TimerIntervalMs"/> (which, when set, must be at least 50 ms).
/// </summary>
/// <param name="VirtualTagId">Stable unique virtual-tag id; only honoured on create (immutable thereafter).</param>
/// <param name="Name">Virtual-tag display name; unique within the owning equipment.</param>
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
/// <param name="ScriptId">The script that computes this tag's value; must be chosen.</param>
/// <param name="ChangeTriggered">Re-evaluate when any referenced input tag changes.</param>
/// <param name="TimerIntervalMs">Optional periodic re-evaluation cadence in ms; <c>null</c> = no timer, otherwise &gt;= 50.</param>
/// <param name="Historize">Whether this tag's values should be historized.</param>
/// <param name="Enabled">Whether this virtual tag is spawned in deployments.</param>
public sealed record VirtualTagInput(
string VirtualTagId,
string Name,
string DataType,
string ScriptId,
bool ChangeTriggered,
int? TimerIntervalMs,
bool Historize,
bool Enabled);
@@ -0,0 +1,174 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class UnsTreeAssemblyTests
{
[Fact]
public void Build_groups_clusters_under_enterprise()
{
var clusters = new[]
{
new ClusterRow("c1", "zb", "SiteA", "Alpha"),
new ClusterRow("c2", "zb", "SiteB", "Bravo"),
};
var tree = UnsTreeAssembly.Build(
clusters,
areas: System.Array.Empty<AreaRow>(),
lines: System.Array.Empty<LineRow>(),
equipment: System.Array.Empty<EquipmentRow>());
tree.Count.ShouldBe(1);
var ent = tree[0];
ent.Kind.ShouldBe(UnsNodeKind.Enterprise);
ent.Key.ShouldBe("ent:zb");
ent.DisplayName.ShouldBe("zb");
ent.Children.Count.ShouldBe(2);
ent.Children.All(c => c.Kind == UnsNodeKind.Cluster).ShouldBeTrue();
ent.Children.Select(c => c.Key).ShouldBe(new[] { "clu:c1", "clu:c2" });
}
[Fact]
public void Build_nests_area_line_equipment_under_owning_cluster()
{
var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
var lines = new[] { new LineRow("l1", "a1", "LineOne") };
var equipment = new[] { new EquipmentRow("e1", "l1", "MC-1", "EquipOne", 0, 0) };
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
var cluster = tree.Single().Children.Single();
cluster.Key.ShouldBe("clu:c1");
cluster.EntityId.ShouldBe("c1");
var area = cluster.Children.Single();
area.Kind.ShouldBe(UnsNodeKind.Area);
area.Key.ShouldBe("area:a1");
area.DisplayName.ShouldBe("AreaOne");
area.ClusterId.ShouldBe("c1");
area.EntityId.ShouldBe("a1");
var line = area.Children.Single();
line.Kind.ShouldBe(UnsNodeKind.Line);
line.Key.ShouldBe("line:l1");
line.DisplayName.ShouldBe("LineOne");
line.ClusterId.ShouldBe("c1");
line.EntityId.ShouldBe("l1");
var eq = line.Children.Single();
eq.Kind.ShouldBe(UnsNodeKind.Equipment);
eq.Key.ShouldBe("eq:e1");
eq.DisplayName.ShouldBe("EquipOne");
eq.ClusterId.ShouldBe("c1");
eq.EntityId.ShouldBe("e1");
}
[Fact]
public void Build_sets_equipment_child_count_and_lazy_flag()
{
var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
var lines = new[] { new LineRow("l1", "a1", "LineOne") };
var equipment = new[]
{
new EquipmentRow("e1", "l1", "MC-1", "WithChildren", 2, 1),
new EquipmentRow("e2", "l1", "MC-2", "Empty", 0, 0),
};
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
var line = tree.Single().Children.Single().Children.Single().Children.Single();
var withChildren = line.Children.Single(e => e.Key == "eq:e1");
withChildren.ChildCount.ShouldBe(3);
withChildren.HasLazyChildren.ShouldBeTrue();
var empty = line.Children.Single(e => e.Key == "eq:e2");
empty.ChildCount.ShouldBe(0);
empty.HasLazyChildren.ShouldBeFalse();
}
[Fact]
public void Build_includes_clusters_with_no_areas()
{
var clusters = new[]
{
new ClusterRow("c1", "zb", "SiteA", "Alpha"),
new ClusterRow("c2", "zb", "SiteB", "Bravo"),
};
var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
var tree = UnsTreeAssembly.Build(
clusters,
areas,
lines: System.Array.Empty<LineRow>(),
equipment: System.Array.Empty<EquipmentRow>());
var ent = tree.Single();
ent.Children.Count.ShouldBe(2);
var emptyCluster = ent.Children.Single(c => c.Key == "clu:c2");
emptyCluster.Children.ShouldBeEmpty();
var populatedCluster = ent.Children.Single(c => c.Key == "clu:c1");
populatedCluster.Children.Single().Key.ShouldBe("area:a1");
}
[Fact]
public void Build_orders_deterministically()
{
// Two enterprises out of order; clusters/areas/lines/equipment scrambled.
// Two clusters with identical Name but different ClusterId to verify tie-break ordering.
var clusters = new[]
{
new ClusterRow("cZ", "zeta", "SiteZ", "Zeta"),
new ClusterRow("c9", "zeta", "SiteZ", "Zeta"),
new ClusterRow("c8", "zeta", "SiteZ", "Zeta"),
new ClusterRow("c2", "alpha", "Site2", "Bravo"),
new ClusterRow("c1", "alpha", "Site1", "Alpha"),
};
var areas = new[]
{
new AreaRow("a2", "c1", "Beta"),
new AreaRow("a1", "c1", "Alpha"),
};
var lines = new[]
{
new LineRow("l2", "a1", "LineB"),
new LineRow("l1", "a1", "LineA"),
};
var equipment = new[]
{
new EquipmentRow("e2", "l1", "MC-2", "EquipB", 0, 0),
new EquipmentRow("e1", "l1", "MC-1", "EquipA", 0, 0),
};
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
// Enterprises ordered by Enterprise (ordinal): "alpha" < "zeta".
tree.Select(e => e.Key).ShouldBe(new[] { "ent:alpha", "ent:zeta" });
// Clusters under "alpha" ordered by Name then ClusterId: "Alpha"(c1) < "Bravo"(c2).
var alphaEnt = tree.Single(e => e.Key == "ent:alpha");
alphaEnt.Children.Select(c => c.Key).ShouldBe(new[] { "clu:c1", "clu:c2" });
// Areas ordered by Name then UnsAreaId: "Alpha"(a1) < "Beta"(a2).
var c1 = alphaEnt.Children.Single(c => c.Key == "clu:c1");
c1.Children.Select(a => a.Key).ShouldBe(new[] { "area:a1", "area:a2" });
// Lines under a1 ordered by Name then UnsLineId: "LineA"(l1) < "LineB"(l2).
var a1 = c1.Children.Single(a => a.Key == "area:a1");
a1.Children.Select(l => l.Key).ShouldBe(new[] { "line:l1", "line:l2" });
// Equipment under l1 ordered by Name then EquipmentId: "EquipA"(e1) < "EquipB"(e2).
var l1 = a1.Children.Single(l => l.Key == "line:l1");
l1.Children.Select(eq => eq.Key).ShouldBe(new[] { "eq:e1", "eq:e2" });
// Clusters with the same Name tie-break by ClusterId ordinal: "c8" < "c9" < "cZ".
var zetaEnt = tree.Single(e => e.Key == "ent:zeta");
zetaEnt.Children.Select(c => c.Key).ShouldBe(new[] { "clu:c8", "clu:c9", "clu:cZ" });
}
}
@@ -0,0 +1,462 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the Area/Line CRUD mutations on <see cref="UnsTreeService"/>, including the
/// decision-#122 area-reassignment guard that blocks moving an area to a different cluster
/// when its driver-bound equipment would be orphaned from its driver's cluster.
/// </summary>
/// <remarks>
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceAreaLineTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-crud-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
// ----- CreateArea -----
/// <summary>A new area is persisted and visible on a fresh context.</summary>
[Fact]
public async Task CreateArea_then_load_shows_it()
{
var (service, dbName) = Fresh();
var result = await service.CreateAreaAsync("MAIN", "AREA-NEW", "assembly", " ");
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
var area = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-NEW");
area.ClusterId.ShouldBe("MAIN");
area.Name.ShouldBe("assembly");
area.Notes.ShouldBeNull(); // whitespace-only notes collapse to null
}
/// <summary>Creating an area whose id already exists returns the duplicate error.</summary>
[Fact]
public async Task CreateArea_duplicate_id_returns_error()
{
var (service, dbName) = Fresh();
await service.CreateAreaAsync("MAIN", "AREA-DUP", "first", null);
var result = await service.CreateAreaAsync("MAIN", "AREA-DUP", "second", null);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Area 'AREA-DUP' already exists.");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Single(a => a.UnsAreaId == "AREA-DUP").Name.ShouldBe("first");
}
// ----- UpdateArea -----
/// <summary>Updating an area changes its name and notes (notes whitespace collapses to null).</summary>
[Fact]
public async Task UpdateArea_changes_name_and_notes()
{
var (service, dbName) = Fresh();
await service.CreateAreaAsync("MAIN", "AREA-1", "old", "old notes");
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-1").RowVersion;
}
var result = await service.UpdateAreaAsync("AREA-1", "new", " ", "MAIN", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
var area = verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-1");
area.Name.ShouldBe("new");
area.Notes.ShouldBeNull();
area.ClusterId.ShouldBe("MAIN");
}
/// <summary>The #122 guard blocks moving an area to a new cluster when driver-bound
/// equipment would be orphaned from its driver's cluster.</summary>
[Fact]
public async Task UpdateArea_reassign_cluster_blocked_when_driver_bound_equipment_would_orphan()
{
var (service, dbName) = Fresh();
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-X", ClusterId = "MAIN", Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-X", UnsAreaId = "AREA-X", Name = "l" });
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-BOUND",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-X",
Name = "m",
MachineCode = "machine_x",
DriverInstanceId = "DRV-MAIN",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-MAIN",
ClusterId = "MAIN",
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.SaveChanges();
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").RowVersion;
}
var result = await service.UpdateAreaAsync("AREA-X", "a", null, "SITE-A", rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("EQ-BOUND");
result.Error.ShouldContain("SITE-A");
result.Error.ShouldContain("MAIN");
// The area must not have been moved.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").ClusterId.ShouldBe("MAIN");
}
/// <summary>The #122 guard allows the move when the area's driver-bound equipment's driver
/// is already in the target cluster (driverCluster == newClusterId → no orphan).</summary>
[Fact]
public async Task UpdateArea_reassign_cluster_allowed_when_driver_is_in_target_cluster()
{
// Seed: AREA-Z in cluster MAIN, a line, equipment bound to DRV-SITE-A whose cluster is
// SITE-A. Reassigning the area to SITE-A must be allowed because the driver is already
// there — the #122 guard's `driverCluster != newClusterId` condition is false.
var (service, dbName) = Fresh();
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-Z", ClusterId = "MAIN", Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-Z", UnsAreaId = "AREA-Z", Name = "l" });
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-SITE-A",
ClusterId = "SITE-A",
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-BOUND-Z",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-Z",
Name = "m",
MachineCode = "machine_z",
DriverInstanceId = "DRV-SITE-A",
});
db.SaveChanges();
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-Z").RowVersion;
}
var result = await service.UpdateAreaAsync("AREA-Z", "a", null, "SITE-A", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
// Verify the area actually moved to SITE-A via a fresh context.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-Z").ClusterId.ShouldBe("SITE-A");
}
/// <summary>The #122 guard allows the move when the equipment under the area is driver-less
/// (DriverInstanceId == null).</summary>
[Fact]
public async Task UpdateArea_reassign_cluster_allowed_when_equipment_driverless()
{
var (service, dbName) = Fresh();
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-Y", ClusterId = "MAIN", Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-Y", UnsAreaId = "AREA-Y", Name = "l" });
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-FREE",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-Y",
Name = "m",
MachineCode = "machine_y",
DriverInstanceId = null,
});
db.SaveChanges();
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").RowVersion;
}
var result = await service.UpdateAreaAsync("AREA-Y", "a", null, "SITE-A", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").ClusterId.ShouldBe("SITE-A");
}
/// <summary>Updating an area that no longer exists returns the row-gone error.</summary>
[Fact]
public async Task UpdateArea_missing_row_returns_error()
{
var (service, _) = Fresh();
var result = await service.UpdateAreaAsync("NOPE", "n", null, "MAIN", []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
// ----- DeleteArea -----
/// <summary>Deleting an area with no lines removes the row.</summary>
[Fact]
public async Task DeleteArea_removes_row()
{
var (service, dbName) = Fresh();
await service.CreateAreaAsync("MAIN", "AREA-DEL", "a", null);
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-DEL").RowVersion;
}
var result = await service.DeleteAreaAsync("AREA-DEL", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsAreas.Any(a => a.UnsAreaId == "AREA-DEL").ShouldBeFalse();
}
/// <summary>Deleting an area that is already gone is a no-op success.</summary>
[Fact]
public async Task DeleteArea_already_gone_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteAreaAsync("GHOST", []);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
// ----- CreateLine -----
/// <summary>A new line is persisted under its area.</summary>
[Fact]
public async Task CreateLine_then_load_shows_it()
{
var (service, dbName) = Fresh();
await service.CreateAreaAsync("MAIN", "AREA-1", "a", null);
var result = await service.CreateLineAsync("AREA-1", "LINE-NEW", "line-a", " ");
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
var line = db.UnsLines.Single(l => l.UnsLineId == "LINE-NEW");
line.UnsAreaId.ShouldBe("AREA-1");
line.Name.ShouldBe("line-a");
line.Notes.ShouldBeNull();
}
/// <summary>Creating a line whose id already exists returns the duplicate error.</summary>
[Fact]
public async Task CreateLine_duplicate_id_returns_error()
{
var (service, _) = Fresh();
await service.CreateLineAsync("AREA-1", "LINE-DUP", "first", null);
var result = await service.CreateLineAsync("AREA-1", "LINE-DUP", "second", null);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Line 'LINE-DUP' already exists.");
}
// ----- UpdateLine -----
/// <summary>Updating a line moves it to a new area and changes its name and notes.</summary>
[Fact]
public async Task UpdateLine_changes_area_name_and_notes()
{
var (service, dbName) = Fresh();
await service.CreateLineAsync("AREA-1", "LINE-1", "old", "old notes");
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-1").RowVersion;
}
var result = await service.UpdateLineAsync("LINE-1", "new", " ", "AREA-2", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
var line = verify.UnsLines.Single(l => l.UnsLineId == "LINE-1");
line.UnsAreaId.ShouldBe("AREA-2");
line.Name.ShouldBe("new");
line.Notes.ShouldBeNull();
}
/// <summary>Updating a line that no longer exists returns the row-gone error.</summary>
[Fact]
public async Task UpdateLine_missing_row_returns_error()
{
var (service, _) = Fresh();
var result = await service.UpdateLineAsync("NOPE", "n", null, "AREA-1", []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
/// <summary>The #122 guard blocks reparenting a line to an area in a different cluster when
/// the line's equipment is driver-bound (the driver lives in the original cluster).</summary>
[Fact]
public async Task UpdateLine_reparent_to_other_cluster_blocked_when_driver_bound()
{
var (service, dbName) = Fresh();
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
// Source area A1 in cluster MAIN.
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-MAIN", ClusterId = "MAIN", Name = "area-main" });
// Target area A2 in cluster SITE-A.
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-SITE-A", ClusterId = "SITE-A", Name = "area-site-a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-BOUND", UnsAreaId = "A1-MAIN", Name = "line" });
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-MAIN-122",
ClusterId = "MAIN",
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-LINE-BOUND",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-BOUND",
Name = "eq",
MachineCode = "mc_line_bound",
DriverInstanceId = "DRV-MAIN-122",
});
db.SaveChanges();
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").RowVersion;
}
var result = await service.UpdateLineAsync("LINE-BOUND", "line", null, "A2-SITE-A", rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("EQ-LINE-BOUND");
result.Error.ShouldContain("A2-SITE-A");
result.Error.ShouldContain("MAIN");
// The line must not have moved.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").UnsAreaId.ShouldBe("A1-MAIN");
}
/// <summary>The #122 guard allows reparenting a line to an area in a different cluster when
/// the line's equipment is driver-less (DriverInstanceId == null).</summary>
[Fact]
public async Task UpdateLine_reparent_to_other_cluster_allowed_when_equipment_driverless()
{
var (service, dbName) = Fresh();
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-FREE", ClusterId = "MAIN", Name = "area-main" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-FREE", ClusterId = "SITE-A", Name = "area-site-a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-FREE", UnsAreaId = "A1-FREE", Name = "line" });
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-LINE-FREE",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-FREE",
Name = "eq",
MachineCode = "mc_line_free",
DriverInstanceId = null,
});
db.SaveChanges();
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").RowVersion;
}
var result = await service.UpdateLineAsync("LINE-FREE", "line", null, "A2-FREE", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
// The line's UnsAreaId must have changed to A2-FREE.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").UnsAreaId.ShouldBe("A2-FREE");
}
// ----- DeleteLine -----
/// <summary>Deleting a line removes the row.</summary>
[Fact]
public async Task DeleteLine_removes_row()
{
var (service, dbName) = Fresh();
await service.CreateLineAsync("AREA-1", "LINE-DEL", "l", null);
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-DEL").RowVersion;
}
var result = await service.DeleteLineAsync("LINE-DEL", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsLines.Any(l => l.UnsLineId == "LINE-DEL").ShouldBeFalse();
}
/// <summary>Deleting a line that is already gone is a no-op success.</summary>
[Fact]
public async Task DeleteLine_already_gone_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteLineAsync("GHOST", []);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
}
@@ -0,0 +1,390 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the Equipment CRUD mutations on <see cref="UnsTreeService"/>, including the
/// system-generated <c>EQ-</c> id, fleet-wide MachineCode uniqueness, and the decision-#122
/// driver-cluster guard that blocks binding equipment to a driver in a different cluster than
/// the equipment's line.
/// </summary>
/// <remarks>
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceEquipmentTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-equip-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
/// requested) is always <c>DRV-1</c>.
/// </summary>
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
if (driverCluster is not null)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = driverCluster,
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
}
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
new(name, machineCode, unsLineId, driverInstanceId,
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
// ----- CreateEquipment -----
/// <summary>A new equipment gets a system-generated EQ- id (EQ- + 12 hex chars) and persists.</summary>
[Fact]
public async Task CreateEquipment_generates_EQ_id_and_persists()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
eq.EquipmentId.ShouldStartWith("EQ-");
eq.EquipmentId.Length.ShouldBe(15); // "EQ-" + 12 hex chars
eq.Name.ShouldBe("machine-1");
eq.UnsLineId.ShouldBe("LINE-1");
eq.Enabled.ShouldBeTrue();
}
/// <summary>Creating equipment with a MachineCode already in the fleet is blocked.</summary>
[Fact]
public async Task CreateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1", null));
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1", null));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("MachineCode 'machine_dup' already exists in this fleet.");
}
/// <summary>Creating equipment with no UNS line returns the pick-a-line error.</summary>
[Fact]
public async Task CreateEquipment_missing_line_blocked()
{
var (service, _) = Fresh();
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "", null));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Pick a UNS line.");
}
/// <summary>The #122 guard blocks binding equipment to a driver in a different cluster than the line.</summary>
[Fact]
public async Task CreateEquipment_driver_in_other_cluster_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("DRV-1");
result.Error.ShouldContain("SITE-A");
result.Error.ShouldContain("MAIN");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
/// <summary>Binding equipment to a driver in the same cluster as the line is allowed.</summary>
[Fact]
public async Task CreateEquipment_driver_in_same_cluster_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBe("DRV-1");
}
/// <summary>Driver-less equipment is allowed regardless of cluster (no #122 guard applies).</summary>
[Fact]
public async Task CreateEquipment_driverless_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBeNull();
}
/// <summary>
/// The #122 guard blocks binding equipment to a driver when the UNS line does not resolve to
/// a cluster (e.g. the line does not exist in the DB).
/// </summary>
[Fact]
public async Task CreateEquipment_driver_bound_unresolvable_line_blocked()
{
var (service, dbName) = Fresh();
// Seed a driver in MAIN cluster, but do NOT create the UnsLine that the input references.
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = "MAIN",
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.SaveChanges();
}
var result = await service.CreateEquipmentAsync(
Input("machine-1", "machine_001", "LINE-BOGUS", "DRV-1"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("LINE-BOGUS");
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
/// <summary>Binding equipment to a DriverInstanceId that does not exist is blocked with a "not found" error.</summary>
[Fact]
public async Task CreateEquipment_driver_not_found_blocked()
{
var (service, dbName) = Fresh();
// Seed area + line in MAIN cluster, but NO DriverInstance.
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(
Input("machine-1", "machine_001", "LINE-1", "DRV-GHOST"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("not found");
result.Error.ShouldContain("DRV-GHOST");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
// ----- UpdateEquipment -----
/// <summary>Updating equipment changes its mutable fields (name, MachineCode, a 40010 field).</summary>
[Fact]
public async Task UpdateEquipment_changes_fields()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1", null,
ZTag: null, SAPID: null, Manufacturer: "Acme", Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: (short)2021,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: false);
var result = await service.UpdateEquipmentAsync(equipmentId, updated, rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
var after = verify.Equipment.Single(e => e.EquipmentId == equipmentId);
after.Name.ShouldBe("machine-renamed");
after.MachineCode.ShouldBe("machine_renamed");
after.Manufacturer.ShouldBe("Acme");
after.YearOfConstruction.ShouldBe((short)2021);
after.Enabled.ShouldBeFalse();
}
/// <summary>Updating equipment that no longer exists returns the row-gone error.</summary>
[Fact]
public async Task UpdateEquipment_missing_row_returns_error()
{
var (service, _) = Fresh();
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1", null), []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
/// <summary>The #122 guard blocks an update that binds equipment to a driver in another cluster.</summary>
[Fact]
public async Task UpdateEquipment_driver_in_other_cluster_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBeNull();
}
/// <summary>Updating equipment with a MachineCode that already belongs to another row is blocked.</summary>
[Fact]
public async Task UpdateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-a", "mc_a", "LINE-1", null));
await service.CreateEquipmentAsync(Input("machine-b", "mc_b", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "mc_a");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
// Try to rename mc_a → mc_b (which already exists).
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-a", "mc_b", "LINE-1", null), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldBe("MachineCode 'mc_b' already exists in this fleet.");
// The original row must be unchanged.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).MachineCode.ShouldBe("mc_a");
}
/// <summary>Updating equipment to bind a driver that is in the SAME cluster as the line is allowed.</summary>
[Fact]
public async Task UpdateEquipment_driver_in_same_cluster_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBe("DRV-1");
}
// ----- DeleteEquipment -----
/// <summary>Deleting equipment removes the row.</summary>
[Fact]
public async Task DeleteEquipment_removes_row()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.DeleteEquipmentAsync(equipmentId, rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Any(e => e.EquipmentId == equipmentId).ShouldBeFalse();
}
/// <summary>Deleting equipment that is already gone is a no-op success.</summary>
[Fact]
public async Task DeleteEquipment_already_gone_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteEquipmentAsync("EQ-ghost", []);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
}
@@ -0,0 +1,200 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the bulk <see cref="UnsTreeService.ImportEquipmentAsync"/> path: valid rows insert,
/// rows whose MachineCode already exists (in the DB or earlier in the same batch) are skipped,
/// rows referencing an unknown UNS line or unknown driver are reported as errors, and the
/// decision-#122 driver-cluster guard rejects a driver in a different cluster than the line.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceImportTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-import-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
/// requested) is always <c>DRV-1</c>.
/// </summary>
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
if (driverCluster is not null)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = driverCluster,
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
}
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
new(name, machineCode, unsLineId, driverInstanceId,
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
/// <summary>Valid rows insert with system-generated EQ- ids and are counted in <c>Inserted</c>.</summary>
[Fact]
public async Task Import_inserts_valid_rows()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", null),
Input("machine-2", "mc_2", "LINE-1", null),
]);
result.Inserted.ShouldBe(2);
result.Skipped.ShouldBe(0);
result.Errors.ShouldBeEmpty();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Count().ShouldBe(2);
var eq = db.Equipment.Single(e => e.MachineCode == "mc_1");
eq.EquipmentId.ShouldStartWith("EQ-");
eq.EquipmentId.Length.ShouldBe(15); // "EQ-" + 12 hex chars
eq.Name.ShouldBe("machine-1");
eq.UnsLineId.ShouldBe("LINE-1");
eq.Enabled.ShouldBeTrue();
}
/// <summary>
/// A row whose MachineCode already exists in the DB is skipped (not an error), and a second row
/// later in the batch that reuses an earlier row's MachineCode is also skipped.
/// </summary>
[Fact]
public async Task Import_skips_duplicate_machinecode()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
// Pre-existing equipment with MachineCode "mc_existing".
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1", null));
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_existing", "LINE-1", null), // dup of DB row → skip
Input("machine-2", "mc_new", "LINE-1", null), // inserts
Input("machine-3", "mc_new", "LINE-1", null), // dup of in-batch row → skip
]);
result.Inserted.ShouldBe(1);
result.Skipped.ShouldBe(2);
result.Errors.ShouldBeEmpty();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Count(e => e.MachineCode == "mc_new").ShouldBe(1);
db.Equipment.Count(e => e.MachineCode == "mc_existing").ShouldBe(1);
}
/// <summary>A row whose UnsLineId does not exist is reported as an error and not inserted.</summary>
[Fact]
public async Task Import_reports_unknown_line()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-BOGUS", null),
Input("machine-2", "mc_2", "LINE-1", null),
]);
result.Inserted.ShouldBe(1);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("LINE-BOGUS");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
db.Equipment.Any(e => e.MachineCode == "mc_2").ShouldBeTrue();
}
/// <summary>A driver-bound row whose DriverInstanceId does not resolve is reported as an error.</summary>
[Fact]
public async Task Import_reports_unknown_driver()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); // no driver seeded
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-GHOST"),
]);
result.Inserted.ShouldBe(0);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("DRV-GHOST");
result.Errors[0].ShouldContain("not found");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
}
/// <summary>
/// The decision-#122 guard rejects a row that binds a driver living in a different cluster than
/// the row's UNS line.
/// </summary>
[Fact]
public async Task Import_enforces_122_cluster()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
]);
result.Inserted.ShouldBe(0);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("decision #122");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
}
/// <summary>A driver in the same cluster as the line imports cleanly.</summary>
[Fact]
public async Task Import_allows_driver_in_same_cluster()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
]);
result.Inserted.ShouldBe(1);
result.Skipped.ShouldBe(0);
result.Errors.ShouldBeEmpty();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "mc_1").DriverInstanceId.ShouldBe("DRV-1");
}
}
@@ -0,0 +1,82 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies <see cref="UnsTreeService.LoadEquipmentChildrenAsync"/> returns Tag leaf nodes
/// followed by VirtualTag leaf nodes, in Name order, with the correct keys and display names.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceLazyTests
{
private static UnsTreeService SeededService()
{
var dbName = $"uns-lazy-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
}
/// <summary>
/// Tags come first, ordered by Name, then VirtualTags; keys follow the tag:/vtag: scheme
/// and the Tag display name embeds the DataType.
/// </summary>
[Fact]
public async Task LoadEquipmentChildren_returns_tags_then_vtags()
{
var service = SeededService();
var children = await service.LoadEquipmentChildrenAsync(UnsTreeTestDb.SeededEquipmentId);
// Seed has 2 tags (speed=Float, running=Boolean) + 1 vtag (computed).
// Tags are ordered by Name: "running" < "speed".
children.Count.ShouldBe(3);
var running = children[0];
running.Kind.ShouldBe(UnsNodeKind.Tag);
running.Key.ShouldBe("tag:TAG-2");
running.EntityId.ShouldBe("TAG-2");
running.ClusterId.ShouldBeNull();
running.DisplayName.ShouldContain("running");
running.DisplayName.ShouldContain("Boolean");
running.ChildCount.ShouldBe(0);
running.HasLazyChildren.ShouldBeFalse();
running.Children.ShouldBeEmpty();
var speed = children[1];
speed.Kind.ShouldBe(UnsNodeKind.Tag);
speed.Key.ShouldBe("tag:TAG-1");
speed.EntityId.ShouldBe("TAG-1");
speed.DisplayName.ShouldContain("speed");
speed.DisplayName.ShouldContain("Float");
var vtag = children[2];
vtag.Kind.ShouldBe(UnsNodeKind.VirtualTag);
vtag.Key.ShouldBe("vtag:VTAG-1");
vtag.EntityId.ShouldBe("VTAG-1");
vtag.ClusterId.ShouldBeNull();
vtag.DisplayName.ShouldContain("computed");
vtag.DisplayName.ShouldContain("VirtualTag");
vtag.ChildCount.ShouldBe(0);
vtag.HasLazyChildren.ShouldBeFalse();
vtag.Children.ShouldBeEmpty();
}
/// <summary>An equipment with no tags or virtual tags returns an empty list.</summary>
[Fact]
public async Task LoadEquipmentChildren_empty_for_equipment_with_none()
{
// Use a fresh named store and add an equipment with no tags/vtags.
var dbName = $"uns-lazy-empty-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
// The orphan equipment id is not in the seeded fixture, so just use a novel id.
const string emptyEquipmentId = "EQ-NO-TAGS";
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var children = await service.LoadEquipmentChildrenAsync(emptyEquipmentId);
children.ShouldBeEmpty();
}
}
@@ -0,0 +1,200 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the load-for-edit projections on <see cref="UnsTreeService"/> that prefill the
/// Area/Line/Equipment edit modals and carry the concurrency token back for last-write-wins saves.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceLoadEditTests
{
private static (UnsTreeService Service, string DbName) Seeded()
{
var dbName = $"uns-loadedit-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>Loading a seeded area maps its fields, owning cluster, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadArea_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadAreaAsync("AREA-1");
dto.ShouldNotBeNull();
dto.UnsAreaId.ShouldBe("AREA-1");
dto.Name.ShouldBe("assembly");
dto.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId);
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing area returns null.</summary>
[Fact]
public async Task LoadArea_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadAreaAsync("NOPE")).ShouldBeNull();
}
/// <summary>Loading a seeded line maps its fields, parent area, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadLine_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadLineAsync("LINE-1");
dto.ShouldNotBeNull();
dto.UnsLineId.ShouldBe("LINE-1");
dto.UnsAreaId.ShouldBe("AREA-1");
dto.Name.ShouldBe("line-a");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing line returns null.</summary>
[Fact]
public async Task LoadLine_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadLineAsync("NOPE")).ShouldBeNull();
}
/// <summary>Loading the seeded equipment maps its identity fields, line, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadEquipment_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
dto.ShouldNotBeNull();
dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
dto.Name.ShouldBe("machine-1");
dto.MachineCode.ShouldBe("machine_001");
dto.UnsLineId.ShouldBe("LINE-1");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing equipment returns null.</summary>
[Fact]
public async Task LoadEquipment_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadEquipmentAsync("NOPE")).ShouldBeNull();
}
/// <summary>
/// LoadDriversForCluster returns every driver in the cluster (any namespace kind), ordered by id,
/// with the <c>"{Id} — {Name} ({DriverType})"</c> display; drivers in other clusters are excluded.
/// </summary>
[Fact]
public async Task LoadDriversForCluster_returns_cluster_drivers()
{
var (service, dbName) = Seeded();
await using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-B",
ClusterId = UnsTreeTestDb.PopulatedClusterId,
NamespaceId = "NS-1",
Name = "modbus-b",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-A",
ClusterId = UnsTreeTestDb.PopulatedClusterId,
NamespaceId = "NS-1",
Name = "galaxy-a",
DriverType = "Galaxy",
DriverConfig = "{}",
});
// A driver in a different cluster must be excluded.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-OTHER",
ClusterId = UnsTreeTestDb.EmptyClusterId,
NamespaceId = "NS-2",
Name = "other",
DriverType = "S7",
DriverConfig = "{}",
});
await db.SaveChangesAsync();
}
var drivers = await service.LoadDriversForClusterAsync(UnsTreeTestDb.PopulatedClusterId);
drivers.Count.ShouldBe(2);
drivers[0].DriverInstanceId.ShouldBe("DRV-A");
drivers[0].Display.ShouldBe("DRV-A — galaxy-a (Galaxy)");
drivers[1].DriverInstanceId.ShouldBe("DRV-B");
drivers[1].Display.ShouldBe("DRV-B — modbus-b (ModbusTcp)");
}
/// <summary>Loading a seeded tag maps its fields, owning equipment, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadTag_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadTagAsync("TAG-1");
dto.ShouldNotBeNull();
dto.TagId.ShouldBe("TAG-1");
dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
dto.Name.ShouldBe("speed");
dto.DriverInstanceId.ShouldBe("DRV-1");
dto.DataType.ShouldBe("Float");
dto.AccessLevel.ShouldBe(TagAccessLevel.Read);
dto.TagConfig.ShouldBe("{}");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing tag returns null.</summary>
[Fact]
public async Task LoadTag_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadTagAsync("NOPE")).ShouldBeNull();
}
/// <summary>Loading a seeded virtual tag maps its fields, owning equipment, and a non-empty RowVersion.</summary>
[Fact]
public async Task LoadVirtualTag_returns_dto()
{
var (service, _) = Seeded();
var dto = await service.LoadVirtualTagAsync("VTAG-1");
dto.ShouldNotBeNull();
dto.VirtualTagId.ShouldBe("VTAG-1");
dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
dto.Name.ShouldBe("computed");
dto.DataType.ShouldBe("Double");
dto.ScriptId.ShouldBe("SCRIPT-1");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>Loading a missing virtual tag returns null.</summary>
[Fact]
public async Task LoadVirtualTag_missing_returns_null()
{
var (service, _) = Seeded();
(await service.LoadVirtualTagAsync("NOPE")).ShouldBeNull();
}
}
@@ -0,0 +1,94 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies <see cref="UnsTreeService.LoadStructureAsync"/> builds the Enterprise→Cluster→
/// Area→Line→Equipment structure from the config database, including per-equipment tag/
/// virtual-tag counts and the retention of empty clusters.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceStructureTests
{
private static UnsTreeService SeededService()
{
var dbName = $"uns-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
}
/// <summary>Root enterprise "zb" carries both clusters, and MAIN exposes the full
/// area→line→equipment path with the expected kinds and keys.</summary>
[Fact]
public async Task LoadStructure_builds_full_hierarchy()
{
var service = SeededService();
var roots = await service.LoadStructureAsync();
var enterprise = roots.ShouldHaveSingleItem();
enterprise.Kind.ShouldBe(UnsNodeKind.Enterprise);
enterprise.DisplayName.ShouldBe("zb");
enterprise.Children.Count.ShouldBe(2);
enterprise.Children.ShouldAllBe(c => c.Kind == UnsNodeKind.Cluster);
var main = enterprise.Children.Single(c => c.ClusterId == UnsTreeTestDb.PopulatedClusterId);
main.Kind.ShouldBe(UnsNodeKind.Cluster);
var area = main.Children.ShouldHaveSingleItem();
area.Kind.ShouldBe(UnsNodeKind.Area);
area.Key.ShouldBe("area:AREA-1");
area.EntityId.ShouldBe("AREA-1");
var line = area.Children.ShouldHaveSingleItem();
line.Kind.ShouldBe(UnsNodeKind.Line);
line.Key.ShouldBe("line:LINE-1");
line.EntityId.ShouldBe("LINE-1");
var equipment = line.Children.ShouldHaveSingleItem();
equipment.Kind.ShouldBe(UnsNodeKind.Equipment);
equipment.Key.ShouldBe($"eq:{UnsTreeTestDb.SeededEquipmentId}");
equipment.EntityId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
equipment.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId);
}
/// <summary>The seeded equipment node's badge count equals tags + virtual tags and it is
/// flagged as lazily expandable.</summary>
[Fact]
public async Task LoadStructure_counts_tags_and_vtags_per_equipment()
{
var service = SeededService();
var roots = await service.LoadStructureAsync();
var equipment = roots
.Single()
.Children.Single(c => c.ClusterId == UnsTreeTestDb.PopulatedClusterId)
.Children.Single()
.Children.Single()
.Children.Single();
// Seed: 2 driver tags + 1 virtual tag (the orphan tag has no equipment and is excluded).
equipment.ChildCount.ShouldBe(3);
equipment.HasLazyChildren.ShouldBeTrue();
}
/// <summary>An empty cluster (no areas) is still rendered as a Cluster node with no children.</summary>
[Fact]
public async Task LoadStructure_includes_empty_clusters()
{
var service = SeededService();
var roots = await service.LoadStructureAsync();
var siteA = roots
.Single()
.Children.Single(c => c.ClusterId == UnsTreeTestDb.EmptyClusterId);
siteA.Kind.ShouldBe(UnsNodeKind.Cluster);
siteA.Children.ShouldBeEmpty();
siteA.ChildCount.ShouldBe(0);
}
}
@@ -0,0 +1,375 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the equipment-bound Tag CRUD mutations on <see cref="UnsTreeService"/>, including
/// TagConfig JSON validity, the namespace-kind guard (tree tags must bind to an Equipment-kind
/// namespace), the decision-#122 driver-cluster guard, duplicate-id / duplicate-name guards, and
/// the driver-candidate loader scoped to the equipment's cluster.
/// </summary>
/// <remarks>
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceTagTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-tag-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds an area→line→equipment path in <paramref name="equipmentCluster"/>. The equipment id is
/// always <c>EQ-1</c>. Optionally seeds an Equipment-kind driver (<c>DRV-EQ</c>) in the equipment's
/// cluster, a SystemPlatform-kind driver (<c>DRV-SP</c>) in the equipment's cluster, and an
/// Equipment-kind driver (<c>DRV-OTHER</c>) in <paramref name="otherCluster"/>.
/// </summary>
private static void SeedHierarchyAndDrivers(
string dbName,
string equipmentCluster,
bool seedEquipmentDriver = false,
bool seedSystemPlatformDriver = false,
string? otherCluster = null)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = equipmentCluster, Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-1",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = "machine-1",
MachineCode = "machine_001",
});
// Equipment-kind namespace in the equipment's cluster.
db.Namespaces.Add(new Namespace
{
NamespaceId = "NS-EQ",
ClusterId = equipmentCluster,
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:zb:eq",
});
// SystemPlatform-kind namespace in the equipment's cluster.
db.Namespaces.Add(new Namespace
{
NamespaceId = "NS-SP",
ClusterId = equipmentCluster,
Kind = NamespaceKind.SystemPlatform,
NamespaceUri = "urn:zb:sp",
});
if (seedEquipmentDriver)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-EQ",
ClusterId = equipmentCluster,
NamespaceId = "NS-EQ",
Name = "equipment driver",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
}
if (seedSystemPlatformDriver)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-SP",
ClusterId = equipmentCluster,
NamespaceId = "NS-SP",
Name = "galaxy driver",
DriverType = "Galaxy",
DriverConfig = "{}",
});
}
if (otherCluster is not null)
{
// Equipment-kind namespace + driver in a different cluster.
db.Namespaces.Add(new Namespace
{
NamespaceId = "NS-OTHER",
ClusterId = otherCluster,
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:zb:other",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-OTHER",
ClusterId = otherCluster,
NamespaceId = "NS-OTHER",
Name = "other-cluster driver",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
}
db.SaveChanges();
}
private static TagInput Input(
string tagId,
string name,
string driverInstanceId,
string tagConfig = "{}") =>
new(tagId, name, driverInstanceId, DataType: "Float",
AccessLevel: TagAccessLevel.Read, WriteIdempotent: false,
PollGroupId: null, TagConfig: tagConfig);
// ----- CreateTag -----
/// <summary>A valid equipment-bound tag persists with EquipmentId set and FolderPath null.</summary>
[Fact]
public async Task CreateTag_equipment_bound_persists()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
var tag = db.Tags.Single(t => t.TagId == "TAG-1");
tag.EquipmentId.ShouldBe("EQ-1");
tag.FolderPath.ShouldBeNull();
tag.DriverInstanceId.ShouldBe("DRV-EQ");
tag.Name.ShouldBe("speed");
tag.DataType.ShouldBe("Float");
}
/// <summary>A tag with invalid TagConfig JSON is blocked.</summary>
[Fact]
public async Task CreateTag_invalid_json_blocked()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
var result = await service.CreateTagAsync(
"EQ-1", Input("TAG-1", "speed", "DRV-EQ", tagConfig: "{ not json"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("TagConfig is not valid JSON.");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>Binding a tag to a driver in a different cluster than the equipment is blocked (#122).</summary>
[Fact]
public async Task CreateTag_driver_in_other_cluster_blocked()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", otherCluster: "SITE-A");
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-OTHER"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("DRV-OTHER");
result.Error.ShouldContain("SITE-A");
result.Error.ShouldContain("MAIN");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>Binding a tree tag to a driver in a SystemPlatform-kind namespace is blocked.</summary>
[Fact]
public async Task CreateTag_driver_systemplatform_namespace_blocked()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedSystemPlatformDriver: true);
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-SP"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("DRV-SP");
result.Error.ShouldContain("Equipment-kind namespace");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>Creating a tag with a TagId that already exists is blocked.</summary>
[Fact]
public async Task CreateTag_duplicate_tagid_blocked()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
var result = await service.CreateTagAsync("EQ-1", Input("TAG-1", "another", "DRV-EQ"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Tag 'TAG-1' already exists.");
}
/// <summary>Creating a tag for an equipment id that does not exist returns a not-found error.</summary>
[Fact]
public async Task CreateTag_unresolvable_equipment_returns_error()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
var result = await service.CreateTagAsync("EQ-NOPE", Input("TAG-1", "speed", "DRV-EQ"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("not found");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>Creating a tag whose Name already exists on the same equipment is blocked.</summary>
[Fact]
public async Task CreateTag_duplicate_name_on_equipment_blocked()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
var result = await service.CreateTagAsync("EQ-1", Input("TAG-2", "speed", "DRV-EQ"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("A tag named 'speed' already exists on this equipment.");
}
// ----- UpdateTag -----
/// <summary>Updating a tag changes its mutable fields and keeps EquipmentId / FolderPath.</summary>
[Fact]
public async Task UpdateTag_changes_fields()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.Tags.Single(t => t.TagId == "TAG-1").RowVersion;
}
var updated = new TagInput("TAG-1", "renamed", "DRV-EQ", DataType: "Int32",
AccessLevel: TagAccessLevel.ReadWrite, WriteIdempotent: true,
PollGroupId: " ", TagConfig: """{ "register": 40001 }""");
var result = await service.UpdateTagAsync("TAG-1", updated, rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
var after = verify.Tags.Single(t => t.TagId == "TAG-1");
after.Name.ShouldBe("renamed");
after.DataType.ShouldBe("Int32");
after.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
after.WriteIdempotent.ShouldBeTrue();
after.PollGroupId.ShouldBeNull(); // whitespace collapses to null
after.EquipmentId.ShouldBe("EQ-1");
after.FolderPath.ShouldBeNull();
}
/// <summary>Updating a tag that no longer exists returns the row-gone error.</summary>
[Fact]
public async Task UpdateTag_missing_row_returns_error()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
var result = await service.UpdateTagAsync("TAG-nope", Input("TAG-nope", "x", "DRV-EQ"), []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
// ----- DeleteTag -----
/// <summary>Deleting a tag removes the row.</summary>
[Fact]
public async Task DeleteTag_removes_row()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
await service.CreateTagAsync("EQ-1", Input("TAG-1", "speed", "DRV-EQ"));
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.Tags.Single(t => t.TagId == "TAG-1").RowVersion;
}
var result = await service.DeleteTagAsync("TAG-1", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Tags.Any(t => t.TagId == "TAG-1").ShouldBeFalse();
}
/// <summary>Deleting a tag that is already gone is a no-op success.</summary>
[Fact]
public async Task DeleteTag_already_gone_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteTagAsync("TAG-ghost", []);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
// ----- LoadTagDriversForEquipmentAsync -----
/// <summary>
/// The driver loader returns only Equipment-kind drivers in the equipment's cluster — excluding
/// SystemPlatform-kind drivers in the same cluster and Equipment-kind drivers in other clusters.
/// </summary>
[Fact]
public async Task LoadTagDriversForEquipment_returns_only_equipment_kind_drivers_in_cluster()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(
dbName,
equipmentCluster: "MAIN",
seedEquipmentDriver: true,
seedSystemPlatformDriver: true,
otherCluster: "SITE-A");
var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-1");
drivers.Count.ShouldBe(1);
drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
drivers[0].Display.ShouldContain("DRV-EQ");
drivers[0].Display.ShouldContain("equipment driver");
}
/// <summary>An unresolvable equipment yields an empty driver list.</summary>
[Fact]
public async Task LoadTagDriversForEquipment_unresolvable_equipment_returns_empty()
{
var (service, dbName) = Fresh();
SeedHierarchyAndDrivers(dbName, equipmentCluster: "MAIN", seedEquipmentDriver: true);
var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-nope");
drivers.ShouldBeEmpty();
}
}
@@ -0,0 +1,307 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the equipment-bound VirtualTag CRUD mutations on <see cref="UnsTreeService"/>, including
/// the equipment-existence guard, the script-chosen guard, the change-or-timer trigger rule, the
/// 50 ms timer minimum, duplicate-id / duplicate-name guards, and the script-candidate loader.
/// </summary>
/// <remarks>
/// The EF InMemory provider enforces neither <c>RowVersion</c> concurrency nor the DbContext CHECK
/// constraints, so the <c>DbUpdateConcurrencyException</c> branches are not exercised here by design,
/// and the service's own trigger/timer guards are what protect the data in these tests.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceVirtualTagTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-vtag-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds an area→line→equipment path (equipment id <c>EQ-1</c>) plus a single script
/// (<c>SCRIPT-1</c>, language CSharp) so the create/update paths have a valid script to bind.
/// </summary>
private static void SeedEquipmentAndScript(string dbName)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-1",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = "machine-1",
MachineCode = "machine_001",
});
db.Scripts.Add(new Script
{
ScriptId = "SCRIPT-1",
Name = "compute speed",
SourceCode = "return 1;",
SourceHash = "hash-1",
Language = "CSharp",
});
db.SaveChanges();
}
private static VirtualTagInput Input(
string virtualTagId,
string name,
string scriptId = "SCRIPT-1",
bool changeTriggered = true,
int? timerIntervalMs = null) =>
new(virtualTagId, name, DataType: "Double", scriptId,
changeTriggered, timerIntervalMs, Historize: false, Enabled: true);
// ----- CreateVirtualTag -----
/// <summary>A valid change-triggered virtual tag persists with EquipmentId set.</summary>
[Fact]
public async Task CreateVirtualTag_persists()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
var vtag = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1");
vtag.EquipmentId.ShouldBe("EQ-1");
vtag.Name.ShouldBe("computed");
vtag.DataType.ShouldBe("Double");
vtag.ScriptId.ShouldBe("SCRIPT-1");
vtag.ChangeTriggered.ShouldBeTrue();
}
/// <summary>Creating a virtual tag for an equipment id that does not exist is blocked.</summary>
[Fact]
public async Task CreateVirtualTag_equipment_not_found_blocked()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
var result = await service.CreateVirtualTagAsync("EQ-NOPE", Input("VTAG-1", "computed"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Equipment 'EQ-NOPE' not found.");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
/// <summary>A virtual tag with neither change-trigger nor a timer is blocked.</summary>
[Fact]
public async Task CreateVirtualTag_no_trigger_blocked()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
var result = await service.CreateVirtualTagAsync(
"EQ-1", Input("VTAG-1", "computed", changeTriggered: false, timerIntervalMs: null));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Pick at least one trigger — change or timer.");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
/// <summary>A virtual tag with a timer below the 50 ms minimum is blocked.</summary>
[Fact]
public async Task CreateVirtualTag_timer_below_50_blocked()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
var result = await service.CreateVirtualTagAsync(
"EQ-1", Input("VTAG-1", "computed", changeTriggered: false, timerIntervalMs: 10));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("TimerIntervalMs must be at least 50 ms.");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
/// <summary>Creating a virtual tag with a VirtualTagId that already exists is blocked.</summary>
[Fact]
public async Task CreateVirtualTag_duplicate_id_blocked()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "another"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("VirtualTag 'VTAG-1' already exists.");
}
/// <summary>Creating a virtual tag whose Name already exists on the same equipment is blocked.</summary>
[Fact]
public async Task CreateVirtualTag_duplicate_name_on_equipment_blocked()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-2", "computed"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("A virtual tag named 'computed' already exists on this equipment.");
}
/// <summary>Creating a virtual tag with no script chosen is blocked.</summary>
[Fact]
public async Task CreateVirtualTag_no_script_blocked()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
var input = Input("VTAG-1", "computed", scriptId: "", changeTriggered: true);
var result = await service.CreateVirtualTagAsync("EQ-1", input);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("Pick a script");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
// ----- UpdateVirtualTag -----
/// <summary>Updating a virtual tag changes its mutable fields and keeps EquipmentId.</summary>
[Fact]
public async Task UpdateVirtualTag_changes_fields()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion;
}
var updated = new VirtualTagInput("VTAG-1", "renamed", DataType: "Int32", ScriptId: "SCRIPT-1",
ChangeTriggered: false, TimerIntervalMs: 250, Historize: true, Enabled: false);
var result = await service.UpdateVirtualTagAsync("VTAG-1", updated, rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
var after = verify.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1");
after.Name.ShouldBe("renamed");
after.DataType.ShouldBe("Int32");
after.ChangeTriggered.ShouldBeFalse();
after.TimerIntervalMs.ShouldBe(250);
after.Historize.ShouldBeTrue();
after.Enabled.ShouldBeFalse();
after.EquipmentId.ShouldBe("EQ-1");
}
/// <summary>Updating a virtual tag that no longer exists returns the row-gone error.</summary>
[Fact]
public async Task UpdateVirtualTag_missing_row_returns_error()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
var result = await service.UpdateVirtualTagAsync("VTAG-nope", Input("VTAG-nope", "x"), []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
/// <summary>Renaming a virtual tag to a name already used by another tag on the same equipment is blocked.</summary>
[Fact]
public async Task UpdateVirtualTag_duplicate_name_on_equipment_blocked()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-A", "vt_a"));
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-B", "vt_b"));
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-A").RowVersion;
}
var input = Input("VTAG-A", "vt_b"); // try to rename to the name already used by VTAG-B
var result = await service.UpdateVirtualTagAsync("VTAG-A", input, rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("already exists on this equipment");
}
// ----- DeleteVirtualTag -----
/// <summary>Deleting a virtual tag removes the row.</summary>
[Fact]
public async Task DeleteVirtualTag_removes_row()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
rv = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion;
}
var result = await service.DeleteVirtualTagAsync("VTAG-1", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
/// <summary>Deleting a virtual tag that is already gone is a no-op success.</summary>
[Fact]
public async Task DeleteVirtualTag_already_gone_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteVirtualTagAsync("VTAG-ghost", []);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
// ----- LoadScriptsAsync -----
/// <summary>The script loader returns the seeded scripts projected to (id, "Name (Language)").</summary>
[Fact]
public async Task LoadScripts_returns_scripts()
{
var (service, dbName) = Fresh();
SeedEquipmentAndScript(dbName);
var scripts = await service.LoadScriptsAsync();
scripts.Count.ShouldBe(1);
scripts[0].ScriptId.ShouldBe("SCRIPT-1");
scripts[0].Display.ShouldBe("compute speed (CSharp)");
}
}
@@ -0,0 +1,149 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Shared in-memory fixture for <c>UnsTreeService</c> structural tests. Builds an
/// <see cref="OtOpcUaConfigDbContext"/> over a named InMemory database and seeds a small,
/// deterministic UNS hierarchy: enterprise "zb" across two clusters (MAIN populated,
/// SITE-A intentionally empty), plus tags and a virtual tag on one equipment node so
/// the per-equipment count joins can be exercised.
/// </summary>
internal static class UnsTreeTestDb
{
/// <summary>The equipment that carries the seeded tags and virtual tag.</summary>
public const string SeededEquipmentId = "EQ-000000000001";
/// <summary>The cluster that has no areas, used to cover the empty-cluster case.</summary>
public const string EmptyClusterId = "SITE-A";
/// <summary>The populated cluster with the area→line→equipment path.</summary>
public const string PopulatedClusterId = "MAIN";
/// <summary>Creates a context over a fresh, uniquely-named InMemory database.</summary>
public static OtOpcUaConfigDbContext Create() => CreateNamed($"uns-{Guid.NewGuid():N}");
/// <summary>Creates a context bound to the supplied InMemory database name.</summary>
public static OtOpcUaConfigDbContext CreateNamed(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(name)
.Options);
/// <summary>
/// Returns an <see cref="IDbContextFactory{TContext}"/> whose contexts all share the
/// supplied InMemory database name, so data seeded by <see cref="SeedNamed"/> is visible
/// to the service under test.
/// </summary>
public static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => new NamedFactory(name);
/// <summary>Seeds the fixture into the supplied (already-bound) context and saves.</summary>
public static void Seed(OtOpcUaConfigDbContext db)
{
// Two clusters under the same enterprise; only MAIN gets a hierarchy.
db.ServerClusters.Add(new ServerCluster
{
ClusterId = PopulatedClusterId,
Name = "Main",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
});
db.ServerClusters.Add(new ServerCluster
{
ClusterId = EmptyClusterId,
Name = "Site A",
Enterprise = "zb",
Site = "site-a",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
});
// MAIN: one area → one line → one equipment.
db.UnsAreas.Add(new UnsArea
{
UnsAreaId = "AREA-1",
ClusterId = PopulatedClusterId,
Name = "assembly",
});
db.UnsLines.Add(new UnsLine
{
UnsLineId = "LINE-1",
UnsAreaId = "AREA-1",
Name = "line-a",
});
db.Equipment.Add(new Equipment
{
EquipmentId = SeededEquipmentId,
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = "machine-1",
MachineCode = "machine_001",
});
// Two driver tags + one virtual tag on the seeded equipment → ChildCount 3.
db.Tags.Add(new Tag
{
TagId = "TAG-1",
DriverInstanceId = "DRV-1",
EquipmentId = SeededEquipmentId,
Name = "speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.Tags.Add(new Tag
{
TagId = "TAG-2",
DriverInstanceId = "DRV-1",
EquipmentId = SeededEquipmentId,
Name = "running",
DataType = "Boolean",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
// A tag with no equipment must be ignored by the count query.
db.Tags.Add(new Tag
{
TagId = "TAG-ORPHAN",
DriverInstanceId = "DRV-1",
EquipmentId = null,
Name = "orphan",
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = "VTAG-1",
EquipmentId = SeededEquipmentId,
Name = "computed",
DataType = "Double",
ScriptId = "SCRIPT-1",
});
db.SaveChanges();
}
/// <summary>Seeds the fixture into a context bound to the supplied InMemory database name.</summary>
public static void SeedNamed(string name)
{
using var db = CreateNamed(name);
Seed(db);
}
/// <summary>
/// Minimal <see cref="IDbContextFactory{TContext}"/> that hands back contexts sharing a
/// single InMemory database name — the test-side stand-in for the runtime pooled factory.
/// </summary>
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateNamed(name));
}
}
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="xunit"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>