193221773d
The subtag fallback's coverage was fixed at SubscribeAlarms time and never re-examined. AlarmWatchListResolver deliberately swallows a Galaxy Repository outage and returns whatever configuration contributed, so a discovery failure at startup left the monitor subscribed with an empty watch-list for the rest of its life. In Auto mode the alarmmgr primary keeps working, so nothing looks wrong until the failover that needs the standby finds it covering zero alarms — with a single LogWarning emitted possibly weeks earlier as the only evidence. ResolveAsync now returns an AlarmWatchListResolution (targets + whether discovery was attempted and whether it failed, with the failure message), which is what the caller needed to tell "discovery produced nothing" from "nothing was configured". GatewayAlarmMonitor grades that resolution against the effective mode and folds the verdict into AlarmProviderStatus, so it reaches the feed, the dashboard badge and every late-joining subscriber: - zero targets while the mode can need them (Auto/ForceSubtag) -> degraded with SubtagWatchListEmpty; discovery enabled but failing adds SubtagDiscoveryFailed and the message. ForceAlarmManager builds no subtag consumer and is not graded. - degraded is now the OR of "subtag is serving" and "the watch-list covers nothing", so the Auto case where alarmmgr is healthy and the standby is empty is visible. DashboardAlarmProviderStatus renders that combination as the amber "Alarm Manager (no subtag standby)" badge rather than the failover label, which would have named the wrong live provider. - on the failover edge (ApplyProviderModeChangeAsync, toMode == Subtag) the monitor re-resolves BEFORE publishing the status and reports any difference as SubtagWatchListStale with both counts. The re-resolution is deliberately NOT re-sent. The worker rejects a second SubscribeAlarms while subscribed (AlarmCommandHandler.Subscribe), and the only expressible replacement — UnsubscribeAlarms then SubscribeAlarms — rebuilds FailoverAlarmConsumer, which restarts on the primary that just failed and discards the warm standby state, so it would flap the feed rather than repair it. The finding's other alternative (treat empty coverage as a lifecycle failure) was rejected too: it would crash-loop the monitor every 5 s on a persistent GR outage and take the working alarmmgr feed down with it. A monitor restart re-resolves and re-sends, and the status now says so. Docs: docs/AlarmClientDiscovery.md gains the grading rules and corrects two stale claims (the resolver method name, and "re-runs discovery on its reconcile cadence and pushes an updated watch-list", which never happened); gateway.md gains the standby-coverage visibility paragraph; docs/GatewayConfiguration.md records the consequence on the UseGalaxyRepository and Mode rows. Tests: SubtagCoverage_WhenDiscoveryFailsAtSubscribe_ProviderStatusIsDegradedWith- Reason and SubtagCoverage_WhenDiscoveryRecoversByFailover_ReResolvesAndReportsDrift drive the fake-worker monitor; two resolver cases pin the new provenance fields. The pre-existing dashboard case for degraded+ALARMMGR now asserts the new label.
304 lines
12 KiB
C#
304 lines
12 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the pure projection/formatting helpers behind the
|
|
/// dashboard Browse and Alarms tabs.
|
|
/// </summary>
|
|
public sealed class DashboardBrowseAndAlarmModelTests
|
|
{
|
|
/// <summary>Verifies that the tree builder links children to parents and promotes orphans to roots.</summary>
|
|
[Fact]
|
|
public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots()
|
|
{
|
|
GalaxyObject area = new() { GobjectId = 1, BrowseName = "AreaA", IsArea = true, ParentGobjectId = 0 };
|
|
GalaxyObject child = new() { GobjectId = 2, BrowseName = "Pump01", ParentGobjectId = 1 };
|
|
GalaxyObject orphan = new() { GobjectId = 3, BrowseName = "Lost", ParentGobjectId = 99 };
|
|
|
|
IReadOnlyList<DashboardBrowseNode> roots = DashboardBrowseTreeBuilder.Build([area, child, orphan]);
|
|
|
|
// The area and the orphan (its parent id is absent) are both roots.
|
|
Assert.Equal(2, roots.Count);
|
|
DashboardBrowseNode areaNode = Assert.Single(roots, node => node.Object.GobjectId == 1);
|
|
Assert.Single(areaNode.Children);
|
|
Assert.Equal(2, areaNode.Children[0].Object.GobjectId);
|
|
Assert.Contains(roots, node => node.Object.GobjectId == 3);
|
|
}
|
|
|
|
/// <summary>Verifies that the tree builder sorts areas before non-area objects.</summary>
|
|
[Fact]
|
|
public void BuildTree_SortsAreasBeforeObjects()
|
|
{
|
|
GalaxyObject instance = new() { GobjectId = 1, BrowseName = "Zeta", IsArea = false };
|
|
GalaxyObject areaB = new() { GobjectId = 2, BrowseName = "Beta", IsArea = true };
|
|
|
|
IReadOnlyList<DashboardBrowseNode> roots = DashboardBrowseTreeBuilder.Build([instance, areaB]);
|
|
|
|
Assert.Equal(2, roots.Count);
|
|
Assert.True(roots[0].IsArea);
|
|
Assert.Equal("Beta", roots[0].DisplayName);
|
|
Assert.False(roots[1].IsArea);
|
|
}
|
|
|
|
/// <summary>Verifies that the formatter renders boolean values correctly.</summary>
|
|
/// <param name="input">The boolean input value.</param>
|
|
/// <param name="expected">The expected formatted output.</param>
|
|
[Theory]
|
|
[InlineData(true, "true")]
|
|
[InlineData(false, "false")]
|
|
public void FormatValue_FormatsBooleans(bool input, string expected)
|
|
{
|
|
MxValue value = new() { DataType = MxDataType.Boolean, BoolValue = input };
|
|
Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value));
|
|
}
|
|
|
|
/// <summary>Verifies that the formatter renders numbers and strings correctly.</summary>
|
|
[Fact]
|
|
public void FormatValue_FormatsNumbersAndStrings()
|
|
{
|
|
Assert.Equal("42", DashboardMxValueFormatter.FormatValue(new MxValue { Int32Value = 42 }));
|
|
Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" }));
|
|
}
|
|
|
|
/// <summary>Verifies that the formatter handles null payloads and null references.</summary>
|
|
[Fact]
|
|
public void FormatValue_HandlesNullPayloadAndNullReference()
|
|
{
|
|
Assert.Equal("-", DashboardMxValueFormatter.FormatValue(null));
|
|
Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true }));
|
|
}
|
|
|
|
/// <summary>Verifies that tag values from successful reads mark good quality.</summary>
|
|
[Fact]
|
|
public void TagValue_FromSuccessfulReadResult_MarksGoodQuality()
|
|
{
|
|
BulkReadResult result = new()
|
|
{
|
|
TagAddress = "Galaxy!Area.Tag",
|
|
WasSuccessful = true,
|
|
Quality = 192,
|
|
Value = new MxValue { DataType = MxDataType.Double, DoubleValue = 1.5 },
|
|
};
|
|
|
|
DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result);
|
|
|
|
Assert.True(value.Ok);
|
|
Assert.True(value.QualityGood);
|
|
Assert.Equal("1.5", value.ValueText);
|
|
Assert.Null(value.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that tag values from failed reads carry the error message.</summary>
|
|
[Fact]
|
|
public void TagValue_FromFailedReadResult_CarriesError()
|
|
{
|
|
BulkReadResult result = new()
|
|
{
|
|
TagAddress = "Galaxy!Area.Bad",
|
|
WasSuccessful = false,
|
|
Quality = 0,
|
|
ErrorMessage = "invalid handle",
|
|
};
|
|
|
|
DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result);
|
|
|
|
Assert.False(value.Ok);
|
|
Assert.False(value.QualityGood);
|
|
Assert.Equal("invalid handle", value.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that active alarms parse provider and acknowledgement state from snapshots.</summary>
|
|
[Fact]
|
|
public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState()
|
|
{
|
|
ActiveAlarmSnapshot unacked = new()
|
|
{
|
|
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
|
Category = "TestArea",
|
|
CurrentState = AlarmConditionState.Active,
|
|
Severity = 500,
|
|
};
|
|
ActiveAlarmSnapshot acked = new()
|
|
{
|
|
AlarmFullReference = "Galaxy!TestArea.TestMachine_002.TestAlarm001",
|
|
CurrentState = AlarmConditionState.ActiveAcked,
|
|
};
|
|
|
|
DashboardActiveAlarm unackedRow = DashboardActiveAlarm.FromSnapshot(unacked);
|
|
DashboardActiveAlarm ackedRow = DashboardActiveAlarm.FromSnapshot(acked);
|
|
|
|
Assert.Equal("Galaxy", unackedRow.Provider);
|
|
Assert.Equal("TestArea", unackedRow.Area);
|
|
Assert.Equal(500, unackedRow.Severity);
|
|
Assert.True(unackedRow.IsUnacknowledged);
|
|
Assert.False(ackedRow.IsUnacknowledged);
|
|
}
|
|
|
|
/// <summary>Verifies that a healthy alarmmgr provider status maps to a green badge.</summary>
|
|
[Fact]
|
|
public void FromProviderStatus_Alarmmgr_NotDegraded_GreenBadge()
|
|
{
|
|
AlarmProviderStatus status = new()
|
|
{
|
|
Mode = AlarmProviderMode.Alarmmgr,
|
|
Degraded = false,
|
|
};
|
|
|
|
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
|
|
|
|
Assert.False(model.IsDegraded);
|
|
Assert.Contains("bg-success", model.BadgeCssClass, StringComparison.Ordinal);
|
|
Assert.Equal(DashboardAlarmProviderStatus.AlarmManagerLabel, model.Label);
|
|
}
|
|
|
|
/// <summary>Verifies that a degraded subtag provider status maps to an amber warning badge.</summary>
|
|
[Fact]
|
|
public void FromProviderStatus_Subtag_Degraded_WarningBadge()
|
|
{
|
|
AlarmProviderStatus status = new()
|
|
{
|
|
Mode = AlarmProviderMode.Subtag,
|
|
Degraded = true,
|
|
Reason = "x",
|
|
};
|
|
|
|
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
|
|
|
|
Assert.True(model.IsDegraded);
|
|
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
|
|
Assert.Equal("x", model.Reason);
|
|
|
|
// Pin the amber label text, not just the CSS class — a label swap
|
|
// would otherwise pass this test.
|
|
Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An explicitly-degraded status whose mode is still Alarmmgr (the
|
|
/// <c>Degraded || Mode==Subtag</c> guard's second, independent branch) must map to the
|
|
/// amber badge, but not to the failover label: GWC-37 gives that combination a meaning —
|
|
/// the alarm manager is serving and the subtag standby behind it covers nothing — so
|
|
/// reporting "Subtag monitoring (degraded)" would name the wrong live provider.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromProviderStatus_Alarmmgr_DegradedFlagSet_WarningBadge()
|
|
{
|
|
AlarmProviderStatus status = new()
|
|
{
|
|
Mode = AlarmProviderMode.Alarmmgr,
|
|
Degraded = true,
|
|
Reason = "independently degraded",
|
|
};
|
|
|
|
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
|
|
|
|
Assert.True(model.IsDegraded);
|
|
Assert.Equal(DashboardAlarmProviderStatus.NoSubtagStandbyLabel, model.Label);
|
|
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
|
|
Assert.Equal(AlarmProviderMode.Alarmmgr, model.Mode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>SinceUtc</c> field must carry the protobuf <c>Since</c>
|
|
/// timestamp converted to a <see cref="DateTimeOffset" />.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromProviderStatus_WithSinceTimestamp_MapsSinceUtc()
|
|
{
|
|
DateTimeOffset since = new(2026, 6, 15, 12, 30, 0, TimeSpan.Zero);
|
|
AlarmProviderStatus status = new()
|
|
{
|
|
Mode = AlarmProviderMode.Subtag,
|
|
Degraded = true,
|
|
Reason = "x",
|
|
Since = Timestamp.FromDateTimeOffset(since),
|
|
};
|
|
|
|
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
|
|
|
|
Assert.Equal(since, model.SinceUtc);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="DashboardAlarmProviderStatus.FromFeed" /> — the entry the
|
|
/// dashboard SignalR snapshot path actually calls — projects a provider-status
|
|
/// feed message into the badge model.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromFeed_ProviderStatusPayload_ProjectsBadge()
|
|
{
|
|
AlarmFeedMessage message = new()
|
|
{
|
|
ProviderStatus = new AlarmProviderStatus
|
|
{
|
|
Mode = AlarmProviderMode.Subtag,
|
|
Degraded = true,
|
|
Reason = "alarmmgr failed",
|
|
},
|
|
};
|
|
|
|
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromFeed(message);
|
|
|
|
Assert.Equal(AlarmProviderMode.Subtag, model.Mode);
|
|
Assert.True(model.IsDegraded);
|
|
Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
|
|
Assert.Equal("alarmmgr failed", model.Reason);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws
|
|
/// <see cref="ArgumentException" /> when the feed message does not carry a
|
|
/// provider-status payload.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromFeed_NonProviderStatusPayload_Throws()
|
|
{
|
|
AlarmFeedMessage message = new()
|
|
{
|
|
SnapshotComplete = true,
|
|
};
|
|
|
|
Assert.Throws<ArgumentException>(() => DashboardAlarmProviderStatus.FromFeed(message));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a configured forced-subtag provider status renders the
|
|
/// distinct "forced" badge (cyan/info), not the amber failover-degraded one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromProviderStatus_Subtag_ForcedReason_ForcedBadge()
|
|
{
|
|
AlarmProviderStatus status = new()
|
|
{
|
|
Mode = AlarmProviderMode.Subtag,
|
|
Degraded = true,
|
|
Reason = ZB.MOM.WW.MxGateway.Server.Alarms.AlarmProviderReasons.ForcedSubtag,
|
|
};
|
|
|
|
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
|
|
|
|
Assert.True(model.IsDegraded);
|
|
Assert.Equal(DashboardAlarmProviderStatus.ForcedSubtagLabel, model.Label);
|
|
Assert.Contains("bg-info", model.BadgeCssClass, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Verifies that the formatter renders array elements and element type correctly.</summary>
|
|
[Fact]
|
|
public void FormatValue_AndDataType_RenderArrayElementsAndElementType()
|
|
{
|
|
MxArray array = new() { ElementDataType = MxDataType.Double };
|
|
array.Dimensions.Add(3u);
|
|
array.DoubleValues = new DoubleArray();
|
|
array.DoubleValues.Values.Add(new[] { 1.5, 2.25, 3.0 });
|
|
MxValue value = new() { ArrayValue = array };
|
|
|
|
Assert.Equal("[1.5, 2.25, 3]", DashboardMxValueFormatter.FormatValue(value));
|
|
Assert.Equal("Double[3]", DashboardMxValueFormatter.FormatDataType(value));
|
|
}
|
|
}
|