# Tabbed Equipment Detail Page (UNS) — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Replace the `/uns` modal-based equipment editor with a dedicated `/uns/equipment/{id}` page carrying `Details · Tags · Virtual Tags · Alarms` tabs; trim the UNS tree so Equipment is a leaf that links to the page; remove the standalone `/scripted-alarms` pages in favour of the per-equipment Alarms tab.

**Architecture:** A single new Blazor Server page (`EquipmentPage.razor`) hosts an in-page `nav-tabs` strip (no route-per-tab). The Tags and Virtual Tags tabs **reuse the existing `TagModal` and `VirtualTagModal` unchanged** (they already take a fixed `EquipmentId`); only the Alarms editor is new. Per-equipment list + alarm CRUD are added to `IUnsTreeService` so they're unit-tested like the rest. The `/uns` tree (`GlobalUns.razor` + `UnsTree.razor`) drops equipment lazy-loading and navigates to the page instead.

**Tech Stack:** .NET 10, Blazor Server (InteractiveServer), EF Core (`OtOpcUaConfigDbContext`, optimistic `RowVersion`), xUnit + Shouldly, EF InMemory test provider. No bUnit.

**Design doc:** `docs/plans/2026-06-11-equipment-page-design.md` (committed master `df2a488b`).

---

## Conventions & guardrails (apply to EVERY task)

- **Branch:** all code tasks run on `feat/uns-equipment-page` off master `df2a488b` (created in Task 0). Never commit to master.
- **Staging:** stage by explicit path only — **never `git add .`**. Never stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`. No force-push, no `--no-verify`.
- **No schema change:** the `ScriptedAlarm` entity already exists. This plan adds only AdminUI service methods + DTOs + one defaulted result field. **No Configuration entity edit, no EF migration.**
- **Build-green invariant:** every task ends with a compiling solution + green tests. The ordering below preserves this (e.g. `LoadEquipmentChildrenAsync` is only removed *after* its last caller is gone).
- **Reuse, don't rewrite:** `TagModal` and `VirtualTagModal` are used verbatim. Do not modify them.
- **Build/test commands:**
  - Build AdminUI: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`
  - Test: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`
  - Single test: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~<Class>.<Method>"`

## Same-file contention (drives serialization)

- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` + `IUnsTreeService.cs` are touched by **Task 1, Task 2, Task 3, Task 8** → those serialize.
- `Components/Pages/Uns/EquipmentPage.razor` is touched by **Task 4, Task 5, Task 6** → those serialize.
- `GlobalUns.razor` + `UnsTree.razor` are touched by **Task 7** only.
- Pages/nav deletions (**Task 9**) touch disjoint files → parallelizable with Task 7/Task 8.

---

## Task 0: Create feature branch

**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none

**Files:** none (git only)

**Step 1:** From master at `df2a488b`:
```bash
git checkout master
git rev-parse --short HEAD          # expect df2a488b
git checkout -b feat/uns-equipment-page
```

**Step 2:** Confirm clean-ish tree (the untracked `sql_login.txt`, `pki/`, `pending.md`, `.fixdocs-batches/`, `OtOpcUa-docs-*.md` and the modified `docker-dev/docker-compose.yml` are pre-existing and MUST be left alone — never stage them).

---

## Task 1: `UnsMutationResult.CreatedId` + populate on equipment create

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (precedes Task 2/3 on `UnsTreeService.cs`)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs:497-543` (`CreateEquipmentAsync`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs`

**Why:** the equipment create→edit redirect (`/uns/equipment/new` → `/uns/equipment/{newId}`) needs the system-generated `EQ-…` id back. A **defaulted** third positional keeps all ~50 existing `new UnsMutationResult(...)` call sites compiling.

**Step 1: Write the failing test** (in `UnsTreeServiceEquipmentTests.cs`, match the file's existing seeding/style):
```csharp
[Fact]
public async Task CreateEquipment_returns_generated_id_in_CreatedId()
{
    var dbName = $"uns-eq-createdid-{Guid.NewGuid():N}";
    UnsTreeTestDb.SeedNamed(dbName);
    var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));

    var input = new EquipmentInput("machine-2", "machine_002", "LINE-1",
        null, null, null, null, null, null, null, null, null, null, null, null, true);

    var result = await service.CreateEquipmentAsync(input);

    result.Ok.ShouldBeTrue();
    result.CreatedId.ShouldNotBeNull();
    result.CreatedId!.ShouldStartWith("EQ-");
}
```

**Step 2: Run it — expect FAIL** (`CreatedId` does not exist / is null):
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~CreateEquipment_returns_generated_id_in_CreatedId"`

**Step 3: Add the field.** In `UnsMutationResult.cs` change the record + doc:
```csharp
/// <param name="Ok">Whether the mutation was applied.</param>
/// <param name="Error">The operator-facing failure message, or <c>null</c> on success.</param>
/// <param name="CreatedId">On a successful create, the new entity's system id (e.g. the generated
/// <c>EQ-…</c> equipment id) so the caller can navigate to it; <c>null</c> for updates/deletes/failures.</param>
public readonly record struct UnsMutationResult(bool Ok, string? Error, string? CreatedId = null);
```

