Files
lmxopcua/docs/plans/2026-06-08-global-uns-management.md
T

32 KiB
Raw Blame History

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.

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:

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>.

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:

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.

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):

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 DbUpdateConcurrencyExceptionError = "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:

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:
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:

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:

// 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:

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.

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:

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:

[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:

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. OnInitializedAsyncSvc.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:

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:

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:

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:

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:

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:

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:
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.