using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Redundancy; /// /// Presentation state for the Trigger-failover control on the cluster redundancy page. /// /// /// /// A pure model behind a thin razor shell — the same split the driver-typed tag editors use, /// and for the same reason: this repo has no bUnit (see /// PageAuthorizationGuardTests), so anything left inside the .razor is verified /// only by driving the page live. The peer guard, the confirm flow, and the /// refused-vs-succeeded outcome are consequential enough to be unit-testable, so they live /// here. /// /// /// What is deliberately NOT here: the markup authorization gate. It is expressed in the razor /// as <AuthorizeView Policy="@ManualFailoverPageModel.RequiredPolicy"> and /// re-checked server-side before the call, so is the single /// source of truth for both. /// /// public sealed class ManualFailoverPageModel { /// /// The policy gating the control, in markup and in the server-side re-check. /// /// /// (Administrator only) rather than the /// the neighbouring cluster-authoring pages use: /// this does not edit configuration, it restarts a production node. ConfigEditor also admits the /// Designer role, which must not be able to bounce the Primary. /// public const string RequiredPolicy = AdminUiPolicies.FleetAdmin; private readonly IManualFailoverService _service; /// Creates the model over the failover service. /// The manual-failover seam; faked in tests. public ManualFailoverPageModel(IManualFailoverService service) => _service = service ?? throw new ArgumentNullException(nameof(service)); /// The last read cluster snapshot, or if it could not be read. public ManualFailoverSnapshot? Snapshot { get; private set; } /// Whether the confirmation dialog is open. public bool ConfirmOpen { get; private set; } /// Status text from the last completed action, if any. public string? StatusMessage { get; private set; } /// Whether reports a failure. public bool StatusIsError { get; private set; } /// Whether the button is enabled — a peer must exist to take over. public bool CanFailOver => Snapshot?.CanFailOver == true; /// /// Why the button is disabled, or when it is enabled. Rendered as the /// button's tooltip so a disabled control explains itself. /// public string? DisabledReason => Snapshot is null ? "Cluster state is unavailable on this node." : Snapshot.CanFailOver ? null : $"Failover needs a driver peer to take over; this mesh has {Snapshot.DriverAddresses.Count} Up driver member(s)."; /// Re-reads live cluster state. Never throws — an unreadable cluster disables the button. public void Refresh() { try { Snapshot = _service.GetSnapshot(); } catch (Exception ex) { // A node whose ActorSystem is not yet up (or not a cluster provider) must render the page, // not 500. The button stays disabled with DisabledReason explaining why. Snapshot = null; StatusIsError = true; StatusMessage = $"Could not read cluster state: {ex.Message}"; } } /// Opens the confirmation dialog. No-op when the peer guard is closed. public void RequestFailover() { if (!CanFailOver) return; StatusMessage = null; StatusIsError = false; ConfirmOpen = true; } /// Closes the confirmation dialog without acting. public void CancelFailover() => ConfirmOpen = false; /// /// Performs the failover the dialog is confirming. /// /// The authenticated user name, recorded in the audit event. public async Task ConfirmFailoverAsync(string actor) { if (!ConfirmOpen) return; ConfirmOpen = false; try { var target = await _service.FailOverDriverPrimaryAsync(actor).ConfigureAwait(false); if (target is null) { // The service re-evaluates the peer guard against live state, which may have changed // since Refresh(). A refusal is not an error — but it must not read as a success. StatusIsError = true; StatusMessage = "Failover refused: no driver peer is available to take over."; } else { StatusIsError = false; StatusMessage = $"Failover requested. {target} is leaving the cluster; its peer takes over as Primary " + "and the node restarts as the youngest member."; } } catch (Exception ex) { StatusIsError = true; StatusMessage = $"Failover failed: {ex.Message}"; } Refresh(); } }