fix(native-alarms): a 4-column CSV import keeps stored MES overrides on the CLI path too (ScadaBridge#104)

The bulk SetInstanceNativeAlarmSourceOverridesCommand handler always wrote the
entry's IsMesReportableOverride, so a CLI import of a legacy 4-column CSV (no
MesReportable column) reset every imported source's MES override to inherit. The
Central UI avoided it only by looking up the stored flag client-side.

NativeAlarmSourceOverrideEntry gains an additive trailing KeepStoredMesReportable
flag: when set, the server leaves an existing row's MES override untouched (a new
row inherits) and ignores IsMesReportableOverride; unset keeps the old meaning
(null = clear to inherit), so older senders are unaffected. The CLI and the Central
UI now build their entries through one rule (ToOverrideEntry on each row type):
no MesReportable column => KeepStoredMesReportable. CLI README and
Component-CentralUI updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:41:33 -05:00
parent a81dac76ed
commit 063fb21815
11 changed files with 197 additions and 16 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ Central cluster only. Sites have no user interface.
- **Source Reference** — the concrete native key for this instance. - **Source Reference** — the concrete native key for this instance.
- **Filter** — the per-instance condition filter. - **Filter** — the per-instance condition filter.
- A **blank field inherits** the template default (the greyed placeholder shows the inherited value for context, mirroring the per-attribute Override field). **Save** and **Clear** act per row — Save persists the row's overrides, Clear reverts the row to the template-inherited binding. Locked template sources are not overridable. - A **blank field inherits** the template default (the greyed placeholder shows the inherited value for context, mirroring the per-attribute Override field). **Save** and **Clear** act per row — Save persists the row's overrides, Clear reverts the row to the template-inherited binding. Locked template sources are not overridable.
- **Bulk retarget CSV import**: an `InputFile` upload on the card header accepts a CSV of `SourceName, Connection, SourceReference, Filter` rows — the same file format and batch rules as the CLI `instance native-alarm-source import --file` (see Component-CLI.md), parsed with the shared `NativeAlarmSourceOverrideCsvParser`. Each row is validated against the template's source bindings (name resolves, source not template-locked, no duplicate source in the file); the import is **all-or-nothing** — any error aborts the whole upload with a per-line error summary and nothing is applied. Semantics are **merge, not full replace**: sources absent from the file keep their existing override, a blank field keeps the inherited value, and a row with all three fields blank clears that source's override. On success the rows are upserted through the same repository path the inline Save uses. - **Bulk retarget CSV import**: an `InputFile` upload on the card header accepts a CSV of `SourceName, Connection, SourceReference, Filter` rows, optionally followed by a `MesReportable` column (`true`/`false`/blank = inherit) — the same file format and batch rules as the CLI `instance native-alarm-source import --file` (see Component-CLI.md), parsed with the shared `NativeAlarmSourceOverrideCsvParser`. Each row is validated against the template's source bindings (name resolves, source not template-locked, no duplicate source in the file); the import is **all-or-nothing** — any error aborts the whole upload with a per-line error summary and nothing is applied. Semantics are **merge, not full replace**: sources absent from the file keep their existing override, a blank field keeps the inherited value, and a row with all three fields blank clears that source's override. A file without the `MesReportable` column leaves the MES flag unspecified, and the server keeps each source's stored MES override (`NativeAlarmSourceOverrideEntry.KeepStoredMesReportable`, #104) — the CLI import sends the same entries. On success the rows are upserted through the same repository path the inline Save uses.
- Filter/search instances by site, area, template, or status. - Filter/search instances by site, area, template, or status.
- **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration. - **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration.
- **Enable** instances — re-activates a disabled instance. - **Enable** instances — re-activates a disabled instance.
@@ -721,10 +721,9 @@ public static class InstanceCommands
return 1; return 1;
} }
var overrides = parseResult.Rows // A legacy 4-column file leaves the MES flag unspecified, so the server keeps the
.Select(r => new NativeAlarmSourceOverrideEntry( // stored overrides (ScadaBridge#104) — the same result as the Central UI import.
r.SourceName, r.Connection, r.SourceReference, r.Filter, r.MesReportable)) var overrides = parseResult.Rows.Select(r => r.ToOverrideEntry()).ToList();
.ToList();
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceNativeAlarmSourceOverridesCommand(id, overrides)); new SetInstanceNativeAlarmSourceOverridesCommand(id, overrides));
+3 -2
View File
@@ -648,8 +648,9 @@ 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`, optionally followed by header is required: `SourceName,Connection,SourceReference,Filter`, optionally followed by
`MesReportable` (`true`, `false` or blank = inherit); a blank override field keeps the `MesReportable` (`true`, `false` or blank = inherit); a blank override field keeps the
inherited value. Without the `MesReportable` column every imported source's MES override inherited value. Without the `MesReportable` column the file does not specify the MES flag and
is reset to inherit, so include it to preserve or set MES-reportable overrides. Every named source must resolve for the instance and each source keeps its stored MES-reportable override (the same as the Central UI import); include
the column to set or clear it. 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
@@ -1885,11 +1885,7 @@
} }
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 => row.ToOverrideEntry()).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;
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -143,6 +144,15 @@ public partial class InstanceConfigure
{ {
/// <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;
/// <summary>
/// The bulk-apply entry for this row. A file with the <c>MesReportable</c> column is
/// authoritative for the flag; a legacy 4-column file leaves it unspecified, so the server
/// keeps the stored value (ScadaBridge#104) — the same entry the CLI import sends.
/// </summary>
/// <returns>The entry for <c>SetInstanceNativeAlarmSourceOverridesCommand</c>.</returns>
public NativeAlarmSourceOverrideEntry ToOverrideEntry() =>
new(SourceName, Connection, SourceReference, Filter, MesReportable, KeepStoredMesReportable: !HasMesColumn);
} }
/// <summary> /// <summary>
@@ -80,12 +80,28 @@ public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId);
/// value). Mirrors <see cref="SetInstanceNativeAlarmSourceOverrideCommand"/>'s fields /// value). Mirrors <see cref="SetInstanceNativeAlarmSourceOverrideCommand"/>'s fields
/// for a single source. /// for a single source.
/// </summary> /// </summary>
/// <remarks>
/// <see cref="IsMesReportableOverride"/> is written as given — <c>null</c> means "inherit the
/// binding's flag" and clears a stored override — unless <see cref="KeepStoredMesReportable"/>
/// is set (ScadaBridge#104). Then the entry does not specify the MES flag at all: the server
/// keeps the instance's stored <c>IsMesReportableOverride</c> (inherit for a new override row)
/// and ignores <see cref="IsMesReportableOverride"/>. The CLI and Central UI CSV imports set it
/// for a legacy 4-column file that has no <c>MesReportable</c> column, so importing such a file
/// never resets MES overrides. Additive trailing optional: older senders keep the old meaning.
/// </remarks>
/// <param name="SourceCanonicalName">The native alarm source's canonical name.</param>
/// <param name="ConnectionNameOverride">Connection retarget, or null to inherit.</param>
/// <param name="SourceReferenceOverride">Source-reference retarget, or null to inherit.</param>
/// <param name="ConditionFilterOverride">Condition-filter retarget, or null to inherit.</param>
/// <param name="IsMesReportableOverride">MES-reportable override, or null to inherit.</param>
/// <param name="KeepStoredMesReportable">True when the entry leaves the MES flag unspecified.</param>
public record NativeAlarmSourceOverrideEntry( public record NativeAlarmSourceOverrideEntry(
string SourceCanonicalName, string SourceCanonicalName,
string? ConnectionNameOverride, string? ConnectionNameOverride,
string? SourceReferenceOverride, string? SourceReferenceOverride,
string? ConditionFilterOverride, string? ConditionFilterOverride,
bool? IsMesReportableOverride = null); bool? IsMesReportableOverride = null,
bool KeepStoredMesReportable = false);
/// <summary> /// <summary>
/// Bulk, all-or-nothing apply of per-instance native-alarm-source overrides — the /// Bulk, all-or-nothing apply of per-instance native-alarm-source overrides — the
@@ -17,7 +17,17 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// </remarks> /// </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); bool? MesReportable = null, bool HasMesColumn = false)
{
/// <summary>
/// The bulk-apply entry for this row. Without the <c>MesReportable</c> column the entry
/// leaves the MES flag unspecified, so the server keeps each source's stored override
/// instead of resetting it to inherit (ScadaBridge#104).
/// </summary>
/// <returns>The entry for <c>SetInstanceNativeAlarmSourceOverridesCommand</c>.</returns>
public ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.NativeAlarmSourceOverrideEntry ToOverrideEntry() =>
new(SourceName, Connection, SourceReference, Filter, MesReportable, KeepStoredMesReportable: !HasMesColumn);
}
/// <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
@@ -1371,7 +1371,9 @@ public class ManagementActor : ReceiveActor
ConnectionNameOverride = entry.ConnectionNameOverride, ConnectionNameOverride = entry.ConnectionNameOverride,
SourceReferenceOverride = entry.SourceReferenceOverride, SourceReferenceOverride = entry.SourceReferenceOverride,
ConditionFilterOverride = entry.ConditionFilterOverride, ConditionFilterOverride = entry.ConditionFilterOverride,
IsMesReportableOverride = entry.IsMesReportableOverride // ScadaBridge#104: an entry that leaves the MES flag unspecified keeps the
// stored value — for a new row that is "inherit".
IsMesReportableOverride = entry.KeepStoredMesReportable ? null : entry.IsMesReportableOverride
}; };
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr); await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
results.Add(ovr); results.Add(ovr);
@@ -1381,7 +1383,8 @@ public class ManagementActor : ReceiveActor
existing.ConnectionNameOverride = entry.ConnectionNameOverride; existing.ConnectionNameOverride = entry.ConnectionNameOverride;
existing.SourceReferenceOverride = entry.SourceReferenceOverride; existing.SourceReferenceOverride = entry.SourceReferenceOverride;
existing.ConditionFilterOverride = entry.ConditionFilterOverride; existing.ConditionFilterOverride = entry.ConditionFilterOverride;
existing.IsMesReportableOverride = entry.IsMesReportableOverride; if (!entry.KeepStoredMesReportable)
existing.IsMesReportableOverride = entry.IsMesReportableOverride;
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing); await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
results.Add(existing); results.Add(existing);
} }
@@ -68,4 +68,35 @@ public class ImportNativeAlarmSourceOverridesTests
Assert.Null(entries[1].SourceReferenceOverride); Assert.Null(entries[1].SourceReferenceOverride);
Assert.Null(entries[1].ConditionFilterOverride); Assert.Null(entries[1].ConditionFilterOverride);
} }
// ── MES flag (ScadaBridge#104) ───────────────────────────────────────────
/// <summary>A legacy 4-column file leaves the MES flag unspecified, so the server keeps it.</summary>
[Fact]
public void FourColumnCsv_EntryKeepsStoredMesFlag()
{
var parsed = NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter\nPressure,ConnA,,");
var entry = Assert.Single(parsed.Rows).ToOverrideEntry();
Assert.True(entry.KeepStoredMesReportable);
Assert.Null(entry.IsMesReportableOverride);
Assert.Equal("ConnA", entry.ConnectionNameOverride);
}
/// <summary>A file with the MesReportable column is authoritative, blank included (= inherit).</summary>
[Fact]
public void FiveColumnCsv_EntrySpecifiesMesFlag()
{
var parsed = NativeAlarmSourceOverrideCsvParser.Parse(
"SourceName,Connection,SourceReference,Filter,MesReportable\nPressure,,,,false\nTemp,,,,");
var pressure = parsed.Rows[0].ToOverrideEntry();
var temp = parsed.Rows[1].ToOverrideEntry();
Assert.False(pressure.KeepStoredMesReportable);
Assert.False(pressure.IsMesReportableOverride);
Assert.False(temp.KeepStoredMesReportable);
Assert.Null(temp.IsMesReportableOverride);
}
} }
@@ -102,6 +102,25 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Null(outcome.Rows[1].MesReportable); Assert.Null(outcome.Rows[1].MesReportable);
} }
/// <summary>
/// ScadaBridge#104: the UI sends the same entry as the CLI — a 4-column file leaves the MES
/// flag to the server (keep stored), a 5-column file specifies it.
/// </summary>
[Fact]
public void ImportRows_MapToTheSameEntriesAsTheCli()
{
var legacy = InstanceConfigure.BuildNativeAlarmSourceCsvImport(
NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter\nBoilerAlarms,,,\n"),
Sources());
var withMes = InstanceConfigure.BuildNativeAlarmSourceCsvImport(
NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter,MesReportable\nBoilerAlarms,,,,\n"),
Sources());
Assert.True(legacy.Rows[0].ToOverrideEntry().KeepStoredMesReportable);
Assert.False(withMes.Rows[0].ToOverrideEntry().KeepStoredMesReportable);
Assert.Null(withMes.Rows[0].ToOverrideEntry().IsMesReportableOverride);
}
/// <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()
@@ -4789,6 +4789,102 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default); _templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
} }
private void StubFlattenedNativeSources(params string[] names)
{
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = names
.Select(n => new ResolvedNativeAlarmSource { CanonicalName = n, IsLocked = false, Source = "Template" })
.ToList()
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
}
/// <summary>
/// ScadaBridge#104: an entry that leaves the MES flag unspecified (a legacy 4-column CSV)
/// keeps the stored MES override on an existing row while the retarget fields are applied.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_KeepsStoredMesOverride()
{
StubFlattenedNativeSources("Pressure");
var existing = new InstanceNativeAlarmSourceOverride("Pressure")
{
InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
};
_templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any<CancellationToken>())
.Returns(existing);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: null, KeepStoredMesReportable: true)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal("Opc2", existing.ConnectionNameOverride);
Assert.True(existing.IsMesReportableOverride);
}
/// <summary>
/// ScadaBridge#104 control: an entry that specifies the flag (a 5-column CSV with a blank
/// MesReportable) still clears the stored MES override to inherit.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesSpecifiedNull_ClearsStoredMesOverride()
{
StubFlattenedNativeSources("Pressure");
var existing = new InstanceNativeAlarmSourceOverride("Pressure")
{
InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
};
_templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any<CancellationToken>())
.Returns(existing);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: null)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Null(existing.IsMesReportableOverride);
}
/// <summary>
/// ScadaBridge#104: a new override row whose entry leaves the MES flag unspecified inherits
/// it, even if the entry carries a stray value.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_NewRowInherits()
{
StubFlattenedNativeSources("Pressure");
InstanceNativeAlarmSourceOverride? added = null;
_templateRepo.AddInstanceNativeAlarmSourceOverrideAsync(
Arg.Do<InstanceNativeAlarmSourceOverride>(o => added = o), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: true, KeepStoredMesReportable: true)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.Null(added!.IsMesReportableOverride);
}
/// <summary>Verifies that set instance native alarm source overrides, when one locked source, rejects whole batch.</summary> /// <summary>Verifies that set instance native alarm source overrides, when one locked source, rejects whole batch.</summary>
[Fact] [Fact]
public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch() public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch()