Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs
T
Joseph Doherty 693a78db7d feat(alarms): structural degraded-status signal for truncated alarm snapshots
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.

Two additive proto3 booleans carry the verdict out:

- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)

The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.

Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.

Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.

Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.

Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.

Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
2026-08-17 04:18:34 -04:00

71 lines
3.0 KiB
C#

using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// One active-alarm row as shown on the dashboard Alarms tab. Projected
/// from an <see cref="ActiveAlarmSnapshot"/> so the Razor component never
/// touches protobuf types directly.
/// </summary>
public sealed record DashboardActiveAlarm(
string Reference,
string Provider,
string Area,
string Source,
string AlarmType,
int Severity,
AlarmConditionState State,
DateTimeOffset? LastTransition,
string OperatorUser,
string OperatorComment,
string Description)
{
/// <summary>Projects a worker active-alarm snapshot into a dashboard alarm row.</summary>
/// <param name="snapshot">The snapshot returned by <c>QueryActiveAlarms</c>.</param>
/// <returns>The projected dashboard alarm.</returns>
public static DashboardActiveAlarm FromSnapshot(ActiveAlarmSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
string provider = string.Empty;
string reference = snapshot.AlarmFullReference ?? string.Empty;
int bang = reference.IndexOf('!', StringComparison.Ordinal);
if (bang > 0)
{
provider = reference[..bang];
}
return new DashboardActiveAlarm(
Reference: reference,
Provider: provider,
Area: snapshot.Category ?? string.Empty,
Source: snapshot.SourceObjectReference ?? string.Empty,
AlarmType: snapshot.AlarmTypeName ?? string.Empty,
Severity: snapshot.Severity,
State: snapshot.CurrentState,
LastTransition: snapshot.LastTransitionTimestamp?.ToDateTimeOffset(),
OperatorUser: snapshot.OperatorUser ?? string.Empty,
OperatorComment: snapshot.OperatorComment ?? string.Empty,
Description: snapshot.Description ?? string.Empty);
}
/// <summary>True when this alarm is active and not yet acknowledged.</summary>
public bool IsUnacknowledged => State == AlarmConditionState.Active;
}
/// <summary>Result of a dashboard active-alarm query.</summary>
/// <param name="Alarms">The active alarms, or an empty list on error.</param>
/// <param name="Error">A diagnostic message when the query failed; otherwise null.</param>
/// <param name="WorkerProcessId">The worker process id backing the dashboard session, when available.</param>
/// <param name="SnapshotTruncated">
/// True when the provider fetch behind <paramref name="Alarms"/> hit its per-fetch cap, so the
/// list may be missing active alarms. Distinct from <paramref name="Error"/>: the query
/// succeeded and every row shown is real — only the set's completeness is in doubt, which the
/// page states as a caveat rather than a failure.
/// </param>
public sealed record DashboardAlarmQueryResult(
IReadOnlyList<DashboardActiveAlarm> Alarms,
string? Error,
int? WorkerProcessId,
bool SnapshotTruncated = false);