Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor
T
Joseph Doherty 36e6ab590b perf(adminui): virtualize the CSV import grid; download exports from an endpoint (R04 P6)
The import review grid rendered every parsed row and re-scanned all rows three
times per render for its counts; RawCsvReviewView recomputes rows + counts once
per change and the grid renders through Virtualize. The export modal embedded the
whole CSV twice (textarea + base64 data: URI); it now shows a 50-line preview
and links to GET /api/raw/tags.csv (ConfigEditor, like /raw), which runs the same
RawTagCsvExport builder and sends it as an attachment.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:40:40 -04:00

344 lines
15 KiB
Plaintext

@* Staged CSV tag import for a Raw-tree Device / TagGroup (B2-WP5).
Flow: upload a file (InputFile) → parse via the T0-2 CsvParser + the per-driver CsvColumnMap →
a REVIEW GRID with a per-row verdict (OK / the validation error) and, per row, which columns were
typed vs. taken from the TagConfigJson fallback → Commit, which calls IRawTreeService.ImportTagsAsync.
ImportTagsAsync is all-or-nothing, so Commit is BLOCKED while any row is invalid — no partial commit.
Modal contract (the RawTree coordinator wires this in place of the OnImportCsv stub):
<RawCsvImportModal @bind-Visible="_importVisible"
DeviceId="@_importDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_importTagGroupId" (target group; its path is prepended to every row)
DriverType="@_importDriverType"
OnSaved="ReloadNodeChildren" />
For a Device node: DeviceId = node.EntityId, TagGroupId = null.
For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import tags — CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_fatal))
{
<div class="alert alert-danger">@_fatal</div>
}
@if (_result is null)
{
<p class="text-muted">
Upload a CSV whose header row carries the columns for a
<span class="mono">@DriverType</span> device.
Target: <span class="mono">@TargetLabel</span>.
</p>
<InputFile OnChange="OnFileSelected" accept=".csv,text/csv" class="form-control" />
@if (_parsing)
{
<p class="text-muted mt-2"><span class="spinner-border spinner-border-sm"></span> Parsing…</p>
}
}
else
{
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<span class="badge bg-secondary">@_result.Rows.Count rows</span>
<span class="badge bg-success">@OkCount valid</span>
@if (ErrorCount > 0)
{
<span class="badge bg-danger">@ErrorCount invalid</span>
}
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="Reset">Choose another file</button>
</div>
@if (ErrorCount > 0)
{
<div class="alert alert-warning py-1">
Import is all-or-nothing — fix or remove the invalid rows before committing.
</div>
}
<div class="table-responsive" style="max-height:50vh;overflow:auto">
<table class="table table-sm table-hover align-middle">
<thead class="sticky-top bg-body">
<tr>
<th style="width:2.5rem"></th>
<th>Row</th>
<th>Name</th>
<th>Group</th>
<th>Verdict</th>
<th>Provenance</th>
<th style="width:2.5rem"></th>
</tr>
</thead>
<tbody>
@* Virtualized (R04 P6): only the rows in the scrolled view render. *@
<Virtualize Items="@_view.VisibleRows" ItemSize="34"
SpacerElement="tr" Context="row">
<tr @key="row.RowNumber" class="@(row.Ok ? "" : "table-danger")">
<td>@(row.Ok ? "✓" : "✕")</td>
<td class="mono">@row.RowNumber</td>
<td class="mono">@row.Name</td>
<td class="mono small">@(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath)</td>
<td>
@if (row.Ok)
{
<span class="text-success">OK</span>
}
else
{
<span class="text-danger">@row.Error</span>
}
</td>
<td class="small">
@if (row.TypedColumnsUsed.Count > 0)
{
<span class="text-muted">typed: </span>
<span class="mono">@string.Join(", ", row.TypedColumnsUsed)</span>
}
@if (row.FallbackKeys.Count > 0)
{
<br /><span class="text-muted">TagConfigJson: </span>
<span class="mono">@string.Join(", ", row.FallbackKeys)</span>
}
@if (row.TypedOverrodeFallback.Count > 0)
{
<br /><span class="badge bg-info text-dark" title="A typed column overrode the same key in TagConfigJson">
typed wins: @string.Join(", ", row.TypedOverrodeFallback)
</span>
}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger"
title="Remove this row from the import"
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
</td>
</tr>
</Virtualize>
</tbody>
</table>
</div>
@if (_commitErrors.Count > 0)
{
<div class="alert alert-danger mt-2">
<strong>Commit rejected:</strong>
<ul class="mb-0">
@foreach (var e in _commitErrors)
{
<li>@e</li>
}
</ul>
</div>
}
}
</div>
<div class="modal-footer">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary"
disabled="@(!CanCommit || _committing)"
@onclick="Commit">
@if (_committing)
{
<span class="spinner-border spinner-border-sm"></span>
}
Commit @RemainingCount tag@(RemainingCount == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (two-way bound).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The target device's logical id. May be blank when <see cref="TagGroupId"/> is supplied
/// (the device is then resolved from the group).</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>The target tag-group's logical id, or null to import at the device root. Its device-relative
/// path is prepended to every imported row's <c>TagGroupPath</c>.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Raised after a successful commit so the coordinator can reload the node's children.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private RawCsvParseResult? _result;
// The grid's filtered rows + counts, recomputed per change rather than per render (R04 P6).
private readonly RawCsvReviewView _view = new();
private List<string> _commitErrors = new();
private string? _fatal;
private bool _parsing;
private bool _committing;
private bool _resolved;
// Bumped on every open and every close; an async continuation compares its captured value after each
// await so a superseded open can never write its target under a different device's displayed modal
// (same idiom as RawBrowseModal / RawManualTagEntryModal).
private int _openGeneration;
private string _effectiveDeviceId = "";
private string? _groupPrefix;
private string TargetLabel =>
string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}";
private int OkCount => _view.OkCount;
private int ErrorCount => _view.ErrorCount;
private int RemainingCount => _view.RemainingCount;
// Commit is unblocked only when every remaining row is valid AND at least one row remains.
private bool CanCommit =>
_result is not null && _fatal is null && RemainingCount > 0 && ErrorCount == 0;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_resolved)
{
_resolved = true;
var generation = ++_openGeneration;
await ResolveTargetAsync(generation);
}
else if (!Visible)
{
_resolved = false;
_openGeneration++;
}
}
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
// Resolve the effective device id + the group-path prefix once per open.
private async Task ResolveTargetAsync(int generation)
{
_effectiveDeviceId = DeviceId;
_groupPrefix = null;
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (IsStale(generation)) { return; }
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
if (string.IsNullOrEmpty(_effectiveDeviceId))
{
_effectiveDeviceId = c.DeviceId;
}
}
}
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
_fatal = null;
_commitErrors = new();
_view.Set(null);
_parsing = true;
var generation = _openGeneration;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
using var reader = new StreamReader(stream);
var text = await reader.ReadToEndAsync();
if (IsStale(generation)) { return; }
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
_view.Set(_result);
_fatal = _result.FatalError;
}
catch (Exception ex)
{
_fatal = $"Could not read the file: {ex.Message}";
_result = null;
_view.Set(null);
}
finally
{
_parsing = false;
}
}
private void RemoveRow(int rowNumber) => _view.Remove(rowNumber);
private void Reset()
{
_result = null;
_fatal = null;
_commitErrors = new();
_view.Set(null);
}
private async Task Commit()
{
if (_result is null || !CanCommit)
{
return;
}
_committing = true;
_commitErrors = new();
var generation = _openGeneration;
var targetDeviceId = _effectiveDeviceId;
try
{
var rows = _view.CommitRows();
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
// The write has landed whether or not the modal was closed/reopened meanwhile, so the parent must
// still refresh its tree; only the modal-local state writes are skipped when stale.
if (IsStale(generation))
{
if (outcome.Errors.Count == 0) { await OnSaved.InvokeAsync(); }
return;
}
if (outcome.Errors.Count > 0)
{
_commitErrors = outcome.Errors.ToList();
return;
}
await OnSaved.InvokeAsync();
await Close();
}
catch (Exception ex)
{
_commitErrors = new() { ex.Message };
}
finally
{
_committing = false;
}
}
private async Task Close()
{
Reset();
Visible = false;
_resolved = false;
_openGeneration++;
await VisibleChanged.InvokeAsync(false);
}
}