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:
@@ -1,5 +1,4 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@using System.Security.Claims
|
||||
|
||||
@* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns
|
||||
@@ -71,33 +70,9 @@
|
||||
</AuthorizeView>
|
||||
</RailFooter>
|
||||
<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>
|
||||
@* No ErrorBoundary here: this layout renders statically (Routes is SSR), so it is not an ancestor
|
||||
of any @rendermode InteractiveServer page and a boundary here would never see their failures.
|
||||
Interactive pages wrap their body in <PageErrorBoundary> instead. *@
|
||||
@Body
|
||||
</ChildContent>
|
||||
</ThemeShell>
|
||||
|
||||
@code {
|
||||
private ErrorBoundary? _boundary;
|
||||
|
||||
[Inject] private NavigationManager Nav { get; set; } = default!;
|
||||
|
||||
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||
|
||||
// A fresh navigation clears a tripped boundary so the next page isn't stuck behind the old error.
|
||||
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
|
||||
=> _boundary?.Recover();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
|
||||
@@ -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") · <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 · <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 · <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 · <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 · <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 · <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") · <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>();
|
||||
|
||||
+36
-6
@@ -49,7 +49,11 @@
|
||||
<div class="col-md-3">
|
||||
<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" />
|
||||
<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>
|
||||
@@ -181,14 +185,29 @@
|
||||
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
|
||||
/// from the device rows at deploy/probe time).</summary>
|
||||
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>
|
||||
internal static string Serialize(AbCipDriverOptions options, string? originalJson = null)
|
||||
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
|
||||
/// <param name="options">The form's options.</param>
|
||||
/// <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()
|
||||
{
|
||||
// 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();
|
||||
_lastParsed = json;
|
||||
_originalJson = json;
|
||||
@@ -221,7 +240,16 @@
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||
// 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
|
||||
public bool EnableAlarmProjection { get; set; } = false;
|
||||
@@ -248,7 +276,9 @@
|
||||
f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll;
|
||||
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
|
||||
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.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval);
|
||||
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();
|
||||
|
||||
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)
|
||||
{
|
||||
_commitErrors = outcome.Errors.ToList();
|
||||
|
||||
Reference in New Issue
Block a user