fix(adminui,client): error boundary inside the interactive scope, stale-commit OnSaved, AbCip browse delay emit, DisposeBridge cancel (review R3)

M1: PageErrorBoundary wraps the body of every InteractiveServer page; the inert layout boundary is removed.
m1: DisposeBridge cancels its CTS when the 5 s wait times out.
m2: a CSV import that commits after the modal closed still raises OnSaved.
m3: document SecurityMode=None no-fallback in Client.CLI.md / Client.UI.md.
m4: browsePageDelayMs written only when loaded or changed; negative flagged.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:31:19 -04:00
parent ca0f1e7735
commit 1475991fd6
39 changed files with 478 additions and 48 deletions
@@ -11,6 +11,7 @@
@inject IRequiredActor<HistorianAdapterActorKey> HistorianActor
@implements IDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alarms historian sink</h4>
</div>
@@ -43,6 +44,7 @@ else
</div>
</section>
}
</PageErrorBoundary>
@code {
private const int PollSeconds = 5;
@@ -14,6 +14,7 @@
@inject IAdminOperationsClient AdminOps
@implements IAsyncDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alerts</h4>
<div class="d-flex align-items-center gap-2">
@@ -131,6 +132,7 @@ else
</div>
</section>
}
</PageErrorBoundary>
@code {
private const int Capacity = 200;
@@ -6,6 +6,7 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IApiKeyAdminService Keys
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">OPC UA API keys</h4>
</div>
@@ -117,6 +118,7 @@
<RawConfirmDialog @bind-Visible="_confirmDeleteVisible" Title="Delete API key"
Message="@_confirmDeleteMessage" OnConfirm="ConfirmDeleteAsync" />
</PageErrorBoundary>
@code {
private IReadOnlyList<ApiKey>? _keys;
@@ -11,6 +11,7 @@
@inject AuthenticationStateProvider AuthState
@implements IDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">OPC UA certificates</h4>
</div>
@@ -108,6 +109,7 @@ else
</section>
}
}
</PageErrorBoundary>
@code {
private List<StoreView>? _rows;
@@ -14,6 +14,7 @@
@inject IAclScopeService Scopes
@inject NavigationManager Nav
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New ACL grant" : "Edit ACL grant") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary btn-sm">Cancel</a>
@@ -154,6 +155,7 @@ else
<AclScopePickerModal Visible="_pickerVisible" ClusterId="@ClusterId"
OnPicked="ScopePicked" OnCancel="() => _pickerVisible = false" />
}
</PageErrorBoundary>
@code {
private static readonly NodePermissions[] PermissionBits =
@@ -8,6 +8,7 @@
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAclScopeService Scopes
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">ACLs &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/acls/new" class="btn btn-primary btn-sm">New ACL grant</a>
@@ -95,6 +96,7 @@ else
}
</section>
}
</PageErrorBoundary>
@code {
[Parameter] public string ClusterId { get; set; } = "";
@@ -7,6 +7,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Queries
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Audit log &middot; <span class="mono">@ClusterId</span></h4>
</div>
@@ -64,6 +65,7 @@ else
}
</section>
}
</PageErrorBoundary>
@code {
private const int PageSize = 200;
@@ -13,6 +13,7 @@
@inject NavigationManager Nav
@inject AuthenticationStateProvider AuthState
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Edit cluster &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
@@ -104,6 +105,7 @@ else
</div>
</EditForm>
}
</PageErrorBoundary>
@code {
[Parameter] public string ClusterId { get; set; } = "";
@@ -2,6 +2,7 @@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Namespaces &middot; <span class="mono">@ClusterId</span></h4>
</div>
@@ -16,6 +17,7 @@
<strong>/uns</strong> pages. This page is a placeholder so existing links do not 404.
</div>
</section>
</PageErrorBoundary>
@code {
[Parameter] public string ClusterId { get; set; } = "";
@@ -7,6 +7,7 @@
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<PageErrorBoundary>
@if (!_loaded)
{
<p>Loading…</p>
@@ -110,6 +111,7 @@ else
}
</section>
}
</PageErrorBoundary>
@code {
[Parameter] public string ClusterId { get; set; } = "";
@@ -12,6 +12,7 @@
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
<PageErrorBoundary>
@if (!_loaded)
{
<p>Loading…</p>
@@ -140,6 +141,7 @@ else
}
</section>
}
</PageErrorBoundary>
@code {
[Parameter] public string ClusterId { get; set; } = "";
@@ -7,6 +7,7 @@
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Clusters</h4>
<a href="/clusters/new" class="btn btn-primary btn-sm">New cluster</a>
@@ -61,6 +62,7 @@ else
</div>
</section>
}
</PageErrorBoundary>
@code {
private List<ServerCluster>? _rows;
@@ -3,6 +3,7 @@
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Namespaces &middot; <span class="mono">@ClusterId</span></h4>
<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.
</div>
</section>
</PageErrorBoundary>
@code {
[Parameter] public string ClusterId { get; set; } = "";
@@ -10,6 +10,7 @@
@inject NavigationManager Nav
@inject AuthenticationStateProvider AuthState
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New cluster</h4>
<a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
@@ -79,6 +80,7 @@
</div>
</div>
</EditForm>
</PageErrorBoundary>
@code {
private FormModel _form = new();
@@ -15,6 +15,7 @@
@inject NavigationManager Nav
@inject AuthenticationStateProvider AuthState
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New node" : "Edit node") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
@@ -122,6 +123,7 @@ else
</div>
</EditForm>
}
</PageErrorBoundary>
@code {
[Parameter] public string ClusterId { get; set; } = "";
@@ -15,6 +15,7 @@
@inject ZB.MOM.WW.OtOpcUa.AdminUI.Deployments.DeploymentDriftProbe DriftProbe
@rendermode InteractiveServer
<PageErrorBoundary>
<PageTitle>Deployments</PageTitle>
<h1>Deployments</h1>
@@ -64,6 +65,7 @@
}
</tbody>
</table>
</PageErrorBoundary>
@code {
// Blob-free projection: the Deployment entity carries a varbinary(max) ArtifactBlob (a whole-fleet
@@ -3,6 +3,7 @@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode InteractiveServer
<PageErrorBoundary>
<PageTitle>ContextMenu demo</PageTitle>
<h1>ContextMenu demo</h1>
@@ -19,6 +20,7 @@
</div>
<p>Last action: <strong>@_lastAction</strong></p>
</PageErrorBoundary>
@code {
private string _lastAction = "(none)";
@@ -15,6 +15,7 @@
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@implements IAsyncDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Fleet status</h4>
</div>
@@ -98,6 +99,7 @@ else
</div>
</section>
}
</PageErrorBoundary>
@code {
private const int RefreshIntervalSeconds = 10;
@@ -21,6 +21,7 @@
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
@implements IAsyncDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Cluster hosts</h4>
</div>
@@ -311,6 +312,7 @@ else
</div>
}
}
</PageErrorBoundary>
@code {
private const int RefreshIntervalSeconds = 5;
@@ -15,6 +15,7 @@
each container via IRawTreeService.LoadChildrenAsync and surfaces per-node
context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@
<PageErrorBoundary>
<PageTitle>Raw</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -48,6 +49,7 @@
</div>
}
</section>
</PageErrorBoundary>
@code {
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
@@ -6,6 +6,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">External ID reservations</h4>
</div>
@@ -50,6 +51,7 @@ else
}
</section>
}
</PageErrorBoundary>
@code {
private List<ExternalIdReservation>? _rows;
@@ -9,6 +9,7 @@
@inject IOptionsSnapshot<LdapOptions> Ldap
@inject ILdapGroupRoleMappingService RoleMappings
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Role grants</h4>
</div>
@@ -103,6 +104,7 @@
}
</section>
}
</PageErrorBoundary>
@code {
private LdapOptions? _options;
@@ -14,6 +14,7 @@
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New script" : "Edit script")</h4>
<a href="/scripts" class="btn btn-outline-secondary btn-sm">Cancel</a>
@@ -72,6 +73,7 @@ else
</div>
</EditForm>
}
</PageErrorBoundary>
@code {
[Parameter] public string? ScriptId { get; set; }
@@ -8,6 +8,7 @@
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
@implements IDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Script log</h4>
<div class="d-flex align-items-center gap-2">
@@ -76,6 +77,7 @@ else
</div>
</section>
}
</PageErrorBoundary>
@code {
private const int Capacity = 500;
@@ -6,6 +6,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Scripts</h4>
<a href="/scripts/new" class="btn btn-primary btn-sm">New script</a>
@@ -104,6 +105,7 @@ else
</div>
</div>
}
</PageErrorBoundary>
@code {
private List<Script>? _rows;
@@ -17,4 +17,6 @@
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. *@
<PageErrorBoundary>
<SecretsPage />
</PageErrorBoundary>
@@ -14,6 +14,7 @@
@inject IUnsTreeService Svc
@inject NavigationManager Nav
<PageErrorBoundary>
<PageTitle>Equipment</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -311,6 +312,7 @@ else
OnSaved="OnAlarmSavedAsync" OnCancel="@(() => { _alarmModalVisible = false; })" />
}
}
</PageErrorBoundary>
@code {
/// <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 NavigationManager Nav
<PageErrorBoundary>
<PageTitle>UNS</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -104,6 +105,7 @@
</div>
</div>
}
</PageErrorBoundary>
@code {
private IReadOnlyList<UnsNode> _roots = Array.Empty<UnsNode>();