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:
Joseph Doherty
2026-04-12 15:52:03 -04:00
parent 9b42b61eb6
commit c5ed5312a9
10 changed files with 647 additions and 26 deletions

View File

@@ -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();
}
}
}

View File

@@ -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 &lt;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");
}
}
}

View File

@@ -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>