diff --git a/docs/plans/2026-06-08-global-uns-management.md b/docs/plans/2026-06-08-global-uns-management.md new file mode 100644 index 00000000..f7548483 --- /dev/null +++ b/docs/plans/2026-06-08-global-uns-management.md @@ -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 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 `