diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs index 295126d5..b76f1fd1 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs @@ -1,5 +1,16 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +/// +/// Shared DPS topic for driver-control commands (, +/// ). Publishers (AdminOperationsActor) and subscribers +/// (DriverHostActor) reference this single constant so renames can't silently +/// desynchronise. +/// +public static class DriverControlTopic +{ + public const string Name = "driver-control"; +} + /// /// AdminUI → AdminOperationsActor: restart the driver actor for one instance. /// A restart fully stops and respawns the actor — loses in-memory state, may briefly diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs index dd941e23..b269531d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs @@ -20,4 +20,12 @@ public sealed record DriverHealthChanged( DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, - DateTime PublishedUtc); + DateTime PublishedUtc) +{ + /// + /// DPS topic name. Both the runtime AkkaDriverHealthPublisher and the AdminUI + /// DriverStatusSignalRBridge reference this single constant so renames can't + /// silently desynchronise publisher and subscriber. + /// + public const string TopicName = "driver-health"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor index 90cdc85e..709582f7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor @@ -157,7 +157,7 @@ private bool _showRestartConfirm; private string? _opResultMessage; private bool _opResultOk; - private System.Timers.Timer? _opResultClearTimer; + private System.Threading.Timer? _opResultClearTimer; protected override async Task OnInitializedAsync() { @@ -257,24 +257,23 @@ { _opResultOk = ok; _opResultMessage = message; - // Auto-clear the result chip after 8 s. + // Auto-clear the result chip after 8 s. System.Threading.Timer is used (not + // System.Timers.Timer) so DisposeAsync can drain any in-flight callback. _opResultClearTimer?.Dispose(); - _opResultClearTimer = new System.Timers.Timer(8_000) { AutoReset = false }; - _opResultClearTimer.Elapsed += async (_, _) => + _opResultClearTimer = new System.Threading.Timer(_ => { _opResultMessage = null; - await InvokeAsync(StateHasChanged); - }; - _opResultClearTimer.Start(); + InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan); } public async ValueTask DisposeAsync() { - // Drain the timer first so an in-flight callback can't invoke StateHasChanged on - // a component that's already releasing its hub. System.Threading.Timer implements - // IAsyncDisposable in .NET 6+; the async dispose awaits any in-flight callback. + // Drain BOTH timers first so an in-flight callback can't invoke StateHasChanged on + // a component whose hub has already been released. System.Threading.Timer's async + // dispose awaits any in-flight callback (.NET 6+). if (_timer is not null) await _timer.DisposeAsync(); - _opResultClearTimer?.Dispose(); + if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync(); if (_hub is not null) await _hub.DisposeAsync(); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusSignalRBridge.cs index 0802ef77..6fc52c9b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusSignalRBridge.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; /// public sealed class DriverStatusSignalRBridge : ReceiveActor { - public const string TopicName = "driver-health"; + public const string TopicName = DriverHealthChanged.TopicName; private readonly IHubContext _hub; private readonly IDriverStatusSnapshotStore _store; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index cc4966ac..d081a3a5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -178,7 +178,7 @@ public sealed class AdminOperationsActor : ReceiveActor { // Broadcast to every DriverHostActor on every node via the driver-control DPS topic. // Only the host that owns the instance will act; others ignore it (id not found in _children). - DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish("driver-control", msg)); + DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(DriverControlTopic.Name, msg)); await using var db = await _dbFactory.CreateDbContextAsync(); db.ConfigEdits.Add(new ConfigEdit @@ -208,7 +208,7 @@ public sealed class AdminOperationsActor : ReceiveActor try { // Broadcast to every DriverHostActor; only the one owning the instance reacts. - DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish("driver-control", msg)); + DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(DriverControlTopic.Name, msg)); await using var db = await _dbFactory.CreateDbContextAsync(); db.ConfigEdits.Add(new ConfigEdit diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs index f48fce0a..4f8e7a59 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/AkkaDriverHealthPublisher.cs @@ -12,8 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher { - /// The DistributedPubSub topic name for driver-health snapshots. - public const string TopicName = "driver-health"; + /// The DistributedPubSub topic name for driver-health snapshots — single source + /// of truth on the message contract itself. + public const string TopicName = DriverHealthChanged.TopicName; private readonly ActorSystem _system; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 413b2154..3e0d2fe9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -37,7 +37,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers { public const string DeploymentsTopic = "deployments"; public const string DeploymentAcksTopic = "deployment-acks"; - public const string DriverControlTopic = "driver-control"; + public const string DriverControlTopic = ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.DriverControlTopic.Name; public static readonly TimeSpan ReconnectInterval = TimeSpan.FromSeconds(30); private readonly IDbContextFactory _dbFactory; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 63459f48..b6f8a296 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -434,13 +434,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Polls and forwards the snapshot to the health publisher. /// Called on every observable state change and by the periodic /// so the AdminUI snapshot store is warmed up for newly-joined SignalR clients. + /// Deduplicates: if the resulting (state, lastSuccess, lastError, errorCount) tuple matches + /// the last publish, this call is a no-op. Stops flood-publishing identical Healthy snapshots + /// every 30s when nothing has changed. Newly-joined SignalR clients still get the current + /// snapshot via DriverStatusHub.JoinDriver which reads the store directly. /// private void PublishHealthSnapshot() { try { var health = _driver.GetHealth(); - _healthPublisher.Publish(_clusterId, _driverInstanceId, health, ErrorCount5Min()); + var errorCount = ErrorCount5Min(); + var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount); + if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint)) + return; + _lastPublishedFingerprint = fingerprint; + _healthPublisher.Publish(_clusterId, _driverInstanceId, health, errorCount); } catch (Exception ex) { @@ -448,6 +457,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } + /// Fingerprint of the last call; null until first publish. + private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount)? _lastPublishedFingerprint; + /// protected override void PostStop() {