feat(ui): admin manual-failover control on the health page
CentralFailoverControl lives in Components/Health/ (matching AuditKpiTiles / SiteCallKpiTiles) rather than inline in Health.razor, so it is testable without standing up the whole dashboard's DI graph. - Admin-gated via AuthorizeView + RequireAdmin. The Health page itself is intentionally all-roles, so the gate belongs on the control, not the page. - Disabled with an explanatory title when the pair has no online standby; the authoritative guard remains server-side against live cluster membership. - Confirmation dialog (IDialogService, the page idiom) warns that singletons hand over, in-flight work on the active node is interrupted, and THIS PAGE will disconnect and reconnect against the new active node -- Traefik routes the UI to the node being restarted, so a working failover otherwise reads as a crash the admin caused. - A refused failover (service returns null) surfaces the refusal; the UI never reports a failover that did not happen. 7 bUnit tests. Two harness requirements that bit first: AuthorizeView needs a cascading AuthenticationState (the app supplies it from the layout), and BunitContext pre-registers a placeholder IAuthorizationService that throws on policy evaluation -- both handled the same way NavMenuTests documents. Runbook paragraphs added to Component-ClusterInfrastructure.md (new Manual Failover section) and docker/README.md.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
@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
|
||||
? "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; }
|
||||
|
||||
[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()
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user