docs(uns): implementation plan + task graph for global UNS management

This commit is contained in:
Joseph Doherty
2026-06-08 12:11:40 -04:00
parent 3361eac6d8
commit 944732e500
2 changed files with 644 additions and 0 deletions
@@ -0,0 +1,622 @@
# Global UNS Management Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Replace the per-cluster UNS/Equipment/Tags AdminUI pages with one global master tree-table at `/uns` (Enterprise → Site/Cluster → Area → Line → Equipment → Tag/VirtualTag), editing every layer via modals backed by a thin, unit-tested `IUnsTreeService`.
**Architecture:** UI-only change; the data model is frozen (no migrations). A new `IUnsTreeService` (AdminUI) owns all data access — a 6-batched-query structural load, lazy per-equipment tag load, and CRUD with `RowVersion` concurrency + the decision-#122 same-cluster guards. A recursive `UnsTree.razor` renders a `UnsNode` view-model; `GlobalUns.razor` hosts the tree + Bootstrap-modal editors folded from the deleted edit pages. The runtime/Phase7/VirtualTag **engine and the Configuration entities are NOT touched.**
**Tech Stack:** .NET 10, Blazor Server (`InteractiveServer`), EF Core (SQL Server prod / InMemory tests), `ZB.MOM.WW.Theme`, xUnit + Shouldly.
---
## Scope notes (read before starting)
1. **TagModal = the generic `TagEdit.razor` form.** The per-driver tag editors
(`ModbusTagRow` etc.) edit the *driver config blob*, a different model from the
`Tag` table. The tree's Tag level shows equipment-bound `Tag` rows, whose canonical
editor is `TagEdit.razor` (raw `TagConfig` JSON textarea). Driver-typed specialized
tag field-sets are **deferred** (follow-up `F-uns-1`), consistent with the design's
"generic editor guaranteed, specialized incremental" clause.
2. **No bUnit.** Logic lives in `IUnsTreeService` + pure VM helpers, unit-tested with
in-memory EF and Shouldly. Razor components are verified by build + manual `/run`.
3. **Concurrency caveat:** the EF **InMemory** provider does not enforce `rowversion`,
so `DbUpdateConcurrencyException` paths are implemented (pass `RowVersion` as
`OriginalValue`, catch the exception) but verified at runtime, not in unit tests —
matching the existing codebase (which has zero concurrency-exception tests).
4. **Decision #122 (same-cluster invariant) is enforced in the service:** an equipment's
driver must live in the same cluster as the equipment's line→area; an area cannot be
reassigned to a cluster where its driver-bound equipment would be orphaned; a tag's
driver must be in the equipment's cluster and in an Equipment-kind namespace.
5. **Hard git rules:** never `git add .` (stage by path); never stage `sql_login.txt` or
`src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API key into a new
tracked file; never force-push or skip hooks. Branch: `feat/global-uns-management`.
6. **Deletions happen LAST** (Task 17), after the modals have folded in the reference
forms — don't delete a page whose form you still need to read.
## Reference: source forms to fold (already on disk)
| Concern | Source page (delete in Task 17) | Service method it becomes |
|---|---|---|
| Area CRUD | `Components/Pages/Clusters/UnsAreaEdit.razor` | `*AreaAsync` |
| Line CRUD | `Components/Pages/Clusters/UnsLineEdit.razor` | `*LineAsync` |
| Equipment CRUD | `Components/Pages/Clusters/EquipmentEdit.razor` | `*EquipmentAsync` |
| Tag CRUD | `Components/Pages/Clusters/TagEdit.razor` | `*TagAsync` |
| VirtualTag CRUD | `Components/Pages/VirtualTagEdit.razor` | `*VirtualTagAsync` |
| CSV import | `Components/Pages/Clusters/ImportEquipment.razor` | reused in Task 15 |
DI registration goes in `EndpointRouteBuilderExtensions.cs` (the `AddAdminUI` method,
after the `IBrowserSessionService` line). Test project:
`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` (has `InternalsVisibleTo` from AdminUI;
**add** `Microsoft.EntityFrameworkCore.InMemory` to its csproj in Task 2).
---
## Task 1: UnsNode view-model + tree-assembly helper
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (foundation)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs`
Define the VM the renderer consumes and a **pure** assembly function (no EF) so it is
trivially unit-testable.
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
public enum UnsNodeKind { Enterprise, Cluster, Area, Line, Equipment, Tag, VirtualTag }
public sealed class UnsNode
{
public required UnsNodeKind Kind { get; init; }
public required string Key { get; init; } // stable per-node id, unique in tree
public required string DisplayName { get; init; }
public string? ClusterId { get; init; } // owning cluster (Area/Line/Equipment)
public string? EntityId { get; init; } // UnsAreaId/UnsLineId/EquipmentId/TagId/VirtualTagId
public int ChildCount { get; set; } // for badge; equipment = tag+vtag count
public bool HasLazyChildren { get; init; } // equipment with ChildCount > 0
public List<UnsNode> Children { get; } = new();
// runtime UI state (not persisted)
public bool Expanded { get; set; }
public bool Loaded { get; set; }
public bool Loading { get; set; }
public string? Error { get; set; }
}
// Flat rows the structural query returns (kept tiny + provider-agnostic).
public readonly record struct ClusterRow(string ClusterId, string Enterprise, string Site, string Name);
public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string Name);
public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name);
public readonly record struct EquipmentRow(string EquipmentId, string UnsLineId, string MachineCode, string Name, int TagCount, int VirtualTagCount);
public static class UnsTreeAssembly
{
// Pure: builds Enterprise→Cluster→Area→Line→Equipment with counts.
public static IReadOnlyList<UnsNode> Build(
IReadOnlyList<ClusterRow> clusters,
IReadOnlyList<AreaRow> areas,
IReadOnlyList<LineRow> lines,
IReadOnlyList<EquipmentRow> equipment) { /* group + nest, see steps */ }
}
```
**Step 1 — failing tests** (`UnsTreeAssemblyTests`):
- `Build_groups_clusters_under_enterprise` — two clusters sharing `Enterprise="zb"` produce one Enterprise node with two Cluster children.
- `Build_nests_area_line_equipment_under_owning_cluster` — an area's branch lands under its `ClusterId` cluster node; line under area; equipment under line.
- `Build_sets_equipment_child_count_and_lazy_flag` — equipment with `TagCount=2,VirtualTagCount=1` gets `ChildCount==3` and `HasLazyChildren==true`; an equipment with zero gets `HasLazyChildren==false`.
- `Build_includes_clusters_with_no_areas` — a cluster with no areas still appears (so you can add areas under it).
- `Build_orders_deterministically` — enterprises, clusters, areas, lines, equipment each ordered by name/id.
Use Shouldly. Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter UnsTreeAssemblyTests` → FAIL (type missing).
**Step 2 — implement** `UnsNode.cs` (enum + VM + rows + `UnsTreeAssembly.Build`). Keys:
`ent:{enterprise}`, `clu:{clusterId}`, `area:{unsAreaId}`, `line:{unsLineId}`,
`eq:{equipmentId}`. Equipment `ChildCount = TagCount + VirtualTagCount`,
`HasLazyChildren = ChildCount > 0`.
**Step 3 — run tests** → PASS.
**Step 4 — commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs
git commit -m "feat(uns): UnsNode VM + pure tree-assembly helper"
```
---
## Task 2: IUnsTreeService + LoadStructureAsync + DI
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocks all service CRUD)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `AddScoped<IUnsTreeService, UnsTreeService>()` after the `IBrowserSessionService` registration in `AddAdminUI`)
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (add `<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs`
- Test (shared helper): `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs`
`UnsTreeService` ctor takes `IDbContextFactory<OtOpcUaConfigDbContext>`.
```csharp
public interface IUnsTreeService
{
Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default);
// (lazy load + CRUD added in later tasks — keep the interface growing per task)
}
```
`LoadStructureAsync` runs **6 queries** (all `AsNoTracking`):
clusters, areas, lines, equipment, and two count maps —
`Tags.Where(t => t.EquipmentId != null).GroupBy(t => t.EquipmentId).Select(g => new { g.Key, C = g.Count() })`
and the equivalent for `VirtualTags` grouped by `EquipmentId`. Project to the `*Row`
records, join counts onto equipment, call `UnsTreeAssembly.Build`.
**Step 1 — test DB helper** `UnsTreeTestDb.cs`: a static `Create()`
`new OtOpcUaConfigDbContext(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase($"uns-{Guid.NewGuid():N}").Options)`
plus a `Seed(...)` helper that inserts a small fixture (1 enterprise, 2 clusters, areas,
lines, equipment, a couple tags + vtags). Mirror `ClusterAuditQueryTests` setup.
**Step 2 — failing tests** (`UnsTreeServiceStructureTests`, construct service over a seeded
InMemory db):
- `LoadStructure_builds_full_hierarchy` — node kinds/keys match the seed.
- `LoadStructure_counts_tags_and_vtags_per_equipment` — badge counts correct.
- `LoadStructure_includes_empty_clusters`.
Run: `dotnet test ...AdminUI.Tests --filter UnsTreeServiceStructure` → FAIL (no InMemory pkg / no service).
**Step 3 — implement** csproj pkg ref, interface, service `LoadStructureAsync`, DI line.
**Step 4 — run tests** → PASS. Also `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` clean.
**Step 5 — commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeTestDb.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceStructureTests.cs
git commit -m "feat(uns): IUnsTreeService structural load + DI registration"
```
---
## Task 3: LoadEquipmentTagsAsync (lazy leaf load)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 10 (different files)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs`
Add `Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default)`
returning Tag nodes (`Kind=Tag`, key `tag:{TagId}`) for `Tag.EquipmentId == equipmentId`
ordered by `Name`, followed by VirtualTag nodes (`Kind=VirtualTag`, key `vtag:{VirtualTagId}`)
for `VirtualTag.EquipmentId == equipmentId`. DisplayName: tag → `Name` (`DataType`), vtag → `Name` (VirtualTag).
**Steps:** failing test (`returns_tags_then_vtags_for_equipment`, `empty_for_equipment_with_none`) → implement → pass → commit.
```bash
git commit -m "feat(uns): lazy per-equipment tag + virtual-tag load"
```
---
## Task 4: Area + Line CRUD in service (+ #122 area-reassignment guard)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11 (different files)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs`
Add result type `public readonly record struct UnsMutationResult(bool Ok, string? Error);`
and methods (translate `UnsAreaEdit`/`UnsLineEdit` logic verbatim, returning the `_error`
strings as `UnsMutationResult.Error` instead of navigating):
```csharp
Task<UnsMutationResult> CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default);
Task<UnsMutationResult> UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default);
Task<UnsMutationResult> UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
```
Guards (carry over from the pages + add #122):
- Create: duplicate-id check (`AnyAsync`).
- Update/Delete: set `db.Entry(e).Property(x => x.RowVersion).OriginalValue = rowVersion`, catch `DbUpdateConcurrencyException``Error = "changed elsewhere…"`.
- Delete Area: catch FK failure → `"…lines still reference this area — remove them first."` Delete Line → `"…equipment still references this line…"`.
- **#122 area reassignment** (`UpdateAreaAsync` when `newClusterId` differs from current):
collect equipment under the area (`lines of area → equipment of lines`) that are
driver-bound; if any such equipment's `DriverInstance.ClusterId != newClusterId`, return
`Error = "Cannot move area to '{newClusterId}': equipment '{id}' is bound to a driver in another cluster (decision #122). Re-home or unbind it first."`
**Step 1 — failing tests:** create/update/delete happy paths; duplicate-id error;
area-reassignment blocked when a driver-bound equipment would orphan; reassignment allowed
when equipment is driver-less. **Step 2** implement. **Step 3** pass. **Step 4** commit:
```bash
git commit -m "feat(uns): area + line CRUD with #122 reassignment guard"
```
---
## Task 5: Equipment CRUD in service
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs`
Translate `EquipmentEdit` logic. Create generates `EquipmentId = $"EQ-{Guid.NewGuid().ToString("N")[..12]}"`
+ `EquipmentUuid`. Use a parameter object to avoid a 16-arg signature:
```csharp
public sealed record EquipmentInput(string Name, string MachineCode, string UnsLineId, string? DriverInstanceId,
string? ZTag, string? SAPID, string? Manufacturer, string? Model, string? SerialNumber, string? HardwareRevision,
string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation, string? ManufacturerUri,
string? DeviceManualUri, bool Enabled);
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
```
Guards: `UnsLineId` required; MachineCode fleet-unique on create; delete FK failure →
`"…tags or virtual tags reference this equipment…"`. **#122 driver-cluster check** on
create + update: if `DriverInstanceId` set, resolve the equipment's cluster via
`UnsLine(input.UnsLineId).UnsAreaId → UnsArea.ClusterId` and require
`DriverInstance(input.DriverInstanceId).ClusterId == that cluster`, else
`Error = "Driver '{id}' is in cluster '{dc}' but the line is in cluster '{lc}' (decision #122)."`
**Steps:** failing tests (create generates id; machinecode-dup blocked; #122 driver mismatch
blocked; driver-less allowed; update/delete) → implement → pass → commit:
```bash
git commit -m "feat(uns): equipment CRUD with #122 driver-cluster guard"
```
---
## Task 6: Tag CRUD in service
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagTests.cs`
Tree tags are always equipment-bound. Provide candidate drivers for the modal +
CRUD:
```csharp
// Drivers eligible for an equipment's tags: same cluster AND Equipment-kind namespace.
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
public sealed record TagInput(string TagId, string Name, string DriverInstanceId, string DataType,
TagAccessLevel AccessLevel, bool WriteIdempotent, string? PollGroupId, string TagConfig);
Task<UnsMutationResult> CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
```
Set `Tag.EquipmentId = equipmentId`, `FolderPath = null` (equipment-bound). Guards:
duplicate TagId; `TagConfig` must parse as JSON (`JsonDocument.Parse`, else
`"TagConfig is not valid JSON."`); driver must be in the equipment's cluster and in an
Equipment-kind namespace (resolve via `DriverInstance.NamespaceId → Namespace.Kind`);
`(EquipmentId,Name)` uniqueness (rely on DB unique index + catch, or pre-check).
**Steps:** failing tests (create equipment-bound tag; invalid-json blocked;
driver-not-in-cluster blocked; SystemPlatform-namespace driver blocked; duplicate name
blocked; update/delete) → implement → pass → commit:
```bash
git commit -m "feat(uns): equipment-bound tag CRUD with namespace + cluster guards"
```
---
## Task 7: VirtualTag CRUD in service
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 10, Task 11
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs`
Translate `VirtualTagEdit`. Provide `LoadScriptsAsync()` (id + display) for the modal.
```csharp
Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default);
public sealed record VirtualTagInput(string VirtualTagId, string Name, string DataType, string ScriptId,
bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled);
Task<UnsMutationResult> CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
```
Guards: duplicate id; trigger rule (`ChangeTriggered || TimerIntervalMs is not null`, and
if timer set it must be `>= 50`); set `EquipmentId = equipmentId`.
**Steps:** failing tests (create; trigger-rule blocked when neither set; timer<50 blocked;
update/delete) → implement → pass → commit:
```bash
git commit -m "feat(uns): equipment-bound virtual-tag CRUD"
```
---
## Task 8: (reserved — merged into Task 7)
_No-op placeholder so downstream task numbers stay stable. Skip._
---
## Task 10: UnsTree.razor recursive renderer (read-only)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4, 5, 6, 7 (service files, disjoint)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor`
Model on `Components/Shared/Drivers/DriverBrowseTree.razor`: a `RenderNode(UnsNode, depth)`
`RenderFragment` recursing via `@RenderNode(child, depth+1)`, `padding-left:{depth*18}px`,
chevron `▼/▶` button for nodes with children (structural children OR `HasLazyChildren`),
`.chip` badge showing `ChildCount`. Per-`Kind` action buttons (✎ edit / add child / 🗑
delete) rendered as `btn btn-sm btn-link`. Parameters:
```csharp
[Parameter, EditorRequired] public IReadOnlyList<UnsNode> Roots { get; set; } = default!;
[Parameter] public EventCallback<UnsNode> OnAddChild { get; set; }
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
[Parameter] public EventCallback<UnsNode> OnDelete { get; set; }
[Parameter] public EventCallback<UnsNode> OnToggleExpand { get; set; } // page handles lazy load
[Parameter] public string? Filter { get; set; } // case-insensitive DisplayName contains
```
Action visibility by `Kind`: Enterprise → none; Cluster → "+ Area" + "⚙ settings" link to
`/clusters/{ClusterId}`; Area → edit/delete + "+ Line"; Line → edit/delete + "+ Equipment";
Equipment → edit/delete + "+ Tag" + "+ Virtual tag"; Tag/VirtualTag → edit/delete.
Spinner row when `node.Loading`; error row when `node.Error != null`.
**Verification:** `dotnet build` clean (no test — pure markup). **Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor
git commit -m "feat(uns): recursive UnsTree renderer"
```
---
## Task 11: GlobalUns.razor page (browse-only)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 2, 3, 10)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
`@page "/uns"`, `@attribute [Authorize]`, `@rendermode RenderMode.InteractiveServer`,
`@inject IUnsTreeService Svc`. Toolbar (`.panel-head`): title, global filter `<input>`
(bound to a `_filter` field passed to `UnsTree.Filter`), expand/collapse-all, an "Import
equipment CSV" button (wired in Task 15), and a "Changes apply on next deployment" note.
`OnInitializedAsync``Svc.LoadStructureAsync()``_roots`. `OnToggleExpand` handler:
toggle `node.Expanded`; if equipment + `!node.Loaded` → set `Loading`, call
`Svc.LoadEquipmentChildrenAsync`, populate `node.Children`, `Loaded=true`, `StateHasChanged`.
No modals yet (added in 1214). Render `<UnsTree Roots="_roots" .../>`.
**Verification:** `dotnet build` clean; manual `/run` → browse `/uns`, expand to tags.
**Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor
git commit -m "feat(uns): GlobalUns page with browsable tree"
```
---
## Task 12: Area + Line modals, wired
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AreaModal.razor`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/LineModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
Each modal: Bootstrap `.modal.fade.show` + `.modal-backdrop` (copy the chrome from
`CollectionEditor.razor`), an `EditForm` with the fields from `UnsAreaEdit`/`UnsLineEdit`
(Area: read-only `UnsAreaId` on edit, Name, served-by **Cluster** `<select>`, Notes;
Line: read-only `UnsLineId` on edit, parent-Area `<select>`, Name, Notes), a `Visible`
parameter, and `OnSaved`/`OnCancel` callbacks. The modal calls the matching
`Svc.*AreaAsync/*LineAsync`, shows `UnsMutationResult.Error` inline, and on success invokes
`OnSaved`. In `GlobalUns`: handle `OnAddChild`/`OnEdit`/`OnDelete` for Cluster/Area/Line
kinds → open the right modal seeded with parent context; on `OnSaved` reload structure
(`LoadStructureAsync`) — simplest correct refresh — and close.
**Verification:** build clean; manual `/run`: add area under a cluster, add line under area,
edit + delete both; confirm #122 block when moving an area with driver-bound equipment.
**Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AreaModal.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/LineModal.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor
git commit -m "feat(uns): area + line modals wired into the tree"
```
---
## Task 13: Equipment modal, wired
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
Fold the full `EquipmentEdit` form (Identity + OPC 40010 sections) into a `modal-xl`.
Driver `<select>` candidates = drivers in the equipment's cluster (add
`Svc.LoadDriversForClusterAsync(clusterId)` if not already available; the equipment's
cluster comes from the parent Line node's `ClusterId`). Create uses the parent Line node;
calls `Svc.CreateEquipmentAsync/UpdateEquipmentAsync/DeleteEquipmentAsync`. Wire add (under
Line) / edit / delete for Equipment kind in `GlobalUns`; reload on save.
**Verification:** build clean; manual `/run`: create equipment under a line, edit identity +
40010 fields, delete; confirm #122 driver-cluster block.
**Commit:**
```bash
git commit -m "feat(uns): equipment modal wired into the tree"
```
---
## Task 14: Tag + VirtualTag modals, wired
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
**TagModal** folds the generic `TagEdit` form **minus** the FolderPath/SystemPlatform branch
(tree tags are always equipment-bound): TagId, Name, Driver `<select>` (from
`Svc.LoadTagDriversForEquipmentAsync`), DataType `<select>`, AccessLevel, WriteIdempotent,
PollGroupId, and the `TagConfig` JSON `<textarea>`. Per scope note 1, this is the generic
editor; driver-typed field-sets are follow-up `F-uns-1`.
**VirtualTagModal** folds `VirtualTagEdit` (Equipment is fixed to the parent node, so drop
the equipment `<select>`; keep Script `<select>` from `Svc.LoadScriptsAsync`, DataType,
ChangeTriggered, TimerIntervalMs, Historize, Enabled). Wire add (under Equipment) / edit /
delete for Tag and VirtualTag kinds; on save, lazy-reload just that equipment's children
(re-call `LoadEquipmentChildrenAsync` for the parent equipment) and the structure counts.
**Verification:** build clean; manual `/run`: add a tag + a virtual tag under an equipment,
edit + delete; confirm invalid-JSON and trigger-rule blocks.
**Commit:**
```bash
git commit -m "feat(uns): tag + virtual-tag modals wired into the tree"
```
---
## Task 15: Import equipment CSV toolbar action
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (edits GlobalUns)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs`
Port `ImportEquipment.razor`'s parse + validate + insert into
`Svc.ImportEquipmentAsync(IEnumerable<EquipmentInput> rows, CancellationToken)` returning a
summary (`int Inserted, int Skipped, IReadOnlyList<string> Errors`). Keep its rules: required
Name/MachineCode/UnsLineId/DriverInstanceId columns, validate UnsLineId + DriverInstanceId
exist, skip MachineCode duplicates, auto-generate EquipmentId per row, enforce the #122
driver-cluster check. The modal hosts the textarea/file paste + a results panel.
**Steps:** failing service test (`Import_inserts_valid_skips_dup_machinecode`,
`Import_reports_unknown_line`) → implement service + modal + toolbar button → pass + build →
manual `/run` import → commit:
```bash
git commit -m "feat(uns): equipment CSV import folded into the tree toolbar"
```
---
## Task 16: Rewire navigation
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 17 prep (but edits different files — safe alongside)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor` (remove the `equipment`, `uns`, `tags` `<li>` tabs)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor` (add `<NavRailItem Href="/uns" Text="UNS" />` in the Navigation section after `Clusters`; remove the `<NavRailItem Href="/virtual-tags" Text="Virtual tags" />` from the Scripting section)
**Verification:** `dotnet build` clean. **Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ClusterNav.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor
git commit -m "feat(uns): add global UNS nav item, drop per-cluster UNS/Equipment/Tags tabs"
```
---
## Task 17: Delete the replaced pages
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (final cleanup)
**Files (delete):**
`Components/Pages/Clusters/ClusterUns.razor`, `UnsAreaEdit.razor`, `UnsLineEdit.razor`,
`ClusterEquipment.razor`, `EquipmentEdit.razor`, `ImportEquipment.razor`, `ClusterTags.razor`,
`TagEdit.razor` (all under `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/`),
and `Components/Pages/VirtualTags.razor`, `Components/Pages/VirtualTagEdit.razor`.
**Steps:**
1. `git rm` the 10 razor files above (the 11th surface, the `/virtual-tags` nav link, was
removed in Task 16).
2. `grep -rn "ClusterUns\|UnsAreaEdit\|UnsLineEdit\|ClusterEquipment\|EquipmentEdit\|ImportEquipment\|ClusterTags\|TagEdit\|/virtual-tags\|/uns/areas\|/uns/lines\|/equipment/new\|/tags/new" src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → expect **zero** dangling references (the only hits should be the new `/uns` components, which don't match these). Fix any stragglers.
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean (Razor route/component removal compiles).
4. Commit:
```bash
git add -A src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/VirtualTags.razor src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/VirtualTagEdit.razor
git commit -m "feat(uns): remove per-cluster UNS/Equipment/Tags + standalone virtual-tag pages"
```
(Staging the specific paths above — NOT a bare `git add .`.)
---
## Task 18: Full verification gate
**Classification:** verification
**Estimated implement time:** ~5 min (+ docker)
**Parallelizable with:** none
**Steps:**
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean.
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` → green; then
`dotnet test ZB.MOM.WW.OtOpcUa.slnx` → green (confirm nothing else regressed).
3. docker-dev `/run`: `docker compose -f docker-dev/docker-compose.yml up -d --build`,
sign in, browse `/uns`, create area→line→equipment→tag→virtual-tag under MAIN, click
**Deploy current configuration**, then
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840 -r -d 6`
and confirm the new branch appears.
4. Confirm the deleted routes 404 and the per-cluster tabs are gone; confirm site clusters
still scope correctly (`:4842` unaffected).
No commit (verification only). On completion → finishing-a-development-branch.
---
## Out of scope / follow-ups
- **`F-uns-1`** — driver-typed specialized Tag field-sets in `TagModal` (bridging the
driver-config tag model to the `Tag` table). The generic JSON editor ships now.
- Reinstating a flat global VirtualTag list (removed; tree owns per-equipment vtags).
- Drag-and-drop reparenting (edit-modal reassignment only).
- Any change to the runtime/Phase7/VirtualTag engine or Configuration entities/migrations.
@@ -0,0 +1,22 @@
{
"planPath": "docs/plans/2026-06-08-global-uns-management.md",
"tasks": [
{"id": 117, "subject": "Task 1: UnsNode VM + tree-assembly helper", "status": "pending"},
{"id": 118, "subject": "Task 2: IUnsTreeService + LoadStructureAsync + DI", "status": "pending", "blockedBy": [117]},
{"id": 119, "subject": "Task 3: LoadEquipmentChildrenAsync (lazy tags)", "status": "pending", "blockedBy": [118]},
{"id": 120, "subject": "Task 4: Area + Line CRUD in service (#122 guard)", "status": "pending", "blockedBy": [118]},
{"id": 121, "subject": "Task 5: Equipment CRUD in service", "status": "pending", "blockedBy": [120]},
{"id": 122, "subject": "Task 6: Tag CRUD in service", "status": "pending", "blockedBy": [121]},
{"id": 123, "subject": "Task 7: VirtualTag CRUD in service", "status": "pending", "blockedBy": [122]},
{"id": 124, "subject": "Task 10: UnsTree.razor recursive renderer", "status": "pending", "blockedBy": [117]},
{"id": 125, "subject": "Task 11: GlobalUns.razor page (browse-only)", "status": "pending", "blockedBy": [118, 119, 124]},
{"id": 126, "subject": "Task 12: Area + Line modals, wired", "status": "pending", "blockedBy": [120, 125]},
{"id": 127, "subject": "Task 13: Equipment modal, wired", "status": "pending", "blockedBy": [121, 126]},
{"id": 128, "subject": "Task 14: Tag + VirtualTag modals, wired", "status": "pending", "blockedBy": [122, 123, 127]},
{"id": 129, "subject": "Task 15: Import equipment CSV toolbar action", "status": "pending", "blockedBy": [121, 128]},
{"id": 130, "subject": "Task 16: Rewire navigation", "status": "pending", "blockedBy": [125]},
{"id": 131, "subject": "Task 17: Delete the replaced pages", "status": "pending", "blockedBy": [126, 127, 128, 129, 130]},
{"id": 132, "subject": "Task 18: Full verification gate", "status": "pending", "blockedBy": [131]}
],
"lastUpdated": "2026-06-08"
}