**Step 4: Populate it** in `CreateEquipmentAsync` — change the final success return (line ~542) from `new UnsMutationResult(true, null)` to:
```csharp
return new UnsMutationResult(true, null, equipmentId);
```
(Leave every other `new UnsMutationResult(...)` call site untouched — the default covers them.)

**Step 5: Run the test — expect PASS.** Then build the AdminUI to confirm no call site broke:
`dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → expect 0 errors.

**Step 6: Commit** (stage by path):
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs
git commit -m "feat(uns): carry created id on UnsMutationResult for equipment create"
```

---

## Task 2: Per-equipment Tag/VirtualTag list service methods

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after Task 1 on `UnsTreeService.cs`)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (add 2 method decls near `LoadEquipmentChildrenAsync`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (add 2 impls after `LoadEquipmentChildrenAsync` ~line 128)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentChildRowsTests.cs`

**Why:** the new page's Tags/Virtual Tags tabs render tables with driver/type columns; the existing `LoadEquipmentChildrenAsync` returns tree `UnsNode`s without those columns. **Do NOT remove `LoadEquipmentChildrenAsync` here** — `GlobalUns` still calls it until Task 7; it's removed in Task 8.

**Step 1: New DTO file** `EquipmentChildRows.cs`:
```csharp
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

/// <summary>A tag row for the equipment page's Tags tab table — display columns plus the id used to
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).</summary>
public sealed record EquipmentTagRow(string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel);

/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
```

**Step 2: Interface decls** in `IUnsTreeService.cs` (after `LoadEquipmentChildrenAsync`, ~line 128, with XML docs in the file's style):
```csharp
/// <summary>Lists the equipment's tags for the Tags-tab table, ordered by Name.</summary>
Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);

/// <summary>Lists the equipment's virtual tags for the Virtual-Tags-tab table, ordered by Name.</summary>
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
```

**Step 3: Failing test** `UnsTreeServiceEquipmentChildRowsTests.cs` (mirror `UnsTreeServiceLazyTests` seeding — `UnsTreeTestDb.SeededEquipmentId` has TAG-1 `speed/Float`, TAG-2 `running/Boolean`, VTAG-1 `computed/Double`):
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;

[Trait("Category", "Unit")]
public sealed class UnsTreeServiceEquipmentChildRowsTests
{
    private static UnsTreeService SeededService()
    {
        var dbName = $"uns-childrows-{Guid.NewGuid():N}";
        UnsTreeTestDb.SeedNamed(dbName);
        return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
    }

    [Fact]
    public async Task LoadTagsForEquipment_returns_tags_in_name_order_scoped()
    {
        var rows = await SeededService().LoadTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);

        rows.Count.ShouldBe(2);                 // the EquipmentId=null orphan tag is excluded
        rows[0].TagId.ShouldBe("TAG-2");        // "running" < "speed"
        rows[0].Name.ShouldBe("running");
        rows[0].DataType.ShouldBe("Boolean");
        rows[1].TagId.ShouldBe("TAG-1");
        rows[1].DataType.ShouldBe("Float");
    }

    [Fact]
    public async Task LoadVirtualTagsForEquipment_returns_vtags_in_name_order()
    {
        var rows = await SeededService().LoadVirtualTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);

        rows.Count.ShouldBe(1);
        rows[0].VirtualTagId.ShouldBe("VTAG-1");
        rows[0].Name.ShouldBe("computed");
        rows[0].DataType.ShouldBe("Double");
    }

    [Fact]
    public async Task LoadTagsForEquipment_empty_for_unknown_equipment()
        => (await SeededService().LoadTagsForEquipmentAsync("EQ-NONE")).ShouldBeEmpty();
}
```

**Step 4: Run — expect FAIL** (methods not defined).

**Step 5: Implement** in `UnsTreeService.cs` after `LoadEquipmentChildrenAsync` (use the same `await using var db = await dbFactory.CreateDbContextAsync(ct);` + `AsNoTracking()` pattern):
```csharp
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    return await db.Tags.AsNoTracking()
        .Where(t => t.EquipmentId == equipmentId)
        .OrderBy(t => t.Name)
        .Select(t => new EquipmentTagRow(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel))
        .ToListAsync(ct);
}

/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    return await db.VirtualTags.AsNoTracking()
        .Where(v => v.EquipmentId == equipmentId)
        .OrderBy(v => v.Name)
        .Select(v => new EquipmentVirtualTagRow(v.VirtualTagId, v.Name, v.DataType, v.ScriptId, v.Enabled))
        .ToListAsync(ct);
}
```

**Step 6: Run tests — expect PASS** (3/3). Build AdminUI → 0 errors.

