32 KiB
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)
- TagModal = the generic
TagEdit.razorform. The per-driver tag editors (ModbusTagRowetc.) edit the driver config blob, a different model from theTagtable. The tree's Tag level shows equipment-boundTagrows, whose canonical editor isTagEdit.razor(rawTagConfigJSON textarea). Driver-typed specialized tag field-sets are deferred (follow-upF-uns-1), consistent with the design's "generic editor guaranteed, specialized incremental" clause. - 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. - Concurrency caveat: the EF InMemory provider does not enforce
rowversion, soDbUpdateConcurrencyExceptionpaths are implemented (passRowVersionasOriginalValue, catch the exception) but verified at runtime, not in unit tests — matching the existing codebase (which has zero concurrency-exception tests). - 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.
- Hard git rules: never
git add .(stage by path); never stagesql_login.txtorsrc/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. - 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 sharingEnterprise="zb"produce one Enterprise node with two Cluster children.Build_nests_area_line_equipment_under_owning_cluster— an area's branch lands under itsClusterIdcluster node; line under area; equipment under line.Build_sets_equipment_child_count_and_lazy_flag— equipment withTagCount=2,VirtualTagCount=1getsChildCount==3andHasLazyChildren==true; an equipment with zero getsHasLazyChildren==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(addAddScoped<IUnsTreeService, UnsTreeService>()after theIBrowserSessionServiceregistration inAddAdminUI) - 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, catchDbUpdateConcurrencyException→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 (
UpdateAreaAsyncwhennewClusterIddiffers from current): collect equipment under the area (lines of area → equipment of lines) that are driver-bound; if any such equipment'sDriverInstance.ClusterId != newClusterId, returnError = "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.
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:
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 theequipment,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 afterClusters; 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:
git rmthe 10 razor files above (the 11th surface, the/virtual-tagsnav link, was removed in Task 16).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/unscomponents, which don't match these). Fix any stragglers.dotnet build ZB.MOM.WW.OtOpcUa.slnx→ clean (Razor route/component removal compiles).- 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:
dotnet build ZB.MOM.WW.OtOpcUa.slnx→ clean.dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests→ green; thendotnet test ZB.MOM.WW.OtOpcUa.slnx→ green (confirm nothing else regressed).- 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, thendotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840 -r -d 6and confirm the new branch appears. - Confirm the deleted routes 404 and the per-cluster tabs are gone; confirm site clusters
still scope correctly (
:4842unaffected).
No commit (verification only). On completion → finishing-a-development-branch.
Out of scope / follow-ups
F-uns-1— driver-typed specialized Tag field-sets inTagModal(bridging the driver-config tag model to theTagtable). 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.