fix(adminui): stale-open guards, validation summaries, key-delete confirm, error boundary (#604)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@using System.Security.Claims
|
||||
|
||||
@* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns
|
||||
@@ -69,5 +70,34 @@
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</RailFooter>
|
||||
<ChildContent>@Body</ChildContent>
|
||||
<ChildContent>
|
||||
@* A throwing page would otherwise kill the whole circuit (blank "reload" bar). The boundary
|
||||
confines it to the page body and offers Recover; navigating away also recovers it. It does NOT
|
||||
catch exceptions from async-void handlers or timer callbacks. *@
|
||||
<ErrorBoundary @ref="_boundary">
|
||||
<ChildContent>@Body</ChildContent>
|
||||
<ErrorContent Context="ex">
|
||||
<div class="alert alert-danger m-3" role="alert">
|
||||
<strong>This page hit an unexpected error.</strong>
|
||||
<div class="small mono mt-1">@ex.GetType().Name: @ex.Message</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger mt-2" @onclick="() => _boundary?.Recover()">Try again</button>
|
||||
</div>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
</ChildContent>
|
||||
</ThemeShell>
|
||||
|
||||
@code {
|
||||
private ErrorBoundary? _boundary;
|
||||
|
||||
[Inject] private NavigationManager Nav { get; set; } = default!;
|
||||
|
||||
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||
|
||||
// A fresh navigation clears a tripped boundary so the next page isn't stuck behind the old error.
|
||||
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
|
||||
=> _boundary?.Recover();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||
@inject IApiKeyAdminService Keys
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -104,7 +105,7 @@
|
||||
@(k.Enabled ? "Revoke" : "Re-enable")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => DeleteAsync(k.ApiKeyId)" disabled="@_busy">Delete</button>
|
||||
@onclick="() => RequestDelete(k)" disabled="@_busy">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -114,6 +115,9 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
<RawConfirmDialog @bind-Visible="_confirmDeleteVisible" Title="Delete API key"
|
||||
Message="@_confirmDeleteMessage" OnConfirm="ConfirmDeleteAsync" />
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<ApiKey>? _keys;
|
||||
private string _newName = "";
|
||||
@@ -123,6 +127,12 @@
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
// Delete is irreversible (the key stops authenticating at once), so it goes through a confirm dialog
|
||||
// that names the key rather than firing from the row button.
|
||||
private bool _confirmDeleteVisible;
|
||||
private string? _confirmDeleteId;
|
||||
private string _confirmDeleteMessage = "";
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
@@ -183,6 +193,19 @@
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private void RequestDelete(ApiKey key)
|
||||
{
|
||||
_confirmDeleteId = key.ApiKeyId;
|
||||
_confirmDeleteMessage =
|
||||
$"Permanently delete API key '{key.Name}' ({key.ApiKeyId})? Clients using it will stop authenticating immediately. This cannot be undone.";
|
||||
_confirmDeleteVisible = true;
|
||||
}
|
||||
|
||||
private async Task ConfirmDeleteAsync()
|
||||
{
|
||||
if (_confirmDeleteId is { } id) { await DeleteAsync(id); }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(string apiKeyId)
|
||||
{
|
||||
_busy = true;
|
||||
|
||||
@@ -34,6 +34,7 @@ else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="clusterEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="newCluster">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
|
||||
@@ -31,6 +31,7 @@ else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||
<section class="panel rise">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user