**Step 7: Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentChildRowsTests.cs
git commit -m "feat(uns): per-equipment tag/virtual-tag list service methods"
```

---

## Task 3: Scripted-alarm service methods + DTOs (move logic into IUnsTreeService)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after Task 2 on `UnsTreeService.cs`)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs` (`ScriptedAlarmEditDto`, `EquipmentAlarmRow`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (5 decls)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (5 impls, append before closing brace)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs`

**Why:** the standalone alarm pages used `IDbContextFactory` directly. Moving the CRUD into the service makes the Alarms tab testable like the rest and keeps the `RowVersion` guard consistent. **The `ScriptedAlarm` entity + all runtime/historian wiring are untouched.** `ScriptedAlarmId` stays operator-typed (it is NOT system-generated — confirmed in the old editor's regex `^[A-Za-z0-9_-]+$`).

**Step 1: Input + DTO files.**
`ScriptedAlarmInput.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

/// <summary>Operator-editable fields for a scripted-alarm create/update on the equipment Alarms tab.
/// The owning equipment is supplied separately (the tab fixes it), so there is no equipment field.</summary>
public sealed record ScriptedAlarmInput(
    string ScriptedAlarmId, string Name, string AlarmType, int Severity, string MessageTemplate,
    string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled);
```
`ScriptedAlarmDtos.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

/// <summary>A scripted-alarm row for the Alarms-tab table.</summary>
public sealed record EquipmentAlarmRow(string ScriptedAlarmId, string Name, string AlarmType, int Severity, string PredicateScriptId, bool Enabled);

/// <summary>A scripted alarm projected for editing, with the concurrency token the modal echoes back.</summary>
public sealed record ScriptedAlarmEditDto(
    string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate,
    string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled, byte[] RowVersion);
```

**Step 2: Interface decls** (append to `IUnsTreeService.cs`, XML-documented in file style):
```csharp
Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default);
Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default);
Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default);
Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default);
```

**Step 3: Failing tests** `UnsTreeServiceScriptedAlarmTests.cs` (use `UnsTreeTestDb.SeededEquipmentId`; InMemory does not enforce FKs so `PredicateScriptId` can be `"SCRIPT-1"`):
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;

[Trait("Category", "Unit")]
public sealed class UnsTreeServiceScriptedAlarmTests
{
    private static (UnsTreeService Svc, string Db) Seeded()
    {
        var dbName = $"uns-alarm-{Guid.NewGuid():N}";
        UnsTreeTestDb.SeedNamed(dbName);
        return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
    }

    private static ScriptedAlarmInput Sample(string id = "SA-1") =>
        new(id, "Over-temp", "LimitAlarm", 700, "{TagPath} hot", "SCRIPT-1", HistorizeToAveva: true, Retain: true, Enabled: true);

    [Fact]
    public async Task Create_then_list_and_load_roundtrips()
    {
        var (svc, _) = Seeded();
        var create = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
        create.Ok.ShouldBeTrue();
        create.CreatedId.ShouldBe("SA-1");

        var rows = await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
        rows.Count.ShouldBe(1);
        rows[0].Name.ShouldBe("Over-temp");
        rows[0].Severity.ShouldBe(700);

        var dto = await svc.LoadScriptedAlarmAsync("SA-1");
        dto.ShouldNotBeNull();
        dto!.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
        dto.HistorizeToAveva.ShouldBeTrue();
        dto.RowVersion.ShouldNotBeNull();
    }

    [Fact]
    public async Task Create_rejects_duplicate_id()
    {
        var (svc, _) = Seeded();
        (await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample())).Ok.ShouldBeTrue();
        var dup = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
        dup.Ok.ShouldBeFalse();
        dup.Error.ShouldNotBeNull();
    }

    [Fact]
    public async Task Update_changes_fields()
    {
        var (svc, _) = Seeded();
        await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
        var dto = await svc.LoadScriptedAlarmAsync("SA-1");

        var upd = await svc.UpdateScriptedAlarmAsync("SA-1",
            Sample() with { Name = "Renamed", Severity = 250, HistorizeToAveva = false }, dto!.RowVersion);
        upd.Ok.ShouldBeTrue();

        var after = await svc.LoadScriptedAlarmAsync("SA-1");
        after!.Name.ShouldBe("Renamed");
        after.Severity.ShouldBe(250);
        after.HistorizeToAveva.ShouldBeFalse();
    }

    [Fact]
    public async Task Delete_removes_row()
    {
        var (svc, _) = Seeded();
        await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
        var dto = await svc.LoadScriptedAlarmAsync("SA-1");

        (await svc.DeleteScriptedAlarmAsync("SA-1", dto!.RowVersion)).Ok.ShouldBeTrue();
        (await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId)).ShouldBeEmpty();
    }
}
```

**Step 4: Run — expect FAIL.**

