c8e2f4da02
Central and each site are SEPARATE Akka clusters, so central cannot act on a
site's membership -- it asks. New TriggerSiteFailover/SiteFailoverAck contract
travels the existing ClusterClient command/control channel (mirroring the
RetryParkedOperation relay); the site's own SiteCommunicationActor performs the
graceful Leave and acks the outcome.
- ClusterFailoverCoordinator moved out of Host into Communication/ClusterState,
beside ActiveNodeEvaluator. Both paths now share ONE oldest-Up implementation;
SiteCommunicationActor cannot reference Host, and the two definitions must not
drift or the node asked to leave stops being the singleton host.
- Site scope is the SITE-SPECIFIC role (site-{SiteId}), not the base Site role --
site singletons are placed on the former, so the base role would move the wrong
node. Pinned by a unit test asserting the role string and by a real-cluster test.
- Site-side guards: refuses a command addressed to another site (a misroute must
never fail over a site the operator did not select), refuses when there is no
peer, and reports a fault as an ack rather than throwing into supervision --
a restart there would drop central's Ask into a bare timeout and lose the reason.
- Ack is sent before the Leave takes effect so it still reaches central.
- UI: the same control now serves both scopes via a SiteId parameter. The site
confirmation deliberately does NOT claim the admin's page will disconnect --
it won't, and crying wolf there devalues the central warning that is real. A
site refusal and an unreachable site surface distinctly.
- Rolling upgrade: a site on an older binary has no handler, so the message
dead-letters and the Ask times out, reported as "site did not respond". That
is the honest outcome; documented on the contract.
Fallout fixed: HealthPageTests now renders the page inside
CascadingAuthenticationState with the real policy set and IAuthorizationService,
because the cards embed an AuthorizeView. That mirrors production, where the
layout supplies the cascading value.
150 lines
5.9 KiB
Plaintext
150 lines
5.9 KiB
Plaintext
@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. *@
|
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
|
<span class="d-inline-flex align-items-center gap-2">
|
|
<button class="btn btn-outline-warning btn-sm"
|
|
disabled="@(!HasPeer || _busy)"
|
|
title="@(HasPeer
|
|
? (IsSite
|
|
? $"Gracefully restart site {SiteId}'s active node; its standby takes over."
|
|
: "Gracefully restart the active node; the standby takes over.")
|
|
: "No standby available — failing over a lone node would be an outage, not a failover.")"
|
|
@onclick="TriggerAsync">
|
|
@(_busy ? "Failing over…" : "Trigger failover")
|
|
</button>
|
|
@if (_message is not null)
|
|
{
|
|
<small class="@(_failed ? "text-danger" : "text-muted")" role="status">@_message</small>
|
|
}
|
|
</span>
|
|
</AuthorizeView>
|
|
|
|
@code {
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Parameter]
|
|
public int OnlineCentralNodeCount { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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. <c>null</c> (the default) means the central pair.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string? SiteId { get; set; }
|
|
|
|
private bool IsSite => !string.IsNullOrWhiteSpace(SiteId);
|
|
|
|
[CascadingParameter]
|
|
private Task<AuthenticationState>? 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Authenticated user name recorded on the audit row.</summary>
|
|
private async Task<string> ResolveActorAsync()
|
|
{
|
|
if (AuthState is null)
|
|
{
|
|
return "unknown";
|
|
}
|
|
|
|
var state = await AuthState;
|
|
return state.User.Identity?.Name
|
|
?? state.User.FindFirst(JwtTokenService.UsernameClaimType)?.Value
|
|
?? "unknown";
|
|
}
|
|
}
|