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:
+183
-29
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user