feat(native-alarms): optional MesReportable column in the native-source override CSV import (ScadaBridge#102 item 4)

CLI import no longer has to reset MES overrides to inherit; a legacy 4-column file
still parses and the Central UI import keeps the stored flag for it.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:31:11 -05:00
parent b540d8ac2c
commit 62fe1e685d
7 changed files with 126 additions and 27 deletions
@@ -688,7 +688,7 @@ public static class InstanceCommands
var importIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true }; var importIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var importFileOption = new Option<string>("--file") var importFileOption = new Option<string>("--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 Required = true
}; };
var importCmd = new Command("import") var importCmd = new Command("import")
@@ -723,7 +723,7 @@ public static class InstanceCommands
var overrides = parseResult.Rows var overrides = parseResult.Rows
.Select(r => new NativeAlarmSourceOverrideEntry( .Select(r => new NativeAlarmSourceOverrideEntry(
r.SourceName, r.Connection, r.SourceReference, r.Filter)) r.SourceName, r.Connection, r.SourceReference, r.Filter, r.MesReportable))
.ToList(); .ToList();
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
+8 -6
View File
@@ -646,8 +646,10 @@ scadabridge --url <url> instance native-alarm-source clear --instance-id <int> -
Bulk-apply native alarm source overrides for a single instance from a CSV file 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 (all-or-nothing — the native-source parity of `instance import-overrides`). The CSV
header is required: `SourceName,Connection,SourceReference,Filter`; a blank override header is required: `SourceName,Connection,SourceReference,Filter`, optionally followed by
field keeps the inherited value. Every named source must resolve for the instance and `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. be unlocked, or the whole batch is rejected before any write.
```sh ```sh
@@ -657,14 +659,14 @@ scadabridge --url <url> instance native-alarm-source import --instance-id <int>
| Option | Required | Description | | Option | Required | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `--instance-id` | yes | Instance ID | | `--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: Example CSV:
```csv ```csv
SourceName,Connection,SourceReference,Filter SourceName,Connection,SourceReference,Filter,MesReportable
Pressure,OpcB,ns=2;s=Pump.Alarm,Active Pressure,OpcB,ns=2;s=Pump.Alarm,Active,true
Module.Temp,,, Module.Temp,,,,
``` ```
#### `instance deploy` #### `instance deploy`
@@ -595,7 +595,7 @@
</small> </small>
</div> </div>
@* Bulk import of native alarm source retargets from a CSV @* 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 format and batch rules as the CLI
`instance native-alarm-source import --file`. Selecting a file `instance native-alarm-source import --file`. Selecting a file
parses it with the shared NativeAlarmSourceOverrideCsvParser and parses it with the shared NativeAlarmSourceOverrideCsvParser and
@@ -1886,7 +1886,10 @@
await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id, await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id,
outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter, 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(); await OnInitializedAsync();
_nasCsvImportSucceeded = true; _nasCsvImportSucceeded = true;
@@ -138,7 +138,8 @@ public partial class InstanceConfigure
/// inline editor's Save does when every field is left blank. /// inline editor's Save does when every field is left blank.
/// </summary> /// </summary>
internal sealed record NativeAlarmSourceCsvImportRow( 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)
{ {
/// <summary>True when all three override fields are blank (row means "inherit everything").</summary> /// <summary>True when all three override fields are blank (row means "inherit everything").</summary>
public bool IsClear => Connection is null && SourceReference is null && Filter is null; public bool IsClear => Connection is null && SourceReference is null && Filter is null;
@@ -227,7 +228,9 @@ public partial class InstanceConfigure
row.SourceName, row.SourceName,
Blank(row.Connection), Blank(row.Connection),
Blank(row.SourceReference), Blank(row.SourceReference),
Blank(row.Filter))); Blank(row.Filter),
row.MesReportable,
row.HasMesColumn));
} }
// All-or-nothing: any error means nothing is applied. // All-or-nothing: any error means nothing is applied.
@@ -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 /// the 1-based source line (the header is line 1) so downstream errors can point
/// back at the operator's file. /// back at the operator's file.
/// </summary> /// </summary>
/// <remarks>
/// <see cref="HasMesColumn"/> is true when the file carried the optional fifth
/// <c>MesReportable</c> column; only then is <see cref="MesReportable"/> authoritative
/// (blank = inherit, <c>true</c>/<c>false</c> = explicit override). Without the column
/// callers keep their existing behaviour for the flag.
/// </remarks>
public sealed record NativeAlarmSourceOverrideCsvRow( 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);
/// <summary> /// <summary>
/// Outcome of parsing a native-alarm-source-override CSV: the successfully-parsed /// 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 /// Pure, dependency-free, quote-aware parser turning per-instance
/// native-alarm-source-override CSV text into structured rows plus per-line errors. /// 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 /// Callers supply the text (no file I/O). The header row is required and
/// case-insensitive (<c>SourceName,Connection,SourceReference,Filter</c>). Fields /// case-insensitive (<c>SourceName,Connection,SourceReference,Filter</c>, optionally followed by
/// <c>MesReportable</c> — blank/<c>true</c>/<c>false</c>, Gitea #102). Fields
/// follow the shared <see cref="CsvLineSplitter"/> RFC-4180 quoting rules — the same /// follow the shared <see cref="CsvLineSplitter"/> RFC-4180 quoting rules — the same
/// splitter <see cref="OverrideCsvParser"/> uses. The parity analogue of /// splitter <see cref="OverrideCsvParser"/> uses. The parity analogue of
/// <see cref="OverrideCsvParser"/> for native alarm source retargets. /// <see cref="OverrideCsvParser"/> for native alarm source retargets.
@@ -34,9 +42,10 @@ public sealed record NativeAlarmSourceOverrideCsvParseResult(
public static class NativeAlarmSourceOverrideCsvParser public static class NativeAlarmSourceOverrideCsvParser
{ {
private const string HeaderError = 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;
/// <summary> /// <summary>
/// Parses native-alarm-source-override CSV <paramref name="csvText"/>. Returns /// Parses native-alarm-source-override CSV <paramref name="csvText"/>. Returns
@@ -54,6 +63,7 @@ public static class NativeAlarmSourceOverrideCsvParser
var lines = (csvText ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); var lines = (csvText ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var headerSeen = false; var headerSeen = false;
var hasMes = false;
for (var i = 0; i < lines.Length; i++) for (var i = 0; i < lines.Length; i++)
{ {
@@ -72,7 +82,7 @@ public static class NativeAlarmSourceOverrideCsvParser
if (!headerSeen) if (!headerSeen)
{ {
if (!TryMatchHeader(fields)) if (!TryMatchHeader(fields, out hasMes))
{ {
errors.Add(HeaderError); errors.Add(HeaderError);
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors); return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
@@ -82,10 +92,11 @@ public static class NativeAlarmSourceOverrideCsvParser
continue; continue;
} }
if (fields.Count != ExpectedColumns) var expected = hasMes ? BaseColumns + 1 : BaseColumns;
if (fields.Count != expected)
{ {
errors.Add( errors.Add(
$"Line {lineNumber}: expected {ExpectedColumns} columns but found {fields.Count}."); $"Line {lineNumber}: expected {expected} columns but found {fields.Count}.");
continue; continue;
} }
@@ -96,12 +107,27 @@ public static class NativeAlarmSourceOverrideCsvParser
continue; 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( rows.Add(new NativeAlarmSourceOverrideCsvRow(
sourceName, sourceName,
NullIfEmpty(fields[1]), NullIfEmpty(fields[1]),
NullIfEmpty(fields[2]), NullIfEmpty(fields[2]),
NullIfEmpty(fields[3]), NullIfEmpty(fields[3]),
lineNumber)); lineNumber,
mes,
hasMes));
} }
if (!headerSeen) if (!headerSeen)
@@ -110,13 +136,17 @@ public static class NativeAlarmSourceOverrideCsvParser
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors); return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
} }
/// <summary>Matches the required 4-column header (case-insensitive).</summary> /// <summary>Matches the required 4-column header, with an optional 5th <c>MesReportable</c> (case-insensitive).</summary>
private static bool TryMatchHeader(IReadOnlyList<string> fields) => private static bool TryMatchHeader(IReadOnlyList<string> fields, out bool hasMes)
fields.Count == ExpectedColumns && {
HeaderEquals(fields[0], "SourceName") && hasMes = fields.Count == BaseColumns + 1;
HeaderEquals(fields[1], "Connection") && return (fields.Count == BaseColumns || hasMes) &&
HeaderEquals(fields[2], "SourceReference") && HeaderEquals(fields[0], "SourceName") &&
HeaderEquals(fields[3], "Filter"); 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) => private static bool HeaderEquals(string field, string expected) =>
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase); string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
@@ -84,6 +84,24 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Null(pump.Filter); Assert.Null(pump.Filter);
} }
/// <summary>Gitea #102: the MesReportable column flows through the import rows.</summary>
[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);
}
/// <summary>Verifies that a row with all fields blank is marked as a clear.</summary> /// <summary>Verifies that a row with all fields blank is marked as a clear.</summary>
[Fact] [Fact]
public void AllBlankRow_IsMarkedAsClear() public void AllBlankRow_IsMarkedAsClear()
@@ -154,4 +154,47 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Empty(result.Errors); Assert.Empty(result.Errors);
Assert.Single(result.Rows); Assert.Single(result.Rows);
} }
/// <summary>Gitea #102: the optional MesReportable column parses blank/true/false.</summary>
[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);
}
/// <summary>Gitea #102: a legacy 4-column file still parses and reports no MES column.</summary>
[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);
}
/// <summary>Gitea #102: an unparseable MesReportable value is a line error.</summary>
[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"));
}
} }