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 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
};
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,
+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
(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 <url> instance native-alarm-source import --instance-id <int>
| 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`
@@ -595,7 +595,7 @@
</small>
</div>
@* 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;
@@ -138,7 +138,8 @@ public partial class InstanceConfigure
/// inline editor's Save does when every field is left blank.
/// </summary>
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>
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.
@@ -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.
/// </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(
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>
/// 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 (<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
/// splitter <see cref="OverrideCsvParser"/> uses. The parity analogue of
/// <see cref="OverrideCsvParser"/> 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;
/// <summary>
/// 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 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);
}
/// <summary>Matches the required 4-column header (case-insensitive).</summary>
private static bool TryMatchHeader(IReadOnlyList<string> fields) =>
fields.Count == ExpectedColumns &&
HeaderEquals(fields[0], "SourceName") &&
HeaderEquals(fields[1], "Connection") &&
HeaderEquals(fields[2], "SourceReference") &&
HeaderEquals(fields[3], "Filter");
/// <summary>Matches the required 4-column header, with an optional 5th <c>MesReportable</c> (case-insensitive).</summary>
private static bool TryMatchHeader(IReadOnlyList<string> 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);
@@ -84,6 +84,24 @@ public class InstanceConfigureNativeAlarmCsvImportTests
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>
[Fact]
public void AllBlankRow_IsMarkedAsClear()
@@ -154,4 +154,47 @@ public class NativeAlarmSourceOverrideCsvParserTests
Assert.Empty(result.Errors);
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"));
}
}