docs(uns): implementation plan + tasks for tabbed equipment detail page

This commit is contained in:
Joseph Doherty
2026-06-11 14:12:42 -04:00
parent df2a488b81
commit 1f904c4502
2 changed files with 900 additions and 0 deletions
+876
View File
@@ -0,0 +1,876 @@
# 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~<Class>.<Method>"`
## 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
/// <param name="Ok">Whether the mutation was applied.</param>
/// <param name="Error">The operator-facing failure message, or <c>null</c> on success.</param>
/// <param name="CreatedId">On a successful create, the new entity's system id (e.g. the generated
/// <c>EQ-…</c> equipment id) so the caller can navigate to it; <c>null</c> for updates/deletes/failures.</param>
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;
/// <summary>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).</summary>
public sealed record EquipmentTagRow(string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel);
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
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
/// <summary>Lists the equipment's tags for the Tags-tab table, ordered by Name.</summary>
Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>Lists the equipment's virtual tags for the Virtual-Tags-tab table, ordered by Name.</summary>
Task<IReadOnlyList<EquipmentVirtualTagRow>> 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
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentTagRow>> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentVirtualTagRow>> 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;
/// <summary>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.</summary>
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;
/// <summary>A scripted-alarm row for the Alarms-tab table.</summary>
public sealed record EquipmentAlarmRow(string ScriptedAlarmId, string Name, string AlarmType, int Severity, string PredicateScriptId, bool Enabled);
/// <summary>A scripted alarm projected for editing, with the concurrency token the modal echoes back.</summary>
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<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default);
Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> 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
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentAlarmRow>> 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);
}
/// <inheritdoc />
public async Task<ScriptedAlarmEditDto?> 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);
}
/// <inheritdoc />
public async Task<UnsMutationResult> 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);
}
/// <inheritdoc />
public async Task<UnsMutationResult> 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."); }
}
/// <inheritdoc />
public async Task<UnsMutationResult> 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 (`<p class="text-muted">…</p>`); 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
<PageTitle>Equipment</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New equipment" : _equipment?.Name ?? EquipmentId)</h4>
<a href="/uns" class="btn btn-outline-secondary btn-sm">Back to UNS</a>
</div>
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else if (!IsNew && _equipment is null)
{
<section class="panel notice rise"><span class="mono">@EquipmentId</span> not found.</section>
}
else
{
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><button class="nav-link @TabClass("details")" @onclick='() => _activeTab = "details"'>Details</button></li>
<li class="nav-item"><button class="nav-link @TabClass("tags")" @onclick='() => _activeTab = "tags"' disabled="@IsNew">Tags</button></li>
<li class="nav-item"><button class="nav-link @TabClass("vtags")" @onclick='() => _activeTab = "vtags"' disabled="@IsNew">Virtual Tags</button></li>
<li class="nav-item"><button class="nav-link @TabClass("alarms")" @onclick='() => _activeTab = "alarms"' disabled="@IsNew">Alarms</button></li>
</ul>
@if (_activeTab == "details")
{
@* Lifted EquipmentModal form: identity (Name/MachineCode/UNS line/Driver/ZTag/SAPID/Enabled)
+ OPC 40010 identification block. EditForm Model=_form OnValidSubmit=SaveAsync. *@
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentDetails">
<DataAnnotationsValidator />
@* ... copy the field markup from EquipmentModal.razor:24-106 (drop the modal-* wrappers) ... *@
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="text-danger small mt-2">@_error</div> }
<div class="mt-3"><button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button></div>
</EditForm>
}
else if (_activeTab == "tags") { <p class="text-muted">Tags tab — wired in Task 5.</p> }
else if (_activeTab == "vtags") { <p class="text-muted">Virtual Tags tab — wired in Task 5.</p> }
else if (_activeTab == "alarms") { <p class="text-muted">Alarms tab — wired in Task 6.</p> }
}
@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 `<select>` options + driver options scoped to the equipment's cluster, the way `GlobalUns.LinesForCluster` / `LoadDriversForClusterAsync` do — but the page doesn't hold the loaded tree. Resolve via the service: add a tiny helper to `IUnsTreeService` ONLY IF NEEDED. **Prefer reuse:** `LoadEquipmentAsync` gives `UnsLineId`; to map a line→cluster and list sibling lines + cluster drivers without the tree, the simplest path is a new lightweight service method `LoadEquipmentPickContextAsync(string? lineId)` returning `(IReadOnlyList<(string Id,string Display)> Lines, IReadOnlyList<(string Id,string Display)> Drivers)`. **If you add it,** it belongs in Task 2's files — but since Task 2 is already committed, add it here in `UnsTreeService.cs`/`IUnsTreeService.cs` and note the contention. Implementation: resolve `clusterId` from `UnsLines→UnsAreas.ClusterId` for `lineId`, list all lines in that cluster (`UnsLine.Name` display), and call the existing cluster-driver query. Keep it `AsNoTracking`. Add one service test for it.
> Implementer note: if resolving the picker context inflates this task past ~300 LOC, STOP and split the picker-context service method into its own small task before continuing. Surface it; don't silently expand.
**Step 3: Build** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → 0 errors. (No unit test for the Razor page — behaviour is live-verified in Task 11. If you added the picker-context service method, add its xUnit test.)
**Step 4: Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor \
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/ # only if a picker-context test was added
git commit -m "feat(uns): equipment detail page shell + Details tab + create-redirect"
```
---
## Task 5: Tags tab + Virtual Tags tab (reuse TagModal + VirtualTagModal)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after Task 4 on `EquipmentPage.razor`)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
- Reference: `Components/Shared/Uns/TagModal.razor`, `VirtualTagModal.razor`, and `GlobalUns.razor:76-90,288-323,369-396,443-473` for the open/save/delete wiring to copy.
**Step 1: Tags tab content.** Replace the `"tags"` placeholder with a table over `_tags` (loaded via `Svc.LoadTagsForEquipmentAsync(EquipmentId!)`), an "Add tag" button, per-row Edit + Delete, and host `<TagModal>` exactly as `GlobalUns` does:
- State: `_tags`, `_tagModalVisible`, `_tagModalIsNew`, `_tagModalExisting (TagEditDto?)`, `_tagModalDriverOptions` (from `Svc.LoadTagDriversForEquipmentAsync(EquipmentId!)`).
- Add: set IsNew=true, Existing=null, load driver options, show modal.
- Edit: `await Svc.LoadTagAsync(tagId)` → Existing, load driver options, show modal.
- `OnSaved`: reload `_tags`, hide modal. `OnCancel`: hide modal.
- Delete: `await Svc.LoadTagAsync(tagId)` for fresh RowVersion → `Svc.DeleteTagAsync(tagId, dto.RowVersion)` → reload `_tags` (mirror `GlobalUns.ConfirmDeleteAsync` Tag branch; a simple confirm is fine — `_tagPendingDelete` + a small inline confirm or reuse the page's own confirm pattern).
```razor
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
Existing="_tagModalExisting" Drivers="_tagModalDriverOptions"
OnSaved="OnTagSavedAsync" OnCancel="() => _tagModalVisible = false" />
```
**Step 2: Virtual Tags tab content.** Same shape over `_vtags` (`Svc.LoadVirtualTagsForEquipmentAsync`), hosting `<VirtualTagModal>` with `Scripts` from `Svc.LoadScriptsAsync()`, Existing from `Svc.LoadVirtualTagAsync(id)`, delete via `Svc.DeleteVirtualTagAsync(id, dto.RowVersion)`:
```razor
<VirtualTagModal Visible="_vtagModalVisible" IsNew="_vtagModalIsNew" EquipmentId="@EquipmentId"
Existing="_vtagModalExisting" Scripts="_vtagScriptOptions"
OnSaved="OnVtagSavedAsync" OnCancel="() => _vtagModalVisible = false" />
```
**Step 3: Lazy tab loads.** Load `_tags`/`_vtags` when their tab is first activated (or in `OnParametersSetAsync` when `!IsNew`). Keep it simple: load both on first switch to the tab.
**Step 4: Build** → 0 errors.
**Step 5: Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor
git commit -m "feat(uns): Tags + Virtual Tags tabs on the equipment page (reuse modals)"
```
---
## Task 6: `ScriptedAlarmModal` + Alarms tab
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after Task 5 on `EquipmentPage.razor`; needs Task 3)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ScriptedAlarmModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
- Reference: `Components/Pages/ScriptedAlarmEdit.razor` (the field markup + `FormModel` to lift) and `Components/Shared/Uns/TagModal.razor` (the modal shell + `Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancel` parameter pattern).
**Step 1: `ScriptedAlarmModal.razor`.** A modal shell (copy TagModal's `@if (Visible)` backdrop + `EditForm`), parameters:
```csharp
[Parameter] public bool Visible { get; set; }
[Parameter] public bool IsNew { get; set; }
[Parameter] public string? EquipmentId { get; set; }
[Parameter] public ScriptedAlarmEditDto? Existing { get; set; }
[Parameter] public IReadOnlyList<(string Id, string Display)> Scripts { get; set; } = Array.Empty<(string, string)>();
[Parameter] public EventCallback OnSaved { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
```
Form fields lifted from `ScriptedAlarmEdit.razor:34-101` minus the **Equipment** picker (equipment is fixed by the tab): `ScriptedAlarmId` (disabled on edit), `Name`, `AlarmType` (`LimitAlarm`/`DiscreteAlarm`/`OffNormalAlarm`/`AlarmCondition`), `Severity`, `PredicateScriptId` (`<select>` 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 `<ScriptedAlarmModal>` (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:
<a class="btn btn-sm btn-link p-0 ms-2" href="@($"/uns/equipment/{node.EntityId}")">Open</a>
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
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:
- `<EquipmentModal>` (`:67-74`), `<TagModal>` (`:76-82`), `<VirtualTagModal>` (`: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 `<a href>` in the tree (Step 1) — no handler needed. (If you prefer routing through a handler, add an `OnOpen` callback; the `<a href>` 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 `<NavRailItem Href="/scripted-alarms" …>` 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.
@@ -0,0 +1,24 @@
{
"planPath": "docs/plans/2026-06-11-equipment-page.md",
"designPath": "docs/plans/2026-06-11-equipment-page-design.md",
"branch": "feat/uns-equipment-page",
"baseBranch": "master",
"baseSha": "df2a488b",
"status": "pending",
"note": "Tabbed Equipment detail page for UNS. Reuse TagModal+VirtualTagModal UNCHANGED; only the Alarms editor is new. NO Configuration entity/EF migration. Same-file contention: UnsTreeService.cs+IUnsTreeService.cs serialize T1->T2->T3->(T4 picker-context if added)->T8; EquipmentPage.razor serializes T4->T5->T6. Build-green invariant: LoadEquipmentChildrenAsync removed (T8) only after its last caller GlobalUns is rewired (T7). EquipmentModal.razor deleted in T7 (TagModal/VirtualTagModal KEPT). UnsMutationResult gains a defaulted CreatedId so ~50 call sites stay compiling.",
"tasks": [
{"id": 267, "planTask": 0, "subject": "T0: Create feature branch", "classification": "trivial", "status": "pending", "blockedBy": []},
{"id": 268, "planTask": 1, "subject": "T1: UnsMutationResult.CreatedId + equipment create", "classification": "small", "status": "pending", "blockedBy": [267]},
{"id": 269, "planTask": 2, "subject": "T2: Per-equipment Tag/VirtualTag list methods", "classification": "standard", "status": "pending", "blockedBy": [268]},
{"id": 270, "planTask": 3, "subject": "T3: Scripted-alarm CRUD in IUnsTreeService", "classification": "standard", "status": "pending", "blockedBy": [269]},
{"id": 271, "planTask": 4, "subject": "T4: EquipmentPage shell + Details tab + create-redirect", "classification": "high-risk", "status": "pending", "blockedBy": [268, 270]},
{"id": 272, "planTask": 5, "subject": "T5: Tags + Virtual Tags tabs (reuse modals)", "classification": "standard", "status": "pending", "blockedBy": [269, 271]},
{"id": 273, "planTask": 6, "subject": "T6: Alarms tab + ScriptedAlarmModal", "classification": "standard", "status": "pending", "blockedBy": [270, 272]},
{"id": 274, "planTask": 7, "subject": "T7: /uns tree surgery; remove EquipmentModal", "classification": "high-risk", "status": "pending", "blockedBy": [271], "parallelizableWith": [276]},
{"id": 275, "planTask": 8, "subject": "T8: Remove dead LoadEquipmentChildrenAsync", "classification": "small", "status": "pending", "blockedBy": [269, 274], "parallelizableWith": [276]},
{"id": 276, "planTask": 9, "subject": "T9: Delete standalone scripted-alarm pages + nav link", "classification": "small", "status": "pending", "blockedBy": [273], "parallelizableWith": [274, 275]},
{"id": 277, "planTask": 10, "subject": "T10: Docs", "classification": "small", "status": "pending", "blockedBy": []},
{"id": 278, "planTask": 11, "subject": "T11: Full-suite gate + live /run verify + finish", "classification": "standard", "status": "pending", "blockedBy": [273, 274, 275, 276, 277]}
],
"lastUpdated": "2026-06-11"
}