Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ba64b1d99 | |||
| 1bb7482c3a | |||
| 983d30cb15 | |||
| 7db9a24403 | |||
| c0346f14ce | |||
| d637b834b9 | |||
| 2beaa43d60 | |||
| 0abd1d8fc2 | |||
| a4a9dc912a | |||
| 307cec5a3d | |||
| d8fba02a5e | |||
| 77024f87da | |||
| 5a392c5db0 | |||
| ab0ff8aedf | |||
| 2836a0704b | |||
| 8b1d3de806 | |||
| ace366ebcf | |||
| 4a32edef1a | |||
| 47b1d2259f | |||
| c9f59e4bd2 | |||
| b33cf1c80d | |||
| c264441b74 | |||
| 2c0297c1af | |||
| cec670f0c8 | |||
| 0f286a70b8 | |||
| 3e8941bce4 | |||
| d9082e22e3 | |||
| 944732e500 | |||
| 3361eac6d8 |
@@ -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 3–6 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 12–14). 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 · <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 · <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 · <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) · @_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) · @_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") · <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 — @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 — @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 · <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 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 · @_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") · <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 — @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 — @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 #44–45)</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") · <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") · <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 — @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 — @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;
|
||||
}
|
||||
}
|
||||
+191
@@ -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 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
|
||||
· <strong>Skipped (existing MachineCode):</strong> @_result.Skipped
|
||||
· <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 #44–45)</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…
|
||||
</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 #44–45).</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 > 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 >= 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>
|
||||
|
||||
Reference in New Issue
Block a user