**Step 5: Implement** the five methods in `UnsTreeService.cs` (append before the final `}`), mirroring the existing dup-check + `db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion` + `catch (DbUpdateConcurrencyException)` patterns. Note `Severity`/`Name`/`AlarmType` are validated client-side in the modal; the service enforces only structural rules (dup id, row-exists, concurrency) — matching the old page:
```csharp
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    return await db.ScriptedAlarms.AsNoTracking()
        .Where(a => a.EquipmentId == equipmentId)
        .OrderBy(a => a.Name)
        .Select(a => new EquipmentAlarmRow(a.ScriptedAlarmId, a.Name, a.AlarmType, a.Severity, a.PredicateScriptId, a.Enabled))
        .ToListAsync(ct);
}

/// <inheritdoc />
public async Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    return await db.ScriptedAlarms.AsNoTracking()
        .Where(a => a.ScriptedAlarmId == scriptedAlarmId)
        .Select(a => new ScriptedAlarmEditDto(a.ScriptedAlarmId, a.EquipmentId, a.Name, a.AlarmType, a.Severity,
            a.MessageTemplate, a.PredicateScriptId, a.HistorizeToAveva, a.Retain, a.Enabled, a.RowVersion))
        .FirstOrDefaultAsync(ct);
}

/// <inheritdoc />
public async Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    if (await db.ScriptedAlarms.AnyAsync(a => a.ScriptedAlarmId == input.ScriptedAlarmId, ct))
        return new UnsMutationResult(false, $"ScriptedAlarm '{input.ScriptedAlarmId}' already exists.");

    db.ScriptedAlarms.Add(new ScriptedAlarm
    {
        ScriptedAlarmId = input.ScriptedAlarmId,
        EquipmentId = equipmentId,
        Name = input.Name,
        AlarmType = input.AlarmType,
        Severity = input.Severity,
        MessageTemplate = input.MessageTemplate,
        PredicateScriptId = input.PredicateScriptId,
        HistorizeToAveva = input.HistorizeToAveva,
        Retain = input.Retain,
        Enabled = input.Enabled,
    });
    await db.SaveChangesAsync(ct);
    return new UnsMutationResult(true, null, input.ScriptedAlarmId);
}

/// <inheritdoc />
public async Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
    if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
    db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
    entity.Name = input.Name;
    entity.AlarmType = input.AlarmType;
    entity.Severity = input.Severity;
    entity.MessageTemplate = input.MessageTemplate;
    entity.PredicateScriptId = input.PredicateScriptId;
    entity.HistorizeToAveva = input.HistorizeToAveva;
    entity.Retain = input.Retain;
    entity.Enabled = input.Enabled;
    try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); }
    catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this scripted alarm while you were editing."); }
}

/// <inheritdoc />
public async Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default)
{
    await using var db = await dbFactory.CreateDbContextAsync(ct);
    var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
    if (entity is null) return new UnsMutationResult(true, null);
    db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
    db.ScriptedAlarms.Remove(entity);
    try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); }
    catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this alarm while you were viewing it."); }
    catch (Exception ex) { return new UnsMutationResult(false, $"Delete failed: {ex.Message}."); }
}
```
(Ensure `ScriptedAlarm` is in scope — `using ZB.MOM.WW.OtOpcUa.Configuration.Entities;` is already imported at the top of `UnsTreeService.cs`.)

**Step 6: Run tests — expect PASS** (4/4). Build → 0 errors.

