docs(uns): implementation plan + task graph for global UNS management
This commit is contained in:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user