# 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 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 Build( IReadOnlyList clusters, IReadOnlyList areas, IReadOnlyList lines, IReadOnlyList 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()` after the `IBrowserSessionService` registration in `AddAdminUI`) - Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj` (add ``) - 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`. ```csharp public interface IUnsTreeService { Task> 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().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> 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 CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default); Task UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default); Task DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default); Task CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default); Task UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default); Task 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 CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default); Task UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default); Task 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> 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 CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default); Task UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default); Task 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> 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 CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default); Task UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default); Task 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 Roots { get; set; } = default!; [Parameter] public EventCallback OnAddChild { get; set; } [Parameter] public EventCallback OnEdit { get; set; } [Parameter] public EventCallback OnDelete { get; set; } [Parameter] public EventCallback 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 `` (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 ``. **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** ``, 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 `` (from `Svc.LoadTagDriversForEquipmentAsync`), DataType `