Surface historian plugin and alarm-tracking health in the status dashboard so operators can detect misconfiguration and runtime degradation that previously showed as fully healthy
Wraps the 4 HistoryRead overrides and OnAlarmAcknowledge with PerformanceMetrics.BeginOperation, adds alarm counters to LmxNodeManager, publishes a structured HistorianPluginOutcome from HistorianPluginLoader, and extends HealthCheckService with plugin-load, history-read, and alarm-ack-failure degradation rules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the load-outcome state machine of <see cref="HistorianPluginLoader"/>.
|
||||
/// </summary>
|
||||
public class HistorianPluginLoaderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// MarkDisabled publishes a Disabled outcome so the dashboard can distinguish
|
||||
/// "feature off" from "load failed."
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MarkDisabled_PublishesDisabledOutcome()
|
||||
{
|
||||
HistorianPluginLoader.MarkDisabled();
|
||||
|
||||
HistorianPluginLoader.LastOutcome.Status.ShouldBe(HistorianPluginStatus.Disabled);
|
||||
HistorianPluginLoader.LastOutcome.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the plugin directory is missing, TryLoad reports NotFound — not LoadFailed —
|
||||
/// and returns null so the server can start with history disabled.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,5 +105,108 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
|
||||
var result = _sut.CheckHealth(ConnectionState.Reconnecting, null);
|
||||
result.Status.ShouldBe("Unhealthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historian enabled but plugin failed to load → Degraded with the plugin error in the message.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historian disabled is healthy regardless of plugin status string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistorianDisabled_ReturnsHealthy()
|
||||
{
|
||||
var historian = new HistorianStatusInfo
|
||||
{
|
||||
Enabled = false,
|
||||
PluginStatus = "Disabled"
|
||||
};
|
||||
|
||||
_sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historian enabled and plugin loaded is healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistorianEnabled_PluginLoaded_ReturnsHealthy()
|
||||
{
|
||||
var historian = new HistorianStatusInfo { Enabled = true, PluginStatus = "Loaded" };
|
||||
_sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HistoryRead operations degrade after only 11 samples with <50% success rate
|
||||
/// (lower threshold than the regular 100-sample rule).
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A HistoryRead sample under the 10-sample threshold does not degrade the service.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alarm acknowledge write failures are latched — any non-zero count degrades the service.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alarm tracking disabled ignores any failure count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmAckWriteFailures_TrackingDisabled_ReturnsHealthy()
|
||||
{
|
||||
var alarms = new AlarmStatusInfo { TrackingEnabled = false, AckWriteFailures = 99 };
|
||||
|
||||
_sut.CheckHealth(ConnectionState.Connected, null, null, alarms).Status.ShouldBe("Healthy");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON exposes the historian plugin status so operators can distinguish
|
||||
/// "disabled by config" from "plugin crashed on load."
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Historian_IncludesPluginStatus()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("PluginStatus");
|
||||
json.ShouldContain("PluginPath");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON exposes alarm counters so operators can see transition/ack activity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Alarms_IncludesCounters()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("TrackingEnabled");
|
||||
json.ShouldContain("TransitionCount");
|
||||
json.ShouldContain("AckWriteFailures");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Historian and Alarms panels render in the HTML dashboard.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_IncludesHistorianAndAlarmPanels()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
|
||||
html.ShouldContain("<h2>Historian</h2>");
|
||||
html.ShouldContain("<h2>Alarms</h2>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The /api/health payload exposes Historian and Alarms component status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetHealthData_Components_IncludeHistorianAndAlarms()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.Components.Historian.ShouldNotBeNullOrEmpty();
|
||||
data.Components.Alarms.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the report service reports healthy when the runtime connection is up.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user