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()
{