diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs index 638c0894..d936ea4e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs @@ -688,7 +688,7 @@ public static class InstanceCommands var importIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; var importFileOption = new Option("--file") { - Description = "Path to the override CSV file (columns: SourceName,Connection,SourceReference,Filter; blank field = inherited)", + Description = "Path to the override CSV file (columns: SourceName,Connection,SourceReference,Filter[,MesReportable]; blank field = inherited)", Required = true }; var importCmd = new Command("import") @@ -723,7 +723,7 @@ public static class InstanceCommands var overrides = parseResult.Rows .Select(r => new NativeAlarmSourceOverrideEntry( - r.SourceName, r.Connection, r.SourceReference, r.Filter)) + r.SourceName, r.Connection, r.SourceReference, r.Filter, r.MesReportable)) .ToList(); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index d412a9c3..eec80ebb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -646,8 +646,10 @@ scadabridge --url instance native-alarm-source clear --instance-id - 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`; a blank override -field keeps the inherited value. Every named source must resolve for the instance and +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 be unlocked, or the whole batch is rejected before any write. ```sh @@ -657,14 +659,14 @@ scadabridge --url instance native-alarm-source import --instance-id | Option | Required | Description | |--------|----------|-------------| | `--instance-id` | yes | Instance ID | -| `--file` | yes | Path to the override CSV (`SourceName,Connection,SourceReference,Filter`; blank field = inherited) | +| `--file` | yes | Path to the override CSV (`SourceName,Connection,SourceReference,Filter[,MesReportable]`; blank field = inherited) | Example CSV: ```csv -SourceName,Connection,SourceReference,Filter -Pressure,OpcB,ns=2;s=Pump.Alarm,Active -Module.Temp,,, +SourceName,Connection,SourceReference,Filter,MesReportable +Pressure,OpcB,ns=2;s=Pump.Alarm,Active,true +Module.Temp,,,, ``` #### `instance deploy` 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 1c267ee0..27895d3c 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 @@ -595,7 +595,7 @@ @* Bulk import of native alarm source retargets from a CSV - (SourceName,Connection,SourceReference,Filter) — the SAME file + (SourceName,Connection,SourceReference,Filter[,MesReportable]) — the SAME file format and batch rules as the CLI `instance native-alarm-source import --file`. Selecting a file parses it with the shared NativeAlarmSourceOverrideCsvParser and @@ -1886,7 +1886,10 @@ await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id, outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter, - _existingNativeOverrides.GetValueOrDefault(row.SourceName)?.IsMesReportableOverride)).ToList())); + // 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())); 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 187bd507..4990c910 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 @@ -138,7 +138,8 @@ public partial class InstanceConfigure /// inline editor's Save does when every field is left blank. /// internal sealed record NativeAlarmSourceCsvImportRow( - string SourceName, string? Connection, string? SourceReference, string? Filter) + string SourceName, string? Connection, string? SourceReference, string? Filter, + bool? MesReportable = null, bool HasMesColumn = false) { /// True when all three override fields are blank (row means "inherit everything"). public bool IsClear => Connection is null && SourceReference is null && Filter is null; @@ -227,7 +228,9 @@ public partial class InstanceConfigure row.SourceName, Blank(row.Connection), Blank(row.SourceReference), - Blank(row.Filter))); + Blank(row.Filter), + row.MesReportable, + row.HasMesColumn)); } // All-or-nothing: any error means nothing is applied. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs index 6fbc11e0..a8528515 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/NativeAlarmSourceOverrideCsvParser.cs @@ -9,8 +9,15 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types; /// the 1-based source line (the header is line 1) so downstream errors can point /// back at the operator's file. /// +/// +/// is true when the file carried the optional fifth +/// MesReportable column; only then is authoritative +/// (blank = inherit, true/false = explicit override). Without the column +/// callers keep their existing behaviour for the flag. +/// public sealed record NativeAlarmSourceOverrideCsvRow( - string SourceName, string? Connection, string? SourceReference, string? Filter, int LineNumber); + string SourceName, string? Connection, string? SourceReference, string? Filter, int LineNumber, + bool? MesReportable = null, bool HasMesColumn = false); /// /// Outcome of parsing a native-alarm-source-override CSV: the successfully-parsed @@ -26,7 +33,8 @@ public sealed record NativeAlarmSourceOverrideCsvParseResult( /// Pure, dependency-free, quote-aware parser turning per-instance /// native-alarm-source-override CSV text into structured rows plus per-line errors. /// Callers supply the text (no file I/O). The header row is required and -/// case-insensitive (SourceName,Connection,SourceReference,Filter). Fields +/// case-insensitive (SourceName,Connection,SourceReference,Filter, optionally followed by +/// MesReportable — blank/true/false, Gitea #102). Fields /// follow the shared RFC-4180 quoting rules — the same /// splitter uses. The parity analogue of /// for native alarm source retargets. @@ -34,9 +42,10 @@ public sealed record NativeAlarmSourceOverrideCsvParseResult( public static class NativeAlarmSourceOverrideCsvParser { private const string HeaderError = - "Missing or invalid header row. Expected 'SourceName,Connection,SourceReference,Filter'."; + "Missing or invalid header row. Expected 'SourceName,Connection,SourceReference,Filter' " + + "optionally followed by ',MesReportable'."; - private const int ExpectedColumns = 4; + private const int BaseColumns = 4; /// /// Parses native-alarm-source-override CSV . Returns @@ -54,6 +63,7 @@ public static class NativeAlarmSourceOverrideCsvParser var lines = (csvText ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); var headerSeen = false; + var hasMes = false; for (var i = 0; i < lines.Length; i++) { @@ -72,7 +82,7 @@ public static class NativeAlarmSourceOverrideCsvParser if (!headerSeen) { - if (!TryMatchHeader(fields)) + if (!TryMatchHeader(fields, out hasMes)) { errors.Add(HeaderError); return new NativeAlarmSourceOverrideCsvParseResult(rows, errors); @@ -82,10 +92,11 @@ public static class NativeAlarmSourceOverrideCsvParser continue; } - if (fields.Count != ExpectedColumns) + var expected = hasMes ? BaseColumns + 1 : BaseColumns; + if (fields.Count != expected) { errors.Add( - $"Line {lineNumber}: expected {ExpectedColumns} columns but found {fields.Count}."); + $"Line {lineNumber}: expected {expected} columns but found {fields.Count}."); continue; } @@ -96,12 +107,27 @@ public static class NativeAlarmSourceOverrideCsvParser continue; } + bool? mes = null; + if (hasMes) + { + var raw = fields[4].Trim(); + if (raw.Length == 0) mes = null; + else if (bool.TryParse(raw, out var b)) mes = b; + else + { + errors.Add($"Line {lineNumber}: MesReportable must be blank, 'true' or 'false' but was '{raw}'."); + continue; + } + } + rows.Add(new NativeAlarmSourceOverrideCsvRow( sourceName, NullIfEmpty(fields[1]), NullIfEmpty(fields[2]), NullIfEmpty(fields[3]), - lineNumber)); + lineNumber, + mes, + hasMes)); } if (!headerSeen) @@ -110,13 +136,17 @@ public static class NativeAlarmSourceOverrideCsvParser return new NativeAlarmSourceOverrideCsvParseResult(rows, errors); } - /// Matches the required 4-column header (case-insensitive). - private static bool TryMatchHeader(IReadOnlyList fields) => - fields.Count == ExpectedColumns && - HeaderEquals(fields[0], "SourceName") && - HeaderEquals(fields[1], "Connection") && - HeaderEquals(fields[2], "SourceReference") && - HeaderEquals(fields[3], "Filter"); + /// Matches the required 4-column header, with an optional 5th MesReportable (case-insensitive). + private static bool TryMatchHeader(IReadOnlyList fields, out bool hasMes) + { + hasMes = fields.Count == BaseColumns + 1; + return (fields.Count == BaseColumns || hasMes) && + HeaderEquals(fields[0], "SourceName") && + HeaderEquals(fields[1], "Connection") && + HeaderEquals(fields[2], "SourceReference") && + HeaderEquals(fields[3], "Filter") && + (!hasMes || HeaderEquals(fields[4], "MesReportable")); + } private static bool HeaderEquals(string field, string expected) => string.Equals(field, expected, StringComparison.OrdinalIgnoreCase); 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 697c63da..fa97e7f8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs @@ -84,6 +84,24 @@ public class InstanceConfigureNativeAlarmCsvImportTests Assert.Null(pump.Filter); } + /// Gitea #102: the MesReportable column flows through the import rows. + [Fact] + public void MesColumn_FlowsThroughToImportRows() + { + var csv = "SourceName,Connection,SourceReference,Filter,MesReportable\n" + + "BoilerAlarms,,,,true\n" + + "PumpAlarms,,,,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.False(outcome.HasErrors); + Assert.True(outcome.Rows[0].HasMesColumn); + Assert.True(outcome.Rows[0].MesReportable); + Assert.True(outcome.Rows[1].HasMesColumn); + Assert.Null(outcome.Rows[1].MesReportable); + } + /// 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.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs index dce12d94..7e941765 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/NativeAlarmSourceOverrideCsvParserTests.cs @@ -154,4 +154,47 @@ public class NativeAlarmSourceOverrideCsvParserTests Assert.Empty(result.Errors); Assert.Single(result.Rows); } + + /// Gitea #102: the optional MesReportable column parses blank/true/false. + [Fact] + public void Parse_MesReportableColumn_ParsesTriState() + { + const string csv = + "SourceName,Connection,SourceReference,Filter,MesReportable\n" + + "A,,,,true\n" + + "B,,,,FALSE\n" + + "C,,,,\n"; + + var result = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + Assert.Empty(result.Errors); + Assert.All(result.Rows, r => Assert.True(r.HasMesColumn)); + Assert.True(result.Rows[0].MesReportable); + Assert.False(result.Rows[1].MesReportable); + Assert.Null(result.Rows[2].MesReportable); + } + + /// Gitea #102: a legacy 4-column file still parses and reports no MES column. + [Fact] + public void Parse_FourColumnFile_HasNoMesColumn() + { + var result = NativeAlarmSourceOverrideCsvParser.Parse( + "SourceName,Connection,SourceReference,Filter\nA,,,\n"); + + Assert.Empty(result.Errors); + var row = Assert.Single(result.Rows); + Assert.False(row.HasMesColumn); + Assert.Null(row.MesReportable); + } + + /// Gitea #102: an unparseable MesReportable value is a line error. + [Fact] + public void Parse_InvalidMesReportable_ReportsLineError() + { + var result = NativeAlarmSourceOverrideCsvParser.Parse( + "SourceName,Connection,SourceReference,Filter,MesReportable\nA,,,,maybe\n"); + + Assert.Empty(result.Rows); + Assert.Contains(result.Errors, e => e.Contains("Line 2") && e.Contains("MesReportable")); + } }