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