feat(centralui): InstanceConfigure CSV bulk override import (T16)

This commit is contained in:
Joseph Doherty
2026-06-18 02:30:33 -04:00
parent 25c9240415
commit d5e7e897c0
3 changed files with 497 additions and 1 deletions
@@ -175,10 +175,49 @@
@* Attribute Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Attribute Overrides</strong>
@* M7-T16: bulk import of attribute overrides from a CSV
(AttributeName,Value[,ElementType]). Selecting a file parses +
validates it against this instance's overridable attributes and —
all-or-nothing — applies every row through the SAME
SetAttributeOverrideAsync path the manual editor uses, or shows the
per-line error list and applies nothing. *@
@if (_overrideAttrs.Count > 0)
{
<label class="btn btn-outline-secondary btn-sm mb-0">
Import overrides (CSV)
<InputFile OnChange="OnCsvImportSelectedAsync"
accept=".csv"
data-test="csv-import-input"
class="d-none"
disabled="@_saving" />
</label>
}
</div>
<div class="card-body p-0">
@if (_csvImportResult is not null)
{
<div class="p-2 pb-0">
<div class="alert @(_csvImportSucceeded ? "alert-success" : "alert-danger") small mb-0"
data-test="csv-import-result">
@if (_csvImportSucceeded)
{
<span>@_csvImportResult</span>
}
else
{
<div class="fw-semibold mb-1">@_csvImportResult</div>
<ul class="mb-0 ps-3">
@foreach (var err in _csvImportErrors)
{
<li>@err</li>
}
</ul>
}
</div>
</div>
}
@if (_overrideAttrs.Count == 0)
{
<p class="text-muted small p-3 mb-0">No overridable (non-locked) attributes in this template.</p>
@@ -557,6 +596,17 @@
// un-parseable List element caught on the pre-submit round-trip).
private Dictionary<string, string> _overrideErrors = new();
// M7-T16: CSV bulk-import result summary. _csvImportResult is the headline
// ("Imported N overrides." or "Import rejected — N error(s)."); on failure the
// per-line messages are listed from _csvImportErrors. Null until an import runs.
private string? _csvImportResult;
private bool _csvImportSucceeded;
private IReadOnlyList<string> _csvImportErrors = Array.Empty<string>();
// Reject pathologically large uploads before buffering — a few hundred KB of
// override CSV is already extreme (thousands of attributes).
private const long MaxCsvImportBytes = 512 * 1024;
// Alarm overrides — read-only state pulled from the repo. The edit modal
// is the only mutation path (one alarm at a time).
private List<TemplateAlarm> _overridableAlarms = new();
@@ -995,6 +1045,122 @@
_saving = false;
}
// ── M7-T16: CSV bulk override import ────────────────────
/// <summary>
/// Handles a selected override CSV. Reads the file text (size-capped), parses it
/// with the shared <see cref="OverrideCsvParser"/>, validates every row against
/// the instance's overridable attributes via <see cref="BuildCsvOverrideImport"/>,
/// and — all-or-nothing — either applies the parsed overrides through the SAME
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses, or
/// shows the per-line error list and applies nothing. The override editor is
/// refreshed in-place so the applied values are immediately visible.
/// </summary>
private async Task OnCsvImportSelectedAsync(InputFileChangeEventArgs e)
{
_saving = true;
_csvImportResult = null;
_csvImportSucceeded = false;
_csvImportErrors = Array.Empty<string>();
try
{
var file = e.File;
if (file.Size > MaxCsvImportBytes)
{
ShowCsvImportFailure(
$"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 = OverrideCsvParser.Parse(text);
var outcome = BuildCsvOverrideImport(parsed, _overrideAttrs);
if (outcome.HasErrors)
{
ShowCsvImportFailure(
$"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.",
outcome.Errors);
return;
}
if (outcome.Overrides.Count == 0)
{
ShowCsvImportFailure("No override rows found in the file.", Array.Empty<string>());
return;
}
// Apply through the EXISTING per-attribute submit path (no new server
// method) — identical to SaveOverrides. Update the editor's in-memory
// state so the applied values render immediately.
var user = await GetCurrentUserAsync();
var failures = new List<string>();
foreach (var (attrName, value) in outcome.Overrides)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
if (result.IsSuccess)
{
_existingOverrides[attrName] = result.Value!;
if (value is null) _overrideValues.Remove(attrName);
else _overrideValues[attrName] = value;
RefreshEditorRowForImport(attrName, value);
}
else
{
failures.Add($"{attrName}: {result.Error}");
}
}
if (failures.Count > 0)
{
ShowCsvImportFailure(
$"Applied {outcome.Overrides.Count - failures.Count} of {outcome.Overrides.Count}; "
+ $"{failures.Count} failed.",
failures);
}
else
{
_csvImportSucceeded = true;
_csvImportResult = $"Imported {outcome.Overrides.Count} override(s).";
_toast.ShowSuccess(_csvImportResult);
}
}
catch (Exception ex)
{
ShowCsvImportFailure($"Import failed: {ex.Message}", Array.Empty<string>());
}
finally
{
_saving = false;
}
}
private void ShowCsvImportFailure(string headline, IReadOnlyList<string> errors)
{
_csvImportSucceeded = false;
_csvImportResult = headline;
_csvImportErrors = errors;
_toast.ShowError(headline);
}
/// <summary>
/// Re-seeds the List editor's working rows for an imported List attribute so the
/// applied value renders immediately. Scalar inputs read <c>_overrideValues</c>
/// directly, so nothing extra is needed for them.
/// </summary>
private void RefreshEditorRowForImport(string attrName, string? value)
{
var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName);
if (attr?.DataType == DataType.List)
_listRows[attrName] = DecodeListRows(value, attr.ElementDataType);
}
// ── Alarm overrides ─────────────────────────────────────
private bool HasOverride(string alarmName) =>
@@ -0,0 +1,153 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary>
/// Code-behind for <c>InstanceConfigure.razor</c> — hosts the pure, side-effect-free
/// core of the CSV bulk-override import (M7-T16) so it can be unit-pinned without
/// standing up the page's ≈7 injected services. The Razor side reads the uploaded
/// 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.
/// </summary>
public partial class InstanceConfigure
{
/// <summary>
/// Outcome of validating a parsed override CSV against an instance's overridable
/// attributes. <see cref="Overrides"/> is the attribute-name → canonical-value-string
/// map 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 CsvOverrideImportOutcome(
IReadOnlyDictionary<string, string?> Overrides,
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 override CSV against the instance's overridable attributes
/// and, if everything checks out, builds the override dict to submit. Pure and
/// deterministic — no I/O, no page state.
///
/// <para>Rules (mirroring the manual editor):</para>
/// <list type="bullet">
/// <item>The attribute name must exist in <paramref name="overridableAttributes"/>.
/// That set is the page's <c>_overrideAttrs</c> (already filtered to
/// non-template-locked rows), so a locked / unknown attribute is rejected
/// exactly as the editor would never offer it.</item>
/// <item>The value must be type-compatible with the attribute's
/// <c>DataType</c>/<c>ElementDataType</c>, validated by round-tripping through
/// <see cref="AttributeValueCodec"/> — the same codec the List editor uses.</item>
/// <item>A null value ("clear the override") is always accepted; downstream the
/// set-override path treats a null/empty value as a clear.</item>
/// </list>
///
/// <para>All-or-nothing: if ANY parser or validation error is present, the
/// returned <see cref="CsvOverrideImportOutcome.Overrides"/> is empty so the
/// caller applies nothing and surfaces the full error list.</para>
/// </summary>
/// <param name="parsed">The result of <see cref="OverrideCsvParser.Parse"/>.</param>
/// <param name="overridableAttributes">The instance's non-locked attributes (the page's <c>_overrideAttrs</c>).</param>
internal static CsvOverrideImportOutcome BuildCsvOverrideImport(
OverrideCsvParseResult parsed,
IReadOnlyList<TemplateAttribute> overridableAttributes)
{
var errors = new List<string>(parsed.Errors);
// Only non-template-locked attributes are overridable. The page already
// passes its pre-filtered _overrideAttrs, but re-filter here so the rule is
// self-contained and a locked attribute is rejected even if a caller passes
// the raw template attribute list.
var byName = overridableAttributes
.Where(a => !a.IsLocked)
.ToDictionary(a => a.Name, StringComparer.Ordinal);
var overrides = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var row in parsed.Rows)
{
if (!byName.TryGetValue(row.AttributeName, out var attr))
{
errors.Add(
$"Line {row.LineNumber}: attribute '{row.AttributeName}' is not an "
+ "overridable attribute on this instance (unknown or template-locked).");
continue;
}
// A null value clears the override — always valid, no type check needed.
if (row.Value is null)
{
overrides[row.AttributeName] = null;
continue;
}
// Type compatibility: round-trip through the shared codec. For List
// attributes the value is canonical JSON validated against the element
// type fixed by the base attribute; for scalars we validate the literal
// against the declared type. Mirrors the manual editor's guard.
var error = ValidateValue(attr, row.Value, row.LineNumber);
if (error is not null)
{
errors.Add(error);
continue;
}
overrides[row.AttributeName] = row.Value;
}
// All-or-nothing: any error means nothing is applied.
if (errors.Count > 0)
return new CsvOverrideImportOutcome(
new Dictionary<string, string?>(StringComparer.Ordinal), errors);
return new CsvOverrideImportOutcome(overrides, 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
/// string when incompatible, otherwise null.
/// </summary>
private static string? ValidateValue(TemplateAttribute attr, string value, int lineNumber)
{
try
{
if (attr.DataType == DataType.List)
{
// Canonical JSON array; Decode throws FormatException on malformed
// JSON or an un-parseable element against the fixed element type.
AttributeValueCodec.Decode(value, DataType.List, attr.ElementDataType ?? DataType.String);
}
else
{
// Scalar: validate the literal parses to the declared type. The codec
// returns scalars unchanged, so we use the List machinery with a
// single-element array to reuse its per-type parse rules.
if (ScalarIsTypeChecked(attr.DataType))
{
var probeJson = AttributeValueCodec.Encode(new List<string> { value });
AttributeValueCodec.Decode(probeJson, DataType.List, attr.DataType);
}
}
}
catch (FormatException ex)
{
return $"Line {lineNumber}: attribute '{attr.Name}' value is not a valid "
+ $"{attr.DataType}{(attr.DataType == DataType.List ? $" of {attr.ElementDataType}" : string.Empty)} — {ex.Message}";
}
return null;
}
/// <summary>
/// True for scalar types the codec can validate. String accepts any literal (no
/// check); the remaining scalar types are parse-checked. Object/Binary aren't
/// overridable scalars in this editor, so they pass through unchecked.
/// </summary>
private static bool ScalarIsTypeChecked(DataType dt) =>
dt is DataType.Int32 or DataType.Float or DataType.Double
or DataType.Boolean or DataType.DateTime;
}