+ @if (isCentral)
+ {
+ @* Admin-only; the control gates itself, and disables when the
+ central pair has no online standby to take over. *@
+
+ }
+
+ Last report:
+ | Last heartbeat:
+ | Seq: @state.LastSequenceNumber
+
+
@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")}"
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs
new file mode 100644
index 00000000..2544e78c
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs
@@ -0,0 +1,156 @@
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+using ZB.MOM.WW.ScadaBridge.Security;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
+
+///
+/// bUnit tests for the admin "Trigger failover" control on the Health dashboard
+/// (decision 2026-07-22). The control is destructive and privileged, so the three
+/// things that must hold are: only an Administrator sees it, it is refused when
+/// there is no standby to take over, and it never fires without an explicit
+/// confirmation.
+///
+public class HealthFailoverButtonTests : BunitContext
+{
+ private readonly IManualFailoverService _failover = Substitute.For
();
+ private readonly IDialogService _dialog = Substitute.For();
+
+ private void Arrange(string role)
+ {
+ var claims = new[]
+ {
+ new Claim(JwtTokenService.UsernameClaimType, "tester"),
+ new Claim(JwtTokenService.RoleClaimType, role)
+ };
+ var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ Services.AddSingleton(new TestAuthStateProvider(user));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
+ // BunitContext pre-registers a placeholder IAuthorizationService that throws when
+ // AuthorizeView evaluates a policy. Force the real service so the admin gate is
+ // genuinely exercised rather than trivially satisfied (same note as NavMenuTests).
+ Services.AddSingleton();
+ Services.AddSingleton(_failover);
+ Services.AddSingleton(_dialog);
+ }
+
+ ///
+ /// AuthorizeView requires a cascading Task<AuthenticationState>, which the app
+ /// supplies from its layout; a bare component render has to provide it explicitly.
+ ///
+ private IRenderedComponent Render(int onlineNodes)
+ {
+ var host = Render(parameters => parameters
+ .Add(p => p.ChildContent, (RenderFragment)(builder =>
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes);
+ builder.CloseComponent();
+ })));
+
+ return host.FindComponent();
+ }
+
+ [Fact]
+ public void Button_is_hidden_for_a_non_admin()
+ {
+ Arrange("Viewer");
+
+ var cut = Render(onlineNodes: 2);
+
+ Assert.Empty(cut.FindAll("button"));
+ }
+
+ [Fact]
+ public void Button_is_rendered_and_enabled_for_an_admin_with_a_standby()
+ {
+ Arrange("Administrator");
+
+ var cut = Render(onlineNodes: 2);
+
+ var button = cut.Find("button");
+ Assert.False(button.HasAttribute("disabled"));
+ Assert.Contains("failover", button.TextContent, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Button_is_disabled_with_an_explanatory_title_when_there_is_no_standby()
+ {
+ Arrange("Administrator");
+
+ var cut = Render(onlineNodes: 1);
+
+ var button = cut.Find("button");
+ Assert.True(button.HasAttribute("disabled"));
+ // The tooltip must say WHY, not just that it is unavailable.
+ Assert.Contains("outage", button.GetAttribute("title"), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Confirming_triggers_the_failover_exactly_once()
+ {
+ Arrange("Administrator");
+ _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
+ _failover.FailOverCentralAsync(Arg.Any())
+ .Returns(Task.FromResult("akka.tcp://scadabridge@central-a:8081"));
+
+ var cut = Render(onlineNodes: 2);
+ await cut.Find("button").ClickAsync(new());
+
+ await _failover.Received(1).FailOverCentralAsync("tester");
+ }
+
+ [Fact]
+ public async Task Cancelling_the_confirmation_does_not_trigger_a_failover()
+ {
+ Arrange("Administrator");
+ _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false);
+
+ var cut = Render(onlineNodes: 2);
+ await cut.Find("button").ClickAsync(new());
+
+ await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task Confirmation_warns_that_this_page_will_disconnect()
+ {
+ // Traefik routes the UI to the ACTIVE node — the very node being failed over — so
+ // the admin's own circuit drops. If the dialog does not say so, a working failover
+ // looks like a crash they caused.
+ Arrange("Administrator");
+ _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
+
+ var cut = Render(onlineNodes: 2);
+ await cut.Find("button").ClickAsync(new());
+
+ await _dialog.Received(1).ConfirmAsync(
+ Arg.Any(),
+ Arg.Is(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task A_refused_failover_surfaces_the_refusal_instead_of_claiming_success()
+ {
+ // The service returns null when the peer guard refuses. The UI must not report a
+ // failover that never happened.
+ Arrange("Administrator");
+ _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
+ _failover.FailOverCentralAsync(Arg.Any()).Returns(Task.FromResult(null));
+
+ var cut = Render(onlineNodes: 2);
+ await cut.Find("button").ClickAsync(new());
+
+ Assert.Contains("no standby", cut.Markup, StringComparison.OrdinalIgnoreCase);
+ }
+}