Operations
");
sb.AppendLine(
@@ -254,7 +310,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
var mxConnected = connectionState == ConnectionState.Connected;
var dbConnected = _galaxyStats?.DbConnected ?? false;
- var health = _healthCheck.CheckHealth(connectionState, _metrics);
+ var historianInfo = BuildHistorianStatusInfo();
+ var alarmInfo = BuildAlarmStatusInfo();
+ var health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo);
var uptime = DateTime.UtcNow - _startTime;
var data = new HealthEndpointData
@@ -265,7 +323,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
{
MxAccess = connectionState.ToString(),
Database = dbConnected ? "Connected" : "Disconnected",
- OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped"
+ OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped",
+ Historian = historianInfo.PluginStatus,
+ Alarms = alarmInfo.TrackingEnabled ? "Enabled" : "Disabled"
},
Uptime = FormatUptime(uptime),
Timestamp = DateTime.UtcNow
@@ -354,6 +414,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
sb.AppendLine(
$"
Role: {data.RedundancyRole} | Mode: {data.RedundancyMode}
");
+ var historianColor = data.Components.Historian == "Loaded" ? "#00cc66"
+ : data.Components.Historian == "Disabled" ? "#666" : "#cc3333";
+ var alarmColor = data.Components.Alarms == "Enabled" ? "#00cc66" : "#666";
+
// Component health cards
sb.AppendLine("
");
sb.AppendLine(
@@ -362,6 +426,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
$"
Galaxy Database
{data.Components.Database}
");
sb.AppendLine(
$"
OPC UA Server
{data.Components.OpcUaServer}
");
+ sb.AppendLine(
+ $"
Historian
{data.Components.Historian}
");
+ sb.AppendLine(
+ $"
Alarm Tracking
{data.Components.Alarms}
");
sb.AppendLine("
");
// Footer
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Historian/HistorianPluginLoaderTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Historian/HistorianPluginLoaderTests.cs
new file mode 100644
index 0000000..ef16dd1
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Historian/HistorianPluginLoaderTests.cs
@@ -0,0 +1,45 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
+using ZB.MOM.WW.LmxOpcUa.Host.Historian;
+
+namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
+{
+ ///
+ /// Verifies the load-outcome state machine of .
+ ///
+ public class HistorianPluginLoaderTests
+ {
+ ///
+ /// MarkDisabled publishes a Disabled outcome so the dashboard can distinguish
+ /// "feature off" from "load failed."
+ ///
+ [Fact]
+ public void MarkDisabled_PublishesDisabledOutcome()
+ {
+ HistorianPluginLoader.MarkDisabled();
+
+ HistorianPluginLoader.LastOutcome.Status.ShouldBe(HistorianPluginStatus.Disabled);
+ HistorianPluginLoader.LastOutcome.Error.ShouldBeNull();
+ }
+
+ ///
+ /// When the plugin directory is missing, TryLoad reports NotFound — not LoadFailed —
+ /// and returns null so the server can start with history disabled.
+ ///
+ [Fact]
+ public void TryLoad_PluginMissing_ReturnsNullWithNotFoundOutcome()
+ {
+ // The test process runs from a bin directory that does not contain a Historian/
+ // subfolder, so TryLoad will take the file-missing branch.
+ var config = new HistorianConfiguration { Enabled = true };
+
+ var result = HistorianPluginLoader.TryLoad(config);
+
+ result.ShouldBeNull();
+ HistorianPluginLoader.LastOutcome.Status.ShouldBe(HistorianPluginStatus.NotFound);
+ HistorianPluginLoader.LastOutcome.PluginPath.ShouldContain("ZB.MOM.WW.LmxOpcUa.Historian.Aveva.dll");
+ HistorianPluginLoader.LastOutcome.Error.ShouldBeNull();
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/HealthCheckServiceTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/HealthCheckServiceTests.cs
index 46e7c7d..79d7679 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/HealthCheckServiceTests.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/HealthCheckServiceTests.cs
@@ -105,5 +105,108 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
var result = _sut.CheckHealth(ConnectionState.Reconnecting, null);
result.Status.ShouldBe("Unhealthy");
}
+
+ ///
+ /// Historian enabled but plugin failed to load → Degraded with the plugin error in the message.
+ ///
+ [Fact]
+ public void HistorianEnabled_PluginLoadFailed_ReturnsDegraded()
+ {
+ var historian = new HistorianStatusInfo
+ {
+ Enabled = true,
+ PluginStatus = "LoadFailed",
+ PluginError = "aahClientManaged.dll could not be loaded"
+ };
+
+ var result = _sut.CheckHealth(ConnectionState.Connected, null, historian);
+
+ result.Status.ShouldBe("Degraded");
+ result.Color.ShouldBe("yellow");
+ result.Message.ShouldContain("LoadFailed");
+ result.Message.ShouldContain("aahClientManaged.dll");
+ }
+
+ ///
+ /// Historian disabled is healthy regardless of plugin status string.
+ ///
+ [Fact]
+ public void HistorianDisabled_ReturnsHealthy()
+ {
+ var historian = new HistorianStatusInfo
+ {
+ Enabled = false,
+ PluginStatus = "Disabled"
+ };
+
+ _sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy");
+ }
+
+ ///
+ /// Historian enabled and plugin loaded is healthy.
+ ///
+ [Fact]
+ public void HistorianEnabled_PluginLoaded_ReturnsHealthy()
+ {
+ var historian = new HistorianStatusInfo { Enabled = true, PluginStatus = "Loaded" };
+ _sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy");
+ }
+
+ ///
+ /// HistoryRead operations degrade after only 11 samples with <50% success rate
+ /// (lower threshold than the regular 100-sample rule).
+ ///
+ [Fact]
+ public void HistoryReadLowSuccessRate_WithLowSampleCount_ReturnsDegraded()
+ {
+ using var metrics = new PerformanceMetrics();
+ for (var i = 0; i < 4; i++)
+ metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10));
+ for (var i = 0; i < 8; i++)
+ metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10), false);
+
+ var result = _sut.CheckHealth(ConnectionState.Connected, metrics);
+
+ result.Status.ShouldBe("Degraded");
+ result.Message.ShouldContain("HistoryReadRaw");
+ }
+
+ ///
+ /// A HistoryRead sample under the 10-sample threshold does not degrade the service.
+ ///
+ [Fact]
+ public void HistoryReadLowSuccessRate_BelowThreshold_ReturnsHealthy()
+ {
+ using var metrics = new PerformanceMetrics();
+ for (var i = 0; i < 5; i++)
+ metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10), false);
+
+ _sut.CheckHealth(ConnectionState.Connected, metrics).Status.ShouldBe("Healthy");
+ }
+
+ ///
+ /// Alarm acknowledge write failures are latched — any non-zero count degrades the service.
+ ///
+ [Fact]
+ public void AlarmAckWriteFailures_AnyCount_ReturnsDegraded()
+ {
+ var alarms = new AlarmStatusInfo { TrackingEnabled = true, AckWriteFailures = 1 };
+
+ var result = _sut.CheckHealth(ConnectionState.Connected, null, null, alarms);
+
+ result.Status.ShouldBe("Degraded");
+ result.Message.ShouldContain("Alarm acknowledge");
+ }
+
+ ///
+ /// Alarm tracking disabled ignores any failure count.
+ ///
+ [Fact]
+ public void AlarmAckWriteFailures_TrackingDisabled_ReturnsHealthy()
+ {
+ var alarms = new AlarmStatusInfo { TrackingEnabled = false, AckWriteFailures = 99 };
+
+ _sut.CheckHealth(ConnectionState.Connected, null, null, alarms).Status.ShouldBe("Healthy");
+ }
}
}
\ No newline at end of file
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs
index 96cb54a..e220599 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Status/StatusReportServiceTests.cs
@@ -108,9 +108,65 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
json.ShouldContain("Subscriptions");
json.ShouldContain("Galaxy");
json.ShouldContain("Operations");
+ json.ShouldContain("Historian");
+ json.ShouldContain("Alarms");
json.ShouldContain("Footer");
}
+ ///
+ /// The dashboard JSON exposes the historian plugin status so operators can distinguish
+ /// "disabled by config" from "plugin crashed on load."
+ ///
+ [Fact]
+ public void GenerateJson_Historian_IncludesPluginStatus()
+ {
+ var sut = CreateService();
+ var json = sut.GenerateJson();
+
+ json.ShouldContain("PluginStatus");
+ json.ShouldContain("PluginPath");
+ }
+
+ ///
+ /// The dashboard JSON exposes alarm counters so operators can see transition/ack activity.
+ ///
+ [Fact]
+ public void GenerateJson_Alarms_IncludesCounters()
+ {
+ var sut = CreateService();
+ var json = sut.GenerateJson();
+
+ json.ShouldContain("TrackingEnabled");
+ json.ShouldContain("TransitionCount");
+ json.ShouldContain("AckWriteFailures");
+ }
+
+ ///
+ /// The Historian and Alarms panels render in the HTML dashboard.
+ ///
+ [Fact]
+ public void GenerateHtml_IncludesHistorianAndAlarmPanels()
+ {
+ var sut = CreateService();
+ var html = sut.GenerateHtml();
+
+ html.ShouldContain("
Historian
");
+ html.ShouldContain("
Alarms
");
+ }
+
+ ///
+ /// The /api/health payload exposes Historian and Alarms component status.
+ ///
+ [Fact]
+ public void GetHealthData_Components_IncludeHistorianAndAlarms()
+ {
+ var sut = CreateService();
+ var data = sut.GetHealthData();
+
+ data.Components.Historian.ShouldNotBeNullOrEmpty();
+ data.Components.Alarms.ShouldNotBeNullOrEmpty();
+ }
+
///
/// Confirms that the report service reports healthy when the runtime connection is up.
///