diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md
index 0d09636d..12895125 100644
--- a/docs/requirements/Component-CentralUI.md
+++ b/docs/requirements/Component-CentralUI.md
@@ -169,7 +169,7 @@ Central cluster only. Sites have no user interface.
- **Source Reference** — the concrete native key for this instance.
- **Filter** — the per-instance condition filter.
- A **blank field inherits** the template default (the greyed placeholder shows the inherited value for context, mirroring the per-attribute Override field). **Save** and **Clear** act per row — Save persists the row's overrides, Clear reverts the row to the template-inherited binding. Locked template sources are not overridable.
- - **Bulk retarget CSV import**: an `InputFile` upload on the card header accepts a CSV of `SourceName, Connection, SourceReference, Filter` rows — the same file format and batch rules as the CLI `instance native-alarm-source import --file` (see Component-CLI.md), parsed with the shared `NativeAlarmSourceOverrideCsvParser`. Each row is validated against the template's source bindings (name resolves, source not template-locked, no duplicate source in the file); the import is **all-or-nothing** — any error aborts the whole upload with a per-line error summary and nothing is applied. Semantics are **merge, not full replace**: sources absent from the file keep their existing override, a blank field keeps the inherited value, and a row with all three fields blank clears that source's override. On success the rows are upserted through the same repository path the inline Save uses.
+ - **Bulk retarget CSV import**: an `InputFile` upload on the card header accepts a CSV of `SourceName, Connection, SourceReference, Filter` rows, optionally followed by a `MesReportable` column (`true`/`false`/blank = inherit) — the same file format and batch rules as the CLI `instance native-alarm-source import --file` (see Component-CLI.md), parsed with the shared `NativeAlarmSourceOverrideCsvParser`. Each row is validated against the template's source bindings (name resolves, source not template-locked, no duplicate source in the file); the import is **all-or-nothing** — any error aborts the whole upload with a per-line error summary and nothing is applied. Semantics are **merge, not full replace**: sources absent from the file keep their existing override, a blank field keeps the inherited value, and a row with all three fields blank clears that source's override. A file without the `MesReportable` column leaves the MES flag unspecified, and the server keeps each source's stored MES override (`NativeAlarmSourceOverrideEntry.KeepStoredMesReportable`, #104) — the CLI import sends the same entries. On success the rows are upserted through the same repository path the inline Save uses.
- Filter/search instances by site, area, template, or status.
- **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration.
- **Enable** instances — re-activates a disabled instance.
diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs
index d936ea4e..d1023fc7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs
@@ -721,10 +721,9 @@ public static class InstanceCommands
return 1;
}
- var overrides = parseResult.Rows
- .Select(r => new NativeAlarmSourceOverrideEntry(
- r.SourceName, r.Connection, r.SourceReference, r.Filter, r.MesReportable))
- .ToList();
+ // A legacy 4-column file leaves the MES flag unspecified, so the server keeps the
+ // stored overrides (ScadaBridge#104) — the same result as the Central UI import.
+ var overrides = parseResult.Rows.Select(r => r.ToOverrideEntry()).ToList();
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceNativeAlarmSourceOverridesCommand(id, overrides));
diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md
index eec80ebb..6b8f1b3b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md
@@ -648,8 +648,9 @@ Bulk-apply native alarm source overrides for a single instance from a CSV file
(all-or-nothing — the native-source parity of `instance import-overrides`). The CSV
header is required: `SourceName,Connection,SourceReference,Filter`, optionally followed by
`MesReportable` (`true`, `false` or blank = inherit); a blank override field keeps the
-inherited value. Without the `MesReportable` column every imported source's MES override
-is reset to inherit, so include it to preserve or set MES-reportable overrides. Every named source must resolve for the instance and
+inherited value. Without the `MesReportable` column the file does not specify the MES flag and
+each source keeps its stored MES-reportable override (the same as the Central UI import); include
+the column to set or clear it. Every named source must resolve for the instance and
be unlocked, or the whole batch is rejected before any write.
```sh
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
index 27895d3c..a9861751 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
@@ -1885,11 +1885,7 @@
}
await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id,
- outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter,
- // A file with the MesReportable column is authoritative; a legacy 4-column file keeps the stored flag.
- row.HasMesColumn
- ? row.MesReportable
- : _existingNativeOverrides.GetValueOrDefault(row.SourceName)?.IsMesReportableOverride)).ToList()));
+ outcome.Rows.Select(row => row.ToOverrideEntry()).ToList()));
await OnInitializedAsync();
_nasCsvImportSucceeded = true;
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.cs
index 4990c910..6d64d097 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.cs
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -143,6 +144,15 @@ public partial class InstanceConfigure
{
/// True when all three override fields are blank (row means "inherit everything").
public bool IsClear => Connection is null && SourceReference is null && Filter is null;
+
+ ///
+ /// The bulk-apply entry for this row. A file with the MesReportable column is
+ /// authoritative for the flag; a legacy 4-column file leaves it unspecified, so the server
+ /// keeps the stored value (ScadaBridge#104) — the same entry the CLI import sends.
+ ///
+ /// The entry for SetInstanceNativeAlarmSourceOverridesCommand.
+ public NativeAlarmSourceOverrideEntry ToOverrideEntry() =>
+ new(SourceName, Connection, SourceReference, Filter, MesReportable, KeepStoredMesReportable: !HasMesColumn);
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs
index 3a6e2a16..e575810c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs
@@ -80,12 +80,28 @@ public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId);
/// value). Mirrors 's fields
/// for a single source.
///
+///
+/// is written as given — null means "inherit the
+/// binding's flag" and clears a stored override — unless
+/// is set (ScadaBridge#104). Then the entry does not specify the MES flag at all: the server
+/// keeps the instance's stored IsMesReportableOverride (inherit for a new override row)
+/// and ignores . The CLI and Central UI CSV imports set it
+/// for a legacy 4-column file that has no MesReportable column, so importing such a file
+/// never resets MES overrides. Additive trailing optional: older senders keep the old meaning.
+///
+/// The native alarm source's canonical name.
+/// Connection retarget, or null to inherit.
+/// Source-reference retarget, or null to inherit.
+/// Condition-filter retarget, or null to inherit.
+/// MES-reportable override, or null to inherit.
+/// True when the entry leaves the MES flag unspecified.
public record NativeAlarmSourceOverrideEntry(
string SourceCanonicalName,
string? ConnectionNameOverride,
string? SourceReferenceOverride,
string? ConditionFilterOverride,
- bool? IsMesReportableOverride = null);
+ bool? IsMesReportableOverride = null,
+ bool KeepStoredMesReportable = false);
///
/// Bulk, all-or-nothing apply of per-instance native-alarm-source overrides — the
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs
index a8528515..897f6d6d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs
@@ -17,7 +17,17 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
///
public sealed record NativeAlarmSourceOverrideCsvRow(
string SourceName, string? Connection, string? SourceReference, string? Filter, int LineNumber,
- bool? MesReportable = null, bool HasMesColumn = false);
+ bool? MesReportable = null, bool HasMesColumn = false)
+{
+ ///
+ /// The bulk-apply entry for this row. Without the MesReportable column the entry
+ /// leaves the MES flag unspecified, so the server keeps each source's stored override
+ /// instead of resetting it to inherit (ScadaBridge#104).
+ ///
+ /// The entry for SetInstanceNativeAlarmSourceOverridesCommand.
+ public ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.NativeAlarmSourceOverrideEntry ToOverrideEntry() =>
+ new(SourceName, Connection, SourceReference, Filter, MesReportable, KeepStoredMesReportable: !HasMesColumn);
+}
///
/// Outcome of parsing a native-alarm-source-override CSV: the successfully-parsed
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index fdebd1ed..4732adac 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -1371,7 +1371,9 @@ public class ManagementActor : ReceiveActor
ConnectionNameOverride = entry.ConnectionNameOverride,
SourceReferenceOverride = entry.SourceReferenceOverride,
ConditionFilterOverride = entry.ConditionFilterOverride,
- IsMesReportableOverride = entry.IsMesReportableOverride
+ // ScadaBridge#104: an entry that leaves the MES flag unspecified keeps the
+ // stored value — for a new row that is "inherit".
+ IsMesReportableOverride = entry.KeepStoredMesReportable ? null : entry.IsMesReportableOverride
};
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
results.Add(ovr);
@@ -1381,7 +1383,8 @@ public class ManagementActor : ReceiveActor
existing.ConnectionNameOverride = entry.ConnectionNameOverride;
existing.SourceReferenceOverride = entry.SourceReferenceOverride;
existing.ConditionFilterOverride = entry.ConditionFilterOverride;
- existing.IsMesReportableOverride = entry.IsMesReportableOverride;
+ if (!entry.KeepStoredMesReportable)
+ existing.IsMesReportableOverride = entry.IsMesReportableOverride;
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
results.Add(existing);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs
index 3994abaa..6863e17e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportNativeAlarmSourceOverridesTests.cs
@@ -68,4 +68,35 @@ public class ImportNativeAlarmSourceOverridesTests
Assert.Null(entries[1].SourceReferenceOverride);
Assert.Null(entries[1].ConditionFilterOverride);
}
+
+ // ── MES flag (ScadaBridge#104) ───────────────────────────────────────────
+
+ /// A legacy 4-column file leaves the MES flag unspecified, so the server keeps it.
+ [Fact]
+ public void FourColumnCsv_EntryKeepsStoredMesFlag()
+ {
+ var parsed = NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter\nPressure,ConnA,,");
+
+ var entry = Assert.Single(parsed.Rows).ToOverrideEntry();
+
+ Assert.True(entry.KeepStoredMesReportable);
+ Assert.Null(entry.IsMesReportableOverride);
+ Assert.Equal("ConnA", entry.ConnectionNameOverride);
+ }
+
+ /// A file with the MesReportable column is authoritative, blank included (= inherit).
+ [Fact]
+ public void FiveColumnCsv_EntrySpecifiesMesFlag()
+ {
+ var parsed = NativeAlarmSourceOverrideCsvParser.Parse(
+ "SourceName,Connection,SourceReference,Filter,MesReportable\nPressure,,,,false\nTemp,,,,");
+
+ var pressure = parsed.Rows[0].ToOverrideEntry();
+ var temp = parsed.Rows[1].ToOverrideEntry();
+
+ Assert.False(pressure.KeepStoredMesReportable);
+ Assert.False(pressure.IsMesReportableOverride);
+ Assert.False(temp.KeepStoredMesReportable);
+ Assert.Null(temp.IsMesReportableOverride);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs
index fa97e7f8..287101f5 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs
@@ -102,6 +102,25 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Null(outcome.Rows[1].MesReportable);
}
+ ///
+ /// ScadaBridge#104: the UI sends the same entry as the CLI — a 4-column file leaves the MES
+ /// flag to the server (keep stored), a 5-column file specifies it.
+ ///
+ [Fact]
+ public void ImportRows_MapToTheSameEntriesAsTheCli()
+ {
+ var legacy = InstanceConfigure.BuildNativeAlarmSourceCsvImport(
+ NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter\nBoilerAlarms,,,\n"),
+ Sources());
+ var withMes = InstanceConfigure.BuildNativeAlarmSourceCsvImport(
+ NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter,MesReportable\nBoilerAlarms,,,,\n"),
+ Sources());
+
+ Assert.True(legacy.Rows[0].ToOverrideEntry().KeepStoredMesReportable);
+ Assert.False(withMes.Rows[0].ToOverrideEntry().KeepStoredMesReportable);
+ Assert.Null(withMes.Rows[0].ToOverrideEntry().IsMesReportableOverride);
+ }
+
/// Verifies that a row with all fields blank is marked as a clear.
[Fact]
public void AllBlankRow_IsMarkedAsClear()
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
index e4c50e93..17b20579 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
@@ -4789,6 +4789,102 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
}
+ private void StubFlattenedNativeSources(params string[] names)
+ {
+ var pipeline = Substitute.For();
+ var flattened = new FlattenedConfiguration
+ {
+ NativeAlarmSources = names
+ .Select(n => new ResolvedNativeAlarmSource { CanonicalName = n, IsLocked = false, Source = "Template" })
+ .ToList()
+ };
+ pipeline.FlattenAndValidateAsync(1, Arg.Any(), Arg.Any())
+ .Returns(Result.Success(
+ new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
+ _services.AddScoped(_ => pipeline);
+ }
+
+ ///
+ /// ScadaBridge#104: an entry that leaves the MES flag unspecified (a legacy 4-column CSV)
+ /// keeps the stored MES override on an existing row while the retarget fields are applied.
+ ///
+ [Fact]
+ public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_KeepsStoredMesOverride()
+ {
+ StubFlattenedNativeSources("Pressure");
+ var existing = new InstanceNativeAlarmSourceOverride("Pressure")
+ {
+ InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
+ };
+ _templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any())
+ .Returns(existing);
+
+ var actor = CreateActor();
+ actor.Tell(Envelope(
+ new SetInstanceNativeAlarmSourceOverridesCommand(1, new List
+ {
+ new("Pressure", "Opc2", null, null, IsMesReportableOverride: null, KeepStoredMesReportable: true)
+ }),
+ "Deployer"));
+
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal("Opc2", existing.ConnectionNameOverride);
+ Assert.True(existing.IsMesReportableOverride);
+ }
+
+ ///
+ /// ScadaBridge#104 control: an entry that specifies the flag (a 5-column CSV with a blank
+ /// MesReportable) still clears the stored MES override to inherit.
+ ///
+ [Fact]
+ public void SetInstanceNativeAlarmSourceOverrides_MesSpecifiedNull_ClearsStoredMesOverride()
+ {
+ StubFlattenedNativeSources("Pressure");
+ var existing = new InstanceNativeAlarmSourceOverride("Pressure")
+ {
+ InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
+ };
+ _templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any())
+ .Returns(existing);
+
+ var actor = CreateActor();
+ actor.Tell(Envelope(
+ new SetInstanceNativeAlarmSourceOverridesCommand(1, new List
+ {
+ new("Pressure", "Opc2", null, null, IsMesReportableOverride: null)
+ }),
+ "Deployer"));
+
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Null(existing.IsMesReportableOverride);
+ }
+
+ ///
+ /// ScadaBridge#104: a new override row whose entry leaves the MES flag unspecified inherits
+ /// it, even if the entry carries a stray value.
+ ///
+ [Fact]
+ public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_NewRowInherits()
+ {
+ StubFlattenedNativeSources("Pressure");
+ InstanceNativeAlarmSourceOverride? added = null;
+ _templateRepo.AddInstanceNativeAlarmSourceOverrideAsync(
+ Arg.Do(o => added = o), Arg.Any())
+ .Returns(Task.CompletedTask);
+
+ var actor = CreateActor();
+ actor.Tell(Envelope(
+ new SetInstanceNativeAlarmSourceOverridesCommand(1, new List
+ {
+ new("Pressure", "Opc2", null, null, IsMesReportableOverride: true, KeepStoredMesReportable: true)
+ }),
+ "Deployer"));
+
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.NotNull(added);
+ Assert.Null(added!.IsMesReportableOverride);
+ }
+
/// Verifies that set instance native alarm source overrides, when one locked source, rejects whole batch.
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch()