@using Microsoft.AspNetCore.Components.Authorization @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Security @inject IManualFailoverService Failover @inject IDialogService Dialog @* Admin-only manual failover of the central pair (decision 2026-07-22). Rendered on the Health dashboard's central-cluster card. The page itself is all-roles, so the gate lives here rather than on the page's [Authorize] attribute. *@ @if (_message is not null) { @_message } @code { /// /// Number of central nodes currently reporting online. Fewer than two means there is no /// standby to take over, so the control is disabled — the guard is enforced again /// server-side in the failover service, which is the authoritative check. /// [Parameter] public int OnlineCentralNodeCount { get; set; } /// /// When set, this control fails over the named SITE pair instead of the central pair. /// Sites are separate Akka clusters reached over the command/control relay, so the site /// path cannot disturb the admin's own session — which is why the confirmation text /// differs. null (the default) means the central pair. /// [Parameter] public string? SiteId { get; set; } private bool IsSite => !string.IsNullOrWhiteSpace(SiteId); [CascadingParameter] private Task? AuthState { get; set; } private bool HasPeer => OnlineCentralNodeCount >= 2; private bool _busy; private bool _failed; private string? _message; private async Task TriggerAsync() { // Central: the admin is almost certainly connected THROUGH the node about to restart // (Traefik routes to the active node), so the dialog must set that expectation or a // working failover reads as a crash they caused. // Site: a different cluster entirely — this page is unaffected, and claiming otherwise // would train operators to ignore the warning that does matter. var confirmed = IsSite ? await Dialog.ConfirmAsync( $"Trigger failover for site {SiteId}?", $"The active node of site {SiteId} will leave its cluster and restart; the site's " + "standby takes over and its Deployment Manager singleton hands over gracefully. " + "In-flight work on that site node is interrupted, and the site is briefly " + "unavailable while the handover completes.", danger: true) : await Dialog.ConfirmAsync( "Trigger central failover?", "The active central node will leave the cluster and restart; the standby takes over " + "and becomes active. Cluster singletons hand over gracefully, but in-flight work on " + "the active node is interrupted. This page is served by the active node, so it will " + "briefly disconnect and reconnect against the new active node.", danger: true); if (!confirmed) { return; } _busy = true; _failed = false; _message = null; try { var actor = await ResolveActorAsync(); if (IsSite) { var outcome = await Failover.FailOverSiteAsync(SiteId!, actor); if (outcome.Accepted) { _message = $"Failover triggered — {outcome.TargetAddress} is leaving the site cluster."; } else { // Carry the site's own words through: a peer-guard refusal and an // unreachable site are different situations for the operator. _failed = true; _message = outcome.ErrorMessage ?? "Refused by the site."; } } else { var target = await Failover.FailOverCentralAsync(actor); if (target is null) { // The server-side peer guard refused. Never report a failover that did not happen. _failed = true; _message = "Refused: no standby available to take over."; } else { _message = $"Failover triggered — {target} is leaving the cluster."; } } } catch (Exception ex) { _failed = true; _message = $"Failover failed: {ex.Message}"; } finally { _busy = false; } } /// Authenticated user name recorded on the audit row. private async Task ResolveActorAsync() { if (AuthState is null) { return "unknown"; } var state = await AuthState; return state.User.Identity?.Name ?? state.User.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown"; } }