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();
+ }
+}