@(alarm.TriggerConfiguration ?? "—")
+ @if (alarm.IsMesReportable)
+ {
+ @* The inherited MES-reportable flag; an instance
+ override of it shows in the Override cell's summary. *@
+ MES
+ }
+ @* Tri-state: blank = inherit the template alarm's flag.
+ "Not reportable" is a real override, not a clear — it
+ pins a reportable template alarm off for this instance. *@
+
+
+
@if (_editingError != null)
@@ -837,6 +855,9 @@
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
private string? _editingInheritedValue; // the inherited config snapshot we diff against on save
private string? _editingPriorityText;
+ // Tri-state MES reportable, as the select's string value: "" = inherited,
+ // "true"/"false" = pinned. Parsed back to bool? on save.
+ private string _editingMesReportableText = "";
private string? _editingError;
private IReadOnlyList _editingAvailableAttributes = Array.Empty();
@@ -1552,10 +1573,23 @@
}
if (ovr.PriorityLevelOverride.HasValue)
parts.Add($"priority={ovr.PriorityLevelOverride.Value}");
+ if (ovr.IsMesReportableOverride is bool m)
+ parts.Add(m ? "mesReportable" : "notMesReportable");
return parts.Count == 0 ? "(empty)" : string.Join(", ", parts);
}
+ ///
+ /// The save-time delta rule: does the modal carry anything worth persisting?
+ /// When it does not, DELETES any existing
+ /// override — so every override term must be represented here or it would be
+ /// silently dropped on save. A false MES-reportable choice counts: it
+ /// pins a reportable template alarm off for this instance, which is a real
+ /// override and not the same as inheriting.
+ ///
+ internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable)
+ => !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue;
+
///
/// Opens the override editor modal pre-populated with the merged
/// (inherited + existing override) config so the user sees the effective
@@ -1577,6 +1611,12 @@
: (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration);
_editingPriorityText = existing?.PriorityLevelOverride?.ToString();
+ _editingMesReportableText = existing?.IsMesReportableOverride switch
+ {
+ true => "true",
+ false => "false",
+ _ => "",
+ };
_editingAvailableAttributes = _flattenedAttributes;
}
@@ -1621,11 +1661,18 @@
: _editingOverrideValue;
}
+ bool? mesReportable = _editingMesReportableText switch
+ {
+ "true" => true,
+ "false" => false,
+ _ => null,
+ };
+
var user = await GetCurrentUserAsync();
var alarmName = _editingAlarm.Name;
- // No diff + no priority → clear any existing override and close.
- if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue)
+ // Nothing overridden at all → clear any existing override and close.
+ if (!HasOverrideDelta(overrideJson, priority, mesReportable))
{
if (_existingAlarmOverrides.ContainsKey(alarmName))
{
@@ -1647,7 +1694,7 @@
else
{
var result = await MutationServices.GetRequiredService().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync(
- Id, alarmName, overrideJson, priority, currentUser));
+ Id, alarmName, overrideJson, priority, currentUser, mesReportable));
if (!result.IsSuccess)
{
_editingError = result.Error;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs
index 5b95beb5..ef603199 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs
@@ -516,4 +516,70 @@ public sealed class InstanceConfigureTests : IClassFixture
+ /// Tri-state MES reportable round-trip. The override editor's third control is a select whose
+ /// blank option means "inherit the template alarm's IsMesReportable"; "Reportable" /
+ /// "Not reportable" pin it per-instance.
+ ///
+ ///
+ /// This is the regression guard for the save-time delta rule: a Save with no config-diff and no
+ /// priority DELETES the override, so a reportable-only choice has to count as a delta on its own
+ /// or it would be silently dropped. The assertion is therefore that choosing ONLY "Reportable"
+ /// (leaving the trigger config and priority untouched) creates the override — badge + a summary
+ /// naming mesReportable — and that switching back to Inherited removes it again.
+ ///
+ ///
+ ///
+ /// The fixture instance is SHARED, so this clears its own override in-body and again in a
+ /// finally, exactly as does.
+ ///
+ ///
+ [SkippableFact]
+ public async Task AlarmOverride_MesReportable_RoundTrips()
+ {
+ Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
+
+ var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
+ try
+ {
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
+ await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);
+
+ // Edit → choose "Reportable" ONLY (no config edit, no priority) → Save.
+ await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
+ var mesSelect = page.Locator("[data-test='alarm-mes-select']");
+ await Assertions.Expect(mesSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await mesSelect.SelectOptionAsync("true");
+ await page.Locator("[data-test='alarm-save-override']").ClickAsync();
+
+ await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+ // Summary part is "mesReportable" (the negative pin renders "notMesReportable").
+ await Assertions.Expect(row).ToContainTextAsync("mesReportable", new() { Timeout = 15_000 });
+
+ // Reopen → back to Inherited (blank) → Save. With nothing else set that is now a
+ // no-delta save, which clears the override outright.
+ await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
+ await Assertions.Expect(mesSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await mesSelect.SelectOptionAsync("");
+ await page.Locator("[data-test='alarm-save-override']").ClickAsync();
+
+ await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
+ .ToHaveCountAsync(0, new() { Timeout = 15_000 });
+ using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
+ var overrides = doc.RootElement.GetProperty("alarmOverrides");
+ Assert.DoesNotContain(overrides.EnumerateArray(), o =>
+ o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
+ }
+ finally
+ {
+ await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
+ }
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs
new file mode 100644
index 00000000..5dafbfff
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs
@@ -0,0 +1,80 @@
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
+
+///
+/// MES reportable (tri-state) on the Instance Configure alarm-override editor.
+///
+/// The instance-level override is a bool?: null means "inherit the
+/// template alarm's IsMesReportable", true/false pin it. The modal surfaces
+/// that as a three-option select (Inherited / Reportable / Not reportable).
+///
+/// InstanceConfigure is a heavyweight page (~7 injected services with
+/// their own graphs), so — consistent with the rest of this suite's coverage of it
+/// (see InstanceConfigureListOverrideTests) — the markup half is a structural
+/// assertion over the component source. The half that carries the real risk is the
+/// save-time delta rule, which is extracted as a pure helper and pinned directly:
+/// the page DELETES the override when it sees no delta, so a reportable-only
+/// override would be silently dropped on save if the new term were missing.
+///
+public class InstanceConfigureMesReportableTests
+{
+ private static string DeploymentComponentsRoot
+ {
+ get
+ {
+ var dir = AppContext.BaseDirectory;
+ for (var i = 0; i < 6 && dir is not null; i++)
+ dir = Directory.GetParent(dir)?.FullName;
+ return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
+ "Components", "Pages", "Deployment");
+ }
+ }
+
+ private static string InstanceConfigureMarkup
+ => File.ReadAllText(Path.Combine(DeploymentComponentsRoot, "InstanceConfigure.razor"));
+
+ [Fact]
+ public void OverrideModal_HasTriStateMesReportableSelect()
+ {
+ var markup = InstanceConfigureMarkup;
+
+ Assert.Contains("data-test=\"alarm-mes-select\"", markup);
+
+ // Three options: Inherited (empty value) / Reportable (true) / Not reportable (false).
+ var selectStart = markup.IndexOf("data-test=\"alarm-mes-select\"", StringComparison.Ordinal);
+ var selectEnd = markup.IndexOf("", selectStart, StringComparison.Ordinal);
+ Assert.True(selectEnd > selectStart, "MES reportable select is not closed.");
+
+ var selectBody = markup[selectStart..selectEnd];
+ var optionCount = selectBody.Split("