From 1f904c4502edc6636f49d103212c50a845a17ffa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 14:12:42 -0400 Subject: [PATCH] docs(uns): implementation plan + tasks for tabbed equipment detail page --- docs/plans/2026-06-11-equipment-page.md | 876 ++++++++++++++++++ .../2026-06-11-equipment-page.md.tasks.json | 24 + 2 files changed, 900 insertions(+) create mode 100644 docs/plans/2026-06-11-equipment-page.md create mode 100644 docs/plans/2026-06-11-equipment-page.md.tasks.json diff --git a/docs/plans/2026-06-11-equipment-page.md b/docs/plans/2026-06-11-equipment-page.md new file mode 100644 index 00000000..463ebee0 --- /dev/null +++ b/docs/plans/2026-06-11-equipment-page.md @@ -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~."` + +## 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. diff --git a/docs/plans/2026-06-11-equipment-page.md.tasks.json b/docs/plans/2026-06-11-equipment-page.md.tasks.json new file mode 100644 index 00000000..e00a74d7 --- /dev/null +++ b/docs/plans/2026-06-11-equipment-page.md.tasks.json @@ -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" +}