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()