# Tabbed Equipment Detail Page (UNS) — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Replace the `/uns` modal-based equipment editor with a dedicated `/uns/equipment/{id}` page carrying `Details · Tags · Virtual Tags · Alarms` tabs; trim the UNS tree so Equipment is a leaf that links to the page; remove the standalone `/scripted-alarms` pages in favour of the per-equipment Alarms tab. **Architecture:** A single new Blazor Server page (`EquipmentPage.razor`) hosts an in-page `nav-tabs` strip (no route-per-tab). The Tags and Virtual Tags tabs **reuse the existing `TagModal` and `VirtualTagModal` unchanged** (they already take a fixed `EquipmentId`); only the Alarms editor is new. Per-equipment list + alarm CRUD are added to `IUnsTreeService` so they're unit-tested like the rest. The `/uns` tree (`GlobalUns.razor` + `UnsTree.razor`) drops equipment lazy-loading and navigates to the page instead. **Tech Stack:** .NET 10, Blazor Server (InteractiveServer), EF Core (`OtOpcUaConfigDbContext`, optimistic `RowVersion`), xUnit + Shouldly, EF InMemory test provider. No bUnit. **Design doc:** `docs/plans/2026-06-11-equipment-page-design.md` (committed master `df2a488b`). --- ## Conventions & guardrails (apply to EVERY task) - **Branch:** all code tasks run on `feat/uns-equipment-page` off master `df2a488b` (created in Task 0). Never commit to master. - **Staging:** stage by explicit path only — **never `git add .`**. Never stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`. No force-push, no `--no-verify`. - **No schema change:** the `ScriptedAlarm` entity already exists. This plan adds only AdminUI service methods + DTOs + one defaulted result field. **No Configuration entity edit, no EF migration.** - **Build-green invariant:** every task ends with a compiling solution + green tests. The ordering below preserves this (e.g. `LoadEquipmentChildrenAsync` is only removed *after* its last caller is gone). - **Reuse, don't rewrite:** `TagModal` and `VirtualTagModal` are used verbatim. Do not modify them. - **Build/test commands:** - Build AdminUI: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` - Test: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` - Single test: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~."` ## Same-file contention (drives serialization) - `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` + `IUnsTreeService.cs` are touched by **Task 1, Task 2, Task 3, Task 8** → those serialize. - `Components/Pages/Uns/EquipmentPage.razor` is touched by **Task 4, Task 5, Task 6** → those serialize. - `GlobalUns.razor` + `UnsTree.razor` are touched by **Task 7** only. - Pages/nav deletions (**Task 9**) touch disjoint files → parallelizable with Task 7/Task 8. --- ## Task 0: Create feature branch **Classification:** trivial **Estimated implement time:** ~1 min **Parallelizable with:** none **Files:** none (git only) **Step 1:** From master at `df2a488b`: ```bash git checkout master git rev-parse --short HEAD # expect df2a488b git checkout -b feat/uns-equipment-page ``` **Step 2:** Confirm clean-ish tree (the untracked `sql_login.txt`, `pki/`, `pending.md`, `.fixdocs-batches/`, `OtOpcUa-docs-*.md` and the modified `docker-dev/docker-compose.yml` are pre-existing and MUST be left alone — never stage them). --- ## Task 1: `UnsMutationResult.CreatedId` + populate on equipment create **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** none (precedes Task 2/3 on `UnsTreeService.cs`) **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs:497-543` (`CreateEquipmentAsync`) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs` **Why:** the equipment create→edit redirect (`/uns/equipment/new` → `/uns/equipment/{newId}`) needs the system-generated `EQ-…` id back. A **defaulted** third positional keeps all ~50 existing `new UnsMutationResult(...)` call sites compiling. **Step 1: Write the failing test** (in `UnsTreeServiceEquipmentTests.cs`, match the file's existing seeding/style): ```csharp [Fact] public async Task CreateEquipment_returns_generated_id_in_CreatedId() { var dbName = $"uns-eq-createdid-{Guid.NewGuid():N}"; UnsTreeTestDb.SeedNamed(dbName); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var input = new EquipmentInput("machine-2", "machine_002", "LINE-1", null, null, null, null, null, null, null, null, null, null, null, null, true); var result = await service.CreateEquipmentAsync(input); result.Ok.ShouldBeTrue(); result.CreatedId.ShouldNotBeNull(); result.CreatedId!.ShouldStartWith("EQ-"); } ``` **Step 2: Run it — expect FAIL** (`CreatedId` does not exist / is null): `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~CreateEquipment_returns_generated_id_in_CreatedId"` **Step 3: Add the field.** In `UnsMutationResult.cs` change the record + doc: ```csharp /// Whether the mutation was applied. /// The operator-facing failure message, or null on success. /// On a successful create, the new entity's system id (e.g. the generated /// EQ-… equipment id) so the caller can navigate to it; null for updates/deletes/failures. public readonly record struct UnsMutationResult(bool Ok, string? Error, string? CreatedId = null); ``` **Step 4: Populate it** in `CreateEquipmentAsync` — change the final success return (line ~542) from `new UnsMutationResult(true, null)` to: ```csharp return new UnsMutationResult(true, null, equipmentId); ``` (Leave every other `new UnsMutationResult(...)` call site untouched — the default covers them.) **Step 5: Run the test — expect PASS.** Then build the AdminUI to confirm no call site broke: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → expect 0 errors. **Step 6: Commit** (stage by path): ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs git commit -m "feat(uns): carry created id on UnsMutationResult for equipment create" ``` --- ## Task 2: Per-equipment Tag/VirtualTag list service methods **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (after Task 1 on `UnsTreeService.cs`) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (add 2 method decls near `LoadEquipmentChildrenAsync`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (add 2 impls after `LoadEquipmentChildrenAsync` ~line 128) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentChildRowsTests.cs` **Why:** the new page's Tags/Virtual Tags tabs render tables with driver/type columns; the existing `LoadEquipmentChildrenAsync` returns tree `UnsNode`s without those columns. **Do NOT remove `LoadEquipmentChildrenAsync` here** — `GlobalUns` still calls it until Task 7; it's removed in Task 8. **Step 1: New DTO file** `EquipmentChildRows.cs`: ```csharp using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// A tag row for the equipment page's Tags tab table — display columns plus the id used to /// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path). public sealed record EquipmentTagRow(string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel); /// A virtual-tag row for the equipment page's Virtual Tags tab table. public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled); ``` **Step 2: Interface decls** in `IUnsTreeService.cs` (after `LoadEquipmentChildrenAsync`, ~line 128, with XML docs in the file's style): ```csharp /// Lists the equipment's tags for the Tags-tab table, ordered by Name. Task> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default); /// Lists the equipment's virtual tags for the Virtual-Tags-tab table, ordered by Name. Task> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default); ``` **Step 3: Failing test** `UnsTreeServiceEquipmentChildRowsTests.cs` (mirror `UnsTreeServiceLazyTests` seeding — `UnsTreeTestDb.SeededEquipmentId` has TAG-1 `speed/Float`, TAG-2 `running/Boolean`, VTAG-1 `computed/Double`): ```csharp using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; [Trait("Category", "Unit")] public sealed class UnsTreeServiceEquipmentChildRowsTests { private static UnsTreeService SeededService() { var dbName = $"uns-childrows-{Guid.NewGuid():N}"; UnsTreeTestDb.SeedNamed(dbName); return new UnsTreeService(UnsTreeTestDb.Factory(dbName)); } [Fact] public async Task LoadTagsForEquipment_returns_tags_in_name_order_scoped() { var rows = await SeededService().LoadTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId); rows.Count.ShouldBe(2); // the EquipmentId=null orphan tag is excluded rows[0].TagId.ShouldBe("TAG-2"); // "running" < "speed" rows[0].Name.ShouldBe("running"); rows[0].DataType.ShouldBe("Boolean"); rows[1].TagId.ShouldBe("TAG-1"); rows[1].DataType.ShouldBe("Float"); } [Fact] public async Task LoadVirtualTagsForEquipment_returns_vtags_in_name_order() { var rows = await SeededService().LoadVirtualTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId); rows.Count.ShouldBe(1); rows[0].VirtualTagId.ShouldBe("VTAG-1"); rows[0].Name.ShouldBe("computed"); rows[0].DataType.ShouldBe("Double"); } [Fact] public async Task LoadTagsForEquipment_empty_for_unknown_equipment() => (await SeededService().LoadTagsForEquipmentAsync("EQ-NONE")).ShouldBeEmpty(); } ``` **Step 4: Run — expect FAIL** (methods not defined). **Step 5: Implement** in `UnsTreeService.cs` after `LoadEquipmentChildrenAsync` (use the same `await using var db = await dbFactory.CreateDbContextAsync(ct);` + `AsNoTracking()` pattern): ```csharp /// public async Task> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.Tags.AsNoTracking() .Where(t => t.EquipmentId == equipmentId) .OrderBy(t => t.Name) .Select(t => new EquipmentTagRow(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel)) .ToListAsync(ct); } /// public async Task> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.VirtualTags.AsNoTracking() .Where(v => v.EquipmentId == equipmentId) .OrderBy(v => v.Name) .Select(v => new EquipmentVirtualTagRow(v.VirtualTagId, v.Name, v.DataType, v.ScriptId, v.Enabled)) .ToListAsync(ct); } ``` **Step 6: Run tests — expect PASS** (3/3). Build AdminUI → 0 errors. **Step 7: Commit:** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentChildRowsTests.cs git commit -m "feat(uns): per-equipment tag/virtual-tag list service methods" ``` --- ## Task 3: Scripted-alarm service methods + DTOs (move logic into IUnsTreeService) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (after Task 2 on `UnsTreeService.cs`) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs` - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs` (`ScriptedAlarmEditDto`, `EquipmentAlarmRow`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (5 decls) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (5 impls, append before closing brace) - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs` **Why:** the standalone alarm pages used `IDbContextFactory` directly. Moving the CRUD into the service makes the Alarms tab testable like the rest and keeps the `RowVersion` guard consistent. **The `ScriptedAlarm` entity + all runtime/historian wiring are untouched.** `ScriptedAlarmId` stays operator-typed (it is NOT system-generated — confirmed in the old editor's regex `^[A-Za-z0-9_-]+$`). **Step 1: Input + DTO files.** `ScriptedAlarmInput.cs`: ```csharp namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// Operator-editable fields for a scripted-alarm create/update on the equipment Alarms tab. /// The owning equipment is supplied separately (the tab fixes it), so there is no equipment field. public sealed record ScriptedAlarmInput( string ScriptedAlarmId, string Name, string AlarmType, int Severity, string MessageTemplate, string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled); ``` `ScriptedAlarmDtos.cs`: ```csharp namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// A scripted-alarm row for the Alarms-tab table. public sealed record EquipmentAlarmRow(string ScriptedAlarmId, string Name, string AlarmType, int Severity, string PredicateScriptId, bool Enabled); /// A scripted alarm projected for editing, with the concurrency token the modal echoes back. public sealed record ScriptedAlarmEditDto( string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate, string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled, byte[] RowVersion); ``` **Step 2: Interface decls** (append to `IUnsTreeService.cs`, XML-documented in file style): ```csharp Task> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default); Task LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default); Task CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default); Task UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default); Task DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default); ``` **Step 3: Failing tests** `UnsTreeServiceScriptedAlarmTests.cs` (use `UnsTreeTestDb.SeededEquipmentId`; InMemory does not enforce FKs so `PredicateScriptId` can be `"SCRIPT-1"`): ```csharp using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; [Trait("Category", "Unit")] public sealed class UnsTreeServiceScriptedAlarmTests { private static (UnsTreeService Svc, string Db) Seeded() { var dbName = $"uns-alarm-{Guid.NewGuid():N}"; UnsTreeTestDb.SeedNamed(dbName); return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); } private static ScriptedAlarmInput Sample(string id = "SA-1") => new(id, "Over-temp", "LimitAlarm", 700, "{TagPath} hot", "SCRIPT-1", HistorizeToAveva: true, Retain: true, Enabled: true); [Fact] public async Task Create_then_list_and_load_roundtrips() { var (svc, _) = Seeded(); var create = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample()); create.Ok.ShouldBeTrue(); create.CreatedId.ShouldBe("SA-1"); var rows = await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId); rows.Count.ShouldBe(1); rows[0].Name.ShouldBe("Over-temp"); rows[0].Severity.ShouldBe(700); var dto = await svc.LoadScriptedAlarmAsync("SA-1"); dto.ShouldNotBeNull(); dto!.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId); dto.HistorizeToAveva.ShouldBeTrue(); dto.RowVersion.ShouldNotBeNull(); } [Fact] public async Task Create_rejects_duplicate_id() { var (svc, _) = Seeded(); (await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample())).Ok.ShouldBeTrue(); var dup = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample()); dup.Ok.ShouldBeFalse(); dup.Error.ShouldNotBeNull(); } [Fact] public async Task Update_changes_fields() { var (svc, _) = Seeded(); await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample()); var dto = await svc.LoadScriptedAlarmAsync("SA-1"); var upd = await svc.UpdateScriptedAlarmAsync("SA-1", Sample() with { Name = "Renamed", Severity = 250, HistorizeToAveva = false }, dto!.RowVersion); upd.Ok.ShouldBeTrue(); var after = await svc.LoadScriptedAlarmAsync("SA-1"); after!.Name.ShouldBe("Renamed"); after.Severity.ShouldBe(250); after.HistorizeToAveva.ShouldBeFalse(); } [Fact] public async Task Delete_removes_row() { var (svc, _) = Seeded(); await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample()); var dto = await svc.LoadScriptedAlarmAsync("SA-1"); (await svc.DeleteScriptedAlarmAsync("SA-1", dto!.RowVersion)).Ok.ShouldBeTrue(); (await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId)).ShouldBeEmpty(); } } ``` **Step 4: Run — expect FAIL.** **Step 5: Implement** the five methods in `UnsTreeService.cs` (append before the final `}`), mirroring the existing dup-check + `db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion` + `catch (DbUpdateConcurrencyException)` patterns. Note `Severity`/`Name`/`AlarmType` are validated client-side in the modal; the service enforces only structural rules (dup id, row-exists, concurrency) — matching the old page: ```csharp /// public async Task> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.ScriptedAlarms.AsNoTracking() .Where(a => a.EquipmentId == equipmentId) .OrderBy(a => a.Name) .Select(a => new EquipmentAlarmRow(a.ScriptedAlarmId, a.Name, a.AlarmType, a.Severity, a.PredicateScriptId, a.Enabled)) .ToListAsync(ct); } /// public async Task LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.ScriptedAlarms.AsNoTracking() .Where(a => a.ScriptedAlarmId == scriptedAlarmId) .Select(a => new ScriptedAlarmEditDto(a.ScriptedAlarmId, a.EquipmentId, a.Name, a.AlarmType, a.Severity, a.MessageTemplate, a.PredicateScriptId, a.HistorizeToAveva, a.Retain, a.Enabled, a.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); if (await db.ScriptedAlarms.AnyAsync(a => a.ScriptedAlarmId == input.ScriptedAlarmId, ct)) return new UnsMutationResult(false, $"ScriptedAlarm '{input.ScriptedAlarmId}' already exists."); db.ScriptedAlarms.Add(new ScriptedAlarm { ScriptedAlarmId = input.ScriptedAlarmId, EquipmentId = equipmentId, Name = input.Name, AlarmType = input.AlarmType, Severity = input.Severity, MessageTemplate = input.MessageTemplate, PredicateScriptId = input.PredicateScriptId, HistorizeToAveva = input.HistorizeToAveva, Retain = input.Retain, Enabled = input.Enabled, }); await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null, input.ScriptedAlarmId); } /// public async Task UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct); if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = input.Name; entity.AlarmType = input.AlarmType; entity.Severity = input.Severity; entity.MessageTemplate = input.MessageTemplate; entity.PredicateScriptId = input.PredicateScriptId; entity.HistorizeToAveva = input.HistorizeToAveva; entity.Retain = input.Retain; entity.Enabled = input.Enabled; try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this scripted alarm while you were editing."); } } /// public async Task DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct); if (entity is null) return new UnsMutationResult(true, null); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; db.ScriptedAlarms.Remove(entity); try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this alarm while you were viewing it."); } catch (Exception ex) { return new UnsMutationResult(false, $"Delete failed: {ex.Message}."); } } ``` (Ensure `ScriptedAlarm` is in scope — `using ZB.MOM.WW.OtOpcUa.Configuration.Entities;` is already imported at the top of `UnsTreeService.cs`.) **Step 6: Run tests — expect PASS** (4/4). Build → 0 errors. **Step 7: Commit:** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \ tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs git commit -m "feat(uns): scripted-alarm CRUD in IUnsTreeService for the equipment Alarms tab" ``` --- ## Task 4: `EquipmentPage` shell + Details tab + create→redirect **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** none (precedes Task 5/6 on `EquipmentPage.razor`) > High-risk because it's a new routed page wiring auth + create/edit/concurrency; serial spec→code review + final integration review at execution time. **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor` - Reference (copy form from): `Components/Shared/Uns/EquipmentModal.razor` (do NOT delete it yet — `GlobalUns` still uses it until Task 7) - Reference (route precedent): `Components/Pages/ScriptEdit.razor` **Scope:** the page shell + the **Details** tab only. Tabs `Tags`/`Virtual Tags`/`Alarms` render a placeholder in this task (`

