Merge review R3 fixes: per-page interactive ErrorBoundary, DisposeBridge cancels on timeout, CSV commit refreshes after close, SecurityMode=None documented, browsePageDelayMs omit-by-default
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -41,6 +41,12 @@ All commands accept these options:
|
|||||||
| `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy |
|
| `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy |
|
||||||
| `--verbose` | Enable debug-level Serilog console logging (default: warning) |
|
| `--verbose` | Enable debug-level Serilog console logging (default: warning) |
|
||||||
|
|
||||||
|
**`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and
|
||||||
|
fails with an `InvalidOperationException` naming the endpoints the server does offer when it has none. The
|
||||||
|
older stack helper silently fell back to the first endpoint (often a secured one), so a command that
|
||||||
|
defaulted to `none` against a secured-only server used to connect and now errors. Pass `-S sign`,
|
||||||
|
`-S encrypt` or `-S signandencrypt` to talk to such a server.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used.
|
When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used.
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| Endpoint URL | OPC UA server endpoint (e.g., `opc.tcp://localhost:4840/OtOpcUa`) |
|
| Endpoint URL | OPC UA server endpoint (e.g., `opc.tcp://localhost:4840/OtOpcUa`) |
|
||||||
| Username / Password | Credentials for `UserName` token authentication |
|
| Username / Password | Credentials for `UserName` token authentication |
|
||||||
| Security Mode | Transport security: None, Sign, SignAndEncrypt |
|
| Security Mode | Transport security: None, Sign, SignAndEncrypt. `None` requires the server to offer a `None` endpoint — there is no fallback to a secured one; connecting fails naming the endpoints offered |
|
||||||
| Failover URLs | Comma-separated backup endpoints for redundancy failover |
|
| Failover URLs | Comma-separated backup endpoints for redundancy failover |
|
||||||
| Session Timeout | Session timeout in seconds (1–3600, default 60) |
|
| Session Timeout | Session timeout in seconds (1–3600, default 60) |
|
||||||
| Certificate Store Path | Path to the client certificate store (folder chooser) |
|
| Certificate Store Path | Path to the client certificate store (folder chooser) |
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ internal static class DisposeBridge
|
|||||||
using var cts = new CancellationTokenSource(Timeout);
|
using var cts = new CancellationTokenSource(Timeout);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Task.Run(() => work(cts.Token), cts.Token).Wait(Timeout);
|
var task = Task.Run(() => work(cts.Token), cts.Token);
|
||||||
|
// Observe a late fault so it never surfaces as an unobserved-task exception.
|
||||||
|
_ = task.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
|
||||||
|
// The CTS's own timer and this Wait race: when Wait loses, `using` would dispose the CTS before
|
||||||
|
// the token ever fired, leaving the work running while the caller goes on to dispose the session.
|
||||||
|
// Cancel explicitly so the in-flight Close/Delete is told to stop.
|
||||||
|
if (!task.Wait(Timeout)) { cts.Cancel(); }
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@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
|
||||||
@@ -71,33 +70,9 @@
|
|||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</RailFooter>
|
</RailFooter>
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
@* A throwing page would otherwise kill the whole circuit (blank "reload" bar). The boundary
|
@* No ErrorBoundary here: this layout renders statically (Routes is SSR), so it is not an ancestor
|
||||||
confines it to the page body and offers Recover; navigating away also recovers it. It does NOT
|
of any @rendermode InteractiveServer page and a boundary here would never see their failures.
|
||||||
catch exceptions from async-void handlers or timer callbacks. *@
|
Interactive pages wrap their body in <PageErrorBoundary> instead. *@
|
||||||
<ErrorBoundary @ref="_boundary">
|
@Body
|
||||||
<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>
|
</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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
@inject IRequiredActor<HistorianAdapterActorKey> HistorianActor
|
@inject IRequiredActor<HistorianAdapterActorKey> HistorianActor
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Alarms historian sink</h4>
|
<h4 class="mb-0">Alarms historian sink</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +44,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const int PollSeconds = 5;
|
private const int PollSeconds = 5;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
@inject IAdminOperationsClient AdminOps
|
@inject IAdminOperationsClient AdminOps
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Alerts</h4>
|
<h4 class="mb-0">Alerts</h4>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
@@ -131,6 +132,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const int Capacity = 200;
|
private const int Capacity = 200;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||||
@inject IApiKeyAdminService Keys
|
@inject IApiKeyAdminService Keys
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">OPC UA API keys</h4>
|
<h4 class="mb-0">OPC UA API keys</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
|
|
||||||
<RawConfirmDialog @bind-Visible="_confirmDeleteVisible" Title="Delete API key"
|
<RawConfirmDialog @bind-Visible="_confirmDeleteVisible" Title="Delete API key"
|
||||||
Message="@_confirmDeleteMessage" OnConfirm="ConfirmDeleteAsync" />
|
Message="@_confirmDeleteMessage" OnConfirm="ConfirmDeleteAsync" />
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IReadOnlyList<ApiKey>? _keys;
|
private IReadOnlyList<ApiKey>? _keys;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">OPC UA certificates</h4>
|
<h4 class="mb-0">OPC UA certificates</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +109,7 @@ else
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<StoreView>? _rows;
|
private List<StoreView>? _rows;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
@inject IAclScopeService Scopes
|
@inject IAclScopeService Scopes
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">@(IsNew ? "New ACL grant" : "Edit ACL grant") · <span class="mono">@ClusterId</span></h4>
|
<h4 class="mb-0">@(IsNew ? "New ACL grant" : "Edit ACL grant") · <span class="mono">@ClusterId</span></h4>
|
||||||
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||||
@@ -154,6 +155,7 @@ else
|
|||||||
<AclScopePickerModal Visible="_pickerVisible" ClusterId="@ClusterId"
|
<AclScopePickerModal Visible="_pickerVisible" ClusterId="@ClusterId"
|
||||||
OnPicked="ScopePicked" OnCancel="() => _pickerVisible = false" />
|
OnPicked="ScopePicked" OnCancel="() => _pickerVisible = false" />
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private static readonly NodePermissions[] PermissionBits =
|
private static readonly NodePermissions[] PermissionBits =
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@inject IAclScopeService Scopes
|
@inject IAclScopeService Scopes
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">ACLs · <span class="mono">@ClusterId</span></h4>
|
<h4 class="mb-0">ACLs · <span class="mono">@ClusterId</span></h4>
|
||||||
<a href="/clusters/@ClusterId/acls/new" class="btn btn-primary btn-sm">New ACL grant</a>
|
<a href="/clusters/@ClusterId/acls/new" class="btn btn-primary btn-sm">New ACL grant</a>
|
||||||
@@ -95,6 +96,7 @@ else
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Queries
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Queries
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Audit log · <span class="mono">@ClusterId</span></h4>
|
<h4 class="mb-0">Audit log · <span class="mono">@ClusterId</span></h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +65,7 @@ else
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const int PageSize = 200;
|
private const int PageSize = 200;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Edit cluster · <span class="mono">@ClusterId</span></h4>
|
<h4 class="mb-0">Edit cluster · <span class="mono">@ClusterId</span></h4>
|
||||||
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||||
@@ -104,6 +105,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||||
@rendermode RenderMode.InteractiveServer
|
@rendermode RenderMode.InteractiveServer
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Namespaces · <span class="mono">@ClusterId</span></h4>
|
<h4 class="mb-0">Namespaces · <span class="mono">@ClusterId</span></h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
<strong>/uns</strong> pages. This page is a placeholder so existing links do not 404.
|
<strong>/uns</strong> pages. This page is a placeholder so existing links do not 404.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
@if (!_loaded)
|
@if (!_loaded)
|
||||||
{
|
{
|
||||||
<p>Loading…</p>
|
<p>Loading…</p>
|
||||||
@@ -110,6 +111,7 @@ else
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
@inject IAuthorizationService AuthorizationService
|
@inject IAuthorizationService AuthorizationService
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
@if (!_loaded)
|
@if (!_loaded)
|
||||||
{
|
{
|
||||||
<p>Loading…</p>
|
<p>Loading…</p>
|
||||||
@@ -140,6 +141,7 @@ else
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Clusters</h4>
|
<h4 class="mb-0">Clusters</h4>
|
||||||
<a href="/clusters/new" class="btn btn-primary btn-sm">New cluster</a>
|
<a href="/clusters/new" class="btn btn-primary btn-sm">New cluster</a>
|
||||||
@@ -61,6 +62,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<ServerCluster>? _rows;
|
private List<ServerCluster>? _rows;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||||
@rendermode RenderMode.InteractiveServer
|
@rendermode RenderMode.InteractiveServer
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Namespaces · <span class="mono">@ClusterId</span></h4>
|
<h4 class="mb-0">Namespaces · <span class="mono">@ClusterId</span></h4>
|
||||||
<a href="/clusters/@ClusterId/namespaces" class="btn btn-outline-secondary btn-sm">Back</a>
|
<a href="/clusters/@ClusterId/namespaces" class="btn btn-outline-secondary btn-sm">Back</a>
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
<strong>/uns</strong> pages. This editor no longer applies.
|
<strong>/uns</strong> pages. This editor no longer applies.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">New cluster</h4>
|
<h4 class="mb-0">New cluster</h4>
|
||||||
<a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
<a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private FormModel _form = new();
|
private FormModel _form = new();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">@(IsNew ? "New node" : "Edit node") · <span class="mono">@ClusterId</span></h4>
|
<h4 class="mb-0">@(IsNew ? "New node" : "Edit node") · <span class="mono">@ClusterId</span></h4>
|
||||||
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||||
@@ -122,6 +123,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = "";
|
[Parameter] public string ClusterId { get; set; } = "";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
@inject ZB.MOM.WW.OtOpcUa.AdminUI.Deployments.DeploymentDriftProbe DriftProbe
|
@inject ZB.MOM.WW.OtOpcUa.AdminUI.Deployments.DeploymentDriftProbe DriftProbe
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<PageTitle>Deployments</PageTitle>
|
<PageTitle>Deployments</PageTitle>
|
||||||
|
|
||||||
<h1>Deployments</h1>
|
<h1>Deployments</h1>
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
// Blob-free projection: the Deployment entity carries a varbinary(max) ArtifactBlob (a whole-fleet
|
// Blob-free projection: the Deployment entity carries a varbinary(max) ArtifactBlob (a whole-fleet
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<PageTitle>ContextMenu demo</PageTitle>
|
<PageTitle>ContextMenu demo</PageTitle>
|
||||||
|
|
||||||
<h1>ContextMenu demo</h1>
|
<h1>ContextMenu demo</h1>
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>Last action: <strong>@_lastAction</strong></p>
|
<p>Last action: <strong>@_lastAction</strong></p>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string _lastAction = "(none)";
|
private string _lastAction = "(none)";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Fleet status</h4>
|
<h4 class="mb-0">Fleet status</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,6 +99,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const int RefreshIntervalSeconds = 10;
|
private const int RefreshIntervalSeconds = 10;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
|
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Cluster hosts</h4>
|
<h4 class="mb-0">Cluster hosts</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,6 +312,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const int RefreshIntervalSeconds = 5;
|
private const int RefreshIntervalSeconds = 5;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
each container via IRawTreeService.LoadChildrenAsync and surfaces per-node
|
each container via IRawTreeService.LoadChildrenAsync and surfaces per-node
|
||||||
context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@
|
context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<PageTitle>Raw</PageTitle>
|
<PageTitle>Raw</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
|
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">External ID reservations</h4>
|
<h4 class="mb-0">External ID reservations</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,6 +51,7 @@ else
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<ExternalIdReservation>? _rows;
|
private List<ExternalIdReservation>? _rows;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
@inject IOptionsSnapshot<LdapOptions> Ldap
|
@inject IOptionsSnapshot<LdapOptions> Ldap
|
||||||
@inject ILdapGroupRoleMappingService RoleMappings
|
@inject ILdapGroupRoleMappingService RoleMappings
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Role grants</h4>
|
<h4 class="mb-0">Role grants</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private LdapOptions? _options;
|
private LdapOptions? _options;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">@(IsNew ? "New script" : "Edit script")</h4>
|
<h4 class="mb-0">@(IsNew ? "New script" : "Edit script")</h4>
|
||||||
<a href="/scripts" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
<a href="/scripts" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||||
@@ -72,6 +73,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string? ScriptId { get; set; }
|
[Parameter] public string? ScriptId { get; set; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
|
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Script log</h4>
|
<h4 class="mb-0">Script log</h4>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
@@ -76,6 +77,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const int Capacity = 500;
|
private const int Capacity = 500;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Scripts</h4>
|
<h4 class="mb-0">Scripts</h4>
|
||||||
<a href="/scripts/new" class="btn btn-primary btn-sm">New script</a>
|
<a href="/scripts/new" class="btn btn-primary btn-sm">New script</a>
|
||||||
@@ -104,6 +105,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Script>? _rows;
|
private List<Script>? _rows;
|
||||||
|
|||||||
@@ -17,4 +17,6 @@
|
|||||||
authorization policy (the router only enforces [Authorize] on the routed page itself,
|
authorization policy (the router only enforces [Authorize] on the routed page itself,
|
||||||
which is now this one), and renders the RCL page as an ordinary child component. *@
|
which is now this one), and renders the RCL page as an ordinary child component. *@
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<SecretsPage />
|
<SecretsPage />
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
@inject IUnsTreeService Svc
|
@inject IUnsTreeService Svc
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<PageTitle>Equipment</PageTitle>
|
<PageTitle>Equipment</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -311,6 +312,7 @@ else
|
|||||||
OnSaved="OnAlarmSavedAsync" OnCancel="@(() => { _alarmModalVisible = false; })" />
|
OnSaved="OnAlarmSavedAsync" OnCancel="@(() => { _alarmModalVisible = false; })" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
/// <summary>The equipment id from the route; null/empty on the <c>/new</c> route (create mode).</summary>
|
/// <summary>The equipment id from the route; null/empty on the <c>/new</c> route (create mode).</summary>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@inject IUnsTreeService Svc
|
@inject IUnsTreeService Svc
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<PageErrorBoundary>
|
||||||
<PageTitle>UNS</PageTitle>
|
<PageTitle>UNS</PageTitle>
|
||||||
|
|
||||||
<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,6 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IReadOnlyList<UnsNode> _roots = Array.Empty<UnsNode>();
|
private IReadOnlyList<UnsNode> _roots = Array.Empty<UnsNode>();
|
||||||
|
|||||||
+36
-6
@@ -49,7 +49,11 @@
|
|||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label" for="abcipBrowsePageDelay">Browse page delay (ms)</label>
|
<label class="form-label" for="abcipBrowsePageDelay">Browse page delay (ms)</label>
|
||||||
<InputNumber @bind-Value="_form.BrowsePageDelayMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" id="abcipBrowsePageDelay" />
|
<InputNumber @bind-Value="_form.BrowsePageDelayMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" id="abcipBrowsePageDelay" />
|
||||||
<div class="form-text">Pause between controller symbol-browse pages (<code>browsePageDelayMs</code>). Default 5 ms; 0 disables the pause.</div>
|
<div class="form-text">Pause between controller symbol-browse pages (<code>browsePageDelayMs</code>). Default 5 ms; 0 disables the pause. Left at the default on a config that never set it, the key is not written.</div>
|
||||||
|
@if (_form.BrowsePageDelayError is { } browseDelayError)
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-1" id="abcipBrowsePageDelayError">@browseDelayError</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,14 +185,29 @@
|
|||||||
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
|
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
|
||||||
/// from the device rows at deploy/probe time).</summary>
|
/// from the device rows at deploy/probe time).</summary>
|
||||||
public string GetConfigJson()
|
public string GetConfigJson()
|
||||||
=> Serialize(_form.ToOptions(_preservedDevices), _originalJson);
|
=> Serialize(_form.ToOptions(_preservedDevices), _originalJson, _form.EmitsBrowsePageDelay);
|
||||||
|
|
||||||
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
|
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
|
||||||
internal static string Serialize(AbCipDriverOptions options, string? originalJson = null)
|
/// <param name="options">The form's options.</param>
|
||||||
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
|
/// <param name="originalJson">The stored config, so unmodelled keys survive.</param>
|
||||||
|
/// <param name="emitBrowsePageDelay">
|
||||||
|
/// False omits <c>browsePageDelayMs</c> from the saved config (it was never set and the operator left the
|
||||||
|
/// default), so a later change of the factory default is not pinned by an unrelated AdminUI save.
|
||||||
|
/// </param>
|
||||||
|
internal static string Serialize(AbCipDriverOptions options, string? originalJson = null, bool emitBrowsePageDelay = true)
|
||||||
|
{
|
||||||
|
var json = DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
|
||||||
|
if (emitBrowsePageDelay || System.Text.Json.Nodes.JsonNode.Parse(json) is not System.Text.Json.Nodes.JsonObject obj) return json;
|
||||||
|
var key = obj.Select(kv => kv.Key).FirstOrDefault(k => string.Equals(k, "browsePageDelayMs", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (key is not null) obj.Remove(key);
|
||||||
|
return obj.ToJsonString();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task EmitAsync()
|
private async Task EmitAsync()
|
||||||
{
|
{
|
||||||
|
// An invalid value is shown inline and NOT emitted: the saved config must never differ silently from
|
||||||
|
// what the field displays (ToOptions would clamp a negative to 0).
|
||||||
|
if (_form.BrowsePageDelayError is not null) return;
|
||||||
var json = GetConfigJson();
|
var json = GetConfigJson();
|
||||||
_lastParsed = json;
|
_lastParsed = json;
|
||||||
_originalJson = json;
|
_originalJson = json;
|
||||||
@@ -221,7 +240,16 @@
|
|||||||
public bool EnableControllerBrowse { get; set; } = false;
|
public bool EnableControllerBrowse { get; set; } = false;
|
||||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||||
// Milliseconds between controller symbol-browse pages (factory key browsePageDelayMs; default 5).
|
// Milliseconds between controller symbol-browse pages (factory key browsePageDelayMs; default 5).
|
||||||
public int BrowsePageDelayMs { get; set; } = 5;
|
public const int DefaultBrowsePageDelayMs = 5;
|
||||||
|
public int BrowsePageDelayMs { get; set; } = DefaultBrowsePageDelayMs;
|
||||||
|
// True when the stored config carried the key; with the default value and no key, it stays absent.
|
||||||
|
private bool _browsePageDelayLoaded;
|
||||||
|
|
||||||
|
/// <summary>Whether the save should write <c>browsePageDelayMs</c>: it was loaded, or differs from the default.</summary>
|
||||||
|
public bool EmitsBrowsePageDelay => _browsePageDelayLoaded || BrowsePageDelayMs != DefaultBrowsePageDelayMs;
|
||||||
|
|
||||||
|
/// <summary>A validation message for the browse page delay, or null when valid.</summary>
|
||||||
|
public string? BrowsePageDelayError => BrowsePageDelayMs < 0 ? "Browse page delay must be 0 or greater." : null;
|
||||||
|
|
||||||
// Alarm projection
|
// Alarm projection
|
||||||
public bool EnableAlarmProjection { get; set; } = false;
|
public bool EnableAlarmProjection { get; set; } = false;
|
||||||
@@ -248,7 +276,9 @@
|
|||||||
f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll;
|
f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll;
|
||||||
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
|
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
|
||||||
f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
|
f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
|
||||||
f.BrowsePageDelayMs = DriverConfigMs.Read(json, "browsePageDelayMs") ?? f.BrowsePageDelayMs;
|
var browseDelay = DriverConfigMs.Read(json, "browsePageDelayMs");
|
||||||
|
f._browsePageDelayLoaded = browseDelay.HasValue;
|
||||||
|
f.BrowsePageDelayMs = browseDelay ?? f.BrowsePageDelayMs;
|
||||||
f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
|
f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
|
||||||
f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval);
|
f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval);
|
||||||
f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
|
f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@* Confines an exception thrown while rendering or handling an event on a page to the page body, instead
|
||||||
|
of killing the whole Blazor circuit (the blank "reload" bar).
|
||||||
|
|
||||||
|
WHERE IT MAY LIVE: this host's <Routes/> is static SSR, so MainLayout renders statically and every
|
||||||
|
@rendermode InteractiveServer page is its OWN interactive root, not a child of anything in the layout.
|
||||||
|
A boundary in the layout therefore never sees a page failure and its Recover/navigation handlers are
|
||||||
|
dead. The boundary has to sit INSIDE the interactive root — i.e. each interactive page wraps its body
|
||||||
|
in <PageErrorBoundary>. Pinned by AdminUiFormSweepGuardTests (every interactive page wraps) and
|
||||||
|
PageErrorBoundaryTests (the component really contains a throw and recovers).
|
||||||
|
|
||||||
|
It does NOT catch exceptions from async-void handlers or timer callbacks. *@
|
||||||
|
<ErrorBoundary @ref="_boundary">
|
||||||
|
<ChildContent>@ChildContent</ChildContent>
|
||||||
|
<ErrorContent Context="ex">
|
||||||
|
<div class="alert alert-danger m-3" role="alert" data-testid="page-error-boundary">
|
||||||
|
<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="Recover">Try again</button>
|
||||||
|
</div>
|
||||||
|
</ErrorContent>
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ErrorBoundary? _boundary;
|
||||||
|
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
[Inject] private NavigationManager Nav { get; set; } = default!;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||||
|
|
||||||
|
/// <summary>Clears a tripped boundary and re-renders the wrapped content.</summary>
|
||||||
|
public void Recover() => _boundary?.Recover();
|
||||||
|
|
||||||
|
// A fresh navigation (e.g. same page, new route parameters, where the component instance is reused)
|
||||||
|
// clears a tripped boundary so the new location isn't stuck behind the old error. InvokeAsync keeps
|
||||||
|
// the Recover on the renderer's dispatcher.
|
||||||
|
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
|
||||||
|
=> _ = InvokeAsync(Recover);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
@@ -305,7 +305,13 @@
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
|
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
|
||||||
if (IsStale(generation)) { return; }
|
// 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)
|
if (outcome.Errors.Count > 0)
|
||||||
{
|
{
|
||||||
_commitErrors = outcome.Errors.ToList();
|
_commitErrors = outcome.Errors.ToList();
|
||||||
|
|||||||
@@ -328,4 +328,36 @@ public sealed class AbCipDriverPageFormSerializationTests
|
|||||||
saved.ShouldContain("futureOption");
|
saved.ShouldContain("futureOption");
|
||||||
System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject().ContainsKey("browsePageDelay").ShouldBeFalse();
|
System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject().ContainsKey("browsePageDelay").ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- review R3-m4: the key is emitted only when loaded or changed from the default; negatives are flagged ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BrowsePageDelayMs_is_omitted_when_never_set_and_left_at_the_default()
|
||||||
|
{
|
||||||
|
var original = """{"futureOption":"keep-me","devices":[{"hostAddress":"ab://10.0.0.1/1,0"}]}""";
|
||||||
|
var loaded = AbCipDriverForm.TryDeserialize(original)!;
|
||||||
|
var form = AbCipDriverForm.FormModel.FromJson(original, loaded);
|
||||||
|
|
||||||
|
var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original, form.EmitsBrowsePageDelay);
|
||||||
|
System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject().ContainsKey("browsePageDelayMs").ShouldBeFalse();
|
||||||
|
saved.ShouldContain("futureOption");
|
||||||
|
|
||||||
|
// Control: changing it away from the default DOES write it, and a loaded key is kept even at the default value.
|
||||||
|
form.BrowsePageDelayMs = 125;
|
||||||
|
var changed = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original, form.EmitsBrowsePageDelay);
|
||||||
|
System.Text.Json.Nodes.JsonNode.Parse(changed)!["browsePageDelayMs"]!.GetValue<int>().ShouldBe(125);
|
||||||
|
|
||||||
|
var withKey = """{"browsePageDelayMs":5,"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}]}""";
|
||||||
|
var f2 = AbCipDriverForm.FormModel.FromJson(withKey, AbCipDriverForm.TryDeserialize(withKey)!);
|
||||||
|
f2.EmitsBrowsePageDelay.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BrowsePageDelayMs_negative_is_a_validation_error_zero_is_valid()
|
||||||
|
{
|
||||||
|
var form = new AbCipDriverForm.FormModel { BrowsePageDelayMs = -1 };
|
||||||
|
form.BrowsePageDelayError.ShouldNotBeNull();
|
||||||
|
form.BrowsePageDelayMs = 0;
|
||||||
|
form.BrowsePageDelayError.ShouldBeNull();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Issue #604 source-scan guards (Razor has no bUnit): an <c>EditForm</c> with a validator but nothing
|
/// 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
|
/// to show its messages is a silent no-op Save, the interactive pages' error boundary is pinned in PageErrorBoundaryTests, and the
|
||||||
/// API-key Delete must not fire straight from the row button.
|
/// API-key Delete must not fire straight from the row button.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AdminUiFormSweepGuardTests
|
public sealed class AdminUiFormSweepGuardTests
|
||||||
@@ -29,15 +29,6 @@ public sealed class AdminUiFormSweepGuardTests
|
|||||||
offenders.ShouldBeEmpty("these forms validate but never show a message: " + string.Join(", ", offenders));
|
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]
|
[Fact]
|
||||||
public void ApiKeys_delete_button_goes_through_the_confirm_dialog()
|
public void ApiKeys_delete_button_goes_through_the_confirm_dialog()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,6 +57,50 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
|
|||||||
Field<string>(modal, "_effectiveDeviceId").ShouldBe("");
|
Field<string>(modal, "_effectiveDeviceId").ShouldBe("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CsvImport_commit_that_lands_after_the_modal_closed_still_raises_OnSaved()
|
||||||
|
{
|
||||||
|
// A write that has landed must refresh the parent's tree even when the operator closed the modal
|
||||||
|
// mid-commit; before the fix the stale guard returned ahead of OnSaved.
|
||||||
|
var saved = await CommitWhileClosed(new RawTagImportResult(1, []));
|
||||||
|
saved.ShouldBe(1);
|
||||||
|
|
||||||
|
// Control: a failed (all-or-nothing) import wrote nothing, so there is nothing to refresh.
|
||||||
|
var savedOnFailure = await CommitWhileClosed(new RawTagImportResult(0, ["row 1: nope"]));
|
||||||
|
savedOnFailure.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CommitWhileClosed(RawTagImportResult outcome)
|
||||||
|
{
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
var svc = Stub<IRawTreeService>(async (method, _) =>
|
||||||
|
{
|
||||||
|
if (method.Name != nameof(IRawTreeService.ImportTagsAsync)) { return null; }
|
||||||
|
await gate.Task;
|
||||||
|
return outcome;
|
||||||
|
});
|
||||||
|
var modal = Make<RawCsvImportModal>(("Svc", svc));
|
||||||
|
var saved = 0;
|
||||||
|
SetParams(modal,
|
||||||
|
("Visible", true), ("DeviceId", "dev-A"), ("TagGroupId", null),
|
||||||
|
("DriverType", "Modbus"),
|
||||||
|
("OnSaved", EventCallback.Factory.Create(this, () => { saved++; })));
|
||||||
|
await Open(modal);
|
||||||
|
|
||||||
|
modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(
|
||||||
|
modal, RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\""));
|
||||||
|
|
||||||
|
var commit = (Task)modal.GetType().GetMethod("Commit", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||||
|
.Invoke(modal, null)!;
|
||||||
|
|
||||||
|
SetParams(modal, ("Visible", false));
|
||||||
|
await Open(modal);
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
await commit;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CsvExport_stale_group_resolve_never_retargets_the_live_open()
|
public async Task CsvExport_stale_group_resolve_never_retargets_the_live_open()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.RenderTree;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
using Microsoft.AspNetCore.Components.Web.Infrastructure;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #604 / review R3-M1: the error boundary must live INSIDE the interactive scope. These tests
|
||||||
|
/// drive the real <see cref="PageErrorBoundary"/> through a real <see cref="Renderer"/> (no bUnit in this
|
||||||
|
/// project): a child that throws while rendering is contained (nothing reaches the renderer's unhandled
|
||||||
|
/// path, i.e. the circuit survives), "Try again" and a location change both recover, and disposing the
|
||||||
|
/// boundary unsubscribes from the navigation manager. A structural guard also pins that every
|
||||||
|
/// interactive page actually wraps its body.
|
||||||
|
/// </summary>
|
||||||
|
#pragma warning disable BL0006
|
||||||
|
// Renderer lives in Microsoft.AspNetCore.Components.RenderTree; subclassing it is the only way to host a
|
||||||
|
// component without a bUnit dependency (same approach as RawBrowseModalStaleOpenTests).
|
||||||
|
public sealed class PageErrorBoundaryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FakeNav _nav = new();
|
||||||
|
private readonly TestRenderer _renderer;
|
||||||
|
|
||||||
|
public PageErrorBoundaryTests()
|
||||||
|
{
|
||||||
|
var sp = new ServiceCollection()
|
||||||
|
.AddSingleton<NavigationManager>(_nav)
|
||||||
|
.AddSingleton<IErrorBoundaryLogger, NullBoundaryLogger>()
|
||||||
|
.BuildServiceProvider();
|
||||||
|
_renderer = new TestRenderer(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_throw_in_the_page_body_is_contained_and_Try_again_recovers()
|
||||||
|
{
|
||||||
|
Thrower.ShouldThrow = false;
|
||||||
|
await _renderer.RenderRootAsync();
|
||||||
|
_renderer.Text.ShouldContain("page body ok"); // positive control: the healthy body renders
|
||||||
|
|
||||||
|
Thrower.ShouldThrow = true;
|
||||||
|
await _renderer.Dispatcher.InvokeAsync(() => _renderer.RerenderThrower());
|
||||||
|
|
||||||
|
_renderer.Unhandled.ShouldBeEmpty(); // contained: the circuit would have survived
|
||||||
|
_renderer.Text.ShouldContain("This page hit an unexpected error.");
|
||||||
|
_renderer.Text.ShouldContain("boom");
|
||||||
|
_renderer.Text.ShouldNotContain("page body ok");
|
||||||
|
|
||||||
|
// "Try again" is a live handler, not dead static markup.
|
||||||
|
Thrower.ShouldThrow = false;
|
||||||
|
_renderer.LastOnClickHandlerId.ShouldNotBe(0UL);
|
||||||
|
await _renderer.Dispatcher.InvokeAsync(() =>
|
||||||
|
_renderer.DispatchEventAsync(_renderer.LastOnClickHandlerId, null, new EventArgs()));
|
||||||
|
|
||||||
|
_renderer.Text.ShouldContain("page body ok");
|
||||||
|
_renderer.Unhandled.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_location_change_recovers_a_tripped_boundary()
|
||||||
|
{
|
||||||
|
Thrower.ShouldThrow = false;
|
||||||
|
await _renderer.RenderRootAsync();
|
||||||
|
Thrower.ShouldThrow = true;
|
||||||
|
await _renderer.Dispatcher.InvokeAsync(() => _renderer.RerenderThrower());
|
||||||
|
_renderer.Text.ShouldContain("This page hit an unexpected error.");
|
||||||
|
|
||||||
|
Thrower.ShouldThrow = false;
|
||||||
|
await _renderer.Dispatcher.InvokeAsync(() => _nav.NavigateTo("http://localhost/other"));
|
||||||
|
await _renderer.Dispatcher.InvokeAsync(() => Task.CompletedTask); // let the queued Recover run
|
||||||
|
|
||||||
|
_renderer.Text.ShouldContain("page body ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disposing_the_boundary_unsubscribes_from_navigation()
|
||||||
|
{
|
||||||
|
Thrower.ShouldThrow = false;
|
||||||
|
await _renderer.RenderRootAsync();
|
||||||
|
FakeNav.HandlerCount(_nav).ShouldBe(1); // positive control: it did subscribe
|
||||||
|
|
||||||
|
_renderer.Dispose();
|
||||||
|
FakeNav.HandlerCount(_nav).ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_interactive_page_wraps_its_body_in_PageErrorBoundary_and_the_layout_does_not_pretend_to()
|
||||||
|
{
|
||||||
|
var components = ComponentsDir();
|
||||||
|
var offenders = new List<string>();
|
||||||
|
foreach (var file in Directory.EnumerateFiles(Path.Combine(components, "Pages"), "*.razor", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(file);
|
||||||
|
if (!text.Contains("@rendermode")) { continue; }
|
||||||
|
// Same text the router compiles: the wrapper must open before, and close after, the markup.
|
||||||
|
var open = text.IndexOf("<PageErrorBoundary>", StringComparison.Ordinal);
|
||||||
|
var close = text.LastIndexOf("</PageErrorBoundary>", StringComparison.Ordinal);
|
||||||
|
var code = text.IndexOf("\n@code", StringComparison.Ordinal);
|
||||||
|
var ok = open > -1 && close > open && (code < 0 || close < code);
|
||||||
|
if (!ok) { offenders.Add(Path.GetRelativePath(components, file).Replace('\\', '/')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
offenders.ShouldBeEmpty("interactive pages without a PageErrorBoundary wrapper: " + string.Join(", ", offenders));
|
||||||
|
|
||||||
|
// The static layout is not an ancestor of interactive pages: a boundary there is inert.
|
||||||
|
File.ReadAllText(Path.Combine(components, "Layout", "MainLayout.razor")).ShouldNotContain("<ErrorBoundary");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _renderer.Dispose();
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Throws from its render when <see cref="ShouldThrow"/> is set.</summary>
|
||||||
|
private sealed class Thrower : ComponentBase
|
||||||
|
{
|
||||||
|
public static volatile bool ShouldThrow;
|
||||||
|
|
||||||
|
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
|
||||||
|
{
|
||||||
|
if (ShouldThrow) { throw new InvalidOperationException("boom"); }
|
||||||
|
builder.AddMarkupContent(0, "page body ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Poke() => StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Host : ComponentBase
|
||||||
|
{
|
||||||
|
internal Thrower? Child;
|
||||||
|
|
||||||
|
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
|
||||||
|
{
|
||||||
|
builder.OpenComponent<PageErrorBoundary>(0);
|
||||||
|
builder.AddAttribute(1, nameof(PageErrorBoundary.ChildContent), (RenderFragment)(b =>
|
||||||
|
{
|
||||||
|
b.OpenComponent<Thrower>(0);
|
||||||
|
b.AddComponentReferenceCapture(1, o => Child = (Thrower)o);
|
||||||
|
b.CloseComponent();
|
||||||
|
}));
|
||||||
|
builder.CloseComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeNav : NavigationManager
|
||||||
|
{
|
||||||
|
public FakeNav() => Initialize("http://localhost/", "http://localhost/");
|
||||||
|
|
||||||
|
protected override void NavigateToCore(string uri, NavigationOptions options)
|
||||||
|
{
|
||||||
|
Uri = uri;
|
||||||
|
NotifyLocationChanged(isInterceptedLink: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int HandlerCount(NavigationManager nav)
|
||||||
|
{
|
||||||
|
var field = typeof(NavigationManager).GetField("_locationChanged", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
field.ShouldNotBeNull("NavigationManager's LocationChanged backing field was renamed; update this probe");
|
||||||
|
return (field.GetValue(nav) as Delegate)?.GetInvocationList().Length ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullBoundaryLogger : IErrorBoundaryLogger
|
||||||
|
{
|
||||||
|
public ValueTask LogErrorAsync(Exception exception) => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestRenderer(IServiceProvider sp) : Renderer(sp, NullLoggerFactory.Instance)
|
||||||
|
{
|
||||||
|
private Host? _host;
|
||||||
|
private readonly List<string> _text = new();
|
||||||
|
|
||||||
|
public List<Exception> Unhandled { get; } = new();
|
||||||
|
|
||||||
|
public ulong LastOnClickHandlerId { get; private set; }
|
||||||
|
|
||||||
|
public string Text => string.Concat(_text);
|
||||||
|
|
||||||
|
public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
|
||||||
|
|
||||||
|
public async Task RenderRootAsync()
|
||||||
|
{
|
||||||
|
await Dispatcher.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_host = (Host)InstantiateComponent(typeof(Host));
|
||||||
|
var id = AssignRootComponentId(_host);
|
||||||
|
await RenderRootComponentAsync(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RerenderThrower()
|
||||||
|
{
|
||||||
|
_host!.Child!.Poke();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void HandleException(Exception exception) => Unhandled.Add(exception);
|
||||||
|
|
||||||
|
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
|
||||||
|
{
|
||||||
|
// Each batch replaces the visible text when it carries any: the swap between ChildContent and
|
||||||
|
// ErrorContent is a whole-subtree change, so its frames are all in that batch.
|
||||||
|
var batchText = new List<string>();
|
||||||
|
var frames = renderBatch.ReferenceFrames;
|
||||||
|
for (var i = 0; i < frames.Count; i++)
|
||||||
|
{
|
||||||
|
ref readonly var f = ref frames.Array[i];
|
||||||
|
switch (f.FrameType)
|
||||||
|
{
|
||||||
|
case RenderTreeFrameType.Text:
|
||||||
|
batchText.Add(f.TextContent);
|
||||||
|
break;
|
||||||
|
case RenderTreeFrameType.Markup:
|
||||||
|
batchText.Add(f.MarkupContent);
|
||||||
|
break;
|
||||||
|
case RenderTreeFrameType.Attribute when f.AttributeEventHandlerId != 0:
|
||||||
|
LastOnClickHandlerId = f.AttributeEventHandlerId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batchText.Count > 0) { _text.Clear(); _text.AddRange(batchText); }
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user