diff --git a/CLAUDE.md b/CLAUDE.md index 7ca4005d..6cc357ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -509,11 +509,13 @@ touching it: - **Deferred, with reasons (do not read "seven observability topics" from the program sketch as the delivered scope):** `redundancy-state` stays on DPS in every mode (pair-local, built from `Cluster.State`, drives ServiceLevel + the Primary gate — genuinely mesh-bound by design, not an - observability panel); `fleet-status` is central-internal (`FleetStatusBroadcaster` builds it from - the admin node's own membership events, `Fleet.razor` polls the Config DB and ignores the feed — - never a node→central stream); `deployment-acks` already rides the Phase 2 gRPC command transport + observability panel); `deployment-acks` already rides the Phase 2 gRPC command transport (`MeshTransport:Mode=Grpc`, `CentralCommandService.ReportApply`) as a command-plane reply, not - telemetry. + telemetry. A third, `fleet-status`, was **DELETED** rather than deferred (2026-08-06, Gitea #542): + `FleetStatusBroadcaster` → DPS → SignalR bridge → `FleetStatusHub` had **zero clients end to end** + (no browser JS, no `HubConnection` — `/fleet` polls `NodeDeploymentState` in the Config DB), its + `DriverHostStatusHeartbeat` input had no production sender, and Phase 6 left its "fleet" as just + central's own pair. Fleet visibility is `/fleet` + the four per-node telemetry dials above. - **The node ALWAYS hosts and ALWAYS double-publishes, in both modes.** Every publish seam emits into a node-local `ITelemetryLocalHub` **and** still publishes DPS, unconditionally; the gRPC server binds whenever `Telemetry:GrpcListenPort > 0`, independent of `Telemetry:Mode`. Only diff --git a/docs/Configuration.md b/docs/Configuration.md index ea007057..1a05b5d4 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -275,11 +275,12 @@ discipline as `MeshTransport:Mode` and `ConfigSource:Mode`. otherwise) — enforced by `TelemetryOptionsValidator`. **`Grpc` mode on either side requires a non-empty `ApiKey`** — fail-closed, same as `ConfigServe`/`LocalDb:Replication`. -**Three DPS observability topics stay out of scope for this transport, on purpose:** +**Two DPS observability topics stay out of scope for this transport, on purpose:** `redundancy-state` (pair-local, built from `Cluster.State`, stays on DPS in both modes — see -[`Redundancy.md`](Redundancy.md#command-transport-centralnode)), `fleet-status` (central-internal; -`Fleet.razor` polls the Config DB, not any stream), and `deployment-acks` (already rides the Phase 2 -gRPC command transport, `CentralCommandService.ReportApply`, as a command-plane reply). See +[`Redundancy.md`](Redundancy.md#command-transport-centralnode)) and `deployment-acks` (already rides +the Phase 2 gRPC command transport, `CentralCommandService.ReportApply`, as a command-plane reply). +A third, `fleet-status`, was **deleted** rather than deferred (2026-08-06, Gitea #542 — it had no +client end to end; `Fleet.razor` polls the Config DB). See [`docs/Telemetry.md`](Telemetry.md) for the full architecture, the reconnect/generation-stamped dialer story, and the rationale for the node-hosts/central-dials inversion. diff --git a/docs/Telemetry.md b/docs/Telemetry.md index c7ce24f9..f873e8e6 100644 --- a/docs/Telemetry.md +++ b/docs/Telemetry.md @@ -78,7 +78,6 @@ What actually changes per mode, precisely: `IDriverStatusSnapshotStore`, `IDriverResilienceStatusStore`). - **The AdminUI panels themselves are untouched.** `/alerts`, `/script-log`, and the `/hosts` driver table all read from the same sinks in both modes — only the sinks' upstream feed swaps. - The `fleet-status` bridge (out of Phase 5 scope — see below) stays on DPS in both modes. Because hosting is unconditional and both ingest paths are always compiled in, a fleet can be flipped node-by-node and central-by-central with no coordination window where telemetry is lost — @@ -130,10 +129,10 @@ Three things to know before touching it: and no reader — the only reference in the repo is its DbSet declaration — while the live data reaches the AdminUI over the `driver-resilience-status` channel above. Keep-or-delete is Gitea **#524**. -## The three deferred channels (NOT migrated in Phase 5 — do not read this as "seven done") +## The three non-migrated channels (NOT migrated in Phase 5 — do not read this as "seven done") The program sketch originally named seven observability topics for Phase 5. Three were scoped out, -each for a distinct, settled reason: +each for a distinct, settled reason — and one of those three has since been deleted outright: - **`redundancy-state`** — bidirectional, built directly from `Cluster.State`, and **pair-local**: it drives ServiceLevel and the Primary data-plane gate, consumed in-process by @@ -143,11 +142,17 @@ each for a distinct, settled reason: in-mesh. Central's *display* of each pair's redundancy state is a Phase 6 cross-mesh concern (a future observability channel, once central no longer shares gossip with any site pair), not a Phase 5 telemetry-panel migration. -- **`fleet-status`** — **central-internal**, not a node→central stream at all. The admin singleton - `FleetStatusBroadcaster` builds it from the admin node's own cluster membership/reachability/ - leader events, and `Fleet.razor` **polls the Config DB** and ignores the feed entirely. There is - nothing here for a per-node gRPC stream to carry. Revisit in Phase 6, once central genuinely - loses gossip visibility of site nodes and needs another way to know a pair's membership. +- **`fleet-status`** — **DELETED, 2026-08-06, Gitea #542.** There is no `fleet-status` channel any + more, on DPS or anywhere else. It was central-internal rather than a node→central stream: the + admin singleton `FleetStatusBroadcaster` built it from the admin node's own cluster membership/ + reachability/leader events and published to a SignalR hub that **no client anywhere ever + connected to** — `Fleet.razor` polls `NodeDeploymentState` in the Config DB and always ignored + the feed. Its second input, `DriverHostStatusHeartbeat`, had no production sender either. Phase 6 + then made the central-only view wrong as well (central's gossip ring is its own pair). The whole + chain — broadcaster, singleton registration, DPS topic, SignalR bridge, `FleetStatusHub`, and the + `FleetStatusChanged`/`FleetNodeStatus` messages — was removed rather than rewritten cross-mesh. + Fleet visibility that does work today: `/fleet` over `NodeDeploymentState`, and the four Phase 5 + telemetry channels above, which are per-node dials and therefore already mesh-independent. - **`deployment-acks`** — already migrated, but onto a different transport: it rides the Phase 2 gRPC command transport (`MeshTransport:Mode=Grpc`, `CentralCommandService.ReportApply`) as a command-plane reply, not an observability broadcast. It was never a Phase 5 candidate. diff --git a/scripts/compliance/phase-6-1-compliance.ps1 b/scripts/compliance/phase-6-1-compliance.ps1 index b2fa0e0b..eea2afcf 100644 --- a/scripts/compliance/phase-6-1-compliance.ps1 +++ b/scripts/compliance/phase-6-1-compliance.ps1 @@ -109,7 +109,8 @@ Write-Host "" Write-Host "Stream E - Admin /hosts (data layer)" Assert-FileExists "DriverInstanceResilienceStatus entity" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstanceResilienceStatus.cs" Assert-FileExists "DriverResilienceStatusTracker present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs" -Assert-Deferred "FleetStatusHub SignalR push + Blazor /hosts column refresh" "Phase 6.1 Stream E.2/E.3 visual-compliance follow-up" +# Gitea #542 (2026-08-06): the FleetStatusHub chain was DELETED (dead end-to-end), so the +# Stream E.2/E.3 "SignalR push + /hosts column refresh" follow-up is retired, not deferred. Write-Host "" Write-Host "Cross-cutting" diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs index 8e72a5db..2c7fe3ec 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs @@ -13,7 +13,7 @@ public enum StartDeploymentOutcome /// /// Reply from the AdminOperationsActor singleton. Accepted means the snapshot /// was sealed and a Deployment row was created; the in-flight deployment can be -/// tracked through fleet-status broadcasts. +/// tracked through the per-node NodeDeploymentState rows the AdminUI's /fleet page reads. /// public sealed record StartDeploymentResult( StartDeploymentOutcome Outcome, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs deleted file mode 100644 index e5095e98..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs +++ /dev/null @@ -1,21 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Commons.Types; - -namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; - -public enum FleetNodeHealth { Healthy, Degraded, Unreachable } - -public sealed record FleetNodeStatus( - NodeId NodeId, - FleetNodeHealth Health, - RevisionHash? CurrentRevision, - DateTime LastSeenUtc); - -/// -/// Periodic fleet-wide status broadcast pushed by FleetStatusBroadcaster to admin UI -/// subscribers via SignalR. -/// -public sealed record FleetStatusChanged( - IReadOnlyList Nodes, - DeploymentId? CurrentDeployment, - DateTime AsOfUtc, - CorrelationId CorrelationId); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs index dfd1a4b1..16c7271f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs @@ -9,8 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; /// /// Akka actor that subscribes to the alerts DistributedPubSub topic and forwards each /// to every SignalR client connected to . -/// Mirrors FleetStatusSignalRBridge's design — one bridge per admin node, hub fan-out is -/// per-node, no cluster-singleton needed. +/// One bridge per admin node — hub fan-out is per-node, so no cluster-singleton is needed. /// public sealed class AlertSignalRBridge : ReceiveActor { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusHub.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusHub.cs deleted file mode 100644 index 07b5f0f3..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusHub.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.SignalR; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; - -namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; - -/// -/// Browser-facing fleet-status push channel. Subscribers receive -/// snapshots whenever the admin-role FleetStatusBroadcaster publishes a diff. -/// -/// Server pushes fleet-status updates to connected clients via FleetStatusSignalRBridge -/// (DistributedPubSub 'fleet-status' → IHubContext<FleetStatusHub>). -/// -[Authorize] -public sealed class FleetStatusHub : Hub -{ - public const string Endpoint = "/hubs/fleet-status"; - public const string MethodName = "fleetStatusChanged"; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusSignalRBridge.cs deleted file mode 100644 index 3b48963c..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusSignalRBridge.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Akka.Actor; -using Akka.Cluster.Tools.PublishSubscribe; -using Akka.Event; -using Microsoft.AspNetCore.SignalR; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; - -namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; - -/// -/// Akka actor that subscribes to the fleet-status DistributedPubSub topic and forwards -/// every snapshot to all SignalR clients connected to -/// . Spawned on admin-role nodes by -/// AddOtOpcUaSignalRBridges. -/// -/// The bridge runs locally on each admin node — every node that hosts the hub also forwards -/// snapshots to its own connected clients. That keeps the hub-to-actor wiring simple (no -/// cluster-singleton coordination needed) at the cost of duplicated DPS subscriptions on -/// multi-admin deployments. Acceptable since the messages are small + the SignalR fan-out -/// is per-node anyway. -/// -public sealed class FleetStatusSignalRBridge : ReceiveActor -{ - public const string TopicName = "fleet-status"; - - private readonly IHubContext _hub; - private readonly ILoggingAdapter _log = Context.GetLogger(); - - /// Creates actor props for a FleetStatusSignalRBridge with the specified hub context. - /// The SignalR hub context. - /// Props for creating a new FleetStatusSignalRBridge actor. - public static Props Props(IHubContext hub) => - Akka.Actor.Props.Create(() => new FleetStatusSignalRBridge(hub)); - - /// Initializes a new instance of the FleetStatusSignalRBridge class. - /// The SignalR hub context for sending messages to clients. - public FleetStatusSignalRBridge(IHubContext hub) - { - _hub = hub; - ReceiveAsync(ForwardAsync); - Receive(_ => { /* DPS confirmation */ }); - } - - /// - protected override void PreStart() => - DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self)); - - private async Task ForwardAsync(FleetStatusChanged msg) - { - try - { - await _hub.Clients.All.SendAsync(FleetStatusHub.MethodName, msg); - } - catch (Exception ex) - { - _log.Warning(ex, "FleetStatusSignalRBridge: SignalR push failed (count={Count})", msg.Nodes.Count); - } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs index 1013b021..c5f3cb4b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs @@ -13,7 +13,6 @@ public static class HubRouteBuilderExtensions /// The same endpoint route builder, for chaining. public static IEndpointRouteBuilder MapOtOpcUaHubs(this IEndpointRouteBuilder app) { - app.MapHub(FleetStatusHub.Endpoint); app.MapHub(AlertHub.Endpoint); app.MapHub(ScriptLogHub.Endpoint); app.MapHub(DriverStatusHub.Endpoint); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs index cf664c67..9e3c7f24 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs @@ -14,7 +14,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; public static class HubServiceCollectionExtensions { - public const string FleetStatusSignalRBridgeName = "fleet-status-signalr-bridge"; public const string AlertSignalRBridgeName = "alert-signalr-bridge"; public const string ScriptLogSignalRBridgeName = "script-log-signalr-bridge"; public const string DriverStatusSignalRBridgeName = "driver-status-signalr-bridge"; @@ -44,8 +43,8 @@ public static class HubServiceCollectionExtensions /// /// Spawns the SignalR bridge actors that forward DPS messages to browser-facing SignalR - /// hubs: fleet-status → , alerts → - /// , script-logs → . + /// hubs: alerts → , script-logs → + /// , driver-health → . /// /// Call inside the admin-role configurator on the shared : /// @@ -62,12 +61,6 @@ public static class HubServiceCollectionExtensions { builder.WithActors((system, registry, resolver) => { - // Fleet-status always stays on DPS in both modes (deferred / out of Phase 5 scope) — it is - // never gated by the telemetry dark switch. - var fleetHub = resolver.GetService>(); - var fleetBridge = system.ActorOf(FleetStatusSignalRBridge.Props(fleetHub), FleetStatusSignalRBridgeName); - registry.Register(fleetBridge); - // The four telemetry sinks are registered identically in both modes; only the upstream that // feeds them swaps (DPS bridges vs. the gRPC dial supervisor). Resolve them once, above the // branch, so both paths feed the SAME singletons the Blazor panels read. @@ -131,7 +124,6 @@ public static class HubServiceCollectionExtensions } /// Marker keys for lookup of the SignalR bridge actors. -public sealed class FleetStatusSignalRBridgeKey { } public sealed class AlertSignalRBridgeKey { } public sealed class ScriptLogSignalRBridgeKey { } public sealed class DriverStatusSignalRBridgeKey { } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/FleetStatusBroadcaster.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/FleetStatusBroadcaster.cs deleted file mode 100644 index b56520f8..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/FleetStatusBroadcaster.cs +++ /dev/null @@ -1,168 +0,0 @@ -using Akka.Actor; -using Akka.Cluster; -using Akka.Cluster.Tools.PublishSubscribe; -using Akka.Event; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; -using ZB.MOM.WW.OtOpcUa.Commons.Types; - -namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; - -/// -/// Admin-role cluster singleton that maintains an in-memory -/// snapshot from cluster membership + reachability events plus per-node heartbeats, and pushes -/// diffs on the fleet-status DistributedPubSub topic. The SignalR hub layer -/// subscribes to that topic and forwards to browser clients. -/// -/// Heartbeat staleness: a node we haven't heard from in flips -/// from to . A -/// cluster Unreachable event flips it straight to . -/// -public sealed class FleetStatusBroadcaster : ReceiveActor, IWithTimers -{ - public const string Topic = "fleet-status"; - public static readonly TimeSpan BroadcastInterval = TimeSpan.FromSeconds(5); - public static readonly TimeSpan HeartbeatTimeout = TimeSpan.FromSeconds(30); - - private readonly Akka.Cluster.Cluster _cluster; - private readonly Action _broadcast; - private readonly ILoggingAdapter _log = Context.GetLogger(); - private readonly Dictionary _nodes = new(); - - /// Gets or sets the timer scheduler for this actor. - public ITimerScheduler Timers { get; set; } = null!; - - /// - /// Per-node heartbeat carrying the node's currently-applied revision hash, sent by - /// DriverHostActor periodically. - /// - public sealed record DriverHostStatusHeartbeat(NodeId NodeId, RevisionHash? CurrentRevision); - - /// Creates actor props for the fleet status broadcaster. - /// Optional custom broadcast delegate; defaults to distributed pub-sub mediator. - /// Props configured to create a FleetStatusBroadcaster. - public static Props Props(Action? broadcast = null) => - Akka.Actor.Props.Create(() => new FleetStatusBroadcaster(broadcast)); - - /// Initializes a new instance of the FleetStatusBroadcaster actor. - /// Optional custom broadcast delegate; if null, uses distributed pub-sub mediator. - public FleetStatusBroadcaster(Action? broadcast = null) - { - _cluster = Akka.Cluster.Cluster.Get(Context.System); - _broadcast = broadcast ?? (msg => - DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(Topic, msg))); - - Receive(e => OnMemberUp(e.Member)); - Receive(e => OnMemberRemoved(e.Member)); - Receive(e => OnUnreachable(e.Member)); - Receive(e => OnReachable(e.Member)); - Receive(_ => PublishSnapshot()); - Receive(_ => PublishSnapshot()); - Receive(OnHeartbeat); - Receive(_ => OnTick()); - } - - /// - protected override void PreStart() - { - _cluster.Subscribe( - Self, - ClusterEvent.InitialStateAsEvents, - typeof(ClusterEvent.IMemberEvent), - typeof(ClusterEvent.LeaderChanged), - typeof(ClusterEvent.RoleLeaderChanged), - typeof(ClusterEvent.ReachabilityEvent)); - Timers.StartPeriodicTimer("tick", Tick.Instance, BroadcastInterval); - } - - /// - protected override void PostStop() => _cluster.Unsubscribe(Self); - - private void OnMemberUp(Member m) - { - if (!TryNode(m, out var nodeId)) return; - _nodes[nodeId] = _nodes.TryGetValue(nodeId, out var prev) - ? prev with { Health = FleetNodeHealth.Healthy, LastSeenUtc = DateTime.UtcNow } - : new NodeRecord(nodeId, FleetNodeHealth.Healthy, CurrentRevision: null, DateTime.UtcNow); - PublishSnapshot(); - } - - private void OnMemberRemoved(Member m) - { - if (!TryNode(m, out var nodeId)) return; - _nodes.Remove(nodeId); - PublishSnapshot(); - } - - private void OnUnreachable(Member m) - { - if (!TryNode(m, out var nodeId) || !_nodes.TryGetValue(nodeId, out var rec)) return; - _nodes[nodeId] = rec with { Health = FleetNodeHealth.Unreachable }; - PublishSnapshot(); - } - - private void OnReachable(Member m) - { - if (!TryNode(m, out var nodeId) || !_nodes.TryGetValue(nodeId, out var rec)) return; - _nodes[nodeId] = rec with { Health = FleetNodeHealth.Healthy, LastSeenUtc = DateTime.UtcNow }; - PublishSnapshot(); - } - - private void OnHeartbeat(DriverHostStatusHeartbeat hb) - { - var rec = _nodes.TryGetValue(hb.NodeId, out var prev) - ? prev with - { - CurrentRevision = hb.CurrentRevision, - LastSeenUtc = DateTime.UtcNow, - Health = prev.Health == FleetNodeHealth.Unreachable ? prev.Health : FleetNodeHealth.Healthy, - } - : new NodeRecord(hb.NodeId, FleetNodeHealth.Healthy, hb.CurrentRevision, DateTime.UtcNow); - _nodes[hb.NodeId] = rec; - } - - /// - /// Periodic tick — flips heartbeat-stale nodes to and - /// rebroadcasts the snapshot regardless so SignalR subscribers always have a recent baseline. - /// - private void OnTick() - { - var stale = DateTime.UtcNow - HeartbeatTimeout; - var flippedCount = 0; - foreach (var kv in _nodes.ToList()) - { - if (kv.Value.LastSeenUtc < stale && kv.Value.Health == FleetNodeHealth.Healthy) - { - _nodes[kv.Key] = kv.Value with { Health = FleetNodeHealth.Degraded }; - flippedCount++; - } - } - if (flippedCount > 0) _log.Debug("FleetStatusBroadcaster flipped {Count} nodes to Degraded", flippedCount); - PublishSnapshot(); - } - - private void PublishSnapshot() - { - var statuses = _nodes.Values - .Select(r => new FleetNodeStatus(r.NodeId, r.Health, r.CurrentRevision, r.LastSeenUtc)) - .ToList(); - var msg = new FleetStatusChanged(statuses, CurrentDeployment: null, DateTime.UtcNow, CorrelationId.NewId()); - _broadcast(msg); - } - - private static bool TryNode(Member m, out NodeId nodeId) - { - nodeId = default; - var host = m.Address.Host; - if (string.IsNullOrWhiteSpace(host)) return false; - nodeId = NodeId.Parse(host); - return true; - } - - private sealed record NodeRecord(NodeId NodeId, FleetNodeHealth Health, RevisionHash? CurrentRevision, DateTime LastSeenUtc); - - public sealed class Tick - { - public static readonly Tick Instance = new(); - private Tick() { } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs index 5a42f356..897e6869 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs @@ -26,12 +26,11 @@ public static class ServiceCollectionExtensions public const string ConfigPublishSingletonName = "config-publish"; public const string AdminOperationsSingletonName = "admin-operations"; public const string AuditWriterSingletonName = "audit-writer"; - public const string FleetStatusSingletonName = "fleet-status"; public const string RedundancyStateSingletonName = "redundancy-state"; public const string ClusterNodeAddressReconcilerSingletonName = "cluster-node-address-reconciler"; /// - /// Registers the admin-role cluster singletons on the AkkaConfigurationBuilder — five with + /// Registers the admin-role cluster singletons on the AkkaConfigurationBuilder — three with /// registry proxies, plus the address reconciler, which is proxy-less because nothing addresses it. /// Must be called against the same builder used by AkkaHostedService so the singletons /// share the host's ActorSystem. @@ -51,7 +50,6 @@ public static class ServiceCollectionExtensions public static AkkaConfigurationBuilder WithOtOpcUaControlPlaneSingletons(this AkkaConfigurationBuilder builder) { var singletonOptions = new ClusterSingletonOptions { Role = AdminRole }; - var proxyOptions = new ClusterSingletonOptions { Role = AdminRole }; // Per-cluster mesh Phase 2: the central end of the central↔node command boundary. // @@ -139,10 +137,11 @@ public static class ServiceCollectionExtensions }, singletonOptions); - builder.WithSingleton( - FleetStatusSingletonName, - (system, registry, resolver) => FleetStatusBroadcaster.Props(), - singletonOptions); + // Gitea #542 (2026-08-06): the fleet-status singleton is NO LONGER registered here — the + // whole FleetStatusBroadcaster → DPS → SignalR bridge → FleetStatusHub chain was DELETED. + // It had zero clients end-to-end (no browser JS, no HubConnection — /fleet reads + // NodeDeploymentState from the ConfigDb on a poll), its DriverHostStatusHeartbeat input had + // no production sender, and after mesh Phase 6 its "fleet" was only central's own pair. // Per-cluster mesh Phase 6: the redundancy-state singleton is NO LONGER registered here. // It was an admin-scoped, fleet-wide singleton — one Primary elected across the whole mesh. @@ -253,7 +252,6 @@ public static class ServiceCollectionExtensions public sealed class ConfigPublishCoordinatorKey { } public sealed class AdminOperationsActorKey { } public sealed class AuditWriterActorKey { } -public sealed class FleetStatusBroadcasterKey { } public sealed class RedundancyStateActorKey { } public sealed class ClusterNodeAddressReconcilerKey { } public sealed class CentralCommunicationActorKey { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs index bfd3ed4d..954f59d6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/TelemetryModeWiringTests.cs @@ -16,12 +16,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hubs; /// Verifies the Phase 5 dark switch in : /// TelemetryDial:Mode = Dps (default) spawns the four telemetry DPS bridge actors and NOT the /// gRPC dial supervisor; Grpc spawns the dial supervisor and NONE of the four telemetry -/// bridges. The fleet-status bridge (deferred / out of Phase 5 scope) is spawned in BOTH modes. -/// The gate is proven on a real from a started Akka host. +/// bridges. The gate is proven on a real from a started Akka host. /// public sealed class TelemetryModeWiringTests { - /// Dps mode: the four telemetry bridges + fleet bridge are registered; the dial supervisor is not. + /// Dps mode: the four telemetry bridges are registered; the dial supervisor is not. [Fact] public async Task Dps_mode_spawns_the_four_telemetry_bridges_and_not_the_dial_supervisor() { @@ -31,7 +30,6 @@ public sealed class TelemetryModeWiringTests { var registry = host.Services.GetRequiredService(); - registry.TryGet(out _).ShouldBeTrue(); registry.TryGet(out _).ShouldBeTrue(); registry.TryGet(out _).ShouldBeTrue(); registry.TryGet(out _).ShouldBeTrue(); @@ -45,7 +43,7 @@ public sealed class TelemetryModeWiringTests } } - /// Grpc mode: the dial supervisor + fleet bridge are registered; none of the four telemetry bridges are. + /// Grpc mode: the dial supervisor is registered; none of the four telemetry bridges are. [Fact] public async Task Grpc_mode_spawns_the_dial_supervisor_and_none_of_the_four_telemetry_bridges() { @@ -55,7 +53,6 @@ public sealed class TelemetryModeWiringTests { var registry = host.Services.GetRequiredService(); - registry.TryGet(out _).ShouldBeTrue(); registry.TryGet(out _).ShouldBeTrue(); registry.TryGet(out _).ShouldBeFalse(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/FleetStatusBroadcasterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/FleetStatusBroadcasterTests.cs deleted file mode 100644 index 35ea7560..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/FleetStatusBroadcasterTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Akka.Actor; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; -using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; - -public sealed class FleetStatusBroadcasterTests : ControlPlaneActorTestBase -{ - /// Verifies that the self member's join appears in the initial fleet status snapshot. - [Fact] - public void Self_member_up_lands_in_snapshot() - { - var probe = CreateTestProbe(); - Sys.ActorOf(FleetStatusBroadcaster.Props(broadcast: msg => probe.Ref.Tell(msg))); - - // The cluster self-joined in the harness — InitialStateAsEvents replays MemberUp for self. - var snapshot = probe.ExpectMsg(TimeSpan.FromSeconds(5)); - snapshot.Nodes.Count.ShouldBeGreaterThanOrEqualTo(1); - snapshot.Nodes.Any(n => n.Health == FleetNodeHealth.Healthy).ShouldBeTrue(); - } - - /// Verifies that heartbeat messages update the node revision in the next broadcast. - [Fact] - public void Heartbeat_updates_revision_in_next_snapshot() - { - var probe = CreateTestProbe(); - var actor = Sys.ActorOf(FleetStatusBroadcaster.Props(broadcast: msg => probe.Ref.Tell(msg))); - - // Drain the initial MemberUp snapshot. - probe.ExpectMsg(TimeSpan.FromSeconds(5)); - - // Send a heartbeat for a node we know about (the self node from the cluster). - var selfHost = Akka.Cluster.Cluster.Get(Sys).SelfAddress.Host!; - var rev = RevisionHash.Parse(new string('c', 64)); - actor.Tell(new FleetStatusBroadcaster.DriverHostStatusHeartbeat(NodeId.Parse(selfHost), rev)); - - // Wait for next periodic broadcast and assert the revision propagated. - var next = probe.FishForMessage( - isMessage: m => m.Nodes.Any(n => n.CurrentRevision == rev), - max: TimeSpan.FromSeconds(10)); - next.Nodes.Single(n => n.NodeId.Value == selfHost).CurrentRevision.ShouldBe(rev); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs index 1e969cc4..01659b98 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs @@ -69,7 +69,7 @@ public sealed class RedundancyStateSingletonRehomeTests registry.TryGet(out _).ShouldBeFalse( "the redundancy singleton was re-homed off the admin node onto every driver node (Phase 6)"); - registry.TryGet(out _).ShouldBeTrue( + registry.TryGet(out _).ShouldBeTrue( "the remaining admin singletons must still register"); }