**Step 7: Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs
git commit -m "feat(uns): scripted-alarm CRUD in IUnsTreeService for the equipment Alarms tab"
```

---

## Task 4: `EquipmentPage` shell + Details tab + create→redirect

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (precedes Task 5/6 on `EquipmentPage.razor`)

> High-risk because it's a new routed page wiring auth + create/edit/concurrency; serial spec→code review + final integration review at execution time.

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
- Reference (copy form from): `Components/Shared/Uns/EquipmentModal.razor` (do NOT delete it yet — `GlobalUns` still uses it until Task 7)
- Reference (route precedent): `Components/Pages/ScriptEdit.razor`

**Scope:** the page shell + the **Details** tab only. Tabs `Tags`/`Virtual Tags`/`Alarms` render a placeholder in this task (`<p class="text-muted">…</p>`); they're wired in Task 5/6.

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

<PageTitle>Equipment</PageTitle>

<div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0">@(IsNew ? "New equipment" : _equipment?.Name ?? EquipmentId)</h4>
    <a href="/uns" class="btn btn-outline-secondary btn-sm">Back to UNS</a>
</div>

@if (_loading)
{
    <p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else if (!IsNew && _equipment is null)
{
    <section class="panel notice rise"><span class="mono">@EquipmentId</span> not found.</section>
}
else
{
    <ul class="nav nav-tabs mb-3">
        <li class="nav-item"><button class="nav-link @TabClass("details")" @onclick='() => _activeTab = "details"'>Details</button></li>
        <li class="nav-item"><button class="nav-link @TabClass("tags")" @onclick='() => _activeTab = "tags"' disabled="@IsNew">Tags</button></li>
        <li class="nav-item"><button class="nav-link @TabClass("vtags")" @onclick='() => _activeTab = "vtags"' disabled="@IsNew">Virtual Tags</button></li>
        <li class="nav-item"><button class="nav-link @TabClass("alarms")" @onclick='() => _activeTab = "alarms"' disabled="@IsNew">Alarms</button></li>
    </ul>

    @if (_activeTab == "details")
    {
        @* Lifted EquipmentModal form: identity (Name/MachineCode/UNS line/Driver/ZTag/SAPID/Enabled)
           + OPC 40010 identification block. EditForm Model=_form OnValidSubmit=SaveAsync. *@
        <EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentDetails">
            <DataAnnotationsValidator />
            @* ... copy the field markup from EquipmentModal.razor:24-106 (drop the modal-* wrappers) ... *@
            @if (!string.IsNullOrWhiteSpace(_error)) { <div class="text-danger small mt-2">@_error</div> }
            <div class="mt-3"><button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button></div>
        </EditForm>
    }
    else if (_activeTab == "tags")     { <p class="text-muted">Tags tab — wired in Task 5.</p> }
    else if (_activeTab == "vtags")    { <p class="text-muted">Virtual Tags tab — wired in Task 5.</p> }
    else if (_activeTab == "alarms")   { <p class="text-muted">Alarms tab — wired in Task 6.</p> }
}

@code {
    [Parameter] public string? EquipmentId { get; set; }
    [SupplyParameterFromQuery] public string? LineId { get; set; }
    private bool IsNew => string.IsNullOrEmpty(EquipmentId);

    private string _activeTab = "details";
    private bool _loading = true;
    private bool _busy;
    private string? _error;
    private EquipmentEditDto? _equipment;
    private FormModel _form = new();
    private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
    private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();

    private string TabClass(string tab) => _activeTab == tab ? "active" : "";

    protected override async Task OnParametersSetAsync()
    {
        _loading = true;
        if (!IsNew)
        {
            _equipment = await Svc.LoadEquipmentAsync(EquipmentId!);
            if (_equipment is not null) { LoadFormFrom(_equipment); await LoadPickersForClusterOfLineAsync(_equipment.UnsLineId); }
        }
        else
        {
            _form = new FormModel { UnsLineId = LineId ?? "" };
            await LoadPickersForClusterOfLineAsync(LineId);
        }
        _loading = false;
    }

    // Build _lineOptions + _driverOptions for the cluster owning the given line, reusing
    // Svc.LoadDriversForClusterAsync. (Resolve clusterId from the line; see note below.)
    private async Task LoadPickersForClusterOfLineAsync(string? lineId) { /* see Step 2 */ }

    private void LoadFormFrom(EquipmentEditDto e) { /* copy EquipmentModal.OnParametersSet edit branch */ }

    private async Task SaveAsync()
    {
        _busy = true; _error = null;
        try
        {
            var input = new EquipmentInput(_form.Name, _form.MachineCode, _form.UnsLineId, _form.DriverInstanceId,
                _form.ZTag, _form.SAPID, _form.Manufacturer, _form.Model, _form.SerialNumber, _form.HardwareRevision,
                _form.SoftwareRevision, _form.YearOfConstruction, _form.AssetLocation, _form.ManufacturerUri,
                _form.DeviceManualUri, _form.Enabled);

            var result = IsNew
                ? await Svc.CreateEquipmentAsync(input)
                : await Svc.UpdateEquipmentAsync(_equipment!.EquipmentId, input, _equipment.RowVersion);

            if (!result.Ok) { _error = result.Error; return; }
            if (IsNew) { Nav.NavigateTo($"/uns/equipment/{result.CreatedId}"); }
            else { _equipment = await Svc.LoadEquipmentAsync(EquipmentId!); if (_equipment is not null) LoadFormFrom(_equipment); }
        }
        finally { _busy = false; }
    }

    private sealed class FormModel { /* identical to EquipmentModal.FormModel:232-251 */ }
}
```

**Step 2: Cluster/line/driver pickers.** The page needs the line `<select>` options + driver options scoped to the equipment's cluster, the way `GlobalUns.LinesForCluster` / `LoadDriversForClusterAsync` do — but the page doesn't hold the loaded tree. Resolve via the service: add a tiny helper to `IUnsTreeService` ONLY IF NEEDED. **Prefer reuse:** `LoadEquipmentAsync` gives `UnsLineId`; to map a line→cluster and list sibling lines + cluster drivers without the tree, the simplest path is a new lightweight service method `LoadEquipmentPickContextAsync(string? lineId)` returning `(IReadOnlyList<(string Id,string Display)> Lines, IReadOnlyList<(string Id,string Display)> Drivers)`. **If you add it,** it belongs in Task 2's files — but since Task 2 is already committed, add it here in `UnsTreeService.cs`/`IUnsTreeService.cs` and note the contention. Implementation: resolve `clusterId` from `UnsLines→UnsAreas.ClusterId` for `lineId`, list all lines in that cluster (`UnsLine.Name` display), and call the existing cluster-driver query. Keep it `AsNoTracking`. Add one service test for it.

> Implementer note: if resolving the picker context inflates this task past ~300 LOC, STOP and split the picker-context service method into its own small task before continuing. Surface it; don't silently expand.

**Step 3: Build** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → 0 errors. (No unit test for the Razor page — behaviour is live-verified in Task 11. If you added the picker-context service method, add its xUnit test.)

