From c8e2f4da0243f23ff21c8b245406e44e8dc1e60b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 07:48:56 -0400 Subject: [PATCH] feat(cluster): site-pair manual failover relayed from the central UI (Task 10) 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. --- .../Health/CentralFailoverControl.razor | 75 ++++++++++++---- .../Components/Pages/Monitoring/Health.razor | 11 ++- .../Services/IManualFailoverService.cs | 24 ++++++ .../RemoteQuery/SiteFailoverRelayMessages.cs | 53 ++++++++++++ .../Actors/SiteCommunicationActor.cs | 76 ++++++++++++++++- .../ClusterFailoverCoordinator.cs | 56 ++++++++++++ .../CommunicationService.cs | 28 ++++++ .../Health/AkkaManualFailoverService.cs | 82 ++++++++++++------ .../Monitoring/HealthFailoverButtonTests.cs | 85 +++++++++++++++++++ .../Pages/HealthPageTests.cs | 56 +++++++++--- .../SiteCommunicationActorTests.cs | 85 +++++++++++++++++++ .../Cluster/SiteFailoverRelayTests.cs | 51 +++++++++++ 12 files changed, 621 insertions(+), 61 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/SiteFailoverRelayMessages.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/ClusterState/ClusterFailoverCoordinator.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SiteFailoverRelayTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor index fbe96dd0..9bb751ff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor @@ -13,7 +13,9 @@