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
|
||||
|
||||
+114
@@ -11,6 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
/// file's text, calls <see cref="OverrideCsvParser.Parse"/>, then feeds the result
|
||||
/// here; on success it applies the returned dict through the SAME
|
||||
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses.
|
||||
///
|
||||
/// <para>The native-alarm-source CSV import (<see cref="BuildNativeAlarmSourceCsvImport"/>)
|
||||
/// follows the identical shape: the Razor side parses the upload with the shared
|
||||
/// <see cref="NativeAlarmSourceOverrideCsvParser"/> — the same parser the CLI
|
||||
/// <c>instance native-alarm-source import --file</c> command uses — validates it here,
|
||||
/// then upserts each row through the SAME repository path the inline editor's Save
|
||||
/// uses.</para>
|
||||
/// </summary>
|
||||
public partial class InstanceConfigure
|
||||
{
|
||||
@@ -108,6 +115,113 @@ public partial class InstanceConfigure
|
||||
return new CsvOverrideImportOutcome(overrides, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One validated native-alarm-source retarget from an imported CSV: the source
|
||||
/// binding's name plus the three optional override fields (null = keep the
|
||||
/// inherited value). A row whose three fields are ALL null means "no override" —
|
||||
/// the caller clears any existing override row for that source, exactly as the
|
||||
/// inline editor's Save does when every field is left blank.
|
||||
/// </summary>
|
||||
internal sealed record NativeAlarmSourceCsvImportRow(
|
||||
string SourceName, string? Connection, string? SourceReference, string? Filter)
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of validating a parsed native-alarm-source-override CSV against an
|
||||
/// instance's template source bindings. <see cref="Rows"/> is the ordered list of
|
||||
/// retargets to apply (populated ONLY when there are no errors — all-or-nothing);
|
||||
/// <see cref="Errors"/> carries parser errors plus per-row validation errors, each
|
||||
/// pointing back at the operator's source line.
|
||||
/// </summary>
|
||||
internal sealed record NativeAlarmSourceCsvImportOutcome(
|
||||
IReadOnlyList<NativeAlarmSourceCsvImportRow> Rows,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
/// <summary>True when at least one parser or validation error was collected.</summary>
|
||||
public bool HasErrors => Errors.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a parsed native-alarm-source-override CSV against the template's
|
||||
/// source bindings and, if everything checks out, builds the retarget list to
|
||||
/// apply. Pure and deterministic — no I/O, no page state.
|
||||
///
|
||||
/// <para>Rules — the same batch rules the CLI's
|
||||
/// <c>instance native-alarm-source import --file</c> path enforces server-side in
|
||||
/// <c>ManagementActor.HandleSetInstanceNativeAlarmSourceOverrides</c>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>The source name must resolve for this instance — an unknown name would
|
||||
/// create a dead override the flattener silently drops.</item>
|
||||
/// <item>The source must not be template-locked.</item>
|
||||
/// <item>A source may appear at most once in the batch — a duplicate makes the
|
||||
/// intended override ambiguous and the last-writer-wins upsert would silently
|
||||
/// discard the earlier row.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Semantics are <b>merge, not full replace</b> (matching the CLI): sources
|
||||
/// absent from the CSV keep whatever override they already have, and a blank field
|
||||
/// within a row keeps the inherited value.</para>
|
||||
///
|
||||
/// <para>All-or-nothing: if ANY parser or validation error is present, the returned
|
||||
/// <see cref="NativeAlarmSourceCsvImportOutcome.Rows"/> is empty so the caller
|
||||
/// applies nothing and surfaces the full error list.</para>
|
||||
/// </summary>
|
||||
/// <param name="parsed">The result of <see cref="NativeAlarmSourceOverrideCsvParser.Parse"/>.</param>
|
||||
/// <param name="templateSources">The template's native alarm source bindings (the page's <c>_nativeSources</c>).</param>
|
||||
/// <returns>The validated retarget outcome; <see cref="NativeAlarmSourceCsvImportOutcome.Rows"/>
|
||||
/// is empty and errors are populated when any row fails validation.</returns>
|
||||
internal static NativeAlarmSourceCsvImportOutcome BuildNativeAlarmSourceCsvImport(
|
||||
NativeAlarmSourceOverrideCsvParseResult parsed,
|
||||
IReadOnlyList<TemplateNativeAlarmSource> templateSources)
|
||||
{
|
||||
var errors = new List<string>(parsed.Errors);
|
||||
var byName = templateSources.ToDictionary(s => s.Name, StringComparer.Ordinal);
|
||||
var rows = new List<NativeAlarmSourceCsvImportRow>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var row in parsed.Rows)
|
||||
{
|
||||
if (!byName.TryGetValue(row.SourceName, out var source))
|
||||
{
|
||||
errors.Add(
|
||||
$"Line {row.LineNumber}: native alarm source '{row.SourceName}' does not "
|
||||
+ "resolve for this instance and cannot be overridden.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.IsLocked)
|
||||
{
|
||||
errors.Add(
|
||||
$"Line {row.LineNumber}: native alarm source '{row.SourceName}' is locked at "
|
||||
+ "the template level and cannot be overridden.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(row.SourceName))
|
||||
{
|
||||
errors.Add(
|
||||
$"Line {row.LineNumber}: native alarm source '{row.SourceName}' appears more "
|
||||
+ "than once in the file.");
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new NativeAlarmSourceCsvImportRow(
|
||||
row.SourceName,
|
||||
Blank(row.Connection),
|
||||
Blank(row.SourceReference),
|
||||
Blank(row.Filter)));
|
||||
}
|
||||
|
||||
// All-or-nothing: any error means nothing is applied.
|
||||
if (errors.Count > 0)
|
||||
return new NativeAlarmSourceCsvImportOutcome(Array.Empty<NativeAlarmSourceCsvImportRow>(), errors);
|
||||
|
||||
return new NativeAlarmSourceCsvImportOutcome(rows, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single non-null override value against the attribute's declared
|
||||
/// type using <see cref="AttributeValueCodec"/>. Returns a line-qualified error
|
||||
|
||||
Reference in New Issue
Block a user