From 705ed6234f974c446724235cc2ec6e920518eb7d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 14:25:59 -0400 Subject: [PATCH] feat(uns): scripted-alarm CRUD in IUnsTreeService for the equipment Alarms tab --- .../Uns/IUnsTreeService.cs | 55 +++++++++++++ .../Uns/ScriptedAlarmDtos.cs | 9 +++ .../Uns/ScriptedAlarmInput.cs | 7 ++ .../Uns/UnsTreeService.cs | 78 +++++++++++++++++++ .../Uns/UnsTreeServiceScriptedAlarmTests.cs | 77 ++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 2758a45f..38c84cf9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -457,4 +457,59 @@ public interface IUnsTreeService /// A token to cancel the operation. /// Success, a missing-row failure, or a concurrency failure. Task<(bool Ok, string? Error)> UpdateScriptSourceAsync(string scriptId, string sourceCode, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Loads the scripted alarms owned by a single equipment as flat row projections for the equipment + /// page's Alarms tab table, ordered by Name. Each row carries the display columns plus the + /// ScriptedAlarmId the table uses to open the edit modal. Reads untracked. Returns an empty + /// list when the equipment has no scripted alarms. + /// + /// The equipment whose scripted alarms to load. + /// A token to cancel the load. + /// The equipment's scripted-alarm rows ordered by Name; empty if it has none. + Task> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default); + + /// + /// Loads a single scripted alarm projected for editing, or null if it no longer exists. + /// Reads untracked and captures the current concurrency token for last-write-wins saves. + /// + /// The scripted alarm to load. + /// A token to cancel the load. + /// The scripted alarm's edit projection, or null when missing. + Task LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default); + + /// + /// Creates a new scripted alarm under an equipment. The ScriptedAlarmId is operator-typed + /// (not system-generated). Fails on a duplicate ScriptedAlarmId. Field validation (severity + /// range, required fields) is enforced client-side in the modal. On success the operator-typed id is + /// echoed back via . + /// + /// The owning equipment. + /// The operator-editable scripted-alarm fields. + /// A token to cancel the operation. + /// Success carrying the created id, or a duplicate-id failure. + Task CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default); + + /// + /// Updates a scripted alarm's mutable fields (name, alarm type, severity, message template, + /// predicate script, and the historize/retain/enabled flags). The owning EquipmentId is + /// preserved (the Alarms tab fixes it). Uses last-write-wins optimistic concurrency on + /// . + /// + /// The scripted alarm to update. + /// The new operator-editable scripted-alarm fields. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a missing-row failure, or a concurrency failure. + Task UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes a scripted alarm. A missing row is treated as success (already gone). Uses last-write-wins + /// optimistic concurrency on . + /// + /// The scripted alarm to delete. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a concurrency failure, or a delete-failed failure. + Task DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs new file mode 100644 index 00000000..43e3744c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmDtos.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// A scripted-alarm row for the Alarms-tab table. +public sealed record EquipmentAlarmRow(string ScriptedAlarmId, string Name, string AlarmType, int Severity, string PredicateScriptId, bool Enabled); + +/// A scripted alarm projected for editing, with the concurrency token the modal echoes back. +public sealed record ScriptedAlarmEditDto( + string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate, + string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled, byte[] RowVersion); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs new file mode 100644 index 00000000..2ce2bc75 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/ScriptedAlarmInput.cs @@ -0,0 +1,7 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// Operator-editable fields for a scripted-alarm create/update on the equipment Alarms tab. +/// The owning equipment is supplied separately (the tab fixes it), so there is no equipment field. +public sealed record ScriptedAlarmInput( + string ScriptedAlarmId, string Name, string AlarmType, int Severity, string MessageTemplate, + string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 335e1249..0fd500e2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -1286,4 +1286,82 @@ public sealed class UnsTreeService(IDbContextFactory dbF return null; } + + /// + public async Task> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + return await db.ScriptedAlarms.AsNoTracking() + .Where(a => a.EquipmentId == equipmentId) + .OrderBy(a => a.Name) + .Select(a => new EquipmentAlarmRow(a.ScriptedAlarmId, a.Name, a.AlarmType, a.Severity, a.PredicateScriptId, a.Enabled)) + .ToListAsync(ct); + } + + /// + public async Task LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + return await db.ScriptedAlarms.AsNoTracking() + .Where(a => a.ScriptedAlarmId == scriptedAlarmId) + .Select(a => new ScriptedAlarmEditDto(a.ScriptedAlarmId, a.EquipmentId, a.Name, a.AlarmType, a.Severity, + a.MessageTemplate, a.PredicateScriptId, a.HistorizeToAveva, a.Retain, a.Enabled, a.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + /// + public async Task CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + if (await db.ScriptedAlarms.AnyAsync(a => a.ScriptedAlarmId == input.ScriptedAlarmId, ct)) + return new UnsMutationResult(false, $"ScriptedAlarm '{input.ScriptedAlarmId}' already exists."); + + db.ScriptedAlarms.Add(new ScriptedAlarm + { + ScriptedAlarmId = input.ScriptedAlarmId, + EquipmentId = equipmentId, + Name = input.Name, + AlarmType = input.AlarmType, + Severity = input.Severity, + MessageTemplate = input.MessageTemplate, + PredicateScriptId = input.PredicateScriptId, + HistorizeToAveva = input.HistorizeToAveva, + Retain = input.Retain, + Enabled = input.Enabled, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, input.ScriptedAlarmId); + } + + /// + public async Task UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct); + if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = input.Name; + entity.AlarmType = input.AlarmType; + entity.Severity = input.Severity; + entity.MessageTemplate = input.MessageTemplate; + entity.PredicateScriptId = input.PredicateScriptId; + entity.HistorizeToAveva = input.HistorizeToAveva; + entity.Retain = input.Retain; + entity.Enabled = input.Enabled; + try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } + catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this scripted alarm while you were editing."); } + } + + /// + public async Task DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct); + if (entity is null) return new UnsMutationResult(true, null); + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.ScriptedAlarms.Remove(entity); + try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } + catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this alarm while you were viewing it."); } + catch (Exception ex) { return new UnsMutationResult(false, $"Delete failed: {ex.Message}."); } + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs new file mode 100644 index 00000000..5547626d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceScriptedAlarmTests.cs @@ -0,0 +1,77 @@ +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 SeededService() + { + var dbName = $"uns-alarm-{Guid.NewGuid():N}"; + UnsTreeTestDb.SeedNamed(dbName); + return new UnsTreeService(UnsTreeTestDb.Factory(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 = SeededService(); + 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 = SeededService(); + (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 = SeededService(); + 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 = SeededService(); + 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(); + } +}