@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";
}
}