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
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
|
|
||||||
@* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns
|
@* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns
|
||||||
@@ -69,5 +70,34 @@
|
|||||||
</NotAuthorized>
|
</NotAuthorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</RailFooter>
|
</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>
|
</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
|
@rendermode RenderMode.InteractiveServer
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||||
@inject IApiKeyAdminService Keys
|
@inject IApiKeyAdminService Keys
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -104,7 +105,7 @@
|
|||||||
@(k.Enabled ? "Revoke" : "Re-enable")
|
@(k.Enabled ? "Revoke" : "Re-enable")
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger"
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
@onclick="() => DeleteAsync(k.ApiKeyId)" disabled="@_busy">Delete</button>
|
@onclick="() => RequestDelete(k)" disabled="@_busy">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -114,6 +115,9 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<RawConfirmDialog @bind-Visible="_confirmDeleteVisible" Title="Delete API key"
|
||||||
|
Message="@_confirmDeleteMessage" OnConfirm="ConfirmDeleteAsync" />
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IReadOnlyList<ApiKey>? _keys;
|
private IReadOnlyList<ApiKey>? _keys;
|
||||||
private string _newName = "";
|
private string _newName = "";
|
||||||
@@ -123,6 +127,12 @@
|
|||||||
private string? _error;
|
private string? _error;
|
||||||
private bool _busy;
|
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();
|
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||||
|
|
||||||
private async Task ReloadAsync()
|
private async Task ReloadAsync()
|
||||||
@@ -183,6 +193,19 @@
|
|||||||
finally { _busy = false; }
|
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)
|
private async Task DeleteAsync(string apiKeyId)
|
||||||
{
|
{
|
||||||
_busy = true;
|
_busy = true;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ else
|
|||||||
{
|
{
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="clusterEdit">
|
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="clusterEdit">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||||
<section class="panel rise" style="animation-delay:.02s">
|
<section class="panel rise" style="animation-delay:.02s">
|
||||||
<div class="panel-head">Identity</div>
|
<div class="panel-head">Identity</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="newCluster">
|
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="newCluster">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||||
<section class="panel rise" style="animation-delay:.02s">
|
<section class="panel rise" style="animation-delay:.02s">
|
||||||
<div class="panel-head">Identity</div>
|
<div class="panel-head">Identity</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ else
|
|||||||
{
|
{
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
|
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||||
<section class="panel rise">
|
<section class="panel rise">
|
||||||
<div class="panel-head">Identity</div>
|
<div class="panel-head">Identity</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
|
|||||||
@@ -123,20 +123,27 @@
|
|||||||
private string? _saveError;
|
private string? _saveError;
|
||||||
private bool _openHandled;
|
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()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
if (Visible && !_openHandled)
|
if (Visible && !_openHandled)
|
||||||
{
|
{
|
||||||
_openHandled = true;
|
_openHandled = true;
|
||||||
await LoadAsync();
|
await LoadAsync(++_openGeneration);
|
||||||
}
|
}
|
||||||
else if (!Visible)
|
else if (!Visible)
|
||||||
{
|
{
|
||||||
_openHandled = false;
|
_openHandled = false;
|
||||||
|
_openGeneration++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync(int generation)
|
||||||
{
|
{
|
||||||
_busy = false; _loadError = null; _saveError = null;
|
_busy = false; _loadError = null; _saveError = null;
|
||||||
_isNew = string.IsNullOrEmpty(DeviceId);
|
_isNew = string.IsNullOrEmpty(DeviceId);
|
||||||
@@ -153,6 +160,7 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
|
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
|
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
|
||||||
_resolvedDriverInstanceId = dto.DriverInstanceId;
|
_resolvedDriverInstanceId = dto.DriverInstanceId;
|
||||||
_driverType = dto.DriverType;
|
_driverType = dto.DriverType;
|
||||||
@@ -166,6 +174,7 @@
|
|||||||
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
|
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
|
||||||
{
|
{
|
||||||
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
|
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
_parentDriverConfig = drv?.DriverConfig ?? "{}";
|
_parentDriverConfig = drv?.DriverConfig ?? "{}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +226,7 @@
|
|||||||
{
|
{
|
||||||
Visible = value;
|
Visible = value;
|
||||||
_openHandled = value;
|
_openHandled = value;
|
||||||
|
_openGeneration++;
|
||||||
await VisibleChanged.InvokeAsync(value);
|
await VisibleChanged.InvokeAsync(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -129,20 +129,27 @@
|
|||||||
private string? _saveError;
|
private string? _saveError;
|
||||||
private bool _openHandled;
|
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()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
if (Visible && !_openHandled)
|
if (Visible && !_openHandled)
|
||||||
{
|
{
|
||||||
_openHandled = true;
|
_openHandled = true;
|
||||||
await LoadAsync();
|
await LoadAsync(++_openGeneration);
|
||||||
}
|
}
|
||||||
else if (!Visible)
|
else if (!Visible)
|
||||||
{
|
{
|
||||||
_openHandled = false;
|
_openHandled = false;
|
||||||
|
_openGeneration++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync(int generation)
|
||||||
{
|
{
|
||||||
_busy = false; _loadError = null; _saveError = null;
|
_busy = false; _loadError = null; _saveError = null;
|
||||||
_isNew = string.IsNullOrEmpty(DriverInstanceId);
|
_isNew = string.IsNullOrEmpty(DriverInstanceId);
|
||||||
@@ -158,6 +165,7 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
|
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
|
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
|
||||||
_driverType = dto.DriverType;
|
_driverType = dto.DriverType;
|
||||||
_name = dto.Name;
|
_name = dto.Name;
|
||||||
@@ -206,6 +214,7 @@
|
|||||||
{
|
{
|
||||||
Visible = value;
|
Visible = value;
|
||||||
_openHandled = value;
|
_openHandled = value;
|
||||||
|
_openGeneration++;
|
||||||
await VisibleChanged.InvokeAsync(value);
|
await VisibleChanged.InvokeAsync(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,20 +85,26 @@
|
|||||||
private int _rowCount;
|
private int _rowCount;
|
||||||
private string? _groupFilter;
|
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()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
if (Visible && !_built)
|
if (Visible && !_built)
|
||||||
{
|
{
|
||||||
_built = true;
|
_built = true;
|
||||||
await BuildAsync();
|
await BuildAsync(++_openGeneration);
|
||||||
}
|
}
|
||||||
else if (!Visible)
|
else if (!Visible)
|
||||||
{
|
{
|
||||||
_built = false;
|
_built = false;
|
||||||
|
_openGeneration++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task BuildAsync()
|
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
|
||||||
|
|
||||||
|
private async Task BuildAsync(int generation)
|
||||||
{
|
{
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -110,6 +116,7 @@
|
|||||||
if (!string.IsNullOrEmpty(TagGroupId))
|
if (!string.IsNullOrEmpty(TagGroupId))
|
||||||
{
|
{
|
||||||
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
if (ctx is { } c)
|
if (ctx is { } c)
|
||||||
{
|
{
|
||||||
_groupFilter = c.GroupPath;
|
_groupFilter = c.GroupPath;
|
||||||
@@ -127,6 +134,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
|
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
|
|
||||||
// When exporting a single group, keep only that group's subtree (its path + descendants).
|
// When exporting a single group, keep only that group's subtree (its path + descendants).
|
||||||
if (!string.IsNullOrEmpty(_groupFilter))
|
if (!string.IsNullOrEmpty(_groupFilter))
|
||||||
@@ -147,11 +155,11 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_error = ex.Message;
|
if (!IsStale(generation)) { _error = ex.Message; }
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_loading = false;
|
if (!IsStale(generation)) { _loading = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +172,7 @@
|
|||||||
private async Task Close()
|
private async Task Close()
|
||||||
{
|
{
|
||||||
_built = false;
|
_built = false;
|
||||||
|
_openGeneration++;
|
||||||
_csv = "";
|
_csv = "";
|
||||||
_dataUri = "";
|
_dataUri = "";
|
||||||
Visible = false;
|
Visible = false;
|
||||||
|
|||||||
@@ -191,6 +191,11 @@
|
|||||||
private bool _committing;
|
private bool _committing;
|
||||||
private bool _resolved;
|
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 _effectiveDeviceId = "";
|
||||||
private string? _groupPrefix;
|
private string? _groupPrefix;
|
||||||
|
|
||||||
@@ -209,17 +214,21 @@
|
|||||||
{
|
{
|
||||||
if (Visible && !_resolved)
|
if (Visible && !_resolved)
|
||||||
{
|
{
|
||||||
await ResolveTargetAsync();
|
|
||||||
_resolved = true;
|
_resolved = true;
|
||||||
|
var generation = ++_openGeneration;
|
||||||
|
await ResolveTargetAsync(generation);
|
||||||
}
|
}
|
||||||
else if (!Visible)
|
else if (!Visible)
|
||||||
{
|
{
|
||||||
_resolved = false;
|
_resolved = false;
|
||||||
|
_openGeneration++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
|
||||||
|
|
||||||
// Resolve the effective device id + the group-path prefix once per open.
|
// Resolve the effective device id + the group-path prefix once per open.
|
||||||
private async Task ResolveTargetAsync()
|
private async Task ResolveTargetAsync(int generation)
|
||||||
{
|
{
|
||||||
_effectiveDeviceId = DeviceId;
|
_effectiveDeviceId = DeviceId;
|
||||||
_groupPrefix = null;
|
_groupPrefix = null;
|
||||||
@@ -227,6 +236,7 @@
|
|||||||
if (!string.IsNullOrEmpty(TagGroupId))
|
if (!string.IsNullOrEmpty(TagGroupId))
|
||||||
{
|
{
|
||||||
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
if (ctx is { } c)
|
if (ctx is { } c)
|
||||||
{
|
{
|
||||||
_groupPrefix = c.GroupPath;
|
_groupPrefix = c.GroupPath;
|
||||||
@@ -244,12 +254,14 @@
|
|||||||
_commitErrors = new();
|
_commitErrors = new();
|
||||||
_removed.Clear();
|
_removed.Clear();
|
||||||
_parsing = true;
|
_parsing = true;
|
||||||
|
var generation = _openGeneration;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
|
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
|
||||||
using var reader = new StreamReader(stream);
|
using var reader = new StreamReader(stream);
|
||||||
var text = await reader.ReadToEndAsync();
|
var text = await reader.ReadToEndAsync();
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
|
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
|
||||||
_fatal = _result.FatalError;
|
_fatal = _result.FatalError;
|
||||||
}
|
}
|
||||||
@@ -283,6 +295,8 @@
|
|||||||
|
|
||||||
_committing = true;
|
_committing = true;
|
||||||
_commitErrors = new();
|
_commitErrors = new();
|
||||||
|
var generation = _openGeneration;
|
||||||
|
var targetDeviceId = _effectiveDeviceId;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var rows = _result.Rows
|
var rows = _result.Rows
|
||||||
@@ -290,7 +304,8 @@
|
|||||||
.Select(r => r.ImportRow!)
|
.Select(r => r.ImportRow!)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
|
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
if (outcome.Errors.Count > 0)
|
if (outcome.Errors.Count > 0)
|
||||||
{
|
{
|
||||||
_commitErrors = outcome.Errors.ToList();
|
_commitErrors = outcome.Errors.ToList();
|
||||||
@@ -315,6 +330,7 @@
|
|||||||
Reset();
|
Reset();
|
||||||
Visible = false;
|
Visible = false;
|
||||||
_resolved = false;
|
_resolved = false;
|
||||||
|
_openGeneration++;
|
||||||
await VisibleChanged.InvokeAsync(false);
|
await VisibleChanged.InvokeAsync(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,11 +80,20 @@
|
|||||||
private string? _error;
|
private string? _error;
|
||||||
private bool _wasVisible;
|
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()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
|
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
|
||||||
|
if (!Visible && _wasVisible) { _openGeneration++; }
|
||||||
if (Visible && !_wasVisible)
|
if (Visible && !_wasVisible)
|
||||||
{
|
{
|
||||||
|
_wasVisible = true;
|
||||||
|
var generation = ++_openGeneration;
|
||||||
_selected.Clear();
|
_selected.Clear();
|
||||||
_error = null;
|
_error = null;
|
||||||
_roots = Array.Empty<RawNode>();
|
_roots = Array.Empty<RawNode>();
|
||||||
@@ -94,6 +103,7 @@
|
|||||||
var root = string.IsNullOrEmpty(EquipmentId)
|
var root = string.IsNullOrEmpty(EquipmentId)
|
||||||
? null
|
? null
|
||||||
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
|
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
|
||||||
|
if (IsStale(generation)) { return; }
|
||||||
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
|
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #604 source-scan guards (Razor has no bUnit): an <c>EditForm</c> with a validator but nothing
|
||||||
|
/// to show its messages is a silent no-op Save, the layout must keep its error boundary, and the
|
||||||
|
/// API-key Delete must not fire straight from the row button.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminUiFormSweepGuardTests
|
||||||
|
{
|
||||||
|
/// <summary>EditForm pages whose validation messages are rendered by a child/section component.</summary>
|
||||||
|
private static readonly HashSet<string> AllowList = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_razor_with_an_EditForm_and_a_DataAnnotationsValidator_renders_validation_messages()
|
||||||
|
{
|
||||||
|
var offenders = new List<string>();
|
||||||
|
foreach (var file in Directory.EnumerateFiles(ComponentsDir(), "*.razor", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(file);
|
||||||
|
if (!text.Contains("<EditForm") || !text.Contains("<DataAnnotationsValidator")) { continue; }
|
||||||
|
if (text.Contains("<ValidationSummary") || text.Contains("<ValidationMessage")) { continue; }
|
||||||
|
var name = Path.GetRelativePath(ComponentsDir(), file).Replace('\\', '/');
|
||||||
|
if (!AllowList.Contains(name)) { offenders.Add(name); }
|
||||||
|
}
|
||||||
|
|
||||||
|
offenders.ShouldBeEmpty("these forms validate but never show a message: " + string.Join(", ", offenders));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainLayout_wraps_the_body_in_an_ErrorBoundary()
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(Path.Combine(ComponentsDir(), "Layout", "MainLayout.razor"));
|
||||||
|
var boundary = text.IndexOf("<ErrorBoundary", StringComparison.Ordinal);
|
||||||
|
boundary.ShouldBeGreaterThan(-1);
|
||||||
|
text.IndexOf("@Body", StringComparison.Ordinal).ShouldBeGreaterThan(boundary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiKeys_delete_button_goes_through_the_confirm_dialog()
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(Path.Combine(ComponentsDir(), "Pages", "ApiKeys.razor"));
|
||||||
|
text.ShouldContain("<RawConfirmDialog");
|
||||||
|
text.ShouldNotContain("DeleteAsync(k.ApiKeyId)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComponentsDir()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI", "Components");
|
||||||
|
if (Directory.Exists(candidate)) { return candidate; }
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
throw new DirectoryNotFoundException("AdminUI Components directory not found above the test binaries.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.RenderTree;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #604: the <c>_openGeneration</c> stale-open guard (already on <c>RawBrowseModal</c> /
|
||||||
|
/// <c>RawManualTagEntryModal</c>) on the five modals that lacked it. Each test opens the modal onto
|
||||||
|
/// target A with the async resolve held open, closes, reopens onto target B, then lets A's resolve
|
||||||
|
/// finish: A's continuation must not write A's state under B's displayed modal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// AdminUI has no bUnit, so components are driven as plain classes (reflection for the injected
|
||||||
|
/// properties, <c>[Parameter]</c>s and private state — same harness as
|
||||||
|
/// <see cref="RawBrowseModalStaleOpenTests"/>). Every test releases the stale resolve LAST and asserts
|
||||||
|
/// the live open's value survived; reverting the guard makes the stale value win.
|
||||||
|
/// </remarks>
|
||||||
|
#pragma warning disable BL0006
|
||||||
|
public sealed class ModalStaleOpenGuardTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<InertRenderer> _renderers = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CsvImport_stale_group_resolve_never_retargets_the_live_open()
|
||||||
|
{
|
||||||
|
var dbName = SeedGroup("TG-A", "dev-A", "GroupA");
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
var modal = Make<RawCsvImportModal>(
|
||||||
|
("ExportReader", new RawTagCsvExportReader(new GatedFactory(dbName, gate.Task))));
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", "TG-A"));
|
||||||
|
var stale = Open(modal);
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", false));
|
||||||
|
await Open(modal);
|
||||||
|
SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", null));
|
||||||
|
await Open(modal);
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
await stale;
|
||||||
|
|
||||||
|
Field<string?>(modal, "_groupPrefix").ShouldBeNull();
|
||||||
|
Field<string>(modal, "_effectiveDeviceId").ShouldBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CsvExport_stale_group_resolve_never_retargets_the_live_open()
|
||||||
|
{
|
||||||
|
var dbName = SeedGroup("TG-A", "dev-A", "GroupA");
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
var modal = Make<RawCsvExportModal>(
|
||||||
|
("ExportReader", new RawTagCsvExportReader(new GatedFactory(dbName, gate.Task))));
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", "TG-A"));
|
||||||
|
var stale = Open(modal);
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", false));
|
||||||
|
await Open(modal);
|
||||||
|
SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", null));
|
||||||
|
await Open(modal);
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
await stale;
|
||||||
|
|
||||||
|
Field<string?>(modal, "_groupFilter").ShouldBeNull();
|
||||||
|
Field<int>(modal, "_rowCount").ShouldBe(0);
|
||||||
|
Field<string>(modal, "_dataUri").ShouldBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeviceModal_stale_load_never_installs_the_old_devices_fields()
|
||||||
|
{
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
var svc = Stub<IRawTreeService>(async (method, args) =>
|
||||||
|
{
|
||||||
|
if (method.Name != nameof(IRawTreeService.LoadDeviceForEditAsync)) { return null; }
|
||||||
|
if ((string)args[0]! == "dev-A")
|
||||||
|
{
|
||||||
|
await gate.Task;
|
||||||
|
return new RawDeviceEditDto("dev-A", "drv", "NameA", "{}", true, "Modbus", []);
|
||||||
|
}
|
||||||
|
return new RawDeviceEditDto("dev-B", "", "NameB", "{}", true, "Modbus", []);
|
||||||
|
});
|
||||||
|
var modal = Make<DeviceModal>(("Svc", svc));
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", true), ("DeviceId", "dev-A"));
|
||||||
|
var stale = Open(modal);
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", false));
|
||||||
|
await Open(modal);
|
||||||
|
SetParams(modal, ("Visible", true), ("DeviceId", "dev-B"));
|
||||||
|
await Open(modal);
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
await stale;
|
||||||
|
|
||||||
|
Field<string>(modal, "_name").ShouldBe("NameB");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DriverConfigModal_stale_load_never_installs_the_old_drivers_fields()
|
||||||
|
{
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
var svc = Stub<IRawTreeService>(async (method, args) =>
|
||||||
|
{
|
||||||
|
if (method.Name != nameof(IRawTreeService.LoadDriverForEditAsync)) { return null; }
|
||||||
|
if ((string)args[0]! == "drv-A")
|
||||||
|
{
|
||||||
|
await gate.Task;
|
||||||
|
return new RawDriverEditDto("drv-A", "NameA", "Modbus", """{"a":1}""", null, []);
|
||||||
|
}
|
||||||
|
return new RawDriverEditDto("drv-B", "NameB", "Modbus", """{"b":2}""", null, []);
|
||||||
|
});
|
||||||
|
var modal = Make<DriverConfigModal>(("Svc", svc));
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", true), ("DriverInstanceId", "drv-A"));
|
||||||
|
var stale = Open(modal);
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", false));
|
||||||
|
await Open(modal);
|
||||||
|
SetParams(modal, ("Visible", true), ("DriverInstanceId", "drv-B"));
|
||||||
|
await Open(modal);
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
await stale;
|
||||||
|
|
||||||
|
Field<string>(modal, "_name").ShouldBe("NameB");
|
||||||
|
Field<string>(modal, "_driverConfigJson").ShouldBe("""{"b":2}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddReferenceModal_stale_root_load_never_replaces_the_live_opens_tree()
|
||||||
|
{
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
var svc = Stub<IUnsTreeService>(async (method, args) =>
|
||||||
|
{
|
||||||
|
if (method.Name != nameof(IUnsTreeService.LoadReferencePickerRootAsync)) { return null; }
|
||||||
|
if ((string)args[0]! == "eq-A")
|
||||||
|
{
|
||||||
|
await gate.Task;
|
||||||
|
return Root("root-A");
|
||||||
|
}
|
||||||
|
return Root("root-B");
|
||||||
|
});
|
||||||
|
var modal = Make<AddReferenceModal>(("Svc", svc));
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", true), ("EquipmentId", "eq-A"));
|
||||||
|
var stale = Open(modal);
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", false));
|
||||||
|
await Open(modal);
|
||||||
|
SetParams(modal, ("Visible", true), ("EquipmentId", "eq-B"));
|
||||||
|
await Open(modal);
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
await stale;
|
||||||
|
|
||||||
|
Field<IReadOnlyList<RawNode>>(modal, "_roots").Single().Key.ShouldBe("root-B");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- harness ----
|
||||||
|
|
||||||
|
private static RawNode Root(string key)
|
||||||
|
=> new() { Kind = RawNodeKind.Cluster, Key = key, DisplayName = key };
|
||||||
|
|
||||||
|
private static string SeedGroup(string tagGroupId, string deviceId, string name)
|
||||||
|
{
|
||||||
|
var dbName = $"modalstale-{Guid.NewGuid():N}";
|
||||||
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
db.TagGroups.Add(new TagGroup { TagGroupId = tagGroupId, DeviceId = deviceId, Name = name });
|
||||||
|
db.SaveChanges();
|
||||||
|
return dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private T Make<T>(params (string Name, object Value)[] injected) where T : class, IComponent, new()
|
||||||
|
{
|
||||||
|
var component = new T();
|
||||||
|
foreach (var (name, value) in injected)
|
||||||
|
{
|
||||||
|
var property = typeof(T).GetProperty(name, BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
property.ShouldNotBeNull($"the injected {name} property was renamed or removed.");
|
||||||
|
property!.SetValue(component, value);
|
||||||
|
}
|
||||||
|
var renderer = new InertRenderer();
|
||||||
|
_renderers.Add(renderer);
|
||||||
|
renderer.Attach(component);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetParams(IComponent modal, params (string Name, object? Value)[] values)
|
||||||
|
{
|
||||||
|
foreach (var (name, value) in values)
|
||||||
|
{
|
||||||
|
var property = modal.GetType().GetProperty(name);
|
||||||
|
property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed.");
|
||||||
|
property!.SetValue(modal, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task Open(IComponent modal)
|
||||||
|
{
|
||||||
|
var method = modal.GetType().GetMethod(
|
||||||
|
"OnParametersSetAsync", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
method.ShouldNotBeNull("the open path moved off OnParametersSetAsync.");
|
||||||
|
await (Task)method!.Invoke(modal, null)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Field<T>(object modal, string name)
|
||||||
|
{
|
||||||
|
var field = modal.GetType().GetField(name, BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
field.ShouldNotBeNull($"the modal's {name} field was renamed or removed.");
|
||||||
|
return (T)field!.GetValue(modal)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A throwaway implementation of a service interface: every call goes to <paramref name="handler"/>,
|
||||||
|
/// whose result is wrapped as the method's <c>Task<T></c>. A null result is a null payload.
|
||||||
|
/// </summary>
|
||||||
|
private static TService Stub<TService>(Func<MethodInfo, object?[], Task<object?>> handler)
|
||||||
|
where TService : class
|
||||||
|
{
|
||||||
|
var proxy = DispatchProxy.Create<TService, StubProxy>();
|
||||||
|
((StubProxy)(object)proxy).Handler = handler;
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var renderer in _renderers) { renderer.Dispose(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DispatchProxy body for <see cref="Stub{TService}"/>.</summary>
|
||||||
|
public class StubProxy : DispatchProxy
|
||||||
|
{
|
||||||
|
/// <summary>Receives every call; set right after construction.</summary>
|
||||||
|
public Func<MethodInfo, object?[], Task<object?>> Handler { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||||
|
{
|
||||||
|
var inner = targetMethod!.ReturnType.GetGenericArguments()[0];
|
||||||
|
var task = Handler(targetMethod, args ?? []);
|
||||||
|
var tcs = typeof(TaskCompletionSource<>).MakeGenericType(inner);
|
||||||
|
var source = Activator.CreateInstance(tcs)!;
|
||||||
|
task.ContinueWith(t =>
|
||||||
|
{
|
||||||
|
if (t.IsFaulted) { tcs.GetMethod("SetException", [typeof(Exception)])!.Invoke(source, [t.Exception!.GetBaseException()]); }
|
||||||
|
else { tcs.GetMethod("SetResult")!.Invoke(source, [t.Result]); }
|
||||||
|
});
|
||||||
|
return tcs.GetProperty("Task")!.GetValue(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GatedFactory(string dbName, Task gate) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public OtOpcUaConfigDbContext CreateDbContext() => RawTreeTestDb.CreateNamed(dbName);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await gate;
|
||||||
|
return CreateDbContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InertRenderer()
|
||||||
|
: Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
|
||||||
|
|
||||||
|
/// <summary>Assigns the component a render handle, as a root component would receive.</summary>
|
||||||
|
public void Attach(IComponent component) => AssignRootComponentId(component);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override void HandleException(Exception exception)
|
||||||
|
=> ExceptionDispatchInfo.Capture(exception).Throw();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InlineDispatcher : Dispatcher
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override bool CheckAccess() => true;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override Task InvokeAsync(Action workItem)
|
||||||
|
{
|
||||||
|
workItem();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override Task InvokeAsync(Func<Task> workItem) => workItem();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
|
||||||
|
=> Task.FromResult(workItem());
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user