47 KiB
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-pageoff masterdf2a488b(created in Task 0). Never commit to master. - Staging: stage by explicit path only — never
git add .. Never stagesql_login.txtorsrc/Server/ZB.MOM.WW.OtOpcUa.Host/pki/. No force-push, no--no-verify. - No schema change: the
ScriptedAlarmentity 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.
LoadEquipmentChildrenAsyncis only removed after its last caller is gone). - Reuse, don't rewrite:
TagModalandVirtualTagModalare 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>"
- Build AdminUI:
Same-file contention (drives serialization)
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs+IUnsTreeService.csare touched by Task 1, Task 2, Task 3, Task 8 → those serialize.Components/Pages/Uns/EquipmentPage.razoris touched by Task 4, Task 5, Task 6 → those serialize.GlobalUns.razor+UnsTree.razorare 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:
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):
[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:
/// <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:
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):
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 nearLoadEquipmentChildrenAsync) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs(add 2 impls afterLoadEquipmentChildrenAsync~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 UnsNodes 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:
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):
/// <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):
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):
/// <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:
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:
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:
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):
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"):
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:
/// <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:
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 —GlobalUnsstill 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):
@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:
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, andGlobalUns.razor:76-90,288-323,369-396,443-473for 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(fromSvc.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(mirrorGlobalUns.ConfirmDeleteAsyncTag branch; a simple confirm is fine —_tagPendingDelete+ a small inline confirm or reuse the page's own confirm pattern).
<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):
<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:
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 +FormModelto lift) andComponents/Shared/Uns/TagModal.razor(the modal shell +Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancelparameter pattern).
Step 1: ScriptedAlarmModal.razor. A modal shell (copy TagModal's @if (Visible) backdrop + EditForm), parameters:
[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:
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:
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:
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;ToggleAsynckeeps only the structural toggle (or equipment toggle becomes a no-op since no expander is shown).HandleAddChildEquipment case (:288-295) andHandleAddVirtualTag(:304-323) entirely.HandleEditEquipment/Tag/VirtualTag cases (:358-389).ConfirmDeleteAsyncTag/VirtualTag branches (:443-473) — equipment delete stays (:487-491); tag/vtag deletes are gone from the tree.OnEquipmentChildModalSavedAsync,RefreshEquipmentChildrenAsync,FindEquipmentNodehelpers (:522-584) — now unused.- The matching cleanup lines in
CloseModals(:606-623).
Step 4: Rewire navigation. Add @inject NavigationManager Nav. Change:
HandleAddChildLine 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 anOnOpencallback; 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:
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'sUnsTreeServiceEquipmentChildRowsTests)
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:
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:
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:
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.
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:
/unstree stops at Equipment (no tag/virtual-tag leaves); equipment row shows Open + Delete.- "Open" →
/uns/equipment/{id}; tabsDetails · Tags · Virtual Tags · Alarmsrender. - 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-alarmsis 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.