**Step 4: Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/   # only if a picker-context test was added
git commit -m "feat(uns): equipment detail page shell + Details tab + create-redirect"
```

---

## Task 5: Tags tab + Virtual Tags tab (reuse TagModal + VirtualTagModal)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after Task 4 on `EquipmentPage.razor`)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
- Reference: `Components/Shared/Uns/TagModal.razor`, `VirtualTagModal.razor`, and `GlobalUns.razor:76-90,288-323,369-396,443-473` for the open/save/delete wiring to copy.

**Step 1: Tags tab content.** Replace the `"tags"` placeholder with a table over `_tags` (loaded via `Svc.LoadTagsForEquipmentAsync(EquipmentId!)`), an "Add tag" button, per-row Edit + Delete, and host `<TagModal>` exactly as `GlobalUns` does:
- State: `_tags`, `_tagModalVisible`, `_tagModalIsNew`, `_tagModalExisting (TagEditDto?)`, `_tagModalDriverOptions` (from `Svc.LoadTagDriversForEquipmentAsync(EquipmentId!)`).
- Add: set IsNew=true, Existing=null, load driver options, show modal.
- Edit: `await Svc.LoadTagAsync(tagId)` → Existing, load driver options, show modal.
- `OnSaved`: reload `_tags`, hide modal. `OnCancel`: hide modal.
- Delete: `await Svc.LoadTagAsync(tagId)` for fresh RowVersion → `Svc.DeleteTagAsync(tagId, dto.RowVersion)` → reload `_tags` (mirror `GlobalUns.ConfirmDeleteAsync` Tag branch; a simple confirm is fine — `_tagPendingDelete` + a small inline confirm or reuse the page's own confirm pattern).
```razor
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
          Existing="_tagModalExisting" Drivers="_tagModalDriverOptions"
          OnSaved="OnTagSavedAsync" OnCancel="() => _tagModalVisible = false" />
```

**Step 2: Virtual Tags tab content.** Same shape over `_vtags` (`Svc.LoadVirtualTagsForEquipmentAsync`), hosting `<VirtualTagModal>` with `Scripts` from `Svc.LoadScriptsAsync()`, Existing from `Svc.LoadVirtualTagAsync(id)`, delete via `Svc.DeleteVirtualTagAsync(id, dto.RowVersion)`:
```razor
<VirtualTagModal Visible="_vtagModalVisible" IsNew="_vtagModalIsNew" EquipmentId="@EquipmentId"
                 Existing="_vtagModalExisting" Scripts="_vtagScriptOptions"
                 OnSaved="OnVtagSavedAsync" OnCancel="() => _vtagModalVisible = false" />
```

**Step 3: Lazy tab loads.** Load `_tags`/`_vtags` when their tab is first activated (or in `OnParametersSetAsync` when `!IsNew`). Keep it simple: load both on first switch to the tab.

**Step 4: Build** → 0 errors.

**Step 5: Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor
git commit -m "feat(uns): Tags + Virtual Tags tabs on the equipment page (reuse modals)"
```

---

## Task 6: `ScriptedAlarmModal` + Alarms tab

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after Task 5 on `EquipmentPage.razor`; needs Task 3)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ScriptedAlarmModal.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
- Reference: `Components/Pages/ScriptedAlarmEdit.razor` (the field markup + `FormModel` to lift) and `Components/Shared/Uns/TagModal.razor` (the modal shell + `Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancel` parameter pattern).

