152 lines
6.0 KiB
Plaintext
152 lines
6.0 KiB
Plaintext
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
|
|
@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 AuthorizedManualFailoverService 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,
|
|
confirmLabel: "Fail over")
|
|
: 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,
|
|
confirmLabel: "Fail over");
|
|
|
|
if (!confirmed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_busy = true;
|
|
_failed = false;
|
|
_message = null;
|
|
|
|
try
|
|
{
|
|
|
|
if (IsSite)
|
|
{
|
|
var outcome = await Failover.FailOverSiteAsync(SiteId!);
|
|
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();
|
|
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";
|
|
}
|
|
}
|