feat(centralui): InstanceConfigure CSV bulk override import (T16)
This commit is contained in:
+167
-1
@@ -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) =>
|
||||
|
||||
+153
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user