feat(central-ui): native-alarm-source CSV import on InstanceConfigure

The Native Alarm Source Overrides card had no bulk affordance — the CSV path
shipped CLI-only (instance native-alarm-source import --file), so the UI could
only retarget one source at a time inline.

Adds a second InputFile on that card, mirroring the attribute importer's UX
(hidden input behind a button-styled label, 512 KB cap, success/error alert
with the per-line error list, toast). Parsing reuses the SHARED
NativeAlarmSourceOverrideCsvParser — the exact parser the CLI uses — and the
new pure InstanceConfigure.BuildNativeAlarmSourceCsvImport applies the same
batch rules the server enforces in
ManagementActor.HandleSetInstanceNativeAlarmSourceOverrides: the source must
resolve, must not be template-locked, and may appear at most once; any error
rejects the whole file and applies nothing.

Semantics match the CLI: merge, not full replace — sources absent from the
file keep their existing override, a blank field keeps the inherited value,
and an all-blank row clears that source's override (equivalent to the CLI's
all-null override row, without leaving a dead row behind).

Persistence reuses the inline editor's path: SaveNativeOverride's upsert body
is extracted to UpsertNativeOverrideCore (no SaveChangesAsync inside), so the
import commits the validated batch in one SaveChangesAsync — no new server
method, no duplicated parsing.

