60febbf2dc
The node manager composes the start/end bounds from each backend's existing raw reads (one authority, HistoryBounds): at-or-before = a capped reverse read (native, or ReverseRawReader's walk-back on a forward-only backend — never AVEVA's synthetic boundary row), at-or-after = a capped forward read. Bounds count toward NumValuesPerNode, the start bound leads the first page, the end bound ends the last (carried in the continuation point). Missing bound = Bad_BoundNotFound (null, at the bound time; one-sided reads ±1 s from the previous value, Table 1 footnotes a/b). A modified read with ReturnBounds is Bad_InvalidArgument (Part 11 6.5.3.3). The .NET SDK's ReadRawModifiedDetails defaults ReturnBounds to TRUE; the existing tests built details without setting it and now set it false. Oracle: every row of Part 11 Table 1 over three backend shapes, plus paging concatenation, ties, Bad bounds, microsecond truncation; conformance pins the lookup primitives on SqlServer + QuestDB.
409 lines
20 KiB
C#
409 lines
20 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// OPC UA HistoryUpdate over a real SDK client session (pluggable historian, Task 13): the encoding and
|
|
/// dispatch the node-manager unit tests drive around. An in-memory updater and data source share ONE
|
|
/// dictionary, so an insert is observable through a wire HistoryRead.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The updater is installed BEFORE the historized node materialises — the HistoryWrite AccessLevel bit
|
|
/// (which the SDK base requires to admit a node to HistoryUpdate) is fixed at creation. The control test
|
|
/// pins the reverse order.
|
|
/// </remarks>
|
|
public sealed class HistoryUpdateOverTheWireTests
|
|
{
|
|
private const string Tagname = "WW.Speed";
|
|
private const NodePermissions Editor =
|
|
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe
|
|
| NodePermissions.HistoryRead | NodePermissions.HistoryUpdate;
|
|
private const NodePermissions ReaderOnly =
|
|
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.HistoryRead;
|
|
|
|
private static readonly DateTime T1 = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
private static readonly DateTime T2 = T1.AddMinutes(1);
|
|
|
|
/// <summary>Insert x2, read back, duplicate insert refused, DeleteAtTime one, read back one.</summary>
|
|
[Fact]
|
|
public async Task Insert_read_duplicate_and_delete_round_trip_over_the_wire()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var store = new SharedStore();
|
|
var rig = await StartAsync("HuWireRoundTrip", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Nm.HistorianDataUpdater = store;
|
|
rig.Nm.HistorianDataSource = store;
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
|
|
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var node = RawNode(session, id);
|
|
|
|
var insert = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.5), (T2, 2.5));
|
|
insert.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
insert.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted, StatusCodes.GoodEntryInserted]);
|
|
|
|
var read = await ReadRawAsync(session, node, ct);
|
|
read.Select(v => (v.SourceTimestamp, (double)v.Value)).ShouldBe([(T1, 1.5), (T2, 2.5)]);
|
|
|
|
var dup = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 9.9));
|
|
dup.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadEntryExists]);
|
|
(await ReadRawAsync(session, node, ct)).Count.ShouldBe(2, "the refused insert changed nothing");
|
|
|
|
var del = await DeleteAtTimeAsync(session, node, ct, T1);
|
|
del.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.Good]);
|
|
|
|
var after = await ReadRawAsync(session, node, ct);
|
|
after.Select(v => (v.SourceTimestamp, (double)v.Value)).ShouldBe([(T2, 2.5)]);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Part 11 modified-value history over the wire (#668): two inserts and a delete by the key principal, then a
|
|
/// HistoryReadRaw with <c>IsReadModified = true</c> decodes as a <see cref="HistoryModifiedData"/> whose
|
|
/// <c>ModificationInfos</c> carry the update type and the principal, and whose values carry the PRIOR value.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_modified_read_returns_the_trail_with_principal_and_update_type_over_the_wire()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var store = new SharedStore();
|
|
var rig = await StartAsync("HuWireModified", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Nm.HistorianDataUpdater = store;
|
|
rig.Nm.HistorianDataSource = store;
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
|
|
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var node = RawNode(session, id);
|
|
await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.5), (T2, 2.5));
|
|
await DeleteAtTimeAsync(session, node, ct, T1);
|
|
|
|
var details = new ReadRawModifiedDetails
|
|
{
|
|
ReturnBounds = false,
|
|
StartTime = T1.AddHours(-1), EndTime = T1.AddHours(1), NumValuesPerNode = 100, IsReadModified = true,
|
|
};
|
|
var response = await session.HistoryReadAsync(
|
|
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
|
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
|
|
var result = response.Results.ShouldHaveSingleItem();
|
|
result.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
var data = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryModifiedData>();
|
|
|
|
data.DataValues.Select(v => v.SourceTimestamp).ShouldBe([T1, T1, T2]);
|
|
data.ModificationInfos.Select(m => m.UpdateType)
|
|
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Delete, HistoryUpdateType.Insert]);
|
|
data.ModificationInfos.ShouldAllBe(m => m.UserName == KeyPrincipal);
|
|
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); // an insert had no prior value
|
|
((double)data.DataValues[1].Value).ShouldBe(1.5); // the deleted sample's prior value
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// ALLOW beside DENY: the same server, same node — the editor session is admitted, the session
|
|
/// without the HistoryUpdate grant gets BadUserAccessDenied and the updater is never called.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_session_without_the_grant_is_denied_and_the_updater_is_not_called()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var store = new SharedStore();
|
|
var rig = await StartAsync("HuWireDeny", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Nm.HistorianDataUpdater = store;
|
|
rig.Nm.HistorianDataSource = store;
|
|
rig.Enforce(
|
|
true, [Key()],
|
|
ClusterGrant(KeyPrincipal, "acl-editor", Editor),
|
|
ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon", ReaderOnly));
|
|
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
|
|
|
|
using var denied = await rig.ConnectAsync(anonymous: true, ct);
|
|
var denial = await UpdateAsync(denied, RawNode(denied, id), PerformUpdateType.Insert, ct, (T1, 1.0));
|
|
denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
store.UpdateCalls.ShouldBe(0, "a denied session never reaches the updater");
|
|
store.Count.ShouldBe(0);
|
|
|
|
using var allowed = await rig.ConnectAsync(anonymous: false, ct);
|
|
var ok = await UpdateAsync(allowed, RawNode(allowed, id), PerformUpdateType.Insert, ct, (T1, 1.0));
|
|
ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
|
store.UpdateCalls.ShouldBe(1);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// ACL flag OFF over the wire: a key's grants collapse into server-wide roles at activation (the production
|
|
/// <see cref="ApiKeyInterimRoleMapper"/>). A key whose principal holds only a Cluster-scoped WriteOperate grant
|
|
/// is denied — live-write authority never implies rewriting history — and the same key with a Cluster-scoped
|
|
/// HistoryUpdate grant is admitted. The allow half keeps the deny from passing against a build that refuses
|
|
/// every HistoryUpdate with the flag off.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Acl_off_a_cluster_history_update_grant_allows_and_write_operate_alone_is_denied()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var store = new SharedStore();
|
|
var rig = await StartAsync("HuWireAclOff", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Nm.HistorianDataUpdater = store;
|
|
rig.Nm.HistorianDataSource = store;
|
|
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
|
|
|
|
var writeOnly = ClusterGrant(KeyPrincipal, "acl-writer", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate);
|
|
rig.Enforce(false, [Key()], writeOnly);
|
|
rig.KeyRoles = ApiKeyInterimRoleMapper.Map([writeOnly], KeyPrincipal);
|
|
rig.KeyRoles.ShouldNotContain(OpcUaDataPlaneRoles.HistoryUpdate);
|
|
using (var writer = await rig.ConnectAsync(anonymous: false, ct))
|
|
{
|
|
var denial = await UpdateAsync(writer, RawNode(writer, id), PerformUpdateType.Insert, ct, (T1, 1.0));
|
|
denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
}
|
|
store.UpdateCalls.ShouldBe(0, "a denied session never reaches the updater");
|
|
|
|
var editor = ClusterGrant(KeyPrincipal, "acl-editor", NodePermissions.Browse | NodePermissions.Read | NodePermissions.HistoryUpdate);
|
|
rig.Enforce(false, [Key()], editor);
|
|
rig.KeyRoles = ApiKeyInterimRoleMapper.Map([editor], KeyPrincipal);
|
|
using var allowed = await rig.ConnectAsync(anonymous: false, ct);
|
|
var ok = await UpdateAsync(allowed, RawNode(allowed, id), PerformUpdateType.Insert, ct, (T1, 1.0));
|
|
ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
|
store.UpdateCalls.ShouldBe(1);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>The HistoryWrite bit is visible on both AccessLevel and UserAccessLevel when an updater is wired.</summary>
|
|
[Fact]
|
|
public async Task The_history_write_bit_is_visible_over_the_wire()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("HuWireBit", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Nm.HistorianDataUpdater = new SharedStore();
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
|
|
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var node = RawNode(session, id);
|
|
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
|
|
.ShouldBe(AccessLevels.HistoryWrite);
|
|
(await ReadByteAsync(session, node, Attributes.UserAccessLevel, ct) & AccessLevels.HistoryWrite)
|
|
.ShouldBe(AccessLevels.HistoryWrite);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Control (no retro-update): materialising the node BEFORE the updater is installed leaves it without
|
|
/// the HistoryWrite bit, so the SDK base answers BadHistoryOperationUnsupported — even for an editor.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_updater_installed_after_the_node_materialised_is_unsupported()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var store = new SharedStore();
|
|
var rig = await StartAsync("HuWireLate", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
|
|
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
|
|
rig.Nm.HistorianDataUpdater = store;
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
|
var node = RawNode(session, id);
|
|
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
|
|
.ShouldBe(0, "the bit is fixed at materialisation");
|
|
|
|
var result = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0));
|
|
result.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
store.UpdateCalls.ShouldBe(0);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static NodeId RawNode(ISession s, string id) =>
|
|
new(id, (ushort)s.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
|
|
private static async Task<byte> ReadByteAsync(ISession session, NodeId node, uint attribute, CancellationToken ct)
|
|
{
|
|
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection
|
|
{
|
|
new ReadValueId { NodeId = node, AttributeId = attribute },
|
|
}, ct);
|
|
return (byte)response.Results.ShouldHaveSingleItem().Value;
|
|
}
|
|
|
|
private static async Task<HistoryUpdateResult> UpdateAsync(
|
|
ISession session, NodeId node, PerformUpdateType mode, CancellationToken ct,
|
|
params (DateTime Time, double Value)[] items)
|
|
{
|
|
var details = new UpdateDataDetails { NodeId = node, PerformInsertReplace = mode };
|
|
foreach (var (time, value) in items)
|
|
details.UpdateValues.Add(new DataValue(new Variant(value), StatusCodes.Good, time, time));
|
|
return await SendAsync(session, details, ct);
|
|
}
|
|
|
|
private static Task<HistoryUpdateResult> DeleteAtTimeAsync(
|
|
ISession session, NodeId node, CancellationToken ct, params DateTime[] times)
|
|
{
|
|
var details = new DeleteAtTimeDetails { NodeId = node };
|
|
foreach (var t in times) details.ReqTimes.Add(t);
|
|
return SendAsync(session, details, ct);
|
|
}
|
|
|
|
private static async Task<HistoryUpdateResult> SendAsync(ISession session, object details, CancellationToken ct)
|
|
{
|
|
var response = await session.HistoryUpdateAsync(
|
|
null, new ExtensionObjectCollection { new ExtensionObject(details) }, ct);
|
|
return response.Results.ShouldHaveSingleItem();
|
|
}
|
|
|
|
private static async Task<List<DataValue>> ReadRawAsync(ISession session, NodeId node, CancellationToken ct)
|
|
{
|
|
var details = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = T1.AddHours(-1),
|
|
EndTime = T1.AddHours(1),
|
|
NumValuesPerNode = 100,
|
|
IsReadModified = false,
|
|
ReturnBounds = false,
|
|
};
|
|
var response = await session.HistoryReadAsync(
|
|
null, new ExtensionObject(details), TimestampsToReturn.Source, false,
|
|
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
|
|
var result = response.Results.ShouldHaveSingleItem();
|
|
StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
|
|
var data = (HistoryData)ExtensionObject.ToEncodeable(result.HistoryData);
|
|
return data.DataValues.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// One dictionary behind both seams, thread-safe and completing on a pool thread (as a real backend
|
|
/// would), so the node manager's blocking bridge is exercised the way production runs it.
|
|
/// </summary>
|
|
private sealed class SharedStore : IHistorianDataUpdater, IHistorianDataSource
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly SortedDictionary<DateTime, double> _samples = [];
|
|
private readonly List<HistoryModifiedValue> _trail = [];
|
|
private int _updateCalls;
|
|
|
|
private void Record(DateTime t, double? prior, HistoryModificationType type, HistoryUpdateContext who) =>
|
|
_trail.Add(new HistoryModifiedValue(
|
|
new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData : StatusCodes.Good, t, t),
|
|
DateTime.UtcNow, type, who.Principal, _trail.Count + 1, t));
|
|
|
|
public int UpdateCalls => Volatile.Read(ref _updateCalls);
|
|
|
|
public int Count { get { lock (_gate) return _samples.Count; } }
|
|
|
|
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
|
|
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context,
|
|
CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _updateCalls);
|
|
await Task.Delay(1, ct).ConfigureAwait(false);
|
|
tag.ShouldBe(Tagname);
|
|
mode.ShouldBe(HistoryUpdateMode.Insert);
|
|
var codes = new List<uint>();
|
|
lock (_gate)
|
|
{
|
|
foreach (var v in values)
|
|
{
|
|
var t = v.TimestampUtc!.Value;
|
|
if (_samples.ContainsKey(t)) { codes.Add(StatusCodes.BadEntryExists); continue; }
|
|
v.Value.TryGetDouble(out var d).ShouldBeTrue();
|
|
_samples[t] = d;
|
|
Record(t, null, HistoryModificationType.Insert, context);
|
|
codes.Add(StatusCodes.GoodEntryInserted);
|
|
}
|
|
}
|
|
return codes;
|
|
}
|
|
|
|
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
|
|
CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
|
|
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(
|
|
string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct)
|
|
{
|
|
await Task.Delay(1, ct).ConfigureAwait(false);
|
|
lock (_gate)
|
|
return timesUtc.Select(t =>
|
|
{
|
|
if (!_samples.Remove(t, out var prior)) return StatusCodes.BadNoEntryExists;
|
|
Record(t, prior, HistoryModificationType.Delete, context);
|
|
return StatusCodes.Good;
|
|
}).ToList();
|
|
}
|
|
|
|
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
|
fullReference.ShouldBe(Tagname);
|
|
lock (_gate)
|
|
{
|
|
var rows = _trail
|
|
.Where(m => m.Value.SourceTimestampUtc >= startUtc && m.Value.SourceTimestampUtc < endUtc)
|
|
.OrderBy(m => m.Value.SourceTimestampUtc).ThenBy(m => m.ModifiedId).ToList();
|
|
return new HistoryModifiedReadResult(rows, false);
|
|
}
|
|
}
|
|
|
|
public async Task<CoreHistoryReadResult> ReadRawAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
|
fullReference.ShouldBe(Tagname);
|
|
lock (_gate)
|
|
{
|
|
var rows = _samples.Where(kv => kv.Key >= startUtc && kv.Key <= endUtc)
|
|
.Select(kv => new DataValueSnapshot(kv.Value, StatusCodes.Good, kv.Key, kv.Key)).ToList();
|
|
return new CoreHistoryReadResult(rows, null);
|
|
}
|
|
}
|
|
|
|
public Task<CoreHistoryReadResult> ReadProcessedAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate,
|
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
|
|
|
public Task<CoreHistoryReadResult> ReadAtTimeAsync(
|
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|