@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; } [CascadingParameter] private Task? AuthState { get; set; } private bool HasPeer => OnlineCentralNodeCount >= 2; private bool _busy; private bool _failed; private string? _message; private async Task TriggerAsync() { // The admin is almost certainly connected THROUGH the node about to restart (Traefik // routes to the active node), so the dialog has to set that expectation or a working // failover reads as a crash they just caused. var confirmed = 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(); 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"; } }