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:
Joseph Doherty
2026-07-22 07:00:10 -04:00
parent f679d5c749
commit caf14a3e03
5 changed files with 309 additions and 5 deletions
@@ -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";
}
}
@@ -218,11 +218,19 @@
<small class="text-muted ms-2">offline since @changedAt.ToString("u")</small>
}
</div>
<small class="text-muted">
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
| Seq: @state.LastSequenceNumber
</small>
<div class="d-flex align-items-center gap-3">
@if (isCentral)
{
@* Admin-only; the control gates itself, and disables when the
central pair has no online standby to take over. *@
<CentralFailoverControl OnlineCentralNodeCount="@OnlineNodeCount(state)" />
}
<small class="text-muted">
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
| Seq: @state.LastSequenceNumber
</small>
</div>
</div>
<div class="card-body p-3">
@if (state.LatestReport != null)
@@ -442,6 +450,15 @@
private string StaleTimeoutDisplay =>
FormatDuration(HealthOptions.Value.MetricsStaleTimeout);
// Online central nodes, from the same ClusterNodes list the Nodes column renders.
// Fewer than two means no standby, which disables the manual-failover control. This is
// a display-side guard only — AkkaManualFailoverService re-checks against live cluster
// membership, which is the authoritative answer.
private static int OnlineNodeCount(SiteHealthState state) =>
state.LatestReport?.ClusterNodes is { Count: > 0 } nodes
? nodes.Count(n => n.IsOnline)
: (state.IsOnline ? 1 : 0);
private static string FormatDuration(TimeSpan span) =>
span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes))
? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}"