Merge #604: AdminUI stale-open guards on 5 modals, ValidationSummary, API-key delete confirm, ErrorBoundary

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:55:40 -04:00
12 changed files with 504 additions and 13 deletions
@@ -123,20 +123,27 @@
private string? _saveError;
private bool _openHandled;
// Bumped on every open and close; LoadAsync re-checks it after each await so a superseded open can't
// install one target's fields under another target's displayed modal (RawBrowseModal idiom).
private int _openGeneration;
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
await LoadAsync();
await LoadAsync(++_openGeneration);
}
else if (!Visible)
{
_openHandled = false;
_openGeneration++;
}
}
private async Task LoadAsync()
private async Task LoadAsync(int generation)
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DeviceId);
@@ -153,6 +160,7 @@
else
{
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
if (IsStale(generation)) { return; }
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
_resolvedDriverInstanceId = dto.DriverInstanceId;
_driverType = dto.DriverType;
@@ -166,6 +174,7 @@
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
if (IsStale(generation)) { return; }
_parentDriverConfig = drv?.DriverConfig ?? "{}";
}
}
@@ -217,6 +226,7 @@
{
Visible = value;
_openHandled = value;
_openGeneration++;
await VisibleChanged.InvokeAsync(value);
}
}
@@ -129,20 +129,27 @@
private string? _saveError;
private bool _openHandled;
// Bumped on every open and close; LoadAsync re-checks it after each await so a superseded open can't
// install one target's fields under another target's displayed modal (RawBrowseModal idiom).
private int _openGeneration;
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
await LoadAsync();
await LoadAsync(++_openGeneration);
}
else if (!Visible)
{
_openHandled = false;
_openGeneration++;
}
}
private async Task LoadAsync()
private async Task LoadAsync(int generation)
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DriverInstanceId);
@@ -158,6 +165,7 @@
else
{
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
if (IsStale(generation)) { return; }
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
_driverType = dto.DriverType;
_name = dto.Name;
@@ -206,6 +214,7 @@
{
Visible = value;
_openHandled = value;
_openGeneration++;
await VisibleChanged.InvokeAsync(value);
}
}
@@ -85,20 +85,26 @@
private int _rowCount;
private string? _groupFilter;
// Bumped on every open and close so a superseded build can't install its CSV under another device's modal.
private int _openGeneration;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_built)
{
_built = true;
await BuildAsync();
await BuildAsync(++_openGeneration);
}
else if (!Visible)
{
_built = false;
_openGeneration++;
}
}
private async Task BuildAsync()
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
private async Task BuildAsync(int generation)
{
_loading = true;
_error = null;
@@ -110,6 +116,7 @@
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (IsStale(generation)) { return; }
if (ctx is { } c)
{
_groupFilter = c.GroupPath;
@@ -127,6 +134,7 @@
}
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
if (IsStale(generation)) { return; }
// When exporting a single group, keep only that group's subtree (its path + descendants).
if (!string.IsNullOrEmpty(_groupFilter))
@@ -147,11 +155,11 @@
}
catch (Exception ex)
{
_error = ex.Message;
if (!IsStale(generation)) { _error = ex.Message; }
}
finally
{
_loading = false;
if (!IsStale(generation)) { _loading = false; }
}
}
@@ -164,6 +172,7 @@
private async Task Close()
{
_built = false;
_openGeneration++;
_csv = "";
_dataUri = "";
Visible = false;
@@ -191,6 +191,11 @@
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;
@@ -209,17 +214,21 @@
{
if (Visible && !_resolved)
{
await ResolveTargetAsync();
_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()
private async Task ResolveTargetAsync(int generation)
{
_effectiveDeviceId = DeviceId;
_groupPrefix = null;
@@ -227,6 +236,7 @@
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (IsStale(generation)) { return; }
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
@@ -244,12 +254,14 @@
_commitErrors = new();
_removed.Clear();
_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);
_fatal = _result.FatalError;
}
@@ -283,6 +295,8 @@
_committing = true;
_commitErrors = new();
var generation = _openGeneration;
var targetDeviceId = _effectiveDeviceId;
try
{
var rows = _result.Rows
@@ -290,7 +304,8 @@
.Select(r => r.ImportRow!)
.ToList();
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
if (IsStale(generation)) { return; }
if (outcome.Errors.Count > 0)
{
_commitErrors = outcome.Errors.ToList();
@@ -315,6 +330,7 @@
Reset();
Visible = false;
_resolved = false;
_openGeneration++;
await VisibleChanged.InvokeAsync(false);
}
}
@@ -80,11 +80,20 @@
private string? _error;
private bool _wasVisible;
// Bumped on every open and close so a superseded picker-root load can't install its tree under a
// different equipment's (or a re-opened) modal (RawBrowseModal idiom).
private int _openGeneration;
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
protected override async Task OnParametersSetAsync()
{
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
if (!Visible && _wasVisible) { _openGeneration++; }
if (Visible && !_wasVisible)
{
_wasVisible = true;
var generation = ++_openGeneration;
_selected.Clear();
_error = null;
_roots = Array.Empty<RawNode>();
@@ -94,6 +103,7 @@
var root = string.IsNullOrEmpty(EquipmentId)
? null
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
if (IsStale(generation)) { return; }
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
_loading = false;
}