feat(native-alarms): per-binding IsMesReportable on native alarm source bindings (ScadaBridge#59)

TemplateNativeAlarmSource.IsMesReportable + tri-state InstanceNativeAlarmSourceOverride
.IsMesReportableOverride flatten onto ResolvedNativeAlarmSource; NativeAlarmActor stamps every
mirrored condition and InstanceActor the quiet-binding placeholder, so ScriptAlarm,
Alarms.MesReportableAsync() and the #60 wire fields carry it. Additive to the 900-999 severity band.
Migration AddNativeAlarmSourceMesReportable. Management commands, CLI --mes-reportable, Central UI
(template dialog checkbox, instance override select), Transport DTOs/importer/diff and the revision
hash (true-only, so existing hashes are unchanged) carry it.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:09:54 -05:00
parent 69a2017000
commit f98369a786
38 changed files with 2579 additions and 43 deletions
@@ -653,6 +653,7 @@
<th style="width: 220px;">Connection override</th>
<th style="width: 220px;">Source reference override</th>
<th style="width: 170px;">Filter override</th>
<th style="width: 150px;">MES reportable</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
@@ -697,6 +698,15 @@
value="@(_nasFilterEdit.GetValueOrDefault(src.Name) ?? "")"
@onchange="e => _nasFilterEdit[src.Name] = string.IsNullOrWhiteSpace((string?)e.Value) ? null : ((string?)e.Value)!.Trim()" />
</td>
<td>
<select class="form-select form-select-sm" data-test="@($"nas-mes-{src.Name}")"
value="@MesText(_nasMesEdit.GetValueOrDefault(src.Name))"
@onchange="e => _nasMesEdit[src.Name] = ParseMes((string?)e.Value)">
<option value="" selected="@(_nasMesEdit.GetValueOrDefault(src.Name) == null)">Inherited (@(src.IsMesReportable ? "reportable" : "not reportable"))</option>
<option value="true" selected="@(_nasMesEdit.GetValueOrDefault(src.Name) == true)">Reportable</option>
<option value="false" selected="@(_nasMesEdit.GetValueOrDefault(src.Name) == false)">Not reportable</option>
</select>
</td>
<td>
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Override actions for {src.Name}")">
<button class="btn btn-success"
@@ -843,6 +853,9 @@
private Dictionary<string, string?> _nasConnEdit = new();
private Dictionary<string, string?> _nasRefEdit = new();
private Dictionary<string, string?> _nasFilterEdit = new();
private Dictionary<string, bool?> _nasMesEdit = new();
private static string MesText(bool? v) => v switch { true => "true", false => "false", _ => "" };
private static bool? ParseMes(string? v) => v switch { "true" => true, "false" => false, _ => null };
// Native-alarm-source CSV bulk-import result summary — same shape as the
// attribute importer's (_csvImport*) trio. Null until an import runs.
@@ -971,6 +984,7 @@
_nasConnEdit[s.Name] = ovr?.ConnectionNameOverride;
_nasRefEdit[s.Name] = ovr?.SourceReferenceOverride;
_nasFilterEdit[s.Name] = ovr?.ConditionFilterOverride;
_nasMesEdit[s.Name] = ovr?.IsMesReportableOverride;
}
_flattenedAttributes = await BuildFlattenedAttributesAsync();
@@ -1751,16 +1765,17 @@
var conn = Blank(_nasConnEdit.GetValueOrDefault(sourceName));
var sref = Blank(_nasRefEdit.GetValueOrDefault(sourceName));
var filt = Blank(_nasFilterEdit.GetValueOrDefault(sourceName));
var mes = _nasMesEdit.GetValueOrDefault(sourceName);
// All blank → no override; clear any existing row.
if (conn == null && sref == null && filt == null)
if (conn == null && sref == null && filt == null && mes == null)
{
await ClearNativeOverrideCore(sourceName);
_toast.ShowSuccess($"No override on '{sourceName}' (inherited).");
return;
}
await UpsertNativeOverrideCore(sourceName, conn, sref, filt);
await UpsertNativeOverrideCore(sourceName, conn, sref, filt, mes);
_toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'.");
}
catch (Exception ex)
@@ -1791,15 +1806,16 @@
/// so a bulk import commits the whole batch in one go. Shared by the inline Save
/// and the CSV import so both take the identical persistence path.
/// </summary>
private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt)
private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt, bool? mes = null)
{
var result = await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverrideCommand(Id, sourceName, conn, sref, filt));
var result = await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverrideCommand(Id, sourceName, conn, sref, filt, mes));
if (result is InstanceNativeAlarmSourceOverride saved) _existingNativeOverrides[sourceName] = saved;
// Keep the inline editor showing what was just persisted.
_nasConnEdit[sourceName] = conn;
_nasRefEdit[sourceName] = sref;
_nasFilterEdit[sourceName] = filt;
_nasMesEdit[sourceName] = mes;
}
private async Task ClearNativeOverrideCore(string sourceName)
@@ -1809,6 +1825,7 @@
_nasConnEdit[sourceName] = null;
_nasRefEdit[sourceName] = null;
_nasFilterEdit[sourceName] = null;
_nasMesEdit[sourceName] = null;
}
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
@@ -1868,7 +1885,8 @@
}
await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id,
outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter)).ToList()));
outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter,
_existingNativeOverrides.GetValueOrDefault(row.SourceName)?.IsMesReportableOverride)).ToList()));
await OnInitializedAsync();
_nasCsvImportSucceeded = true;
@@ -1041,7 +1041,7 @@
=> ShowNativeSourceDialogAsync(
editId: null,
new TemplateNativeAlarmSourceDialog.NativeSourceDraft(
string.Empty, string.Empty, string.Empty, null, null, false));
string.Empty, string.Empty, string.Empty, null, null, false, false));
private Task OpenEditNativeSourceDialog(TemplateNativeAlarmSource src)
=> ShowNativeSourceDialogAsync(
@@ -1052,7 +1052,8 @@
src.SourceReference,
src.ConditionFilter,
src.Description,
src.IsLocked));
src.IsLocked,
src.IsMesReportable));
private async Task ShowNativeSourceDialogAsync(
int? editId,
@@ -1070,6 +1071,7 @@
InitialFilter="@initial.ConditionFilter"
InitialDescription="@initial.Description"
InitialIsLocked="initial.IsLocked"
InitialIsMesReportable="initial.IsMesReportable"
AlarmCapableConnections="connections"
OnSaveAsync="d => SaveNativeSourceDraftAsync(editId, d)" />);
@@ -1097,7 +1099,8 @@
await MutationServices.ExecuteConfigurationAsync(new UpdateTemplateNativeAlarmSourceCommand(id, existing.Name,
draft.ConnectionName.Trim(), draft.SourceReference.Trim(),
string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim(),
string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(), draft.IsLocked));
string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(), draft.IsLocked,
draft.IsMesReportable));
_toast.ShowSuccess($"Native alarm source '{existing.Name}' updated.");
return null;
}
@@ -1114,10 +1117,12 @@
SourceReference = draft.SourceReference.Trim(),
ConditionFilter = string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim(),
Description = string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(),
IsLocked = draft.IsLocked
IsLocked = draft.IsLocked,
IsMesReportable = draft.IsMesReportable
};
await MutationServices.ExecuteConfigurationAsync(new AddTemplateNativeAlarmSourceCommand(source.TemplateId, source.Name,
source.ConnectionName, source.SourceReference, source.ConditionFilter, source.Description, source.IsLocked));
source.ConnectionName, source.SourceReference, source.ConditionFilter, source.Description, source.IsLocked,
source.IsMesReportable));
_toast.ShowSuccess($"Native alarm source '{source.Name}' added.");
return null;
}
@@ -51,6 +51,12 @@
<label class="form-check-label" for="nasLocked">Locked</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_nasIsMesReportable" id="nasMesReportable" />
<label class="form-check-label" for="nasMesReportable">MES reportable (every condition under this source)</label>
</div>
</div>
@if (_error != null)
{
<div class="col-12"><div class="text-danger small" data-test="nas-form-error">@_error</div></div>
@@ -77,6 +83,7 @@
[Parameter] public string? InitialFilter { get; set; }
[Parameter] public string? InitialDescription { get; set; }
[Parameter] public bool InitialIsLocked { get; set; }
[Parameter] public bool InitialIsMesReportable { get; set; }
/// <summary>Connections whose protocol can surface native alarms (OPC UA / MxGateway).</summary>
[Parameter] public IReadOnlyList<DataConnection> AlarmCapableConnections { get; set; }
@@ -94,6 +101,7 @@
private string? _nasFilter;
private string? _nasDescription;
private bool _nasIsLocked;
private bool _nasIsMesReportable;
private string? _error;
private bool _busy;
@@ -105,6 +113,7 @@
_nasFilter = InitialFilter;
_nasDescription = InitialDescription;
_nasIsLocked = InitialIsLocked;
_nasIsMesReportable = InitialIsMesReportable;
}
private async Task Submit()
@@ -121,7 +130,8 @@
_nasSourceRef,
_nasFilter,
_nasDescription,
_nasIsLocked);
_nasIsLocked,
_nasIsMesReportable);
var error = await OnSaveAsync(draft);
if (error == null)
@@ -146,11 +156,13 @@
/// <param name="ConditionFilter">Optional condition filter; blank mirrors every condition.</param>
/// <param name="Description">Optional free-text description.</param>
/// <param name="IsLocked">Whether instances may override the binding.</param>
/// <param name="IsMesReportable">Whether every condition under the source is MES-reportable.</param>
public sealed record NativeSourceDraft(
string Name,
string ConnectionName,
string SourceReference,
string? ConditionFilter,
string? Description,
bool IsLocked);
bool IsLocked,
bool IsMesReportable = false);
}