`); they're wired in Task 5/6. **Step 1: Create `EquipmentPage.razor`.** Structure (fill the Details form by copying the `_form` fields, `OnParametersSet` mapping, `SaveAsync` body and `FormModel` from `EquipmentModal.razor:33-251`, adapting the save to navigate): ```razor @page "/uns/equipment/new" @page "/uns/equipment/{EquipmentId}" @attribute [Microsoft.AspNetCore.Authorization.Authorize] @rendermode RenderMode.InteractiveServer @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @inject IUnsTreeService Svc @inject NavigationManager Nav Equipment

@(IsNew ? "New equipment" : _equipment?.Name ?? EquipmentId)

Back to UNS
@if (_loading) {

Loading…

} else if (!IsNew && _equipment is null) {
@EquipmentId not found.
} else { @if (_activeTab == "details") { @* Lifted EquipmentModal form: identity (Name/MachineCode/UNS line/Driver/ZTag/SAPID/Enabled) + OPC 40010 identification block. EditForm Model=_form OnValidSubmit=SaveAsync. *@ @* ... copy the field markup from EquipmentModal.razor:24-106 (drop the modal-* wrappers) ... *@ @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
}
} else if (_activeTab == "tags") {

Tags tab — wired in Task 5.

} else if (_activeTab == "vtags") {

Virtual Tags tab — wired in Task 5.

} else if (_activeTab == "alarms") {

Alarms tab — wired in Task 6.

} } @code { [Parameter] public string? EquipmentId { get; set; } [SupplyParameterFromQuery] public string? LineId { get; set; } private bool IsNew => string.IsNullOrEmpty(EquipmentId); private string _activeTab = "details"; private bool _loading = true; private bool _busy; private string? _error; private EquipmentEditDto? _equipment; private FormModel _form = new(); private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>(); private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>(); private string TabClass(string tab) => _activeTab == tab ? "active" : ""; protected override async Task OnParametersSetAsync() { _loading = true; if (!IsNew) { _equipment = await Svc.LoadEquipmentAsync(EquipmentId!); if (_equipment is not null) { LoadFormFrom(_equipment); await LoadPickersForClusterOfLineAsync(_equipment.UnsLineId); } } else { _form = new FormModel { UnsLineId = LineId ?? "" }; await LoadPickersForClusterOfLineAsync(LineId); } _loading = false; } // Build _lineOptions + _driverOptions for the cluster owning the given line, reusing // Svc.LoadDriversForClusterAsync. (Resolve clusterId from the line; see note below.) private async Task LoadPickersForClusterOfLineAsync(string? lineId) { /* see Step 2 */ } private void LoadFormFrom(EquipmentEditDto e) { /* copy EquipmentModal.OnParametersSet edit branch */ } private async Task SaveAsync() { _busy = true; _error = null; try { var input = new EquipmentInput(_form.Name, _form.MachineCode, _form.UnsLineId, _form.DriverInstanceId, _form.ZTag, _form.SAPID, _form.Manufacturer, _form.Model, _form.SerialNumber, _form.HardwareRevision, _form.SoftwareRevision, _form.YearOfConstruction, _form.AssetLocation, _form.ManufacturerUri, _form.DeviceManualUri, _form.Enabled); var result = IsNew ? await Svc.CreateEquipmentAsync(input) : await Svc.UpdateEquipmentAsync(_equipment!.EquipmentId, input, _equipment.RowVersion); if (!result.Ok) { _error = result.Error; return; } if (IsNew) { Nav.NavigateTo($"/uns/equipment/{result.CreatedId}"); } else { _equipment = await Svc.LoadEquipmentAsync(EquipmentId!); if (_equipment is not null) LoadFormFrom(_equipment); } } finally { _busy = false; } } private sealed class FormModel { /* identical to EquipmentModal.FormModel:232-251 */ } } ``` **Step 2: Cluster/line/driver pickers.** The page needs the line `` over `Scripts`), `MessageTemplate`, `HistorizeToAveva` (default true), `Retain`, `Enabled`. `FormModel` = `ScriptedAlarmEdit.FormModel:222-235` minus `EquipmentId`. `OnParametersSet` rebuilds `_form` from `Existing` on edit / fresh on create. `SaveAsync`: ```csharp var input = new ScriptedAlarmInput(_form.ScriptedAlarmId, _form.Name, _form.AlarmType, _form.Severity, _form.MessageTemplate, _form.PredicateScriptId, _form.HistorizeToAveva, _form.Retain, _form.Enabled); var result = IsNew ? await Svc.CreateScriptedAlarmAsync(EquipmentId!, input) : await Svc.UpdateScriptedAlarmAsync(Existing!.ScriptedAlarmId, input, Existing.RowVersion); if (result.Ok) await OnSaved.InvokeAsync(); else _error = result.Error; ``` Validation is the DataAnnotations on `FormModel` (`[Required]`, `[Range(1,1000)]`, `ScriptedAlarmId` regex `^[A-Za-z0-9_-]+$`) — same as the old page. **Step 2: Alarms tab content** in `EquipmentPage.razor`: table over `_alarms` (`Svc.LoadAlarmsForEquipmentAsync`), "Add alarm" button, per-row Edit + Delete, host `` (Existing via `Svc.LoadScriptedAlarmAsync(id)`, Scripts via `Svc.LoadScriptsAsync()`, delete via `Svc.LoadScriptedAlarmAsync(id)` for RowVersion then `Svc.DeleteScriptedAlarmAsync(id, dto.RowVersion)`). `OnSaved` reloads `_alarms` + hides modal. **Step 3: Build** → 0 errors. **Step 4: Commit:** ```bash git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ScriptedAlarmModal.razor \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor git commit -m "feat(uns): Alarms tab + ScriptedAlarmModal on the equipment page" ``` --- ## Task 7: `/uns` tree surgery — Equipment becomes a leaf; remove EquipmentModal **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** Task 9 > High-risk: surgical edits to the 671-line main UNS page + the shared tree renderer. Serial spec→code review + final integration review. **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor` - Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor` **Step 1: `UnsTree.razor` Equipment actions** (`:119-128`): replace the equipment `+ Tag` / `+ Virtual tag` / `Edit` buttons with an **Open** link to the page; keep **Delete**: ```razor case UnsNodeKind.Equipment: Open break; ``` Also drop the `Tag`/`VirtualTag` action cases (`:130-136`) — those nodes are no longer rendered. The `OnAddVirtualTag` parameter becomes unused; remove it (and its callers in `GlobalUns`). **Step 2: Make equipment a leaf.** In `UnsNode`/`UnsTreeAssembly.BuildEquipment` (`UnsNode.cs:233-246`) set `HasLazyChildren = false` and `ChildCount` can stay (badge is harmless) — but with `HasLazyChildren=false` and no eager children, the tree shows no expander. Simplest: in `BuildEquipment`, set `HasLazyChildren = false` (drop the `childCount > 0` expander). Keep the `ChildCount` badge if desired, or set 0. (Decide: keeping the count badge is informative; set `HasLazyChildren = false` only.) **Step 3: `GlobalUns.razor` removals.** Remove: - `` (`:67-74`), `` (`:76-82`), `` (`:84-90`) usages. - The equipment/tag/vtag modal state fields (`:147-167`), `_childRefreshEquipmentId` (`:172-173`). - `ToggleAsync`'s equipment lazy-load branch (`:226-247`) — equipment no longer expands; `ToggleAsync` keeps only the structural toggle (or equipment toggle becomes a no-op since no expander is shown). - `HandleAddChild` Equipment case (`:288-295`) and `HandleAddVirtualTag` (`:304-323`) entirely. - `HandleEdit` Equipment/Tag/VirtualTag cases (`:358-389`). - `ConfirmDeleteAsync` Tag/VirtualTag branches (`:443-473`) — equipment delete stays (`:487-491`); tag/vtag deletes are gone from the tree. - `OnEquipmentChildModalSavedAsync`, `RefreshEquipmentChildrenAsync`, `FindEquipmentNode` helpers (`:522-584`) — now unused. - The matching cleanup lines in `CloseModals` (`:606-623`). **Step 4: Rewire navigation.** Add `@inject NavigationManager Nav`. Change: - `HandleAddChild` **Line** case (`:279-286`): instead of opening EquipmentModal, `Nav.NavigateTo($"/uns/equipment/new?lineId={node.EntityId}")`. - Equipment "Open" is already an `` in the tree (Step 1) — no handler needed. (If you prefer routing through a handler, add an `OnOpen` callback; the `` is simpler and sufficient.) **Step 5: Delete `EquipmentModal.razor`** (its only consumer is now gone). `TagModal.razor` + `VirtualTagModal.razor` are **kept** (reused by `EquipmentPage`). **Step 6: Build** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → 0 errors. Note: `LoadEquipmentChildrenAsync` is now unreferenced by `GlobalUns` but **still defined** — that's fine (removed in Task 8). Grep to confirm no other `GlobalUns` references to the removed helpers remain. **Step 7: Commit:** ```bash git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs git commit -m "feat(uns): equipment is a tree leaf linking to the detail page; drop EquipmentModal" ``` --- ## Task 8: Remove now-dead `LoadEquipmentChildrenAsync` + port its test **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 9 (disjoint files), after Task 7 **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (remove decl `:120-128`) - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (remove impl `:86-128`) - Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs` (coverage replaced by Task 2's `UnsTreeServiceEquipmentChildRowsTests`) **Step 1: Confirm dead.** `grep -rn "LoadEquipmentChildrenAsync" src tests` → expect only the interface decl, the impl, and the lazy test (all being removed). If any other caller exists, STOP and report. **Step 2: Remove** the interface decl + impl + the `UnsNode`-returning child-node code, and `git rm` the lazy test (its behaviour is now covered by the row-DTO tests). **Step 3: Build + test** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` → green. **Step 4: Commit:** ```bash git rm tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs git commit -m "refactor(uns): drop dead LoadEquipmentChildrenAsync (replaced by row-DTO list methods)" ``` --- ## Task 9: Delete standalone scripted-alarm pages + nav link **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 7, Task 8 (disjoint files), after Task 6 **Files:** - Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor` - Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor` - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor:24` (remove the `` line) **Step 1: Grep-sweep** `grep -rn "scripted-alarms" src tests docs` to find every reference. Expected: the two pages (being deleted), `MainLayout.razor:24`, and possibly docs. The `/alerts` runtime page is separate (route `/alerts`) and stays. If an **integration test** navigates to `/scripted-alarms`, update or remove that assertion (report it if non-trivial). **Step 2: Delete the two pages, remove the nav line.** **Step 3: Build** → 0 errors. (No nav-rail item should reference a deleted route.) **Step 4: Commit:** ```bash git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor \ src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor git commit -m "feat(uns): remove standalone scripted-alarm pages + nav link (alarms now per-equipment)" ``` --- ## Task 10: Docs **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 7/8/9 (doc files only) **Files:** - Modify: `docs/Uns.md` - Modify: `docs/ScriptedAlarms.md` - Modify: `docs/AlarmTracking.md` **Step 1:** `docs/Uns.md` — the tree now stops at Equipment (no inline tags/virtual tags); document the new `/uns/equipment/{id}` page and its `Details · Tags · Virtual Tags · Alarms` tabs; "Add equipment" navigates to the page; tags/virtual tags/alarms are managed on the page, not the tree. **Step 2:** `docs/ScriptedAlarms.md` + `docs/AlarmTracking.md` — repoint the AdminUI-surface notes from the standalone `/scripted-alarms` pages to the equipment page's **Alarms tab**. Keep all runtime/historian/OPC-Part-9 content unchanged (only the editing surface moved). **Step 3: Commit:** ```bash git add docs/Uns.md docs/ScriptedAlarms.md docs/AlarmTracking.md git commit -m "docs(uns): document the equipment page tabs; repoint alarm-editing surface" ``` --- ## Task 11: Full-suite gate + live `/run` verify + finish **Classification:** standard (verification) **Estimated implement time:** ~5 min (+ user-driven live verify) **Parallelizable with:** none (last) **Files:** none (verification + finishing) **Step 1: Full build + test.** ```bash dotnet build ZB.MOM.WW.OtOpcUa.slnx # 0 errors dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests # green ``` (Run the broader `dotnet test ZB.MOM.WW.OtOpcUa.slnx` if time permits; the AdminUI suite is the load-bearing one for this change.) **Step 2: Live `/run` (user drives — the agent does NOT sign in).** Verify on docker-dev: - `/uns` tree stops at Equipment (no tag/virtual-tag leaves); equipment row shows **Open** + **Delete**. - "Open" → `/uns/equipment/{id}`; tabs `Details · Tags · Virtual Tags · Alarms` render. - Details edit saves; "Add equipment" on a Line → `/uns/equipment/new?lineId=…` → Create → redirects to `/uns/equipment/{newId}` with tabs live. - Tags tab: add/edit (driver-typed editor) + delete round-trip. Virtual Tags tab: add/edit (inline Monaco) + delete. Alarms tab: add/edit/delete; HistorizeToAveva defaults on. - `/scripted-alarms` is gone (404) and absent from the nav rail. **Step 3: Finish.** REQUIRED SUB-SKILL: `superpowers-extended-cc:finishing-a-development-branch`. Default intent (carried from this session): merge `feat/uns-equipment-page` to master + push. --- ## Task dependency summary ``` T0 ─┬─ T1 ─ T2 ─ T3 ───────────── T8 (after T7) │ │ │ │ └─────┴─ T4 ─ T5 ─ T6 ─ T7 ─┐ │ │ ├─ T11 │ T9 (after T6)──────┤ └───────────────── T10 ──────────────┘ ``` - **UnsTreeService.cs / IUnsTreeService.cs** serialize: T1 → T2 → T3 → (T4 picker-context, if added) → T8. - **EquipmentPage.razor** serializes: T4 → T5 → T6. - T7 (GlobalUns/UnsTree) needs T4 (route exists); ∥ with T9/T10. - T8 needs T7 (last caller gone) + T2 (replacement exists). - T9 needs T6 (Alarms tab is the replacement surface); ∥ with T7/T8/T10. - T11 last.