docs(uns): implementation plan + tasks for tabbed equipment detail page
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
# Tabbed Equipment Detail Page (UNS) — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the `/uns` modal-based equipment editor with a dedicated `/uns/equipment/{id}` page carrying `Details · Tags · Virtual Tags · Alarms` tabs; trim the UNS tree so Equipment is a leaf that links to the page; remove the standalone `/scripted-alarms` pages in favour of the per-equipment Alarms tab.
|
||||
|
||||
**Architecture:** A single new Blazor Server page (`EquipmentPage.razor`) hosts an in-page `nav-tabs` strip (no route-per-tab). The Tags and Virtual Tags tabs **reuse the existing `TagModal` and `VirtualTagModal` unchanged** (they already take a fixed `EquipmentId`); only the Alarms editor is new. Per-equipment list + alarm CRUD are added to `IUnsTreeService` so they're unit-tested like the rest. The `/uns` tree (`GlobalUns.razor` + `UnsTree.razor`) drops equipment lazy-loading and navigates to the page instead.
|
||||
|
||||
**Tech Stack:** .NET 10, Blazor Server (InteractiveServer), EF Core (`OtOpcUaConfigDbContext`, optimistic `RowVersion`), xUnit + Shouldly, EF InMemory test provider. No bUnit.
|
||||
|
||||
**Design doc:** `docs/plans/2026-06-11-equipment-page-design.md` (committed master `df2a488b`).
|
||||
|
||||
---
|
||||
|
||||
## Conventions & guardrails (apply to EVERY task)
|
||||
|
||||
- **Branch:** all code tasks run on `feat/uns-equipment-page` off master `df2a488b` (created in Task 0). Never commit to master.
|
||||
- **Staging:** stage by explicit path only — **never `git add .`**. Never stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`. No force-push, no `--no-verify`.
|
||||
- **No schema change:** the `ScriptedAlarm` entity already exists. This plan adds only AdminUI service methods + DTOs + one defaulted result field. **No Configuration entity edit, no EF migration.**
|
||||
- **Build-green invariant:** every task ends with a compiling solution + green tests. The ordering below preserves this (e.g. `LoadEquipmentChildrenAsync` is only removed *after* its last caller is gone).
|
||||
- **Reuse, don't rewrite:** `TagModal` and `VirtualTagModal` are used verbatim. Do not modify them.
|
||||
- **Build/test commands:**
|
||||
- Build AdminUI: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`
|
||||
- Test: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`
|
||||
- Single test: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~<Class>.<Method>"`
|
||||
|
||||
## Same-file contention (drives serialization)
|
||||
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` + `IUnsTreeService.cs` are touched by **Task 1, Task 2, Task 3, Task 8** → those serialize.
|
||||
- `Components/Pages/Uns/EquipmentPage.razor` is touched by **Task 4, Task 5, Task 6** → those serialize.
|
||||
- `GlobalUns.razor` + `UnsTree.razor` are touched by **Task 7** only.
|
||||
- Pages/nav deletions (**Task 9**) touch disjoint files → parallelizable with Task 7/Task 8.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Create feature branch
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~1 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** none (git only)
|
||||
|
||||
**Step 1:** From master at `df2a488b`:
|
||||
```bash
|
||||
git checkout master
|
||||
git rev-parse --short HEAD # expect df2a488b
|
||||
git checkout -b feat/uns-equipment-page
|
||||
```
|
||||
|
||||
**Step 2:** Confirm clean-ish tree (the untracked `sql_login.txt`, `pki/`, `pending.md`, `.fixdocs-batches/`, `OtOpcUa-docs-*.md` and the modified `docker-dev/docker-compose.yml` are pre-existing and MUST be left alone — never stage them).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `UnsMutationResult.CreatedId` + populate on equipment create
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (precedes Task 2/3 on `UnsTreeService.cs`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs:497-543` (`CreateEquipmentAsync`)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs`
|
||||
|
||||
**Why:** the equipment create→edit redirect (`/uns/equipment/new` → `/uns/equipment/{newId}`) needs the system-generated `EQ-…` id back. A **defaulted** third positional keeps all ~50 existing `new UnsMutationResult(...)` call sites compiling.
|
||||
|
||||
**Step 1: Write the failing test** (in `UnsTreeServiceEquipmentTests.cs`, match the file's existing seeding/style):
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateEquipment_returns_generated_id_in_CreatedId()
|
||||
{
|
||||
var dbName = $"uns-eq-createdid-{Guid.NewGuid():N}";
|
||||
UnsTreeTestDb.SeedNamed(dbName);
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var input = new EquipmentInput("machine-2", "machine_002", "LINE-1",
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, true);
|
||||
|
||||
var result = await service.CreateEquipmentAsync(input);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.CreatedId.ShouldNotBeNull();
|
||||
result.CreatedId!.ShouldStartWith("EQ-");
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run it — expect FAIL** (`CreatedId` does not exist / is null):
|
||||
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~CreateEquipment_returns_generated_id_in_CreatedId"`
|
||||
|
||||
**Step 3: Add the field.** In `UnsMutationResult.cs` change the record + doc:
|
||||
```csharp
|
||||
/// <param name="Ok">Whether the mutation was applied.</param>
|
||||
/// <param name="Error">The operator-facing failure message, or <c>null</c> on success.</param>
|
||||
/// <param name="CreatedId">On a successful create, the new entity's system id (e.g. the generated
|
||||
/// <c>EQ-…</c> equipment id) so the caller can navigate to it; <c>null</c> for updates/deletes/failures.</param>
|
||||
public readonly record struct UnsMutationResult(bool Ok, string? Error, string? CreatedId = null);
|
||||
```
|
||||
|
||||
**Step 4: Populate it** in `CreateEquipmentAsync` — change the final success return (line ~542) from `new UnsMutationResult(true, null)` to:
|
||||
```csharp
|
||||
return new UnsMutationResult(true, null, equipmentId);
|
||||
```
|
||||
(Leave every other `new UnsMutationResult(...)` call site untouched — the default covers them.)
|
||||
|
||||
**Step 5: Run the test — expect PASS.** Then build the AdminUI to confirm no call site broke:
|
||||
`dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → expect 0 errors.
|
||||
|
||||
**Step 6: Commit** (stage by path):
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs
|
||||
git commit -m "feat(uns): carry created id on UnsMutationResult for equipment create"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Per-equipment Tag/VirtualTag list service methods
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (after Task 1 on `UnsTreeService.cs`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (add 2 method decls near `LoadEquipmentChildrenAsync`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (add 2 impls after `LoadEquipmentChildrenAsync` ~line 128)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentChildRowsTests.cs`
|
||||
|
||||
**Why:** the new page's Tags/Virtual Tags tabs render tables with driver/type columns; the existing `LoadEquipmentChildrenAsync` returns tree `UnsNode`s without those columns. **Do NOT remove `LoadEquipmentChildrenAsync` here** — `GlobalUns` still calls it until Task 7; it's removed in Task 8.
|
||||
|
||||
**Step 1: New DTO file** `EquipmentChildRows.cs`:
|
||||
```csharp
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>A tag row for the equipment page's Tags tab table — display columns plus the id used to
|
||||
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).</summary>
|
||||
public sealed record EquipmentTagRow(string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel);
|
||||
|
||||
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
|
||||
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
|
||||
```
|
||||
|
||||
**Step 2: Interface decls** in `IUnsTreeService.cs` (after `LoadEquipmentChildrenAsync`, ~line 128, with XML docs in the file's style):
|
||||
```csharp
|
||||
/// <summary>Lists the equipment's tags for the Tags-tab table, ordered by Name.</summary>
|
||||
Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Lists the equipment's virtual tags for the Virtual-Tags-tab table, ordered by Name.</summary>
|
||||
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
```
|
||||
|
||||
**Step 3: Failing test** `UnsTreeServiceEquipmentChildRowsTests.cs` (mirror `UnsTreeServiceLazyTests` seeding — `UnsTreeTestDb.SeededEquipmentId` has TAG-1 `speed/Float`, TAG-2 `running/Boolean`, VTAG-1 `computed/Double`):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceEquipmentChildRowsTests
|
||||
{
|
||||
private static UnsTreeService SeededService()
|
||||
{
|
||||
var dbName = $"uns-childrows-{Guid.NewGuid():N}";
|
||||
UnsTreeTestDb.SeedNamed(dbName);
|
||||
return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTagsForEquipment_returns_tags_in_name_order_scoped()
|
||||
{
|
||||
var rows = await SeededService().LoadTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
|
||||
|
||||
rows.Count.ShouldBe(2); // the EquipmentId=null orphan tag is excluded
|
||||
rows[0].TagId.ShouldBe("TAG-2"); // "running" < "speed"
|
||||
rows[0].Name.ShouldBe("running");
|
||||
rows[0].DataType.ShouldBe("Boolean");
|
||||
rows[1].TagId.ShouldBe("TAG-1");
|
||||
rows[1].DataType.ShouldBe("Float");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadVirtualTagsForEquipment_returns_vtags_in_name_order()
|
||||
{
|
||||
var rows = await SeededService().LoadVirtualTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
|
||||
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].VirtualTagId.ShouldBe("VTAG-1");
|
||||
rows[0].Name.ShouldBe("computed");
|
||||
rows[0].DataType.ShouldBe("Double");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadTagsForEquipment_empty_for_unknown_equipment()
|
||||
=> (await SeededService().LoadTagsForEquipmentAsync("EQ-NONE")).ShouldBeEmpty();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run — expect FAIL** (methods not defined).
|
||||
|
||||
**Step 5: Implement** in `UnsTreeService.cs` after `LoadEquipmentChildrenAsync` (use the same `await using var db = await dbFactory.CreateDbContextAsync(ct);` + `AsNoTracking()` pattern):
|
||||
```csharp
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
return await db.Tags.AsNoTracking()
|
||||
.Where(t => t.EquipmentId == equipmentId)
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(t => new EquipmentTagRow(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
return await db.VirtualTags.AsNoTracking()
|
||||
.Where(v => v.EquipmentId == equipmentId)
|
||||
.OrderBy(v => v.Name)
|
||||
.Select(v => new EquipmentVirtualTagRow(v.VirtualTagId, v.Name, v.DataType, v.ScriptId, v.Enabled))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6: Run tests — expect PASS** (3/3). Build AdminUI → 0 errors.
|
||||
|
||||
**Step 7: Commit:**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentChildRowsTests.cs
|
||||
git commit -m "feat(uns): per-equipment tag/virtual-tag list service methods"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Scripted-alarm service methods + DTOs (move logic into IUnsTreeService)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (after Task 2 on `UnsTreeService.cs`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs` (`ScriptedAlarmEditDto`, `EquipmentAlarmRow`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (5 decls)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (5 impls, append before closing brace)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs`
|
||||
|
||||
**Why:** the standalone alarm pages used `IDbContextFactory` directly. Moving the CRUD into the service makes the Alarms tab testable like the rest and keeps the `RowVersion` guard consistent. **The `ScriptedAlarm` entity + all runtime/historian wiring are untouched.** `ScriptedAlarmId` stays operator-typed (it is NOT system-generated — confirmed in the old editor's regex `^[A-Za-z0-9_-]+$`).
|
||||
|
||||
**Step 1: Input + DTO files.**
|
||||
`ScriptedAlarmInput.cs`:
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>Operator-editable fields for a scripted-alarm create/update on the equipment Alarms tab.
|
||||
/// The owning equipment is supplied separately (the tab fixes it), so there is no equipment field.</summary>
|
||||
public sealed record ScriptedAlarmInput(
|
||||
string ScriptedAlarmId, string Name, string AlarmType, int Severity, string MessageTemplate,
|
||||
string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled);
|
||||
```
|
||||
`ScriptedAlarmDtos.cs`:
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>A scripted-alarm row for the Alarms-tab table.</summary>
|
||||
public sealed record EquipmentAlarmRow(string ScriptedAlarmId, string Name, string AlarmType, int Severity, string PredicateScriptId, bool Enabled);
|
||||
|
||||
/// <summary>A scripted alarm projected for editing, with the concurrency token the modal echoes back.</summary>
|
||||
public sealed record ScriptedAlarmEditDto(
|
||||
string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate,
|
||||
string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled, byte[] RowVersion);
|
||||
```
|
||||
|
||||
**Step 2: Interface decls** (append to `IUnsTreeService.cs`, XML-documented in file style):
|
||||
```csharp
|
||||
Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default);
|
||||
Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default);
|
||||
Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default);
|
||||
```
|
||||
|
||||
**Step 3: Failing tests** `UnsTreeServiceScriptedAlarmTests.cs` (use `UnsTreeTestDb.SeededEquipmentId`; InMemory does not enforce FKs so `PredicateScriptId` can be `"SCRIPT-1"`):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceScriptedAlarmTests
|
||||
{
|
||||
private static (UnsTreeService Svc, string Db) Seeded()
|
||||
{
|
||||
var dbName = $"uns-alarm-{Guid.NewGuid():N}";
|
||||
UnsTreeTestDb.SeedNamed(dbName);
|
||||
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
|
||||
}
|
||||
|
||||
private static ScriptedAlarmInput Sample(string id = "SA-1") =>
|
||||
new(id, "Over-temp", "LimitAlarm", 700, "{TagPath} hot", "SCRIPT-1", HistorizeToAveva: true, Retain: true, Enabled: true);
|
||||
|
||||
[Fact]
|
||||
public async Task Create_then_list_and_load_roundtrips()
|
||||
{
|
||||
var (svc, _) = Seeded();
|
||||
var create = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
|
||||
create.Ok.ShouldBeTrue();
|
||||
create.CreatedId.ShouldBe("SA-1");
|
||||
|
||||
var rows = await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].Name.ShouldBe("Over-temp");
|
||||
rows[0].Severity.ShouldBe(700);
|
||||
|
||||
var dto = await svc.LoadScriptedAlarmAsync("SA-1");
|
||||
dto.ShouldNotBeNull();
|
||||
dto!.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
|
||||
dto.HistorizeToAveva.ShouldBeTrue();
|
||||
dto.RowVersion.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_rejects_duplicate_id()
|
||||
{
|
||||
var (svc, _) = Seeded();
|
||||
(await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample())).Ok.ShouldBeTrue();
|
||||
var dup = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
|
||||
dup.Ok.ShouldBeFalse();
|
||||
dup.Error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_changes_fields()
|
||||
{
|
||||
var (svc, _) = Seeded();
|
||||
await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
|
||||
var dto = await svc.LoadScriptedAlarmAsync("SA-1");
|
||||
|
||||
var upd = await svc.UpdateScriptedAlarmAsync("SA-1",
|
||||
Sample() with { Name = "Renamed", Severity = 250, HistorizeToAveva = false }, dto!.RowVersion);
|
||||
upd.Ok.ShouldBeTrue();
|
||||
|
||||
var after = await svc.LoadScriptedAlarmAsync("SA-1");
|
||||
after!.Name.ShouldBe("Renamed");
|
||||
after.Severity.ShouldBe(250);
|
||||
after.HistorizeToAveva.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_removes_row()
|
||||
{
|
||||
var (svc, _) = Seeded();
|
||||
await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
|
||||
var dto = await svc.LoadScriptedAlarmAsync("SA-1");
|
||||
|
||||
(await svc.DeleteScriptedAlarmAsync("SA-1", dto!.RowVersion)).Ok.ShouldBeTrue();
|
||||
(await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId)).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run — expect FAIL.**
|
||||
|
||||
**Step 5: Implement** the five methods in `UnsTreeService.cs` (append before the final `}`), mirroring the existing dup-check + `db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion` + `catch (DbUpdateConcurrencyException)` patterns. Note `Severity`/`Name`/`AlarmType` are validated client-side in the modal; the service enforces only structural rules (dup id, row-exists, concurrency) — matching the old page:
|
||||
```csharp
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
return await db.ScriptedAlarms.AsNoTracking()
|
||||
.Where(a => a.EquipmentId == equipmentId)
|
||||
.OrderBy(a => a.Name)
|
||||
.Select(a => new EquipmentAlarmRow(a.ScriptedAlarmId, a.Name, a.AlarmType, a.Severity, a.PredicateScriptId, a.Enabled))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
return await db.ScriptedAlarms.AsNoTracking()
|
||||
.Where(a => a.ScriptedAlarmId == scriptedAlarmId)
|
||||
.Select(a => new ScriptedAlarmEditDto(a.ScriptedAlarmId, a.EquipmentId, a.Name, a.AlarmType, a.Severity,
|
||||
a.MessageTemplate, a.PredicateScriptId, a.HistorizeToAveva, a.Retain, a.Enabled, a.RowVersion))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
if (await db.ScriptedAlarms.AnyAsync(a => a.ScriptedAlarmId == input.ScriptedAlarmId, ct))
|
||||
return new UnsMutationResult(false, $"ScriptedAlarm '{input.ScriptedAlarmId}' already exists.");
|
||||
|
||||
db.ScriptedAlarms.Add(new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = input.ScriptedAlarmId,
|
||||
EquipmentId = equipmentId,
|
||||
Name = input.Name,
|
||||
AlarmType = input.AlarmType,
|
||||
Severity = input.Severity,
|
||||
MessageTemplate = input.MessageTemplate,
|
||||
PredicateScriptId = input.PredicateScriptId,
|
||||
HistorizeToAveva = input.HistorizeToAveva,
|
||||
Retain = input.Retain,
|
||||
Enabled = input.Enabled,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null, input.ScriptedAlarmId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
entity.Name = input.Name;
|
||||
entity.AlarmType = input.AlarmType;
|
||||
entity.Severity = input.Severity;
|
||||
entity.MessageTemplate = input.MessageTemplate;
|
||||
entity.PredicateScriptId = input.PredicateScriptId;
|
||||
entity.HistorizeToAveva = input.HistorizeToAveva;
|
||||
entity.Retain = input.Retain;
|
||||
entity.Enabled = input.Enabled;
|
||||
try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); }
|
||||
catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this scripted alarm while you were editing."); }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
if (entity is null) return new UnsMutationResult(true, null);
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
db.ScriptedAlarms.Remove(entity);
|
||||
try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); }
|
||||
catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this alarm while you were viewing it."); }
|
||||
catch (Exception ex) { return new UnsMutationResult(false, $"Delete failed: {ex.Message}."); }
|
||||
}
|
||||
```
|
||||
(Ensure `ScriptedAlarm` is in scope — `using ZB.MOM.WW.OtOpcUa.Configuration.Entities;` is already imported at the top of `UnsTreeService.cs`.)
|
||||
|
||||
**Step 6: Run tests — expect PASS** (4/4). Build → 0 errors.
|
||||
|
||||
**Step 7: Commit:**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs
|
||||
git commit -m "feat(uns): scripted-alarm CRUD in IUnsTreeService for the equipment Alarms tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `EquipmentPage` shell + Details tab + create→redirect
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (precedes Task 5/6 on `EquipmentPage.razor`)
|
||||
|
||||
> High-risk because it's a new routed page wiring auth + create/edit/concurrency; serial spec→code review + final integration review at execution time.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
|
||||
- Reference (copy form from): `Components/Shared/Uns/EquipmentModal.razor` (do NOT delete it yet — `GlobalUns` still uses it until Task 7)
|
||||
- Reference (route precedent): `Components/Pages/ScriptEdit.razor`
|
||||
|
||||
**Scope:** the page shell + the **Details** tab only. Tabs `Tags`/`Virtual Tags`/`Alarms` render a placeholder in this task (`<p class="text-muted">…</p>`); they're wired in Task 5/6.
|
||||
|
||||
**Step 1: Create `EquipmentPage.razor`.** Structure (fill the Details form by copying the `_form` fields, `OnParametersSet` mapping, `SaveAsync` body and `FormModel` from `EquipmentModal.razor:33-251`, adapting the save to navigate):
|
||||
```razor
|
||||
@page "/uns/equipment/new"
|
||||
@page "/uns/equipment/{EquipmentId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject IUnsTreeService Svc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Equipment</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New equipment" : _equipment?.Name ?? EquipmentId)</h4>
|
||||
<a href="/uns" class="btn btn-outline-secondary btn-sm">Back to UNS</a>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _equipment is null)
|
||||
{
|
||||
<section class="panel notice rise"><span class="mono">@EquipmentId</span> not found.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @TabClass("details")" @onclick='() => _activeTab = "details"'>Details</button></li>
|
||||
<li class="nav-item"><button class="nav-link @TabClass("tags")" @onclick='() => _activeTab = "tags"' disabled="@IsNew">Tags</button></li>
|
||||
<li class="nav-item"><button class="nav-link @TabClass("vtags")" @onclick='() => _activeTab = "vtags"' disabled="@IsNew">Virtual Tags</button></li>
|
||||
<li class="nav-item"><button class="nav-link @TabClass("alarms")" @onclick='() => _activeTab = "alarms"' disabled="@IsNew">Alarms</button></li>
|
||||
</ul>
|
||||
|
||||
@if (_activeTab == "details")
|
||||
{
|
||||
@* Lifted EquipmentModal form: identity (Name/MachineCode/UNS line/Driver/ZTag/SAPID/Enabled)
|
||||
+ OPC 40010 identification block. EditForm Model=_form OnValidSubmit=SaveAsync. *@
|
||||
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentDetails">
|
||||
<DataAnnotationsValidator />
|
||||
@* ... copy the field markup from EquipmentModal.razor:24-106 (drop the modal-* wrappers) ... *@
|
||||
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="text-danger small mt-2">@_error</div> }
|
||||
<div class="mt-3"><button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button></div>
|
||||
</EditForm>
|
||||
}
|
||||
else if (_activeTab == "tags") { <p class="text-muted">Tags tab — wired in Task 5.</p> }
|
||||
else if (_activeTab == "vtags") { <p class="text-muted">Virtual Tags tab — wired in Task 5.</p> }
|
||||
else if (_activeTab == "alarms") { <p class="text-muted">Alarms tab — wired in Task 6.</p> }
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
[SupplyParameterFromQuery] public string? LineId { get; set; }
|
||||
private bool IsNew => string.IsNullOrEmpty(EquipmentId);
|
||||
|
||||
private string _activeTab = "details";
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
private EquipmentEditDto? _equipment;
|
||||
private FormModel _form = new();
|
||||
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
|
||||
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
|
||||
|
||||
private string TabClass(string tab) => _activeTab == tab ? "active" : "";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
if (!IsNew)
|
||||
{
|
||||
_equipment = await Svc.LoadEquipmentAsync(EquipmentId!);
|
||||
if (_equipment is not null) { LoadFormFrom(_equipment); await LoadPickersForClusterOfLineAsync(_equipment.UnsLineId); }
|
||||
}
|
||||
else
|
||||
{
|
||||
_form = new FormModel { UnsLineId = LineId ?? "" };
|
||||
await LoadPickersForClusterOfLineAsync(LineId);
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
// Build _lineOptions + _driverOptions for the cluster owning the given line, reusing
|
||||
// Svc.LoadDriversForClusterAsync. (Resolve clusterId from the line; see note below.)
|
||||
private async Task LoadPickersForClusterOfLineAsync(string? lineId) { /* see Step 2 */ }
|
||||
|
||||
private void LoadFormFrom(EquipmentEditDto e) { /* copy EquipmentModal.OnParametersSet edit branch */ }
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var input = new EquipmentInput(_form.Name, _form.MachineCode, _form.UnsLineId, _form.DriverInstanceId,
|
||||
_form.ZTag, _form.SAPID, _form.Manufacturer, _form.Model, _form.SerialNumber, _form.HardwareRevision,
|
||||
_form.SoftwareRevision, _form.YearOfConstruction, _form.AssetLocation, _form.ManufacturerUri,
|
||||
_form.DeviceManualUri, _form.Enabled);
|
||||
|
||||
var result = IsNew
|
||||
? await Svc.CreateEquipmentAsync(input)
|
||||
: await Svc.UpdateEquipmentAsync(_equipment!.EquipmentId, input, _equipment.RowVersion);
|
||||
|
||||
if (!result.Ok) { _error = result.Error; return; }
|
||||
if (IsNew) { Nav.NavigateTo($"/uns/equipment/{result.CreatedId}"); }
|
||||
else { _equipment = await Svc.LoadEquipmentAsync(EquipmentId!); if (_equipment is not null) LoadFormFrom(_equipment); }
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel { /* identical to EquipmentModal.FormModel:232-251 */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Cluster/line/driver pickers.** The page needs the line `<select>` options + driver options scoped to the equipment's cluster, the way `GlobalUns.LinesForCluster` / `LoadDriversForClusterAsync` do — but the page doesn't hold the loaded tree. Resolve via the service: add a tiny helper to `IUnsTreeService` ONLY IF NEEDED. **Prefer reuse:** `LoadEquipmentAsync` gives `UnsLineId`; to map a line→cluster and list sibling lines + cluster drivers without the tree, the simplest path is a new lightweight service method `LoadEquipmentPickContextAsync(string? lineId)` returning `(IReadOnlyList<(string Id,string Display)> Lines, IReadOnlyList<(string Id,string Display)> Drivers)`. **If you add it,** it belongs in Task 2's files — but since Task 2 is already committed, add it here in `UnsTreeService.cs`/`IUnsTreeService.cs` and note the contention. Implementation: resolve `clusterId` from `UnsLines→UnsAreas.ClusterId` for `lineId`, list all lines in that cluster (`UnsLine.Name` display), and call the existing cluster-driver query. Keep it `AsNoTracking`. Add one service test for it.
|
||||
|
||||
> Implementer note: if resolving the picker context inflates this task past ~300 LOC, STOP and split the picker-context service method into its own small task before continuing. Surface it; don't silently expand.
|
||||
|
||||
**Step 3: Build** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → 0 errors. (No unit test for the Razor page — behaviour is live-verified in Task 11. If you added the picker-context service method, add its xUnit test.)
|
||||
|
||||
**Step 4: Commit:**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ # only if a picker-context test was added
|
||||
git commit -m "feat(uns): equipment detail page shell + Details tab + create-redirect"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Tags tab + Virtual Tags tab (reuse TagModal + VirtualTagModal)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (after Task 4 on `EquipmentPage.razor`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
|
||||
- Reference: `Components/Shared/Uns/TagModal.razor`, `VirtualTagModal.razor`, and `GlobalUns.razor:76-90,288-323,369-396,443-473` for the open/save/delete wiring to copy.
|
||||
|
||||
**Step 1: Tags tab content.** Replace the `"tags"` placeholder with a table over `_tags` (loaded via `Svc.LoadTagsForEquipmentAsync(EquipmentId!)`), an "Add tag" button, per-row Edit + Delete, and host `<TagModal>` exactly as `GlobalUns` does:
|
||||
- State: `_tags`, `_tagModalVisible`, `_tagModalIsNew`, `_tagModalExisting (TagEditDto?)`, `_tagModalDriverOptions` (from `Svc.LoadTagDriversForEquipmentAsync(EquipmentId!)`).
|
||||
- Add: set IsNew=true, Existing=null, load driver options, show modal.
|
||||
- Edit: `await Svc.LoadTagAsync(tagId)` → Existing, load driver options, show modal.
|
||||
- `OnSaved`: reload `_tags`, hide modal. `OnCancel`: hide modal.
|
||||
- Delete: `await Svc.LoadTagAsync(tagId)` for fresh RowVersion → `Svc.DeleteTagAsync(tagId, dto.RowVersion)` → reload `_tags` (mirror `GlobalUns.ConfirmDeleteAsync` Tag branch; a simple confirm is fine — `_tagPendingDelete` + a small inline confirm or reuse the page's own confirm pattern).
|
||||
```razor
|
||||
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
|
||||
Existing="_tagModalExisting" Drivers="_tagModalDriverOptions"
|
||||
OnSaved="OnTagSavedAsync" OnCancel="() => _tagModalVisible = false" />
|
||||
```
|
||||
|
||||
**Step 2: Virtual Tags tab content.** Same shape over `_vtags` (`Svc.LoadVirtualTagsForEquipmentAsync`), hosting `<VirtualTagModal>` with `Scripts` from `Svc.LoadScriptsAsync()`, Existing from `Svc.LoadVirtualTagAsync(id)`, delete via `Svc.DeleteVirtualTagAsync(id, dto.RowVersion)`:
|
||||
```razor
|
||||
<VirtualTagModal Visible="_vtagModalVisible" IsNew="_vtagModalIsNew" EquipmentId="@EquipmentId"
|
||||
Existing="_vtagModalExisting" Scripts="_vtagScriptOptions"
|
||||
OnSaved="OnVtagSavedAsync" OnCancel="() => _vtagModalVisible = false" />
|
||||
```
|
||||
|
||||
**Step 3: Lazy tab loads.** Load `_tags`/`_vtags` when their tab is first activated (or in `OnParametersSetAsync` when `!IsNew`). Keep it simple: load both on first switch to the tab.
|
||||
|
||||
**Step 4: Build** → 0 errors.
|
||||
|
||||
**Step 5: Commit:**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor
|
||||
git commit -m "feat(uns): Tags + Virtual Tags tabs on the equipment page (reuse modals)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `ScriptedAlarmModal` + Alarms tab
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (after Task 5 on `EquipmentPage.razor`; needs Task 3)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ScriptedAlarmModal.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor`
|
||||
- Reference: `Components/Pages/ScriptedAlarmEdit.razor` (the field markup + `FormModel` to lift) and `Components/Shared/Uns/TagModal.razor` (the modal shell + `Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancel` parameter pattern).
|
||||
|
||||
**Step 1: `ScriptedAlarmModal.razor`.** A modal shell (copy TagModal's `@if (Visible)` backdrop + `EditForm`), parameters:
|
||||
```csharp
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public bool IsNew { get; set; }
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
[Parameter] public ScriptedAlarmEditDto? Existing { get; set; }
|
||||
[Parameter] public IReadOnlyList<(string Id, string Display)> Scripts { get; set; } = Array.Empty<(string, string)>();
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
```
|
||||
Form fields lifted from `ScriptedAlarmEdit.razor:34-101` minus the **Equipment** picker (equipment is fixed by the tab): `ScriptedAlarmId` (disabled on edit), `Name`, `AlarmType` (`LimitAlarm`/`DiscreteAlarm`/`OffNormalAlarm`/`AlarmCondition`), `Severity`, `PredicateScriptId` (`<select>` over `Scripts`), `MessageTemplate`, `HistorizeToAveva` (default true), `Retain`, `Enabled`. `FormModel` = `ScriptedAlarmEdit.FormModel:222-235` minus `EquipmentId`. `OnParametersSet` rebuilds `_form` from `Existing` on edit / fresh on create. `SaveAsync`:
|
||||
```csharp
|
||||
var input = new ScriptedAlarmInput(_form.ScriptedAlarmId, _form.Name, _form.AlarmType, _form.Severity,
|
||||
_form.MessageTemplate, _form.PredicateScriptId, _form.HistorizeToAveva, _form.Retain, _form.Enabled);
|
||||
var result = IsNew
|
||||
? await Svc.CreateScriptedAlarmAsync(EquipmentId!, input)
|
||||
: await Svc.UpdateScriptedAlarmAsync(Existing!.ScriptedAlarmId, input, Existing.RowVersion);
|
||||
if (result.Ok) await OnSaved.InvokeAsync(); else _error = result.Error;
|
||||
```
|
||||
Validation is the DataAnnotations on `FormModel` (`[Required]`, `[Range(1,1000)]`, `ScriptedAlarmId` regex `^[A-Za-z0-9_-]+$`) — same as the old page.
|
||||
|
||||
**Step 2: Alarms tab content** in `EquipmentPage.razor`: table over `_alarms` (`Svc.LoadAlarmsForEquipmentAsync`), "Add alarm" button, per-row Edit + Delete, host `<ScriptedAlarmModal>` (Existing via `Svc.LoadScriptedAlarmAsync(id)`, Scripts via `Svc.LoadScriptsAsync()`, delete via `Svc.LoadScriptedAlarmAsync(id)` for RowVersion then `Svc.DeleteScriptedAlarmAsync(id, dto.RowVersion)`). `OnSaved` reloads `_alarms` + hides modal.
|
||||
|
||||
**Step 3: Build** → 0 errors.
|
||||
|
||||
**Step 4: Commit:**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ScriptedAlarmModal.razor \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor
|
||||
git commit -m "feat(uns): Alarms tab + ScriptedAlarmModal on the equipment page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `/uns` tree surgery — Equipment becomes a leaf; remove EquipmentModal
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 9
|
||||
|
||||
> High-risk: surgical edits to the 671-line main UNS page + the shared tree renderer. Serial spec→code review + final integration review.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor`
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor`
|
||||
|
||||
**Step 1: `UnsTree.razor` Equipment actions** (`:119-128`): replace the equipment `+ Tag` / `+ Virtual tag` / `Edit` buttons with an **Open** link to the page; keep **Delete**:
|
||||
```razor
|
||||
case UnsNodeKind.Equipment:
|
||||
<a class="btn btn-sm btn-link p-0 ms-2" href="@($"/uns/equipment/{node.EntityId}")">Open</a>
|
||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
|
||||
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
|
||||
break;
|
||||
```
|
||||
Also drop the `Tag`/`VirtualTag` action cases (`:130-136`) — those nodes are no longer rendered. The `OnAddVirtualTag` parameter becomes unused; remove it (and its callers in `GlobalUns`).
|
||||
|
||||
**Step 2: Make equipment a leaf.** In `UnsNode`/`UnsTreeAssembly.BuildEquipment` (`UnsNode.cs:233-246`) set `HasLazyChildren = false` and `ChildCount` can stay (badge is harmless) — but with `HasLazyChildren=false` and no eager children, the tree shows no expander. Simplest: in `BuildEquipment`, set `HasLazyChildren = false` (drop the `childCount > 0` expander). Keep the `ChildCount` badge if desired, or set 0. (Decide: keeping the count badge is informative; set `HasLazyChildren = false` only.)
|
||||
|
||||
**Step 3: `GlobalUns.razor` removals.** Remove:
|
||||
- `<EquipmentModal>` (`:67-74`), `<TagModal>` (`:76-82`), `<VirtualTagModal>` (`:84-90`) usages.
|
||||
- The equipment/tag/vtag modal state fields (`:147-167`), `_childRefreshEquipmentId` (`:172-173`).
|
||||
- `ToggleAsync`'s equipment lazy-load branch (`:226-247`) — equipment no longer expands; `ToggleAsync` keeps only the structural toggle (or equipment toggle becomes a no-op since no expander is shown).
|
||||
- `HandleAddChild` Equipment case (`:288-295`) and `HandleAddVirtualTag` (`:304-323`) entirely.
|
||||
- `HandleEdit` Equipment/Tag/VirtualTag cases (`:358-389`).
|
||||
- `ConfirmDeleteAsync` Tag/VirtualTag branches (`:443-473`) — equipment delete stays (`:487-491`); tag/vtag deletes are gone from the tree.
|
||||
- `OnEquipmentChildModalSavedAsync`, `RefreshEquipmentChildrenAsync`, `FindEquipmentNode` helpers (`:522-584`) — now unused.
|
||||
- The matching cleanup lines in `CloseModals` (`:606-623`).
|
||||
|
||||
**Step 4: Rewire navigation.** Add `@inject NavigationManager Nav`. Change:
|
||||
- `HandleAddChild` **Line** case (`:279-286`): instead of opening EquipmentModal, `Nav.NavigateTo($"/uns/equipment/new?lineId={node.EntityId}")`.
|
||||
- Equipment "Open" is already an `<a href>` in the tree (Step 1) — no handler needed. (If you prefer routing through a handler, add an `OnOpen` callback; the `<a href>` is simpler and sufficient.)
|
||||
|
||||
**Step 5: Delete `EquipmentModal.razor`** (its only consumer is now gone). `TagModal.razor` + `VirtualTagModal.razor` are **kept** (reused by `EquipmentPage`).
|
||||
|
||||
**Step 6: Build** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` → 0 errors. Note: `LoadEquipmentChildrenAsync` is now unreferenced by `GlobalUns` but **still defined** — that's fine (removed in Task 8). Grep to confirm no other `GlobalUns` references to the removed helpers remain.
|
||||
|
||||
**Step 7: Commit:**
|
||||
```bash
|
||||
git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/EquipmentModal.razor
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs
|
||||
git commit -m "feat(uns): equipment is a tree leaf linking to the detail page; drop EquipmentModal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Remove now-dead `LoadEquipmentChildrenAsync` + port its test
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 9 (disjoint files), after Task 7
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (remove decl `:120-128`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (remove impl `:86-128`)
|
||||
- Delete: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs` (coverage replaced by Task 2's `UnsTreeServiceEquipmentChildRowsTests`)
|
||||
|
||||
**Step 1: Confirm dead.** `grep -rn "LoadEquipmentChildrenAsync" src tests` → expect only the interface decl, the impl, and the lazy test (all being removed). If any other caller exists, STOP and report.
|
||||
|
||||
**Step 2: Remove** the interface decl + impl + the `UnsNode`-returning child-node code, and `git rm` the lazy test (its behaviour is now covered by the row-DTO tests).
|
||||
|
||||
**Step 3: Build + test** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` → green.
|
||||
|
||||
**Step 4: Commit:**
|
||||
```bash
|
||||
git rm tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
|
||||
git commit -m "refactor(uns): drop dead LoadEquipmentChildrenAsync (replaced by row-DTO list methods)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Delete standalone scripted-alarm pages + nav link
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 7, Task 8 (disjoint files), after Task 6
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor`
|
||||
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor:24` (remove the `<NavRailItem Href="/scripted-alarms" …>` line)
|
||||
|
||||
**Step 1: Grep-sweep** `grep -rn "scripted-alarms" src tests docs` to find every reference. Expected: the two pages (being deleted), `MainLayout.razor:24`, and possibly docs. The `/alerts` runtime page is separate (route `/alerts`) and stays. If an **integration test** navigates to `/scripted-alarms`, update or remove that assertion (report it if non-trivial).
|
||||
|
||||
**Step 2: Delete the two pages, remove the nav line.**
|
||||
|
||||
**Step 3: Build** → 0 errors. (No nav-rail item should reference a deleted route.)
|
||||
|
||||
**Step 4: Commit:**
|
||||
```bash
|
||||
git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor
|
||||
git commit -m "feat(uns): remove standalone scripted-alarm pages + nav link (alarms now per-equipment)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Docs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 7/8/9 (doc files only)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/Uns.md`
|
||||
- Modify: `docs/ScriptedAlarms.md`
|
||||
- Modify: `docs/AlarmTracking.md`
|
||||
|
||||
**Step 1:** `docs/Uns.md` — the tree now stops at Equipment (no inline tags/virtual tags); document the new `/uns/equipment/{id}` page and its `Details · Tags · Virtual Tags · Alarms` tabs; "Add equipment" navigates to the page; tags/virtual tags/alarms are managed on the page, not the tree.
|
||||
|
||||
**Step 2:** `docs/ScriptedAlarms.md` + `docs/AlarmTracking.md` — repoint the AdminUI-surface notes from the standalone `/scripted-alarms` pages to the equipment page's **Alarms tab**. Keep all runtime/historian/OPC-Part-9 content unchanged (only the editing surface moved).
|
||||
|
||||
**Step 3: Commit:**
|
||||
```bash
|
||||
git add docs/Uns.md docs/ScriptedAlarms.md docs/AlarmTracking.md
|
||||
git commit -m "docs(uns): document the equipment page tabs; repoint alarm-editing surface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Full-suite gate + live `/run` verify + finish
|
||||
|
||||
**Classification:** standard (verification)
|
||||
**Estimated implement time:** ~5 min (+ user-driven live verify)
|
||||
**Parallelizable with:** none (last)
|
||||
|
||||
**Files:** none (verification + finishing)
|
||||
|
||||
**Step 1: Full build + test.**
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx # 0 errors
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests # green
|
||||
```
|
||||
(Run the broader `dotnet test ZB.MOM.WW.OtOpcUa.slnx` if time permits; the AdminUI suite is the load-bearing one for this change.)
|
||||
|
||||
**Step 2: Live `/run` (user drives — the agent does NOT sign in).** Verify on docker-dev:
|
||||
- `/uns` tree stops at Equipment (no tag/virtual-tag leaves); equipment row shows **Open** + **Delete**.
|
||||
- "Open" → `/uns/equipment/{id}`; tabs `Details · Tags · Virtual Tags · Alarms` render.
|
||||
- Details edit saves; "Add equipment" on a Line → `/uns/equipment/new?lineId=…` → Create → redirects to `/uns/equipment/{newId}` with tabs live.
|
||||
- Tags tab: add/edit (driver-typed editor) + delete round-trip. Virtual Tags tab: add/edit (inline Monaco) + delete. Alarms tab: add/edit/delete; HistorizeToAveva defaults on.
|
||||
- `/scripted-alarms` is gone (404) and absent from the nav rail.
|
||||
|
||||
**Step 3: Finish.** REQUIRED SUB-SKILL: `superpowers-extended-cc:finishing-a-development-branch`. Default intent (carried from this session): merge `feat/uns-equipment-page` to master + push.
|
||||
|
||||
---
|
||||
|
||||
## Task dependency summary
|
||||
|
||||
```
|
||||
T0 ─┬─ T1 ─ T2 ─ T3 ───────────── T8 (after T7)
|
||||
│ │ │
|
||||
│ └─────┴─ T4 ─ T5 ─ T6 ─ T7 ─┐
|
||||
│ │ ├─ T11
|
||||
│ T9 (after T6)──────┤
|
||||
└───────────────── T10 ──────────────┘
|
||||
```
|
||||
- **UnsTreeService.cs / IUnsTreeService.cs** serialize: T1 → T2 → T3 → (T4 picker-context, if added) → T8.
|
||||
- **EquipmentPage.razor** serializes: T4 → T5 → T6.
|
||||
- T7 (GlobalUns/UnsTree) needs T4 (route exists); ∥ with T9/T10.
|
||||
- T8 needs T7 (last caller gone) + T2 (replacement exists).
|
||||
- T9 needs T6 (Alarms tab is the replacement surface); ∥ with T7/T8/T10.
|
||||
- T11 last.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-11-equipment-page.md",
|
||||
"designPath": "docs/plans/2026-06-11-equipment-page-design.md",
|
||||
"branch": "feat/uns-equipment-page",
|
||||
"baseBranch": "master",
|
||||
"baseSha": "df2a488b",
|
||||
"status": "pending",
|
||||
"note": "Tabbed Equipment detail page for UNS. Reuse TagModal+VirtualTagModal UNCHANGED; only the Alarms editor is new. NO Configuration entity/EF migration. Same-file contention: UnsTreeService.cs+IUnsTreeService.cs serialize T1->T2->T3->(T4 picker-context if added)->T8; EquipmentPage.razor serializes T4->T5->T6. Build-green invariant: LoadEquipmentChildrenAsync removed (T8) only after its last caller GlobalUns is rewired (T7). EquipmentModal.razor deleted in T7 (TagModal/VirtualTagModal KEPT). UnsMutationResult gains a defaulted CreatedId so ~50 call sites stay compiling.",
|
||||
"tasks": [
|
||||
{"id": 267, "planTask": 0, "subject": "T0: Create feature branch", "classification": "trivial", "status": "pending", "blockedBy": []},
|
||||
{"id": 268, "planTask": 1, "subject": "T1: UnsMutationResult.CreatedId + equipment create", "classification": "small", "status": "pending", "blockedBy": [267]},
|
||||
{"id": 269, "planTask": 2, "subject": "T2: Per-equipment Tag/VirtualTag list methods", "classification": "standard", "status": "pending", "blockedBy": [268]},
|
||||
{"id": 270, "planTask": 3, "subject": "T3: Scripted-alarm CRUD in IUnsTreeService", "classification": "standard", "status": "pending", "blockedBy": [269]},
|
||||
{"id": 271, "planTask": 4, "subject": "T4: EquipmentPage shell + Details tab + create-redirect", "classification": "high-risk", "status": "pending", "blockedBy": [268, 270]},
|
||||
{"id": 272, "planTask": 5, "subject": "T5: Tags + Virtual Tags tabs (reuse modals)", "classification": "standard", "status": "pending", "blockedBy": [269, 271]},
|
||||
{"id": 273, "planTask": 6, "subject": "T6: Alarms tab + ScriptedAlarmModal", "classification": "standard", "status": "pending", "blockedBy": [270, 272]},
|
||||
{"id": 274, "planTask": 7, "subject": "T7: /uns tree surgery; remove EquipmentModal", "classification": "high-risk", "status": "pending", "blockedBy": [271], "parallelizableWith": [276]},
|
||||
{"id": 275, "planTask": 8, "subject": "T8: Remove dead LoadEquipmentChildrenAsync", "classification": "small", "status": "pending", "blockedBy": [269, 274], "parallelizableWith": [276]},
|
||||
{"id": 276, "planTask": 9, "subject": "T9: Delete standalone scripted-alarm pages + nav link", "classification": "small", "status": "pending", "blockedBy": [273], "parallelizableWith": [274, 275]},
|
||||
{"id": 277, "planTask": 10, "subject": "T10: Docs", "classification": "small", "status": "pending", "blockedBy": []},
|
||||
{"id": 278, "planTask": 11, "subject": "T11: Full-suite gate + live /run verify + finish", "classification": "standard", "status": "pending", "blockedBy": [273, 274, 275, 276, 277]}
|
||||
],
|
||||
"lastUpdated": "2026-06-11"
|
||||
}
|
||||
Reference in New Issue
Block a user