@page "/clusters/{ClusterId}/redundancy" @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Authorization @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.AdminUI.Redundancy @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy @inject IDbContextFactory DbFactory @inject IManualFailoverService FailoverService @inject AuthenticationStateProvider AuthState @inject IAuthorizationService AuthorizationService @if (!_loaded) {

Loading…

} else if (_cluster is null) {
Cluster @ClusterId was not found. Back to list.
} else {

@_cluster.Name · Redundancy

@_cluster.ClusterId
v2 redundancy is computed at runtime by RedundancyStateActor on each admin node. The values below are the static configuration; the resolved live ServiceLevel for each peer is broadcast on the redundancy-state DPS topic and consumed by the OPC UA host's ServerStatus publisher. See docs/v2/Architecture-v2.md.
Cluster redundancy
Mode@_cluster.RedundancyMode
Node count@_cluster.NodeCount
Live redundancy
Driver Primary @(_failover.Snapshot?.PrimaryAddress ?? "—")
Up driver members @(_failover.Snapshot is { DriverAddresses.Count: > 0 } s ? string.Join(", ", s.DriverAddresses) : "—")

Read live from cluster state on this node. Mesh-wide scope: the Primary is elected once per Akka mesh, not per cluster row — in the current single-mesh topology this acts on the whole mesh's Primary, which may be a node of another Cluster. See docs/Redundancy.md.

@if (_failover.DisabledReason is { } reason) { @reason }
@if (_failover.ConfirmOpen) {
Fail over the driver Primary?
  • @(_failover.Snapshot?.PrimaryAddress) leaves the cluster and its process restarts.
  • Its peer becomes Primary and advertises ServiceLevel 250.
  • Connected OPC UA clients re-select the new Primary.
}
@if (_failover.StatusMessage is { } msg) {
@msg
}
Node service-level configuration
@if (_nodes is null || _nodes.Count == 0) {
No nodes registered.
} else {
@foreach (var n in _nodes) { }
Node ID ApplicationUri ServiceLevel base Notes
@n.NodeId @n.ApplicationUri @n.ServiceLevelBase @if (n.ServiceLevelBase >= 200) { Primary preference } else if (n.ServiceLevelBase >= 100) { Secondary preference } else { Custom }
}
} @code { [Parameter] public string ClusterId { get; set; } = ""; private bool _loaded; private ServerCluster? _cluster; private List? _nodes; // Everything consequential about the failover control (peer guard, confirm flow, outcome text) // lives in this pure model rather than in the markup — the repo has no bUnit, so logic left in a // .razor is verified only by driving the page. Covered by ManualFailoverPageModelTests. private ManualFailoverPageModel _failover = default!; protected override async Task OnInitializedAsync() { _failover = new ManualFailoverPageModel(FailoverService); _failover.Refresh(); await using var db = await DbFactory.CreateDbContextAsync(); _cluster = await db.ServerClusters.AsNoTracking() .FirstOrDefaultAsync(c => c.ClusterId == ClusterId); if (_cluster is not null) { _nodes = await db.ClusterNodes.AsNoTracking() .Where(n => n.ClusterId == ClusterId) .OrderBy(n => n.NodeId) .ToListAsync(); } _loaded = true; } /// /// Defense-in-depth: the button is FleetAdmin-gated in markup, but this handler runs on the /// server circuit — re-check the policy before bouncing a production node (the same pattern the /// certificate-store actions use). /// private async Task ConfirmFailoverAsync() { var authState = await AuthState.GetAuthenticationStateAsync(); if (!(await AuthorizationService.AuthorizeAsync( authState.User, null, ManualFailoverPageModel.RequiredPolicy)).Succeeded) { _failover.CancelFailover(); return; } await _failover.ConfirmFailoverAsync(authState.User.Identity?.Name ?? "system"); } }