Files
lmxopcua/docs/plans/2026-06-11-equipment-page.md
T

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-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:

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 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 UnsNodes without those columns. Do NOT remove LoadEquipmentChildrenAsync hereGlobalUns 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 — 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):

@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, 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).
<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 + 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:

[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; 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:

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:

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)"

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:

  • /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.