**Step 1: `ScriptedAlarmModal.razor`.** A modal shell (copy TagModal's `@if (Visible)` backdrop + `EditForm`), parameters:
```csharp
[Parameter] public bool Visible { get; set; }
[Parameter] public bool IsNew { get; set; }
[Parameter] public string? EquipmentId { get; set; }
[Parameter] public ScriptedAlarmEditDto? Existing { get; set; }
[Parameter] public IReadOnlyList<(string Id, string Display)> Scripts { get; set; } = Array.Empty<(string, string)>();
[Parameter] public EventCallback OnSaved { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
```
Form fields lifted from `ScriptedAlarmEdit.razor:34-101` minus the **Equipment** picker (equipment is fixed by the tab): `ScriptedAlarmId` (disabled on edit), `Name`, `AlarmType` (`LimitAlarm`/`DiscreteAlarm`/`OffNormalAlarm`/`AlarmCondition`), `Severity`, `PredicateScriptId` (`<select>` over `Scripts`), `MessageTemplate`, `HistorizeToAveva` (default true), `Retain`, `Enabled`. `FormModel` = `ScriptedAlarmEdit.FormModel:222-235` minus `EquipmentId`. `OnParametersSet` rebuilds `_form` from `Existing` on edit / fresh on create. `SaveAsync`:
```csharp
var input = new ScriptedAlarmInput(_form.ScriptedAlarmId, _form.Name, _form.AlarmType, _form.Severity,
    _form.MessageTemplate, _form.PredicateScriptId, _form.HistorizeToAveva, _form.Retain, _form.Enabled);
var result = IsNew
    ? await Svc.CreateScriptedAlarmAsync(EquipmentId!, input)
    : await Svc.UpdateScriptedAlarmAsync(Existing!.ScriptedAlarmId, input, Existing.RowVersion);
if (result.Ok) await OnSaved.InvokeAsync(); else _error = result.Error;
```
Validation is the DataAnnotations on `FormModel` (`[Required]`, `[Range(1,1000)]`, `ScriptedAlarmId` regex `^[A-Za-z0-9_-]+$`) — same as the old page.

**Step 2: Alarms tab content** in `EquipmentPage.razor`: table over `_alarms` (`Svc.LoadAlarmsForEquipmentAsync`), "Add alarm" button, per-row Edit + Delete, host `<ScriptedAlarmModal>` (Existing via `Svc.LoadScriptedAlarmAsync(id)`, Scripts via `Svc.LoadScriptsAsync()`, delete via `Svc.LoadScriptedAlarmAsync(id)` for RowVersion then `Svc.DeleteScriptedAlarmAsync(id, dto.RowVersion)`). `OnSaved` reloads `_alarms` + hides modal.

**Step 3: Build** → 0 errors.

**Step 4: Commit:**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ScriptedAlarmModal.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor
git commit -m "feat(uns): Alarms tab + ScriptedAlarmModal on the equipment page"
```

---

## Task 7: `/uns` tree surgery — Equipment becomes a leaf; remove EquipmentModal

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 9

> High-risk: surgical edits to the 671-line main UNS page + the shared tree renderer. Serial spec→code review + final integration review.

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor`
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor`

**Step 1: `UnsTree.razor` Equipment actions** (`:119-128`): replace the equipment `+ Tag` / `+ Virtual tag` / `Edit` buttons with an **Open** link to the page; keep **Delete**:
```razor
case UnsNodeKind.Equipment:
    <a class="btn btn-sm btn-link p-0 ms-2" href="@($"/uns/equipment/{node.EntityId}")">Open</a>
    <button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
            @onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
    break;
```
Also drop the `Tag`/`VirtualTag` action cases (`:130-136`) — those nodes are no longer rendered. The `OnAddVirtualTag` parameter becomes unused; remove it (and its callers in `GlobalUns`).

**Step 2: Make equipment a leaf.** In `UnsNode`/`UnsTreeAssembly.BuildEquipment` (`UnsNode.cs:233-246`) set `HasLazyChildren = false` and `ChildCount` can stay (badge is harmless) — but with `HasLazyChildren=false` and no eager children, the tree shows no expander. Simplest: in `BuildEquipment`, set `HasLazyChildren = false` (drop the `childCount > 0` expander). Keep the `ChildCount` badge if desired, or set 0. (Decide: keeping the count badge is informative; set `HasLazyChildren = false` only.)

**Step 3: `GlobalUns.razor` removals.** Remove:
- `<EquipmentModal>` (`:67-74`), `<TagModal>` (`:76-82`), `<VirtualTagModal>` (`:84-90`) usages.
- The equipment/tag/vtag modal state fields (`:147-167`), `_childRefreshEquipmentId` (`:172-173`).
- `ToggleAsync`'s equipment lazy-load branch (`:226-247`) — equipment no longer expands; `ToggleAsync` keeps only the structural toggle (or equipment toggle becomes a no-op since no expander is shown).
- `HandleAddChild` Equipment case (`:288-295`) and `HandleAddVirtualTag` (`:304-323`) entirely.
- `HandleEdit` Equipment/Tag/VirtualTag cases (`:358-389`).
- `ConfirmDeleteAsync` Tag/VirtualTag branches (`:443-473`) — equipment delete stays (`:487-491`); tag/vtag deletes are gone from the tree.
- `OnEquipmentChildModalSavedAsync`, `RefreshEquipmentChildrenAsync`, `FindEquipmentNode` helpers (`:522-584`) — now unused.
- The matching cleanup lines in `CloseModals` (`:606-623`).

**Step 4: Rewire navigation.** Add `@inject NavigationManager Nav`. Change:
- `HandleAddChild` **Line** case (`:279-286`): instead of opening EquipmentModal, `Nav.NavigateTo($"/uns/equipment/new?lineId={node.EntityId}")`.
- Equipment "Open" is already an `<a href>` in the tree (Step 1) — no handler needed. (If you prefer routing through a handler, add an `OnOpen` callback; the `<a href>` is simpler and sufficient.)

**Step 5: Delete `EquipmentModal.razor`** (its only consumer is now gone). `TagModal.razor` + `VirtualTagModal.razor` are **kept** (reused by `EquipmentPage`).

**Step 6: Build** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → 0 errors. Note: `LoadEquipmentChildrenAsync` is now unreferenced by `GlobalUns` but **still defined** — that's fine (removed in Task 8). Grep to confirm no other `GlobalUns` references to the removed helpers remain.

**Step 7: Commit:**
```bash
git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs
git commit -m "feat(uns): equipment is a tree leaf linking to the detail page; drop EquipmentModal"
```

---

## Task 8: Remove now-dead `LoadEquipmentChildrenAsync` + port its test

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 9 (disjoint files), after Task 7

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (remove decl `:120-128`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (remove impl `:86-128`)
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs` (coverage replaced by Task 2's `UnsTreeServiceEquipmentChildRowsTests`)

**Step 1: Confirm dead.** `grep -rn "LoadEquipmentChildrenAsync" src tests` → expect only the interface decl, the impl, and the lazy test (all being removed). If any other caller exists, STOP and report.

**Step 2: Remove** the interface decl + impl + the `UnsNode`-returning child-node code, and `git rm` the lazy test (its behaviour is now covered by the row-DTO tests).

**Step 3: Build + test** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` → green.

**Step 4: Commit:**
```bash
git rm tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
git commit -m "refactor(uns): drop dead LoadEquipmentChildrenAsync (replaced by row-DTO list methods)"
```

---

## Task 9: Delete standalone scripted-alarm pages + nav link

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 7, Task 8 (disjoint files), after Task 6

**Files:**
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor`
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor:24` (remove the `<NavRailItem Href="/scripted-alarms" …>` line)

**Step 1: Grep-sweep** `grep -rn "scripted-alarms" src tests docs` to find every reference. Expected: the two pages (being deleted), `MainLayout.razor:24`, and possibly docs. The `/alerts` runtime page is separate (route `/alerts`) and stays. If an **integration test** navigates to `/scripted-alarms`, update or remove that assertion (report it if non-trivial).

**Step 2: Delete the two pages, remove the nav line.**

**Step 3: Build** → 0 errors. (No nav-rail item should reference a deleted route.)

**Step 4: Commit:**
```bash
git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor \
       src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor
git commit -m "feat(uns): remove standalone scripted-alarm pages + nav link (alarms now per-equipment)"
```

---

## Task 10: Docs

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7/8/9 (doc files only)

**Files:**
- Modify: `docs/Uns.md`
- Modify: `docs/ScriptedAlarms.md`
- Modify: `docs/AlarmTracking.md`

**Step 1:** `docs/Uns.md` — the tree now stops at Equipment (no inline tags/virtual tags); document the new `/uns/equipment/{id}` page and its `Details · Tags · Virtual Tags · Alarms` tabs; "Add equipment" navigates to the page; tags/virtual tags/alarms are managed on the page, not the tree.

**Step 2:** `docs/ScriptedAlarms.md` + `docs/AlarmTracking.md` — repoint the AdminUI-surface notes from the standalone `/scripted-alarms` pages to the equipment page's **Alarms tab**. Keep all runtime/historian/OPC-Part-9 content unchanged (only the editing surface moved).

**Step 3: Commit:**
```bash
git add docs/Uns.md docs/ScriptedAlarms.md docs/AlarmTracking.md
git commit -m "docs(uns): document the equipment page tabs; repoint alarm-editing surface"
```

---

## Task 11: Full-suite gate + live `/run` verify + finish

**Classification:** standard (verification)
**Estimated implement time:** ~5 min (+ user-driven live verify)
**Parallelizable with:** none (last)

**Files:** none (verification + finishing)

**Step 1: Full build + test.**
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx                          # 0 errors
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests     # green
```
(Run the broader `dotnet test ZB.MOM.WW.OtOpcUa.slnx` if time permits; the AdminUI suite is the load-bearing one for this change.)

**Step 2: Live `/run` (user drives — the agent does NOT sign in).** Verify on docker-dev:
- `/uns` tree stops at Equipment (no tag/virtual-tag leaves); equipment row shows **Open** + **Delete**.
- "Open" → `/uns/equipment/{id}`; tabs `Details · Tags · Virtual Tags · Alarms` render.
- Details edit saves; "Add equipment" on a Line → `/uns/equipment/new?lineId=…` → Create → redirects to `/uns/equipment/{newId}` with tabs live.
- Tags tab: add/edit (driver-typed editor) + delete round-trip. Virtual Tags tab: add/edit (inline Monaco) + delete. Alarms tab: add/edit/delete; HistorizeToAveva defaults on.
- `/scripted-alarms` is gone (404) and absent from the nav rail.

**Step 3: Finish.** REQUIRED SUB-SKILL: `superpowers-extended-cc:finishing-a-development-branch`. Default intent (carried from this session): merge `feat/uns-equipment-page` to master + push.

---

## Task dependency summary

```
T0 ─┬─ T1 ─ T2 ─ T3 ───────────── T8 (after T7)
    │        │     │
    │        └─────┴─ T4 ─ T5 ─ T6 ─ T7 ─┐
    │                              │       ├─ T11
    │                  T9 (after T6)──────┤
    └───────────────── T10 ──────────────┘
```
- **UnsTreeService.cs / IUnsTreeService.cs** serialize: T1 → T2 → T3 → (T4 picker-context, if added) → T8.
- **EquipmentPage.razor** serializes: T4 → T5 → T6.
- T7 (GlobalUns/UnsTree) needs T4 (route exists); ∥ with T9/T10.
- T8 needs T7 (last caller gone) + T2 (replacement exists).
- T9 needs T6 (Alarms tab is the replacement surface); ∥ with T7/T8/T10.
- T11 last.
