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:
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
+5
-2
@@ -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;
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+18
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user