Tests: InstanceConfigureNativeAlarmCsvImportTests (9) — happy path, blank-field
inheritance, all-blank clear, merge semantics, unknown/locked/duplicate source
and parser-error rejection, plus structural pins on the InputFile wiring.
Docs: Component-CentralUI.md native-alarm-source card gains the import bullet.
This commit is contained in:
Joseph Doherty
2026-08-01 11:11:06 -04:00
parent 697be0ce43
commit a26d6ba317
4 changed files with 497 additions and 29 deletions
@@ -431,14 +431,59 @@
@* Native Alarm Source Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Native Alarm Source Overrides</strong>
<small class="text-muted ms-2">
Retarget an inherited native alarm source binding for this instance.
Leave a field blank to keep the inherited value.
</small>
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<div>
<strong>Native Alarm Source Overrides</strong>
<small class="text-muted ms-2">
Retarget an inherited native alarm source binding for this instance.
Leave a field blank to keep the inherited value.
</small>
</div>
@* Bulk import of native alarm source retargets from a CSV
(SourceName,Connection,SourceReference,Filter) — 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
validates every row against this template's sources; all-or-nothing,
it either upserts each row through the SAME repository path the
inline Save uses, or shows the per-line error list and applies
nothing. Merge semantics — sources absent from the file keep their
existing override. *@
@if (_nativeSources.Count > 0)
{
<label class="btn btn-outline-secondary btn-sm mb-0 flex-shrink-0 ms-2">
Import overrides (CSV)
<InputFile OnChange="OnNativeAlarmSourceCsvImportSelectedAsync"
accept=".csv"
data-test="nas-csv-import-input"
class="d-none"
disabled="@_saving" />
</label>
}
</div>
<div class="card-body p-0">
@if (_nasCsvImportResult is not null)
{
<div class="p-2 pb-0">
<div class="alert @(_nasCsvImportSucceeded ? "alert-success" : "alert-danger") small mb-0"
data-test="nas-csv-import-result">
@if (_nasCsvImportSucceeded)
{
<span>@_nasCsvImportResult</span>
}
else
{
<div class="fw-semibold mb-1">@_nasCsvImportResult</div>
<ul class="mb-0 ps-3">
@foreach (var err in _nasCsvImportErrors)
{
<li>@err</li>
}
</ul>
}
</div>
</div>
}
@if (_nativeSources.Count == 0)
{
<p class="text-muted small p-3 mb-0">No native alarm sources on this template.</p>
@@ -623,6 +668,12 @@
private Dictionary<string, string?> _nasRefEdit = new();
private Dictionary<string, string?> _nasFilterEdit = new();
// Native-alarm-source CSV bulk-import result summary — same shape as the
// attribute importer's (_csvImport*) trio. Null until an import runs.
private string? _nasCsvImportResult;
private bool _nasCsvImportSucceeded;
private IReadOnlyList<string> _nasCsvImportErrors = Array.Empty<string>();
// Override edit modal state — non-null while the modal is open.
private TemplateAlarm? _editingAlarm;
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
@@ -1368,29 +1419,8 @@
return;
}
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(sourceName)
{
InstanceId = Id,
ConnectionNameOverride = conn,
SourceReferenceOverride = sref,
ConditionFilterOverride = filt
};
await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
await TemplateEngineRepository.SaveChangesAsync();
_existingNativeOverrides[sourceName] = ovr;
}
else
{
existing.ConnectionNameOverride = conn;
existing.SourceReferenceOverride = sref;
existing.ConditionFilterOverride = filt;
await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
await TemplateEngineRepository.SaveChangesAsync();
_existingNativeOverrides[sourceName] = existing;
}
await UpsertNativeOverrideCore(sourceName, conn, sref, filt);
await TemplateEngineRepository.SaveChangesAsync();
_toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'.");
}
catch (Exception ex)
@@ -1415,6 +1445,42 @@
_saving = false;
}
/// <summary>
/// Upserts one native alarm source override row and re-seeds the inline editor's
/// state for that source. Does NOT call <c>SaveChangesAsync</c> — the caller does,
/// 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)
{
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(sourceName)
{
InstanceId = Id,
ConnectionNameOverride = conn,
SourceReferenceOverride = sref,
ConditionFilterOverride = filt
};
await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
_existingNativeOverrides[sourceName] = ovr;
}
else
{
existing.ConnectionNameOverride = conn;
existing.SourceReferenceOverride = sref;
existing.ConditionFilterOverride = filt;
await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
_existingNativeOverrides[sourceName] = existing;
}
// Keep the inline editor showing what was just persisted.
_nasConnEdit[sourceName] = conn;
_nasRefEdit[sourceName] = sref;
_nasFilterEdit[sourceName] = filt;
}
private async Task ClearNativeOverrideCore(string sourceName)
{
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
@@ -1431,6 +1497,94 @@
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
// ── Native alarm source CSV bulk import ─────────────────
/// <summary>
/// Handles a selected native-alarm-source-override CSV. Reads the file text
/// (size-capped the same way as the attribute importer), parses it with the shared
/// <see cref="NativeAlarmSourceOverrideCsvParser"/> — the exact parser the CLI
/// <c>instance native-alarm-source import --file</c> command uses — validates every
/// row via <see cref="BuildNativeAlarmSourceCsvImport"/>, and — all-or-nothing —
/// either upserts each row through the SAME repository path the inline Save uses,
/// or shows the per-line error list and applies nothing. Merge semantics, matching
/// the CLI: 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.
/// </summary>
private async Task OnNativeAlarmSourceCsvImportSelectedAsync(InputFileChangeEventArgs e)
{
_saving = true;
_nasCsvImportResult = null;
_nasCsvImportSucceeded = false;
_nasCsvImportErrors = Array.Empty<string>();
try
{
var file = e.File;
if (file.Size > MaxCsvImportBytes)
{
ShowNativeCsvImportFailure(
$"File too large ({file.Size:N0} bytes). The maximum is {MaxCsvImportBytes:N0} bytes.",
Array.Empty<string>());
return;
}
string text;
using (var reader = new StreamReader(file.OpenReadStream(MaxCsvImportBytes)))
{
text = await reader.ReadToEndAsync();
}
var parsed = NativeAlarmSourceOverrideCsvParser.Parse(text);
var outcome = BuildNativeAlarmSourceCsvImport(parsed, _nativeSources);
if (outcome.HasErrors)
{
ShowNativeCsvImportFailure(
$"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.",
outcome.Errors);
return;
}
if (outcome.Rows.Count == 0)
{
ShowNativeCsvImportFailure("No override rows found in the file.", Array.Empty<string>());
return;
}
// Every row was validated up front, so the batch applies as a unit through
// the same repository upsert/clear the inline editor uses.
foreach (var row in outcome.Rows)
{
if (row.IsClear)
await ClearNativeOverrideCore(row.SourceName);
else
await UpsertNativeOverrideCore(
row.SourceName, row.Connection, row.SourceReference, row.Filter);
}
await TemplateEngineRepository.SaveChangesAsync();
_nasCsvImportSucceeded = true;
_nasCsvImportResult = $"Imported {outcome.Rows.Count} native alarm source override(s).";
_toast.ShowSuccess(_nasCsvImportResult);
}
catch (Exception ex)
{
ShowNativeCsvImportFailure($"Import failed: {ex.Message}", Array.Empty<string>());
}
finally
{
_saving = false;
}
}
private void ShowNativeCsvImportFailure(string headline, IReadOnlyList<string> errors)
{
_nasCsvImportSucceeded = false;
_nasCsvImportResult = headline;
_nasCsvImportErrors = errors;
_toast.ShowError(headline);
}
/// <summary>
/// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum
/// to the canonical SCADA type string the AlarmTriggerEditor compares