Compare commits
19 Commits
da0084886a
...
9d529b82b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d529b82b2 | |||
| 18ebfe7194 | |||
| 4cfa645bfb | |||
| 6ac95ea888 | |||
| 421d2d305e | |||
| 17d9a4cd2c | |||
| ce8fc092c8 | |||
| f1a7c34b69 | |||
| c259614a78 | |||
| cbb5e83633 | |||
| 735534a455 | |||
| 63ec226582 | |||
| c8a1bcef98 | |||
| 43973d34de | |||
| af10af1573 | |||
| 30a23458e9 | |||
| 54e21a8107 | |||
| b07394447f | |||
| 73f2a032e0 |
@@ -111,7 +111,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`AkkaHostedService.BuildRoles`). Singletons scope to the **site-specific** role.
|
||||
- **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so intra-cluster Akka remoting remains open to anyone who can reach the remoting port, and that boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
|
||||
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Proto evolution is **additive only** and field numbers are never reused (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror, then 24 for `AckTime`). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle, automated by `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]` (it always restores the csproj; an active `<Protobuf>` item must never be committed — it breaks the Docker build). **Stream events are BATCHED as of R2 (2026-08-15)** — a site emitting ~37.5k events/s used to pay one gRPC message per event. Additive wire shape, no new RPC: `batching_supported` on the request (`InstanceStreamRequest` field 3 / `SiteStreamRequest` field 2) plus a `SiteStreamEvent.batch` oneof case (**field 4**) carrying a new `SiteStreamEventBatch { repeated SiteStreamEvent events = 1 }`. **The proto3 default of that flag IS the negotiation, and it is load-bearing** — a batch frame reaches a pre-R2 central as `EventOneofCase.None`, whose `ConvertToDomainEvent` returns null, so the whole batch would vanish with no error anywhere; an old central cannot set the flag, so it never gets one, and an old site ignores the unknown request field and keeps sending per-event frames the new client's `ForEachEvent` unpack handles as the single-event case. Server side is `SiteStreamEventBatcher`, a per-subscriber pump **downstream of `StreamRelayActor`'s bounded DropOldest channel** — so it changes framing only and **does NOT move the burst ceiling** (deferred register row 31; that ceiling lives in the shared publish stage upstream of the BroadcastHub). It never delays a lone event: it drains the already-queued backlog for free and lingers only once a backlog is proven, then emits a single-event buffer as a plain frame. `GrpcStreamBatchMaxEvents` (100, `1` disables) and `GrpcStreamBatchWindow` (25 ms, validated **strictly under 250 ms** = the load test's end-to-end P99 threshold); measured worst case P99 25.4 ms. **Benchmark-proven at target scale 2026-08-18** (the LoadHarness wire path — real Kestrel h2c + real `SiteStreamGrpcClient`, `--wire-probes-per-site`): 899,807 events in 12,462 frames (mean 72.2 ev/frame), wire P99 43.4 ms (5.8× inside the 250 ms budget — a 75-event burst never hits the 100 cap, so every batch pays the full 25 ms window: this workload's worst-case latency posture), zero drops/faults; results doc §8. Histogram `scadabridge.site.stream.batch_size`, recorded only on negotiated streams (`ScadaBridgeTelemetry.MeterName` is already in the `ObservedMeters` allowlist). **The client reconnects on graceful (OK-status) stream completion, not just on fault (arch-review remediation, commit `34a3f4bb`)** — Kestrel's `MaxStreamLifetime`/`Grpc.AspNetCore.Server` max-connection-age periodically ends a healthy stream with a normal completion, which the client used to treat as terminal (no reconnect attempt), silently killing a site's live feed until the next process restart (observed up to ~4h on the rig); live-probed at a 2-minute forced lifetime, reconnect lands within one reconcile tick and `IsLive` reflects the gap in between.
|
||||
- Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface.
|
||||
- Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface. Computed alarms carry an `IsMesReportable` flag (template checkbox, instance-overridable via a nullable override) exposed to scripts as `ScriptAlarm.IsMesReportable` / `Alarms.MesReportableAsync()` (2026-09-11); native alarms have no per-alarm row and keep the 900–999 MES severity band.
|
||||
- **`AckTime` mirror enrichment + the `Alarms` script accessor (MES alarm-status API Phase 1, 2026-08-01).** `AlarmStateChanged` carries an additive `AckTime` (`DateTimeOffset?`), mirrored on the vendored `AlarmStateUpdate` proto as **field 24** and persisted inside `native_alarm_state`'s `metadata_json` — deliberately NOT a new column, because that table is `RegisterReplicated` and LocalDb builds its CDC triggers from the column list at registration time. Set only while a condition is active AND acknowledged (so it is null while unacked and cleared on re-raise); the DCL stamps the source's own ack instant for OPC UA (new SelectClause **index 18** = `AckedState/TransitionTime`) and its observation time of the ack transition for MxGateway, which supplies none. Site `Call` scripts read alarms via the new **`Alarms.CurrentAsync()`** accessor (`ScriptRuntimeContext` + `ScriptGlobals`, local Ask on `GetAlarmSnapshotRequest`, returns `Commons.Types.Scripts.ScriptAlarm`), mirrored on `ScriptCompileSurface` AND the Central UI `SandboxScriptHost` editor surface. The trust model needed no change — it is a deny-list over API roots, not an allow-list of context members. Plan: `docs/plans/2026-06-30-mes-alarm-status-api.md` (Phases 2–4 are deployed config, not repo).
|
||||
- OPC UA cert trust is **site-local and not persisted centrally** (follow-up): the verify-endpoint probe captures an untrusted server cert but **NEVER trusts it**, and DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to **BOTH** site nodes — `CertStoreActor` runs on every site node, not as a singleton, so PKI stores stay consistent across failover.
|
||||
|
||||
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Seed the MES-REPORTABLE ALARM demo on the docker rig
|
||||
# (design docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md, Task 13): one template whose
|
||||
# three computed alarms differ in how they acquire the MES-reportable flag, one site Call script
|
||||
# that projects them through the new script API, and two inbound methods that a MES client calls.
|
||||
#
|
||||
# What it proves end-to-end:
|
||||
# * `template alarm add --mes-reportable` -> ScriptAlarm.IsMesReportable == true (HighTemp)
|
||||
# * no flag on the template -> ScriptAlarm.IsMesReportable == false (TempSpike)
|
||||
# * `instance alarm-override set --mes-reportable true` BEATS a template false (SensorFault)
|
||||
# * `Alarms.MesReportableAsync()` is the pre-filtered read; `Alarms.CurrentAsync()` is not.
|
||||
#
|
||||
# What it creates (site-a / site id 1 — everything is static, so no data connection is needed):
|
||||
# - Template MesAlarmDemo
|
||||
# * Temperature (Double, STATIC, value 20) — writing it trips the computed alarms synchronously
|
||||
# * HighTemp HiLo hi=80 hihi=95 priority 500 --mes-reportable
|
||||
# * TempSpike ValueMatch match=100 priority 300 (not reportable)
|
||||
# * SensorFault ValueMatch match=-1 priority 700 (not reportable ON THE TEMPLATE)
|
||||
# * AlarmStatus (Call) — the MES alarm-status projection, with the filter parameters
|
||||
# - Instance MesAlarmDemo01 on site 1, with an alarm override making SensorFault reportable
|
||||
# - Inbound methods AlarmStatus (full filter) and SimpleAlarmStatus (flagged-only shorthand),
|
||||
# both routing to the site script via Route.To(MachineCode)
|
||||
# - API key "mes-alarm-demo" scoped to both methods — the token is printed ONCE
|
||||
#
|
||||
# IDEMPOTENT: every object is looked up by name first; scripts and methods are UPDATED in place.
|
||||
# Only the API key cannot be re-created (key display names are not unique and the token is not
|
||||
# retrievable) — a re-run says so instead of minting a second key.
|
||||
#
|
||||
# Prerequisites: bash docker/deploy.sh; docker/seed-sites.sh; build the CLI first:
|
||||
# dotnet build src/ZB.MOM.WW.ScadaBridge.CLI
|
||||
# (this script invokes the Debug binary by path — see $CLI below).
|
||||
#
|
||||
# Live gate: MES_ALARM_DEMO_KEY=<token> bash docker/verify-mes-alarm-demo.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
|
||||
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
|
||||
SITE_ID="${SITE_ID:-1}"
|
||||
TEMPLATE_NAME="${TEMPLATE_NAME:-MesAlarmDemo}"
|
||||
INSTANCE_NAME="${INSTANCE_NAME:-MesAlarmDemo01}"
|
||||
KEY_NAME="${KEY_NAME:-mes-alarm-demo}"
|
||||
|
||||
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
|
||||
# stdin = JSON array, $1 = key, $2 = value -> the matching row's id, or "" when absent
|
||||
find_id_opt() {
|
||||
python3 -c 'import json,sys
|
||||
k,v=sys.argv[1],sys.argv[2]
|
||||
print(next((str(x["id"]) for x in json.load(sys.stdin) if str(x.get(k))==v), ""))' "$1" "$2"
|
||||
}
|
||||
# stdin = template get JSON, $1 = child collection ("attributes"/"alarms"/"scripts"), $2 = name
|
||||
child_id() {
|
||||
python3 -c 'import json,sys
|
||||
t=json.load(sys.stdin); coll,name=sys.argv[1],sys.argv[2]
|
||||
print(next((str(c["id"]) for c in (t.get(coll) or []) if c.get("name")==name), ""))' "$1" "$2"
|
||||
}
|
||||
|
||||
# The MES alarm-status projection. Parameters are all optional except MachineCode; the filter
|
||||
# arrives from the inbound method's AlarmFilter object flattened into scalars.
|
||||
#
|
||||
# Two things worth knowing when reading this:
|
||||
# * `Active && !IsConfiguredPlaceholder` is the canonical "really in alarm" filter — the
|
||||
# snapshot retains a row per configured alarm, including cleared ones and the placeholder
|
||||
# rows that stand in for native source bindings holding no conditions.
|
||||
# * COMPUTED alarms report Acknowledged == true and AckTime == null by construction
|
||||
# (AlarmConditionStateFactory.ForComputed) — there is no ack-back for them — so StatusCode
|
||||
# reads "Triggered.Acked" and AckDT is null on this demo. Native (mirrored) conditions are
|
||||
# where the ack fields carry real source data.
|
||||
read -r -d '' SITE_SCRIPT_CODE <<'CS' || true
|
||||
var code = Parameters["MachineCode"]?.ToString() ?? "";
|
||||
var flaggedOnly = Parameters.Get<bool?>("FlaggedOnly") ?? false;
|
||||
var includeAck = Parameters.Get<bool?>("IncludeAcked") ?? true;
|
||||
var minSev = Parameters.Get<int?>("MinSeverity");
|
||||
var maxSev = Parameters.Get<int?>("MaxSeverity");
|
||||
var nameFilter = Parameters.ContainsKey("NameFilter") ? Parameters["NameFilter"]?.ToString() : null;
|
||||
try {
|
||||
var alarms = flaggedOnly ? await Alarms.MesReportableAsync() : await Alarms.CurrentAsync();
|
||||
var infos = alarms
|
||||
.Where(a => a.Active && !a.IsConfiguredPlaceholder)
|
||||
.Where(a => minSev == null || a.Severity >= minSev)
|
||||
.Where(a => maxSev == null || a.Severity <= maxSev)
|
||||
.Where(a => string.IsNullOrEmpty(nameFilter) || a.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => includeAck || !a.Acknowledged)
|
||||
.Select(a => new {
|
||||
Name = a.Name,
|
||||
HierarchicalName = code + "." + a.Name,
|
||||
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message,
|
||||
IsFlaggedForMES = a.IsMesReportable,
|
||||
Severity = a.Severity,
|
||||
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
||||
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp).ToString("o"),
|
||||
AckDT = a.AckTime?.ToString("o"),
|
||||
AckComment = a.OperatorComment })
|
||||
.ToList();
|
||||
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
|
||||
} catch (Exception ex) {
|
||||
return new { WasSuccessful = false, ErrorText = "AlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
|
||||
}
|
||||
CS
|
||||
|
||||
SITE_PARAMS='{"type":"object","properties":{"MachineCode":{"type":"string"},"FlaggedOnly":{"type":"boolean"},"IncludeAcked":{"type":"boolean"},"MinSeverity":{"type":"integer"},"MaxSeverity":{"type":"integer"},"NameFilter":{"type":"string"}},"required":["MachineCode"]}'
|
||||
RETURN_DEF='{"type":"object","properties":{"WasSuccessful":{"type":"boolean"},"ErrorText":{"type":"string"},"Alarms":{"type":"array","items":{"type":"object","properties":{"Name":{"type":"string"},"HierarchicalName":{"type":"string"},"Description":{"type":"string"},"IsFlaggedForMES":{"type":"boolean"},"Severity":{"type":"integer"},"StatusCode":{"type":"string"},"TriggeredDT":{"type":"string"},"AckDT":{"type":"string"},"AckComment":{"type":"string"}}}}},"required":["WasSuccessful","Alarms"]}'
|
||||
|
||||
# Inbound method: flatten the nested AlarmFilter object into the site script's scalar parameters.
|
||||
# An object parameter materializes as Dictionary<string, object?>; integers arrive as long.
|
||||
read -r -d '' METHOD_CODE <<'CS' || true
|
||||
var code = (Parameters.ContainsKey("MachineCode") ? Parameters["MachineCode"]?.ToString() : null) ?? "";
|
||||
var f = Parameters.ContainsKey("AlarmFilter") ? Parameters["AlarmFilter"] as System.Collections.Generic.IDictionary<string, object?> : null;
|
||||
object? G(string k) => f != null && f.TryGetValue(k, out var v) ? v : null;
|
||||
try {
|
||||
return await Route.To(code).Call("AlarmStatus", new {
|
||||
MachineCode = code,
|
||||
FlaggedOnly = G("FlaggedOnly") is bool fo && fo,
|
||||
IncludeAcked = !(G("IncludeAcked") is bool ia) || ia,
|
||||
MinSeverity = G("MinSeverity") is long mn ? (int?)(int)mn : null,
|
||||
MaxSeverity = G("MaxSeverity") is long mx ? (int?)(int)mx : null,
|
||||
NameFilter = G("NameFilter")?.ToString() });
|
||||
} catch (Exception ex) when (ex.Message.Contains("not found on instance")) {
|
||||
return new { WasSuccessful = false, ErrorText = $"Alarm status is not supported on machine '{code}'", Alarms = Array.Empty<object>() };
|
||||
} catch (Exception ex) {
|
||||
return new { WasSuccessful = false, ErrorText = "AlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
|
||||
}
|
||||
CS
|
||||
|
||||
# Shorthand for a MES client that only ever wants the flagged alarms.
|
||||
read -r -d '' SIMPLE_METHOD_CODE <<'CS' || true
|
||||
var code = (Parameters.ContainsKey("MachineCode") ? Parameters["MachineCode"]?.ToString() : null) ?? "";
|
||||
try {
|
||||
return await Route.To(code).Call("AlarmStatus", new {
|
||||
MachineCode = code, FlaggedOnly = true, IncludeAcked = true,
|
||||
MinSeverity = (int?)null, MaxSeverity = (int?)null, NameFilter = (string)null });
|
||||
} catch (Exception ex) when (ex.Message.Contains("not found on instance")) {
|
||||
return new { WasSuccessful = false, ErrorText = $"Alarm status is not supported on machine '{code}'", Alarms = Array.Empty<object>() };
|
||||
} catch (Exception ex) {
|
||||
return new { WasSuccessful = false, ErrorText = "AlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
|
||||
}
|
||||
CS
|
||||
|
||||
METHOD_PARAMS='{"type":"object","properties":{"MachineCode":{"type":"string"},"AlarmFilter":{"type":"object","properties":{"FlaggedOnly":{"type":"boolean"},"IncludeAcked":{"type":"boolean"},"MinSeverity":{"type":"integer"},"MaxSeverity":{"type":"integer"},"NameFilter":{"type":"string"}}}},"required":["MachineCode"]}'
|
||||
SIMPLE_METHOD_PARAMS='{"type":"object","properties":{"MachineCode":{"type":"string"}},"required":["MachineCode"]}'
|
||||
|
||||
echo "1/8 Template $TEMPLATE_NAME..."
|
||||
TEMPLATE_ID=$("$CLI" template list "${A[@]}" | find_id_opt name "$TEMPLATE_NAME")
|
||||
if [ -z "$TEMPLATE_ID" ]; then
|
||||
TEMPLATE_ID=$("$CLI" template create "${A[@]}" --name "$TEMPLATE_NAME" \
|
||||
--description "MES-reportable alarm flag demo (docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md)" | json_id)
|
||||
echo " created id=$TEMPLATE_ID"
|
||||
else
|
||||
echo " exists id=$TEMPLATE_ID"
|
||||
fi
|
||||
|
||||
echo "2/8 Static attribute Temperature (Double, 20)..."
|
||||
if [ -z "$("$CLI" template get "${A[@]}" --id "$TEMPLATE_ID" | child_id attributes Temperature)" ]; then
|
||||
"$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Temperature \
|
||||
--data-type Double --value 20 --description "Static process value; writing it trips the computed alarms synchronously" > /dev/null
|
||||
echo " added"
|
||||
else
|
||||
echo " exists"
|
||||
fi
|
||||
|
||||
echo "3/8 Alarms HighTemp / TempSpike / SensorFault..."
|
||||
TPL_JSON=$("$CLI" template get "${A[@]}" --id "$TEMPLATE_ID")
|
||||
if [ -z "$(echo "$TPL_JSON" | child_id alarms HighTemp)" ]; then
|
||||
"$CLI" template alarm add "${A[@]}" --template-id "$TEMPLATE_ID" --name HighTemp --trigger-type HiLo \
|
||||
--attribute Temperature --hi 80 --hihi 95 --priority 500 --mes-reportable \
|
||||
--description "High temperature — reportable to MES from the template" > /dev/null
|
||||
echo " added HighTemp (mes-reportable)"
|
||||
else
|
||||
echo " HighTemp exists"
|
||||
fi
|
||||
if [ -z "$(echo "$TPL_JSON" | child_id alarms TempSpike)" ]; then
|
||||
"$CLI" template alarm add "${A[@]}" --template-id "$TEMPLATE_ID" --name TempSpike --trigger-type ValueMatch \
|
||||
--attribute Temperature --match-value 100 --priority 300 \
|
||||
--description "Exact spike value — deliberately NOT reportable" > /dev/null
|
||||
echo " added TempSpike (not reportable)"
|
||||
else
|
||||
echo " TempSpike exists"
|
||||
fi
|
||||
if [ -z "$(echo "$TPL_JSON" | child_id alarms SensorFault)" ]; then
|
||||
# --match-value=-1 (not a space) so the leading dash is not parsed as another option.
|
||||
"$CLI" template alarm add "${A[@]}" --template-id "$TEMPLATE_ID" --name SensorFault --trigger-type ValueMatch \
|
||||
--attribute Temperature --match-value=-1 --priority 700 \
|
||||
--description "Sensor fault sentinel — not reportable on the template; the INSTANCE overrides it to true" > /dev/null
|
||||
echo " added SensorFault (not reportable on the template)"
|
||||
else
|
||||
echo " SensorFault exists"
|
||||
fi
|
||||
|
||||
echo "4/8 Site script AlarmStatus (Call)..."
|
||||
SCRIPT_ID=$("$CLI" template get "${A[@]}" --id "$TEMPLATE_ID" | child_id scripts AlarmStatus)
|
||||
if [ -n "$SCRIPT_ID" ]; then
|
||||
"$CLI" template script update "${A[@]}" --id "$SCRIPT_ID" --name AlarmStatus --trigger-type Call \
|
||||
--parameters "$SITE_PARAMS" --return-def "$RETURN_DEF" --code "$SITE_SCRIPT_CODE" > /dev/null
|
||||
echo " updated id=$SCRIPT_ID"
|
||||
else
|
||||
SCRIPT_ID=$("$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name AlarmStatus --trigger-type Call \
|
||||
--parameters "$SITE_PARAMS" --return-def "$RETURN_DEF" --code "$SITE_SCRIPT_CODE" | json_id)
|
||||
echo " added id=$SCRIPT_ID"
|
||||
fi
|
||||
|
||||
echo "5/8 Instance $INSTANCE_NAME on site $SITE_ID..."
|
||||
INSTANCE_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$INSTANCE_NAME")
|
||||
if [ -z "$INSTANCE_ID" ]; then
|
||||
INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$TEMPLATE_ID" --site-id "$SITE_ID" | json_id)
|
||||
echo " created id=$INSTANCE_ID"
|
||||
else
|
||||
echo " exists id=$INSTANCE_ID"
|
||||
fi
|
||||
|
||||
echo "6/8 Instance alarm override: SensorFault --mes-reportable true (beats the template false)..."
|
||||
"$CLI" instance alarm-override set "${A[@]}" --instance-id "$INSTANCE_ID" --alarm SensorFault --mes-reportable true > /dev/null
|
||||
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null
|
||||
echo " override set and instance deployed"
|
||||
|
||||
echo "7/8 Inbound methods AlarmStatus / SimpleAlarmStatus..."
|
||||
UPSERTED_METHOD_ID=""
|
||||
upsert_method() { # $1 = name, $2 = params schema, $3 = script -> sets UPSERTED_METHOD_ID
|
||||
local existing
|
||||
existing=$("$CLI" api-method list "${A[@]}" | find_id_opt name "$1")
|
||||
if [ -n "$existing" ]; then
|
||||
# KNOWN TRAP: a non-compiling UPDATE keeps serving the OLD delegate silently. The
|
||||
# flow-debug smoke below is what actually proves the new body compiled and runs.
|
||||
"$CLI" api-method update "${A[@]}" --id "$existing" --timeout 30 \
|
||||
--parameters "$2" --return-def "$RETURN_DEF" --script "$3" > /dev/null
|
||||
echo " updated $1 id=$existing"
|
||||
else
|
||||
existing=$("$CLI" api-method create "${A[@]}" --name "$1" --timeout 30 \
|
||||
--parameters "$2" --return-def "$RETURN_DEF" --script "$3" | json_id)
|
||||
echo " created $1 id=$existing"
|
||||
fi
|
||||
UPSERTED_METHOD_ID="$existing"
|
||||
}
|
||||
upsert_method AlarmStatus "$METHOD_PARAMS" "$METHOD_CODE"; METHOD_ID="$UPSERTED_METHOD_ID"
|
||||
upsert_method SimpleAlarmStatus "$SIMPLE_METHOD_PARAMS" "$SIMPLE_METHOD_CODE"; SIMPLE_METHOD_ID="$UPSERTED_METHOD_ID"
|
||||
|
||||
echo "8/8 API key '$KEY_NAME' scoped to AlarmStatus,SimpleAlarmStatus..."
|
||||
EXISTING_KEY=$("$CLI" security api-key list "${A[@]}" | python3 -c '
|
||||
import json,sys
|
||||
name=sys.argv[1]
|
||||
rows=json.load(sys.stdin)
|
||||
print("yes" if any((r.get("name") or r.get("displayName"))==name for r in rows) else "")' "$KEY_NAME")
|
||||
if [ -n "$EXISTING_KEY" ]; then
|
||||
echo " key '$KEY_NAME' already exists — the token cannot be re-shown."
|
||||
echo " Re-use the MES_ALARM_DEMO_KEY from the first run, or delete the key"
|
||||
echo " (security api-key delete --key-id ...) and re-run this script."
|
||||
else
|
||||
KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods AlarmStatus,SimpleAlarmStatus)
|
||||
TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' 2>/dev/null \
|
||||
|| echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1)
|
||||
echo ""
|
||||
echo "MES_ALARM_DEMO_KEY=$TOKEN"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "MES_ALARM_DEMO_TEMPLATE_ID=$TEMPLATE_ID MES_ALARM_DEMO_INSTANCE_ID=$INSTANCE_ID"
|
||||
echo "MES_ALARM_DEMO_METHOD_ID=$METHOD_ID MES_ALARM_DEMO_SIMPLE_METHOD_ID=$SIMPLE_METHOD_ID"
|
||||
echo ""
|
||||
echo "Smoke (also the compile check an api-method UPDATE does not give you):"
|
||||
echo " $CLI flow-debug run-method --method-id $METHOD_ID --params '{\"MachineCode\":\"$INSTANCE_NAME\"}' ${A[*]}"
|
||||
echo "Trip an alarm / clear it:"
|
||||
echo " $CLI flow-debug run-value-change --instance-id $INSTANCE_ID --attribute Temperature --value 96 ${A[*]}"
|
||||
echo " $CLI flow-debug run-value-change --instance-id $INSTANCE_ID --attribute Temperature --value 20 ${A[*]}"
|
||||
echo "Live gate: MES_ALARM_DEMO_KEY=<token> bash $SCRIPT_DIR/verify-mes-alarm-demo.sh"
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Live gate for the MES-REPORTABLE ALARM flag (design
|
||||
# docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md, Task 14). Drives the objects seeded by
|
||||
# docker/seed-mes-alarm-demo.sh through the REAL inbound HTTP surface (Traefik -> active central ->
|
||||
# site script -> Instance Actor alarm snapshot), so every assertion below is end-to-end.
|
||||
#
|
||||
# Steps:
|
||||
# A 96 -> AlarmStatus : only HighTemp, IsFlaggedForMES true (template flag)
|
||||
# B 100 -> AlarmStatus : HighTemp(true) + TempSpike(false) unfiltered;
|
||||
# FlaggedOnly=true drops TempSpike and keeps HighTemp
|
||||
# C -1 -> SimpleAlarmStatus : SensorFault true (INSTANCE override beats template false),
|
||||
# HighTemp absent (-1 trips no high band)
|
||||
# D -1 -> AlarmStatus MinSeverity : 600 leaves SensorFault (700) only
|
||||
# E 20 -> AlarmStatus : WasSuccessful true, zero alarms
|
||||
# F DelmiaSim01 : machine without the script -> "not supported on machine"
|
||||
# G NoSuchMachine : unknown machine -> "not found"
|
||||
#
|
||||
# Usage: MES_ALARM_DEMO_KEY=<token from the seed> bash docker/verify-mes-alarm-demo.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
|
||||
URL="${SCADABRIDGE_URL:-http://localhost:9000}"
|
||||
A=(--url "$URL" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
|
||||
KEY="${MES_ALARM_DEMO_KEY:?set MES_ALARM_DEMO_KEY to the token printed by docker/seed-mes-alarm-demo.sh}"
|
||||
INSTANCE_NAME="${INSTANCE_NAME:-MesAlarmDemo01}"
|
||||
# A deployed instance that does NOT have an AlarmStatus script (docker/seed-delmia-sim.sh).
|
||||
OTHER_INSTANCE="${OTHER_INSTANCE:-DelmiaSim01}"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
pass() { PASS=$((PASS+1)); echo " PASS $*"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " FAIL $*"; }
|
||||
|
||||
find_id_opt() {
|
||||
python3 -c 'import json,sys
|
||||
k,v=sys.argv[1],sys.argv[2]
|
||||
print(next((str(x["id"]) for x in json.load(sys.stdin) if str(x.get(k))==v), ""))' "$1" "$2"
|
||||
}
|
||||
|
||||
INSTANCE_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$INSTANCE_NAME")
|
||||
[ -n "$INSTANCE_ID" ] || { echo "instance $INSTANCE_NAME not found — run docker/seed-mes-alarm-demo.sh first"; exit 1; }
|
||||
echo "instance $INSTANCE_NAME id=$INSTANCE_ID"
|
||||
|
||||
trip() { # $1 = Temperature value; the write trips the computed alarms synchronously at the site
|
||||
"$CLI" flow-debug run-value-change "${A[@]}" --instance-id "$INSTANCE_ID" --attribute Temperature --value="$1" > /dev/null
|
||||
sleep 2
|
||||
}
|
||||
|
||||
call() { # $1 = method name, $2 = JSON body -> response body on stdout
|
||||
curl -sS -X POST "$URL/api/$1" -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d "$2"
|
||||
}
|
||||
|
||||
# $1 = response JSON, $2.. = python assertion expressions over `d` (the parsed response) and
|
||||
# `alarms` (a {name: row} map); prints OK or the offending value.
|
||||
assert() { # $1 = label, $2 = response, $3 = python expression
|
||||
local out
|
||||
out=$(python3 -c '
|
||||
import json,sys
|
||||
d=json.loads(sys.argv[1])
|
||||
body=d.get("data", d) if isinstance(d, dict) else d
|
||||
alarms={a["Name"]: a for a in (body.get("Alarms") or [])}
|
||||
names=sorted(alarms)
|
||||
ok=eval(sys.argv[2])
|
||||
print("OK" if ok else "got WasSuccessful=%r names=%r rows=%s" % (body.get("WasSuccessful"), names, json.dumps([alarms[n] for n in names])[:600]))
|
||||
' "$2" "$3" 2>&1) || { fail "$1 (assertion error: $out)"; return; }
|
||||
if [ "$out" = "OK" ]; then pass "$1"; else fail "$1 — $out"; fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== A: Temperature 96 -> HighTemp only, flagged by the TEMPLATE ==="
|
||||
trip 96
|
||||
RA=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "A1 WasSuccessful true, exactly 1 alarm, named HighTemp" "$RA" \
|
||||
'body.get("WasSuccessful") is True and names==["HighTemp"]'
|
||||
# StatusCode is "Triggered.Acked", not "Triggered": AlarmConditionStateFactory.ForComputed reports
|
||||
# Acknowledged == true for every computed alarm (there is no ack-back for them), and AckDT is null.
|
||||
assert "A2 HighTemp IsFlaggedForMES true, Severity 500, StatusCode Triggered.Acked, AckDT null" "$RA" \
|
||||
'alarms["HighTemp"]["IsFlaggedForMES"] is True and alarms["HighTemp"]["Severity"]==500 and alarms["HighTemp"]["StatusCode"]=="Triggered.Acked" and alarms["HighTemp"]["AckDT"] is None'
|
||||
assert "A3 HierarchicalName is <machine>.<alarm>" "$RA" \
|
||||
'alarms["HighTemp"]["HierarchicalName"]=="'"$INSTANCE_NAME"'.HighTemp"'
|
||||
# H (design §6 step 7): cross-check the API answer against the site's own view of the same moment.
|
||||
# NOTE the snapshot's own `isMesReportable` is always false here and that is BY DESIGN — the flag is
|
||||
# deliberately not on the gRPC protos in this slice (design §2.2), so central never sees it; only the
|
||||
# site-local script snapshot is stamped. The cross-check is therefore about alarm ACTIVITY.
|
||||
SNAP=$("$CLI" debug snapshot "${A[@]}" --id "$INSTANCE_ID" 2>/dev/null || echo '{}')
|
||||
HSTATE=$(echo "$SNAP" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
rows=(d.get("data") or d).get("alarmStates") or []
|
||||
r=next((r for r in rows if r.get("alarmName")=="HighTemp"), None)
|
||||
# AlarmState: Active=0, Normal=1; condition.active is the same fact spelled out.
|
||||
print("MISSING" if r is None else ("Active" if (r.get("condition") or {}).get("active") or r.get("state")==0 else "Normal"))' 2>/dev/null || echo "MISSING")
|
||||
if [ "$HSTATE" = "Active" ]; then pass "H debug snapshot shows HighTemp Active"; else fail "H debug snapshot HighTemp state=$HSTATE"; fi
|
||||
|
||||
echo ""
|
||||
echo "=== B: Temperature 100 -> HighTemp(HiHi) + TempSpike; FlaggedOnly drops TempSpike ==="
|
||||
trip 100
|
||||
RB=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"FlaggedOnly\":false}}")
|
||||
assert "B1 unfiltered: HighTemp + TempSpike" "$RB" \
|
||||
'body.get("WasSuccessful") is True and names==["HighTemp","TempSpike"]'
|
||||
assert "B2 HighTemp flagged true, TempSpike flagged false" "$RB" \
|
||||
'alarms["HighTemp"]["IsFlaggedForMES"] is True and alarms["TempSpike"]["IsFlaggedForMES"] is False'
|
||||
RB2=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"FlaggedOnly\":true}}")
|
||||
assert "B3 FlaggedOnly=true: HighTemp present, TempSpike absent" "$RB2" \
|
||||
'body.get("WasSuccessful") is True and names==["HighTemp"]'
|
||||
|
||||
echo ""
|
||||
echo "=== C: Temperature -1 -> SensorFault reportable by INSTANCE OVERRIDE ==="
|
||||
trip -1
|
||||
RC=$(call SimpleAlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "C1 SimpleAlarmStatus returns SensorFault only (flagged-only)" "$RC" \
|
||||
'body.get("WasSuccessful") is True and names==["SensorFault"]'
|
||||
assert "C2 SensorFault IsFlaggedForMES true (override beats template false), Severity 700" "$RC" \
|
||||
'alarms["SensorFault"]["IsFlaggedForMES"] is True and alarms["SensorFault"]["Severity"]==700'
|
||||
RC2=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "C3 unfiltered at -1: HighTemp absent, SensorFault present" "$RC2" \
|
||||
'names==["SensorFault"]'
|
||||
|
||||
echo ""
|
||||
echo "=== D: MinSeverity 600 keeps SensorFault (700) only ==="
|
||||
RD=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"MinSeverity\":600}}")
|
||||
assert "D1 MinSeverity=600 -> SensorFault only" "$RD" \
|
||||
'body.get("WasSuccessful") is True and names==["SensorFault"] and alarms["SensorFault"]["Severity"]>=600'
|
||||
|
||||
echo ""
|
||||
echo "=== E: Temperature 20 -> everything clear ==="
|
||||
trip 20
|
||||
RE=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "E1 WasSuccessful true with zero alarms" "$RE" \
|
||||
'body.get("WasSuccessful") is True and names==[]'
|
||||
|
||||
echo ""
|
||||
echo "=== F: a machine without the AlarmStatus script ==="
|
||||
OTHER_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$OTHER_INSTANCE")
|
||||
if [ -z "$OTHER_ID" ]; then
|
||||
echo " SKIP $OTHER_INSTANCE is not on this rig (docker/seed-delmia-sim.sh not run) — step F skipped"
|
||||
else
|
||||
RF=$(call AlarmStatus "{\"MachineCode\":\"$OTHER_INSTANCE\"}")
|
||||
assert "F1 WasSuccessful false, ErrorText says not supported on machine" "$RF" \
|
||||
'body.get("WasSuccessful") is False and "not supported on machine" in (body.get("ErrorText") or "")'
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== G: an unknown machine ==="
|
||||
RG=$(call AlarmStatus '{"MachineCode":"NoSuchMachine"}')
|
||||
assert "G1 WasSuccessful false, ErrorText contains 'not found'" "$RG" \
|
||||
'body.get("WasSuccessful") is False and "not found" in (body.get("ErrorText") or "")'
|
||||
|
||||
echo ""
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "PASS $PASS/$TOTAL"
|
||||
else
|
||||
echo "FAIL $FAIL of $TOTAL (passed $PASS/$TOTAL)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -214,7 +214,7 @@ try {
|
||||
|
||||
## 6. Key design decisions — ALL DECIDED 2026-08-01 (design review with user)
|
||||
|
||||
1. **`IsFlaggedForMES` for native alarms — DECIDED: severity band.** MES relevance is encoded in the alarm severity itself: MES-relevant alarms are configured (in the Galaxy alarm priority) into a **dedicated band 900–999** reserved exclusively for MES-relevant alarms, so it cannot collide with ordinary criticality tuning. Scripts carry `MesBandMin = 900` / `MesBandMax = 999` as named constants; `IsFlaggedForMES = (Severity in band)` is reported honestly per row; `SimpleAlarmStatus` (always flagged-only) and `AlarmStatus` with `FlaggedOnly=true` filter by the band predicate. Since `Severity` is returned verbatim, MES's own `MinSeverity`/`MaxSeverity` filters compose naturally with the band. No new tables, code flags, or allow-lists. **Operational prerequisite:** the Galaxy alarm priorities for MES-relevant CvdReactor alarms must be set into 900–999 before the endpoints are meaningful.
|
||||
1. **Superseded for COMPUTED alarms 2026-09-11** by `2026-09-11-mes-reportable-alarm-flag-design.md` — still the rule for native alarms. **`IsFlaggedForMES` for native alarms — DECIDED: severity band.** MES relevance is encoded in the alarm severity itself: MES-relevant alarms are configured (in the Galaxy alarm priority) into a **dedicated band 900–999** reserved exclusively for MES-relevant alarms, so it cannot collide with ordinary criticality tuning. Scripts carry `MesBandMin = 900` / `MesBandMax = 999` as named constants; `IsFlaggedForMES = (Severity in band)` is reported honestly per row; `SimpleAlarmStatus` (always flagged-only) and `AlarmStatus` with `FlaggedOnly=true` filter by the band predicate. Since `Severity` is returned verbatim, MES's own `MinSeverity`/`MaxSeverity` filters compose naturally with the band. No new tables, code flags, or allow-lists. **Operational prerequisite:** the Galaxy alarm priorities for MES-relevant CvdReactor alarms must be set into 900–999 before the endpoints are meaningful.
|
||||
|
||||
2. **MESReceiver version — DECIDED: dropped entirely.** With no native alarm sources there is no live state; a config-only catalog answer was judged not worth shipping. Only CvdReactor implements the scripts; other machines get the router's clean "not supported on machine" error (§5.1). No BTDB `MachineAlarm` dependency remains.
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
|
||||
| 68 | ~~**`deploy.sh` run from a git worktree silently rehomes every node's bind-mount data dir**~~ **RESOLVED 2026-08-15 (guard added in the same close-out).** The compose files' bind mounts are relative (`./site-a-node-a/data`), so a deploy from a worktree checkout mounts the worktree's copies — the rig keeps running and looks healthy, but its SQLite stores split from the main checkout's and everything written there is orphaned by the next deploy from main. Bit the rows-46–53 close-out live: the seeding agent's `rig-probe-1` deploy persisted into worktree-homed dirs and "vanished" after the orchestrator's redeploy (no tombstone, no product bug — LWW/tombstone forensics ruled out deletion; the row sat in `.claude/worktrees/…/docker/site-a-node-a/data/site-localdb.db` the whole time). Both `docker/deploy.sh` and `docker-env2/deploy.sh` now refuse to run from a `.claude/worktrees/` path (override: `SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1`). | Found during the rows-46–53 close-out | Closed 2026-08-15. | — |
|
||||
| 119 | **Port the QA/prod MES receiver scripts onto the reset-aware `WriteBatchAndWaitAsync` overload (Gitea #36)** *(Medium)*. Template scripts CvdReactor MesMoveIn (13) / MesMoveOut (14) / MesSampleData (36) and base MESReceiver MoveIn/MoveOut (15, 16, 25, 26, 37, 38) still reset `<X>CompleteFlag` with a plain write and rely on `Task.Delay(1000)`; QA carries a config-only `WaitAsync(false, 10 s)` patch on 13/14 (deployment record 110). Rewrite: `var hs = await rx.Attributes.WriteBatchAndWaitAsync(values, "<X>Flag", true, "<X>CompleteFlag", true, 25 s, "<X>CompleteFlag", false, 10 s); if (!hs.Matched) return new { WasSuccessful=false, ErrorText = hs.ResetFailed ? "Timeout waiting for <X>CompleteFlag to reset" : "Timeout waiting for <X> to complete", BatchID=0 };` and delete the delay. Requires the #36 build on the target central + sites first (the overload does not exist on older sites → deploy-gate compile failure, by design). | Gitea #36, design `2026-09-10-handshake-reset-echo-design.md` §8 | Repo-only scope was the user's decision for the #36 session; the script bodies live only in the QA/prod databases. | Next QA deployment of a build ≥ this change (write a deployment record; then prod). |
|
||||
| 120 | **Data-sourced SCALAR writes reach the OPC UA server as String Variants — every non-String node rejects them with BadTypeMismatch (0x80740000)** *(Medium)*. `InstanceActor.HandleSetDataAttribute` / `HandleWriteAttributeBatch` decode only List values before the DCL write ("Scalars are unchanged"), so the codec-encoded string (`"True"`, `"42.5"`) reaches `RealOpcUaClient.WriteValuesAsync`, which wraps it in `new Variant(value)` — a String variant sent to a Boolean/Double node. Found live 2026-09-10 running `docker/seed-handshake-demo.sh` against opc-plc `Motor.Running`/`Pump.Running`/`Motor.Speed` (`SetAttribute('Complete') failed: OPC UA write failed with status: 0x80740000`); the seed was retargeted onto the rig's only two writable String nodes. The MxGateway path is unaffected in practice: prod's Delmia/MES handshakes write the same strings through the gateway today. Fix candidates: (a) decode scalars by declared `DataType` in the InstanceActor before the DCL write — also changes what MxGateway receives, so it needs a gateway-side compatibility check; (b) adapter-local: `RealOpcUaClient` coerces to the node's DataType (it already resolves `DataTypeIds` in `EnrichVariableTypeInfoAsync`), leaving the MxGateway wire untouched — preferred. Pin with a `RealOpcUaClientArrayWriteTests`-style test for a Boolean scalar. | Found live 2026-09-10 during the #36 rig gate | Out of scope for #36 (handshake ordering); touches the production write path and needs the MxGateway compatibility question answered first. | Before any OPC UA-bound template relies on script writes to non-String nodes (none does today — until this gate the rig's script writes to OPC UA were static-only). |
|
||||
| 121 | **No source-level `IsMesReportable` on native alarm source bindings** *(Low)*. `TemplateNativeAlarmSource` / `InstanceNativeAlarmSourceOverride` carry no per-alarm MES-relevance flag — "every condition under this binding is reportable" is not expressible, only the 900–999 severity-band convention. | `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md` | Native alarms have no per-alarm config row, and the design chose not to invent one for this slice. | A native-source machine needs per-alarm MES relevance beyond the severity band. |
|
||||
| 122 | **`IsMesReportable` does not ride the wire** *(Low)*. `AlarmStateUpdate` (vendored proto) and `DebugAlarmStateDto` have no `IsMesReportable` field, so central-side surfaces (Debug View live stream, Alarm Summary) always see `false` even though the site-side snapshot stamps the real value. Adding it is additive-only: `AlarmStateUpdate` field 25, `DebugAlarmStateDto` field 20, via a manual `regen-proto.sh` toggle-build-copy-untoggle. | `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md` | No consumer needed it in this slice — the flag exists to serve `Alarms.MesReportableAsync()` for scripts, not a central UI surface. | Operators ask to see the MES-reportable flag live on Debug View / Alarm Summary. |
|
||||
|
||||
## Resolved (verified against the code 2026-07-10)
|
||||
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# MES-Reportable Alarm Flag — Design
|
||||
|
||||
**Date:** 2026-09-11
|
||||
**Status:** IMPLEMENTED 2026-09-11 — merged to main @ 4cfa645b; rig live gate PASS 14/14 + Playwright override round-trip PASS (§8). QA/prod NOT deployed (remote exec on hold).
|
||||
**Supersedes (partially):** the severity-band decision in
|
||||
[`2026-06-30-mes-alarm-status-api.md`](2026-06-30-mes-alarm-status-api.md) §6.1 for **computed**
|
||||
alarms only (native alarms keep the band — see §2.3).
|
||||
**Component touchpoints:** Template Engine (#1), Configuration Database, Site Runtime (#3),
|
||||
Script Analysis (#25), Central UI, CLI, Transport (#24), Inbound API (#14, deployed config only).
|
||||
**Mockup:** https://claude.ai/code/artifact/02ffcbf9-b87f-4e12-852d-38e2910c6056 (artifact "MES Reportable Alarms").
|
||||
**Implementation plan:** [`2026-09-11-mes-reportable-alarm-flag.md`](2026-09-11-mes-reportable-alarm-flag.md) (16 tasks).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
The legacy WWSupport / APIServer MES alarm endpoints decided which alarms were MES-relevant by a
|
||||
per-machine row in the BTDB `MachineAlarm` table (`Name`, `Severity`, `FlaggedForMES`). ScadaBridge
|
||||
already owns the alarm definition on the template, so the flag belongs there too:
|
||||
|
||||
1. Add a **`IsMesReportable` boolean (checkbox, default `false`)** to template alarms, and a
|
||||
matching **tri-state override** (`inherited` / `true` / `false`) on instance alarm overrides.
|
||||
2. Carry the flag through flattening to the site so the script engine's alarm read
|
||||
(`Alarms.CurrentAsync()`) exposes it and a script can **filter by it**.
|
||||
3. Ship an **inbound API method that routes to the instance and performs the alarm check**,
|
||||
returning the legacy `AlarmStatusResponse` shape with `IsFlaggedForMES` driven by the flag.
|
||||
4. Prove all of it on the **local docker rig** (`docker/`) — seed template, instance, method, key,
|
||||
trip an alarm, call the endpoint.
|
||||
|
||||
No BTDB dependency anywhere. Everything the check needs lives on the deployed configuration.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions and scope
|
||||
|
||||
### 2.1 Where the flag lives (data model)
|
||||
|
||||
| Layer | Change | Semantics |
|
||||
|---|---|---|
|
||||
| `TemplateAlarm` (Commons entity) | `bool IsMesReportable` | Default `false`. Inherits down the template chain like `PriorityLevel`. |
|
||||
| `InstanceAlarmOverride` (Commons entity) | `bool? IsMesReportableOverride` | `null` = inherit from template (the existing nullable-means-inherited convention, same as `PriorityLevelOverride`). |
|
||||
| `ResolvedAlarm` (flattened DTO) | `bool IsMesReportable` | Effective value after override; rides `deployed_configurations.config_json` unchanged — **no site schema change**. |
|
||||
| Config DB | migration `AddAlarmMesReportable` | `TemplateAlarms.IsMesReportable bit NOT NULL DEFAULT 0`; `InstanceAlarmOverrides.IsMesReportableOverride bit NULL`. |
|
||||
|
||||
Naming follows the entity idiom already in the file (`IsLocked`, `IsInherited`). The legacy JSON
|
||||
name `IsFlaggedForMES` is preserved **only on the API response**, where MES clients depend on it.
|
||||
|
||||
**Locking:** the flag is an ordinary overridable field. A `Locked` alarm cannot be overridden at all
|
||||
(existing rule, `LockEnforcer.ValidateAlarmOverride`); an unlocked alarm's flag can be flipped per
|
||||
instance. `Name`/`TriggerType` stay the only fixed fields.
|
||||
|
||||
### 2.2 How the site exposes it (script engine)
|
||||
|
||||
The flag is stamped onto the alarm snapshot the script engine already reads:
|
||||
|
||||
- `InstanceActor` records `IsMesReportable` per alarm at Alarm Actor spawn (beside the existing
|
||||
`_alarmPriorities` map) and **stamps it in `BuildAlarmStatesSnapshot()`** on every row
|
||||
(`with { IsMesReportable = … }`), retained or fallback. One place, so it cannot drift from an emit
|
||||
site. `AlarmActor` itself is untouched.
|
||||
- `AlarmStateChanged` gains an additive init-only `bool IsMesReportable` (default `false`) — the
|
||||
same additive convention as `AckTime`. **It is deliberately NOT added to the gRPC protos in this
|
||||
slice**; central never sees it (see §2.4).
|
||||
- `ScriptAlarm` gains a trailing `bool IsMesReportable` positional parameter. Native rows are
|
||||
always `false`.
|
||||
- `AlarmsAccessor` gains **`MesReportableAsync(ct)`** = `CurrentAsync` filtered to
|
||||
`IsMesReportable`. `CurrentAsync()` is unchanged (an optional bool before the `CancellationToken`
|
||||
would break positional callers). Scripts can equally `.Where(a => a.IsMesReportable)`.
|
||||
- The accessor stub is mirrored on both hand-maintained compile surfaces — `ScriptCompileSurface`
|
||||
(`CompileAlarmsAccessor`) and the Central UI `SandboxScriptHost` (`SandboxAlarmsAccessor`) — and
|
||||
pinned by `CompileSurfaceParityTests`. `FlowShapePredictor` does not name accessor members, so
|
||||
it needs no change.
|
||||
|
||||
### 2.3 Native alarms are OUT of scope (and why)
|
||||
|
||||
Native alarms (OPC UA A&C / MxGateway mirrors) have **no per-alarm configuration row** anywhere:
|
||||
only the *source binding* (`TemplateNativeAlarmSource`) is configured, and the individual conditions
|
||||
are discovered at runtime. There is nowhere to put a per-alarm checkbox.
|
||||
|
||||
Consequences, stated plainly:
|
||||
|
||||
- This feature covers **computed `TemplateAlarm`s**. The production CvdReactor endpoints, whose
|
||||
alarms are all native sources, are **not changed** by it and keep the severity-band convention
|
||||
(900–999) from the 2026-06-30 plan. `IsFlaggedForMES` for a native row therefore stays
|
||||
`Severity in band`; for a computed row it becomes the flag.
|
||||
- Follow-up options, recorded in the deferred-work register: (a) a source-level
|
||||
`IsMesReportable` on `TemplateNativeAlarmSource` + its override ("every condition under this
|
||||
binding is reportable"), or (b) a new per-condition config entity — the first of its kind. Neither
|
||||
is designed here.
|
||||
|
||||
### 2.4 Central visibility is a follow-up
|
||||
|
||||
The Alarm Summary / Debug View rows are `AlarmStateChanged` values that arrive over gRPC. Showing a
|
||||
"MES" badge there needs additive proto fields (`AlarmStateUpdate` field 25, `DebugAlarmStateDto`
|
||||
field 20, manual `regen-proto.sh`) plus mapper changes. Not needed for the script check or the
|
||||
inbound API, so it is **deferred** (register row) to keep this slice free of wire changes.
|
||||
Design-time surfaces (template Alarms tab, Instance Configure overrides) DO show the flag.
|
||||
|
||||
### 2.5 The inbound API method (deployed config, rig)
|
||||
|
||||
`AlarmStatus` — the full-filter endpoint from the legacy spec, minus the BTDB machine lookup
|
||||
(the rig has no BTDB; `MachineCode` is the instance `UniqueName`, which is exactly what
|
||||
`Route.To(code)` resolves). Production adds its `SAPID → Code` lookup in front, as `MesMoveIn` does.
|
||||
|
||||
Request (`--parameters`, JSON Schema):
|
||||
```json
|
||||
{ "type":"object",
|
||||
"properties":{
|
||||
"MachineCode":{"type":"string"},
|
||||
"AlarmFilter":{"type":"object","properties":{
|
||||
"FlaggedOnly":{"type":"boolean"}, "IncludeAcked":{"type":"boolean"},
|
||||
"MinSeverity":{"type":"integer"}, "MaxSeverity":{"type":"integer"},
|
||||
"NameFilter":{"type":"string"}}}},
|
||||
"required":["MachineCode"] }
|
||||
```
|
||||
Response (`--return-def`): `{ WasSuccessful, ErrorText, Alarms: AlarmInfo[] }` with
|
||||
`AlarmInfo = { Name, HierarchicalName, Description, IsFlaggedForMES, Severity, StatusCode,
|
||||
TriggeredDT, AckDT, AckComment }` — byte-compatible with the legacy contract.
|
||||
|
||||
Router body: `Route.To(MachineCode).Call("AlarmStatus", { MachineCode, ...AlarmFilter })`,
|
||||
catching `"not found on instance"` → `Alarms status is not supported on machine '<code>'`, any
|
||||
other exception → `WasSuccessful=false` with `Alarms` cleared (legacy all-or-nothing).
|
||||
|
||||
Site script `AlarmStatus` (Call, root of the demo template): `Alarms.CurrentAsync()` →
|
||||
`Active && !IsConfiguredPlaceholder` → `FlaggedOnly ? IsMesReportable : true` → severity bounds →
|
||||
name substring → `IncludeAcked` → project to `AlarmInfo` with `IsFlaggedForMES = a.IsMesReportable`.
|
||||
Filter order matches the legacy spec.
|
||||
|
||||
A `SimpleAlarmStatus` variant (flagged-only, acked always included) is one extra `api-method create`
|
||||
with the same site script and `FlaggedOnly=true` hard-wired; included in the seed as the second
|
||||
endpoint so both legacy routes are demonstrated.
|
||||
|
||||
### 2.6 Rig demo (all local)
|
||||
|
||||
`docker/seed-mes-alarm-demo.sh` (idempotent by name lookup, same style as `seed-flow-log-demo.sh`):
|
||||
|
||||
| Object | Detail |
|
||||
|---|---|
|
||||
| Template `MesAlarmDemo` | static `Double` attribute `Temperature` = 20 (static ⇒ a value-change trips alarms synchronously, no device round-trip, no OPC UA String-only trap). |
|
||||
| Alarm `HighTemp` | HiLo on `Temperature`, hi 80 / hihi 95, priority 500, **`--mes-reportable`**. |
|
||||
| Alarm `TempSpike` | ValueMatch `Temperature == 100`, priority 300, not reportable. |
|
||||
| Alarm `SensorFault` | ValueMatch `Temperature == -1`, priority 700, not reportable **on the template**. |
|
||||
| Script `AlarmStatus` | Call, root; body per §2.5. |
|
||||
| Instance `MesAlarmDemo01` | site 1; **alarm override `SensorFault` → `IsMesReportableOverride = true`** to prove the override path; deployed. |
|
||||
| Inbound `AlarmStatus`, `SimpleAlarmStatus` | per §2.5; one API key `mes-alarm-demo` scoped to both. |
|
||||
|
||||
`docker/verify-mes-alarm-demo.sh`:
|
||||
|
||||
1. `flow-debug run-value-change --instance-id … --attribute Temperature --value 96` → HighTemp active.
|
||||
2. `curl POST /api/AlarmStatus {"MachineCode":"MesAlarmDemo01"}` → 1 row, `IsFlaggedForMES=true`.
|
||||
3. `--value 100` → TempSpike active; `AlarmStatus` with `FlaggedOnly=true` → still only
|
||||
HighTemp-style rows (TempSpike excluded); `FlaggedOnly=false` → both, TempSpike `false`.
|
||||
4. `--value -1` → SensorFault active; `SimpleAlarmStatus` → SensorFault present with
|
||||
`IsFlaggedForMES=true` (override wins over the template's `false`).
|
||||
5. `--value 20` → all clear; `AlarmStatus` → empty `Alarms`, `WasSuccessful=true`.
|
||||
6. `AlarmStatus {"MachineCode":"DelmiaSim01"}` (no script) → `WasSuccessful=false`, "not
|
||||
supported on machine".
|
||||
7. Cross-check step 2 against `debug snapshot --id …` and the Flow Debugger replay.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data flow
|
||||
|
||||
```
|
||||
Central UI / CLI ──AddTemplateAlarm(IsMesReportable)──► ManagementActor ► TemplateService ► TemplateAlarms
|
||||
Central UI / CLI ──SetInstanceAlarmOverride(IsMesReportableOverride?)──► InstanceService ► InstanceAlarmOverrides
|
||||
│
|
||||
FlatteningService.ResolveInheritedAlarms → ResolvedAlarm.IsMesReportable
|
||||
FlatteningService.ApplyInstanceAlarmOverrides (ovr ?? existing)
|
||||
│ deploy (config_json, unchanged schema)
|
||||
▼
|
||||
Site: InstanceActor spawns AlarmActors; _alarmMesReportable[name] = alarm.IsMesReportable
|
||||
BuildAlarmStatesSnapshot() stamps IsMesReportable on every AlarmStateChanged row
|
||||
GetAlarmSnapshotResponse ─► ScriptRuntimeContext.ToScriptAlarm ─► ScriptAlarm.IsMesReportable
|
||||
│
|
||||
site script AlarmStatus: Alarms.CurrentAsync() / MesReportableAsync() → AlarmInfo[]
|
||||
▲
|
||||
Central inbound method AlarmStatus ── Route.To(MachineCode).Call("AlarmStatus", filter) ──┘
|
||||
◄── X-API-Key ── MES client (curl on the rig)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives considered
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| **Keep the severity band (900–999) as the flag** (2026-06-30 decision) | Rejected for computed alarms: it couples MES relevance to priority, which operators tune for other reasons, and it is invisible in the template editor. Kept for native alarms only because there is no per-alarm row. |
|
||||
| **Central lookup table** (a `MesReportableAlarms` table joined at request time, i.e. re-creating BTDB `MachineAlarm`) | Rejected: the check runs at the site (that is the whole point of `Route.To().Call`) and the central table would be a second source of truth beside the template. |
|
||||
| **Stamp the flag in `AlarmActor` emits** instead of the snapshot | Rejected: three emit sites to keep in sync; the snapshot builder already owns the "fallback row for a quiet alarm" case, so stamping there covers fired and unfired alarms in one place. |
|
||||
| **`CurrentAsync(bool reportableOnly = false, ct)`** | Rejected: an optional bool before the token breaks existing positional `CurrentAsync(ct)` callers on three mirrored surfaces. A separate `MesReportableAsync` is additive. |
|
||||
| **Put `IsMesReportable` on the protos now** | Deferred (§2.4): no consumer in this slice; proto regen is manual and adds review surface. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling
|
||||
|
||||
- **Override on a locked alarm** → existing `LockEnforcer` error; UI hides locked alarms from the
|
||||
override list already.
|
||||
- **Override with no delta** — Instance Configure deletes an override when neither config nor
|
||||
priority is set; the delta check must include the tri-state so a reportable-only override is
|
||||
**not** silently deleted (known trap, `InstanceConfigure.razor` ~line 1628).
|
||||
- **Old bundles / old artifacts**: the Transport DTO fields are trailing with defaults; a bundle
|
||||
without them imports as `false`/`null`. A `FlattenedConfiguration` serialized before the change
|
||||
deserializes with `IsMesReportable=false`.
|
||||
- **Mixed version**: a NEW central deploying to an OLD site — the site ignores the unknown JSON
|
||||
property; scripts there see `false` everywhere. Upgrade sites with the central, as always.
|
||||
- **Inbound method**: unknown machine → `Instance '…' not found` text passed through as
|
||||
`ErrorText`; missing script → the "not supported" mapping; everything else → `WasSuccessful=false`
|
||||
with `Alarms` cleared. Never an HTTP 500 for a handled case.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
| Layer | Tests |
|
||||
|---|---|
|
||||
| Commons | entity + message convention sweeps pass unchanged (plain `bool`, record init-prop); `ScriptAlarm` positional back-compat test. |
|
||||
| Config DB | migration applies on SQL Server + SQLite test double; `SchemaConfigurationTests` persist round-trip of the two new columns. |
|
||||
| Template Engine | `FlatteningServiceTests`: template `true` → resolved `true`; override `false` wins; override `null` inherits; inherited (derived-template) alarm carries base value; `LockEnforcerTests` unchanged. |
|
||||
| Site Runtime | `InstanceActor` snapshot stamps the flag on fired AND unfired alarms; `AlarmsAccessorTests`: `MesReportableAsync` filters; `CompileSurfaceParityTests` sees the new member on all mirrors. |
|
||||
| Script Analysis / Central UI | compile-surface + `SandboxScriptHost` compile a script using `MesReportableAsync()` and `a.IsMesReportable`. |
|
||||
| Management | `ManagementActorTests` add/update/override carry the field; authz matrix unchanged (no new command names). |
|
||||
| CLI | `EditingParityTests` rows for `template alarm add --mes-reportable` and `instance alarm-override set --mes-reportable false`; README updated. |
|
||||
| Transport | `RoundTripEquivalenceTests` seeds non-default values (the reflection sweep fails otherwise); `TemplateChildEquality`/`ArtifactDiff` tests see the flag as a change. |
|
||||
| Central UI | structural test on `TemplateAlarmDialog.razor` (`@bind="_isMesReportable"`); bUnit/Playwright override round-trip `AlarmOverride_MesReportable_RoundTrips` beside the priority one. |
|
||||
| Rig | `docker/verify-mes-alarm-demo.sh` steps 1–7 (§2.6). |
|
||||
|
||||
---
|
||||
|
||||
## 7. Documents to update in the same change
|
||||
|
||||
`Component-TemplateEngine.md` (alarm fields, override granularity table), `Component-SiteRuntime.md`
|
||||
(`Alarms` accessor + `MesReportableAsync`), `Component-ScriptAnalysis.md` (surface list),
|
||||
`Component-InboundAPI.md` (routing example: AlarmStatus), `Component-CentralUI.md` (dialog + override
|
||||
editor), `Component-ConfigurationDatabase.md` (migration), CLI `README.md`, `CLAUDE.md` native-alarm
|
||||
bullet (flag vs band), `2026-06-30-mes-alarm-status-api.md` §6.1 (note: superseded for computed
|
||||
alarms), deferred-work register (native source-level flag; central badge/proto).
|
||||
|
||||
---
|
||||
|
||||
## 8. Rig live gate results (2026-09-11)
|
||||
|
||||
Seeded by `docker/seed-mes-alarm-demo.sh`, gated by `docker/verify-mes-alarm-demo.sh` against the
|
||||
`docker/` rig (8 nodes, rebuilt from this branch). Config DB migration applied on boot:
|
||||
|
||||
```
|
||||
[14:19:43 INF] [Central/scadabridge-central-a] Applying migration '20260911135501_AddAlarmMesReportable'.
|
||||
```
|
||||
|
||||
### 8.1 Seeded objects
|
||||
|
||||
| Object | Id | Notes |
|
||||
|---|---|---|
|
||||
| Template `MesAlarmDemo` | 5255 | `Temperature` Double **static**, value 20 |
|
||||
| Alarm `HighTemp` | — | HiLo hi=80 hihi=95, priority 500, **`--mes-reportable`** |
|
||||
| Alarm `TempSpike` | — | ValueMatch `100`, priority 300, not reportable |
|
||||
| Alarm `SensorFault` | — | ValueMatch `-1`, priority 700, **not reportable on the template** |
|
||||
| Site script `AlarmStatus` (Call) | 1045 | `Alarms.CurrentAsync()` / `Alarms.MesReportableAsync()` + the filter params |
|
||||
| Instance `MesAlarmDemo01` (site-a) | 3847 | `instance alarm-override set --alarm SensorFault --mes-reportable true` |
|
||||
| Inbound method `AlarmStatus` | 2224 | nested `AlarmFilter` object param, routes via `Route.To(MachineCode)` |
|
||||
| Inbound method `SimpleAlarmStatus` | 2225 | flagged-only shorthand |
|
||||
| API key `mes-alarm-demo` | — | scoped to both methods; token printed once |
|
||||
|
||||
Second seed run reported `exists` for template/attribute/all three alarms/instance, `updated` for
|
||||
both scripts and both methods, and refused to mint a second API key — **idempotent**, same ids.
|
||||
|
||||
### 8.2 Gate: `PASS 14/14`
|
||||
|
||||
| Step | Stimulus | Result |
|
||||
|---|---|---|
|
||||
| A1 | `96` → `AlarmStatus` | `WasSuccessful=true`, exactly 1 row, `HighTemp` |
|
||||
| A2 | same | `IsFlaggedForMES=true` (template flag), `Severity=500`, `StatusCode="Triggered.Acked"`, `AckDT=null` |
|
||||
| A3 | same | `HierarchicalName="MesAlarmDemo01.HighTemp"` |
|
||||
| H | same moment | `debug snapshot --id 3847` shows `HighTemp` Active (`condition.active=true`) |
|
||||
| B1/B2 | `100` → `FlaggedOnly=false` | 2 rows: `HighTemp` **true**, `TempSpike` **false** (HiHi still active at 100, so both) |
|
||||
| B3 | `100` → `FlaggedOnly=true` | 1 row, `HighTemp`; `TempSpike` excluded by `MesReportableAsync()` |
|
||||
| C1/C2 | `-1` → `SimpleAlarmStatus` | 1 row, `SensorFault`, `IsFlaggedForMES=true`, `Severity=700` — **the instance override beat the template's `false`** |
|
||||
| C3 | `-1` → `AlarmStatus` | `SensorFault` only; `HighTemp` absent (−1 trips no high band) |
|
||||
| D1 | `-1` + `MinSeverity=600` | `SensorFault` only (700 ≥ 600; HighTemp 500 and TempSpike 300 filtered) |
|
||||
| E1 | `20` | `WasSuccessful=true`, `Alarms=[]` |
|
||||
| F1 | `MachineCode=DelmiaSim01` | `WasSuccessful=false`, `ErrorText` contains "not supported on machine" (`not found on instance` catch) |
|
||||
| G1 | `MachineCode=NoSuchMachine` | `WasSuccessful=false`, `ErrorText` contains "not found" |
|
||||
|
||||
Browser round-trip: `dotnet test … --filter AlarmOverride_MesReportable_RoundTrips` →
|
||||
**Passed: 1, Failed: 0** (12 s).
|
||||
|
||||
### 8.3 Facts the gate established (read before extending the demo)
|
||||
|
||||
- **`StatusCode` is `"Triggered.Acked"` for every computed alarm, and `AckDT` is always `null`.**
|
||||
`AlarmConditionStateFactory.ForComputed` reports `Acknowledged: true` — computed alarms have no
|
||||
ack-back, so there is nothing to be unacknowledged about. A filter such as the script's
|
||||
`IncludeAcked=false` therefore drops *every* computed alarm; it is only meaningful over mirrored
|
||||
native conditions. The plan's expected `"Triggered"` was wrong and the assertion was corrected.
|
||||
- **`Severity` is the alarm's effective priority**, and for a HiLo alarm it is the *band* priority
|
||||
(`LevelPriority`), falling back to the alarm priority when no per-band priority is configured —
|
||||
which is why `HighTemp` reads 500 at HiHi and `MinSeverity=600` cleanly isolates `SensorFault`.
|
||||
- **The `debug snapshot` / stream DTO reports `isMesReportable: false` for every alarm, including
|
||||
`HighTemp`.** That is §2.2 working as specified — the flag is deliberately not on the gRPC protos
|
||||
in this slice, so central never sees it and only the site-local script snapshot is stamped. Step H
|
||||
therefore cross-checks alarm *activity*, not the flag. (Register item: central badge/proto.)
|
||||
- **`ValueMatch` compares `value?.ToString()` as an ordinal string**, so `-1` and `100` matched the
|
||||
Double attribute's rendered form on this rig. A ValueMatch alarm over a Double is a string
|
||||
comparison, not a numeric one — `RangeViolation` is the numeric trigger.
|
||||
- `--match-value=-1` must use the `=` form; a space-separated `-1` is parsed as another option.
|
||||
|
||||
### 8.4 Deviations from the plan
|
||||
|
||||
1. Step A's `StatusCode` expectation changed from `"Triggered"` to `"Triggered.Acked"` (§8.3).
|
||||
2. Step H parses `alarmStates[].condition.active` (the snapshot has no textual state field; the
|
||||
`state` enum is `Active=0`, `Normal=1`).
|
||||
3. `SensorFault` is seeded with `--attribute Temperature` — a `ValueMatch` trigger needs the watched
|
||||
attribute, which the plan's sketch omitted.
|
||||
4. `site deploy-artifacts` is not called: the demo has no shared scripts or system-wide artifacts,
|
||||
and `instance deploy` after the override is what puts the alarms and the script on site-a.
|
||||
@@ -0,0 +1,672 @@
|
||||
# MES-Reportable Alarm Flag Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the legacy BTDB `MachineAlarm.FlaggedForMES` lookup with an `IsMesReportable` checkbox on template alarms (instance-overridable), expose it to site scripts through `Alarms.CurrentAsync()` / `Alarms.MesReportableAsync()`, and prove it end-to-end on the docker rig through an `AlarmStatus` inbound API method.
|
||||
|
||||
**Architecture:** One additive `bool` on `TemplateAlarm` + one `bool?` on `InstanceAlarmOverride` (null = inherit), resolved by the flattener into `ResolvedAlarm.IsMesReportable`, which already ships to the site inside `deployed_configurations.config_json`. The site `InstanceActor` stamps the flag on its alarm snapshot in ONE place (`BuildAlarmStatesSnapshot`), so `ScriptAlarm.IsMesReportable` is correct for fired and unfired alarms. No proto/wire change; native alarms (source bindings, no per-alarm row) are out of scope and stay on the 900–999 severity band. The inbound method and site script are deployed config seeded by `docker/seed-mes-alarm-demo.sh`.
|
||||
|
||||
**Tech Stack:** .NET 10, EF Core (SQL Server migrations), Akka.NET, Roslyn scripting, Blazor Server + Bootstrap, System.CommandLine CLI, bash + `scadabridge` CLI on the docker rig.
|
||||
|
||||
**Design:** `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md`. Mockup: artifact "MES Reportable Alarms".
|
||||
|
||||
**Conventions for every task** (from `CLAUDE.md` + memory):
|
||||
- Work in a dedicated worktree (Task 0). `EnterWorktree` is broken here — use `git worktree add`.
|
||||
- Run **targeted** tests per task (`dotnet test <proj> --filter …`), the affected-project build, and the full solution build only in Task 15.
|
||||
- Never `git stash` (shared across worktrees). Commit with pathspecs. End every commit message with
|
||||
`Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB`.
|
||||
- Every public member needs an XML doc comment (`checkdocs` enforces it).
|
||||
- Message contracts and Transport DTOs evolve **additively with trailing defaulted parameters** — never reorder.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Worktree
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** none (git only)
|
||||
|
||||
**Step 1:** Create the worktree and confirm a clean build baseline.
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/ScadaBridge
|
||||
git worktree add -b feat/mes-reportable-alarm-flag ../ScadaBridge-mes-reportable main
|
||||
cd ../ScadaBridge-mes-reportable
|
||||
dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | tail -3
|
||||
```
|
||||
Expected: `0 Error(s)`. All later tasks run from `/Users/dohertj2/Desktop/ScadaBridge-mes-reportable` (absolute paths in every command — Bash cwd drifts).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Commons — entity, override and resolved-alarm fields
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (everything depends on it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAlarm.cs:18` (after `IsLocked`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAlarmOverride.cs:44` (after `PriorityLevelOverride`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs:125` (after `IsLocked` in `ResolvedAlarm`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/EntityConventionTests.cs` (existing sweep, must stay green)
|
||||
|
||||
**Step 1: Add the three properties**
|
||||
|
||||
`TemplateAlarm.cs`:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// When true, the alarm is reported to MES by the alarm-status API
|
||||
/// (<c>IsFlaggedForMES</c> on the legacy contract). Default false. Overridable per
|
||||
/// instance via <see cref="Instances.InstanceAlarmOverride.IsMesReportableOverride"/>
|
||||
/// unless the alarm is locked.
|
||||
/// </summary>
|
||||
public bool IsMesReportable { get; set; }
|
||||
```
|
||||
`InstanceAlarmOverride.cs`:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Replaces the alarm's <c>IsMesReportable</c> flag when set. <c>null</c> = keep inherited.
|
||||
/// </summary>
|
||||
public bool? IsMesReportableOverride { get; set; }
|
||||
```
|
||||
`FlattenedConfiguration.cs` (`ResolvedAlarm`):
|
||||
```csharp
|
||||
/// <summary>Gets whether the alarm is reported to MES (template value after any instance override).</summary>
|
||||
public bool IsMesReportable { get; init; }
|
||||
```
|
||||
|
||||
**Step 2: Build + run the convention sweeps**
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~EntityConventionTests|FullyQualifiedName~MessageConventionTests" 2>&1 | tail -3
|
||||
```
|
||||
Expected: all pass.
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.Commons
|
||||
git commit -m "feat(commons): IsMesReportable on TemplateAlarm, ResolvedAlarm; IsMesReportableOverride on InstanceAlarmOverride"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Configuration Database — migration
|
||||
|
||||
**Classification:** high-risk (migration)
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3, Task 6, Task 8 (after Task 1)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<ts>_AddAlarmMesReportable.cs` (+ `.Designer.cs`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs` (generated)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs:144-157`
|
||||
|
||||
**Step 1: Write the failing test** — extend the existing TemplateAlarm persist test (or add beside it):
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task TemplateAlarm_And_Override_PersistMesReportableFlags()
|
||||
{
|
||||
// arrange as the sibling test does (in-memory/SQLite context + a template + an instance)
|
||||
var alarm = new TemplateAlarm("HighTemp") { TemplateId = template.Id, TriggerType = AlarmTriggerType.HiLo, PriorityLevel = 500, IsMesReportable = true };
|
||||
var ovr = new InstanceAlarmOverride("HighTemp") { InstanceId = instance.Id, IsMesReportableOverride = false };
|
||||
ctx.TemplateAlarms.Add(alarm); ctx.InstanceAlarmOverrides.Add(ovr);
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.ChangeTracker.Clear();
|
||||
|
||||
Assert.True((await ctx.TemplateAlarms.SingleAsync(a => a.Id == alarm.Id)).IsMesReportable);
|
||||
Assert.False((await ctx.InstanceAlarmOverrides.SingleAsync(o => o.Id == ovr.Id)).IsMesReportableOverride);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Scaffold the migration — BUILD FIRST, no `--no-build`** (memory: `--no-build` scaffolds an empty migration):
|
||||
```bash
|
||||
dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase 2>&1 | tail -1
|
||||
dotnet ef migrations add AddAlarmMesReportable \
|
||||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||||
```
|
||||
Verify the `Up()` matches the `20260517000628_AddDerivedAlarmFields.cs` shape:
|
||||
```csharp
|
||||
migrationBuilder.AddColumn<bool>(name: "IsMesReportable", table: "TemplateAlarms", type: "bit", nullable: false, defaultValue: false);
|
||||
migrationBuilder.AddColumn<bool>(name: "IsMesReportableOverride", table: "InstanceAlarmOverrides", type: "bit", nullable: true);
|
||||
```
|
||||
If the file is empty, `rm` the two generated files and re-run the build + add.
|
||||
|
||||
**Step 3: Run the tests**
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter "FullyQualifiedName~SchemaConfigurationTests" 2>&1 | tail -3
|
||||
```
|
||||
Expected: PASS. No explicit EF mapping change is needed (`bool`/`bool?` map by convention).
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests
|
||||
git commit -m "feat(config-db): AddAlarmMesReportable migration (TemplateAlarms.IsMesReportable, InstanceAlarmOverrides.IsMesReportableOverride)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Template Engine — flattening
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2, Task 6, Task 8
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:450-461` (projection) and `:358-363` (override merge)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs` (model on `Flatten_Alarms_ResolvedFromChain` at :221)
|
||||
|
||||
**Step 1: Write three failing tests** (copy the arrange block of `Flatten_Alarms_ResolvedFromChain`):
|
||||
- `Flatten_MesReportable_TemplateValueCarried` — template alarm `IsMesReportable = true` → resolved `IsMesReportable == true`; a second alarm left default → `false`.
|
||||
- `Flatten_MesReportable_OverrideFalseWins` — template `true`, override `IsMesReportableOverride = false` → resolved `false`, `Source == "Override"`.
|
||||
- `Flatten_MesReportable_OverrideNullInherits` — template `true`, override with only `PriorityLevelOverride = 7` → resolved `IsMesReportable == true`.
|
||||
|
||||
**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter "FullyQualifiedName~Flatten_MesReportable"` → 3 FAIL.
|
||||
|
||||
**Step 3: Implement**
|
||||
In the projection (`:450`):
|
||||
```csharp
|
||||
IsLocked = alarm.IsLocked,
|
||||
IsMesReportable = alarm.IsMesReportable,
|
||||
```
|
||||
In `ApplyInstanceAlarmOverrides` (`:358`):
|
||||
```csharp
|
||||
PriorityLevel = ovr.PriorityLevelOverride ?? existing.PriorityLevel,
|
||||
IsMesReportable = ovr.IsMesReportableOverride ?? existing.IsMesReportable,
|
||||
```
|
||||
|
||||
**Step 4: Run** the same filter → 3 PASS; then the whole `Flattening` folder filter to confirm no regression.
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening
|
||||
git commit -m "feat(template-engine): flatten IsMesReportable with null-inherits override semantics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Template Engine — service write paths
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (Task 5, 10, 11 depend on the new signature)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:~619-627` (`UpdateAlarmAsync` field copy block: `existing.PriorityLevel = proposed.PriorityLevel; …`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs:218-280` (`SetAlarmOverrideAsync`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/` (the existing `TemplateService`/`InstanceService` alarm tests — find with `grep -rn "UpdateAlarmAsync\|SetAlarmOverrideAsync" tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests`)
|
||||
|
||||
**Step 1: Failing tests**
|
||||
- `UpdateAlarmAsync_CopiesIsMesReportable`: existing alarm `false`, proposed `true` → stored `true`.
|
||||
- `SetAlarmOverrideAsync_StoresIsMesReportableOverride`: call with `isMesReportableOverride: true` → repo receives an override with `IsMesReportableOverride == true`; a second call with `null` clears it.
|
||||
|
||||
**Step 2: Implement**
|
||||
`TemplateService.UpdateAlarmAsync` — beside `existing.IsLocked = proposed.IsLocked;`:
|
||||
```csharp
|
||||
existing.IsMesReportable = proposed.IsMesReportable;
|
||||
```
|
||||
`InstanceService.SetAlarmOverrideAsync` — new trailing optional parameter **after `user`** (all current callers pass five positional args; grep `SetAlarmOverrideAsync(` across `src/` and `tests/` to confirm):
|
||||
```csharp
|
||||
string user,
|
||||
bool? isMesReportableOverride = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
```
|
||||
and in both branches:
|
||||
```csharp
|
||||
existingOverride.IsMesReportableOverride = isMesReportableOverride;
|
||||
…
|
||||
PriorityLevelOverride = priorityLevelOverride,
|
||||
IsMesReportableOverride = isMesReportableOverride
|
||||
```
|
||||
Update the XML `<param>` docs. `AddAlarmAsync` needs nothing — it persists the entity as given.
|
||||
|
||||
**Step 3: Run** the TemplateEngine test project filtered to `Alarm` → PASS.
|
||||
|
||||
**Step 4: Commit** `feat(template-engine): persist IsMesReportable on alarm update; IsMesReportableOverride on SetAlarmOverrideAsync`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Management commands + ManagementActor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs:45-46`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs:39-43`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:3173-3201`, `:1171-1182`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`
|
||||
|
||||
**Step 1: Failing tests** (model on the existing add-alarm / set-override tests in `ManagementActorTests`):
|
||||
- `AddTemplateAlarm_CarriesIsMesReportable` — send `new AddTemplateAlarmCommand(1, "HighTemp", "HiLo", 500, null, null, false, IsMesReportable: true)`; assert the `TemplateAlarm` handed to `TemplateService.AddAlarmAsync` has `IsMesReportable == true`.
|
||||
- `SetInstanceAlarmOverride_PassesIsMesReportableOverride` — `new SetInstanceAlarmOverrideCommand(10, "HighTemp", null, null, IsMesReportableOverride: false)` → `InstanceService.SetAlarmOverrideAsync` receives `false`.
|
||||
|
||||
**Step 2: Implement** — trailing optionals, never reorder:
|
||||
```csharp
|
||||
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked, bool IsMesReportable = false);
|
||||
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked, bool IsMesReportable = false);
|
||||
```
|
||||
```csharp
|
||||
public record SetInstanceAlarmOverrideCommand(
|
||||
int InstanceId,
|
||||
string AlarmCanonicalName,
|
||||
string? TriggerConfigurationOverride,
|
||||
int? PriorityLevelOverride,
|
||||
bool? IsMesReportableOverride = null);
|
||||
```
|
||||
`HandleAddAlarm` / `HandleUpdateAlarm`: add `IsMesReportable = cmd.IsMesReportable` to both initializers. `HandleSetInstanceAlarmOverride`: pass `cmd.IsMesReportableOverride` after `user.Username`.
|
||||
|
||||
Note the PLAN-08 T9 finding: Newtonsoft ignores C# ctor optional-parameter defaults on the wire → an old CLI that omits the field yields `default(T)` = `false` / `null`, which is exactly the intended inherit/off default.
|
||||
|
||||
**Step 3: Run**
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~ManagementActorTests|FullyQualifiedName~RequiredRoleMatrixTests" 2>&1 | tail -3
|
||||
```
|
||||
Expected: PASS. `RequiredRoleMatrixTests` is untouched — no new command names.
|
||||
|
||||
**Step 4: Commit** `feat(management): IsMesReportable on alarm commands, IsMesReportableOverride on SetInstanceAlarmOverride`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Site Runtime — stamp the flag on the alarm snapshot
|
||||
|
||||
**Classification:** high-risk (actor)
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2, Task 3, Task 8
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs:109` (append after `IsConfiguredPlaceholder`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:53` (field), `:1982` (spawn), `:1569-1613` (`BuildAlarmStatesSnapshot`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs` (has the `GetAlarmSnapshotRequest` harness — add beside it)
|
||||
|
||||
**Step 1: Failing test** `AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms`: deploy a `FlattenedConfiguration` with two computed alarms (`HighTemp` `IsMesReportable=true`, `TempSpike` `false`); drive `HighTemp` active (write the attribute past the setpoint, as the sibling tests do); Ask `GetAlarmSnapshotRequest`; assert the `HighTemp` row (retained event) has `IsMesReportable == true` and the `TempSpike` row (fallback) `false`.
|
||||
|
||||
**Step 2: Implement**
|
||||
`AlarmStateChanged.cs`:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// True when the alarm's deployed configuration flags it as MES-reportable
|
||||
/// (<c>ResolvedAlarm.IsMesReportable</c>). Stamped by the Instance Actor on its alarm
|
||||
/// snapshot; always false for native conditions, which have no per-alarm config row.
|
||||
/// NOT carried on the gRPC stream in this slice (no proto field) — central sees the
|
||||
/// default. Additive; default false.
|
||||
/// </summary>
|
||||
public bool IsMesReportable { get; init; }
|
||||
```
|
||||
`InstanceActor.cs`:
|
||||
```csharp
|
||||
private readonly Dictionary<string, bool> _alarmMesReportable = new();
|
||||
…
|
||||
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
|
||||
_alarmMesReportable[alarm.CanonicalName] = alarm.IsMesReportable;
|
||||
```
|
||||
`BuildAlarmStatesSnapshot()` — stamp every row in one place (records support `with`):
|
||||
```csharp
|
||||
var states = _latestAlarmEvents.Values
|
||||
.Select(e => e with { IsMesReportable = _alarmMesReportable.GetValueOrDefault(e.AlarmName) })
|
||||
.ToList();
|
||||
foreach (var name in _alarmActors.Keys)
|
||||
{
|
||||
if (_latestAlarmEvents.ContainsKey(name)) continue;
|
||||
states.Add(new AlarmStateChanged(/* unchanged five args */)
|
||||
{
|
||||
IsMesReportable = _alarmMesReportable.GetValueOrDefault(name)
|
||||
});
|
||||
}
|
||||
```
|
||||
Native placeholder rows are untouched (`false`). Also clear `_alarmMesReportable` wherever `_alarmPriorities` is cleared on undeploy/redeploy (grep `_alarmPriorities.Clear`).
|
||||
|
||||
**Step 3: Run**
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~InstanceActorNativeAlarmTests|FullyQualifiedName~InstanceActorTests" 2>&1 | tail -3
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~MessageConventionTests|FullyQualifiedName~AlarmStateChanged" 2>&1 | tail -3
|
||||
```
|
||||
Expected: PASS (the Commons additive/back-compat tests for `AlarmStateChanged` still deserialize a pre-flag JSON to `false`).
|
||||
|
||||
**Step 4: Commit** `feat(site-runtime): stamp IsMesReportable on the Instance Actor alarm snapshot`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Script surface — `ScriptAlarm.IsMesReportable` + `Alarms.MesReportableAsync()`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8, Task 9 (after Task 6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptAlarm.cs:58-79` (trailing positional `bool IsMesReportable = false` + `<param>` doc)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs:1263-1304` (`GetAlarmsAsync`, `ToScriptAlarm`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:372-401` (`AlarmsAccessor`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs:150-157` (`CompileAlarmsAccessor`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs:151-164` (`SandboxAlarmsAccessor`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/AlarmsAccessorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs`, the ScriptAnalysis compile-surface test that compiles an `Alarms.CurrentAsync()` script (grep `CurrentAsync` under `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests`), and the CentralUI `SandboxScriptHost` diagnose-clean test (grep `Alarms.CurrentAsync` under `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests`).
|
||||
|
||||
**Step 1: Failing tests**
|
||||
- `AlarmsAccessorTests.CurrentAsync_ProjectsIsMesReportable` — respond with a row `{ IsMesReportable = true }` → `ScriptAlarm.IsMesReportable == true`.
|
||||
- `AlarmsAccessorTests.MesReportableAsync_FiltersToFlaggedRows` — respond with one flagged + one unflagged row → exactly the flagged one.
|
||||
- Compile-surface + sandbox tests: extend the existing script literal to `var r = await Alarms.MesReportableAsync(); var f = r.Where(a => a.IsMesReportable);` → compiles with zero diagnostics.
|
||||
- `CompileSurfaceParityTests` already pairs `AlarmsAccessor` ↔ `CompileAlarmsAccessor` (`:112`) — it fails automatically until the mirror gains the member.
|
||||
|
||||
**Step 2: Implement**
|
||||
`ScriptAlarm`: append `bool IsMesReportable = false` as the last positional parameter (existing 21-arg call sites keep compiling).
|
||||
`ScriptRuntimeContext`:
|
||||
```csharp
|
||||
internal async Task<IReadOnlyList<ScriptAlarm>> GetMesReportableAlarmsAsync(CancellationToken cancellationToken = default)
|
||||
=> (await GetAlarmsAsync(cancellationToken)).Where(a => a.IsMesReportable).ToList();
|
||||
…
|
||||
IsConfiguredPlaceholder: a.IsConfiguredPlaceholder,
|
||||
IsMesReportable: a.IsMesReportable);
|
||||
```
|
||||
`AlarmsAccessor`:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// <see cref="CurrentAsync"/> restricted to alarms whose deployed configuration flags
|
||||
/// them MES-reportable (template checkbox, or an instance override). Native conditions
|
||||
/// are never included — they carry no per-alarm flag. Placeholder rows are excluded.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<ScriptAlarm>> MesReportableAsync(CancellationToken cancellationToken = default)
|
||||
=> _ctx.GetMesReportableAlarmsAsync(cancellationToken);
|
||||
```
|
||||
`CompileAlarmsAccessor`: same signature → `throw new NotSupportedException(CompileOnly)`.
|
||||
`SandboxAlarmsAccessor`: same signature → `throw new ScriptSandboxException("Alarms.MesReportableAsync() reads the site Instance Actor's live alarm conditions, which aren't available in the central Test Run sandbox — deploy to a site to exercise alarm-reading scripts.")`.
|
||||
|
||||
**Step 3: Run** the four test projects filtered to `Alarms|CompileSurfaceParity|SandboxScriptHost` → PASS.
|
||||
|
||||
**Step 4: Commit** `feat(scripts): ScriptAlarm.IsMesReportable + Alarms.MesReportableAsync() mirrored on both compile surfaces`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Transport bundles
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 3, Task 6, Task 7
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:212-221` (`TemplateAlarmDto` — append `bool IsMesReportable = false`), `:395-398` (`InstanceAlarmOverrideDto` — append `bool? IsMesReportableOverride = null`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs:66-74`, `:285-288`, `:346-359`, `:603-611`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/TemplateChildEquality.cs:57-65`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs:488-496`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:2168-2178`, `:2392-2416`, `:4357-4364`, `:4904-4916`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs:233-241`, `:808-812`; `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs`; `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs`
|
||||
|
||||
**Step 1: Failing tests**
|
||||
- Round-trip guard: set `IsMesReportable = true` on the seeded `High` alarm (`:233`) and `IsMesReportableOverride = false` on the seeded `HiAlarm` override (`:808`). The reflection sweep now fails until the DTO carries both.
|
||||
- `ArtifactDiffTests`: an alarm differing only in `IsMesReportable` classifies as Changed (not Identical); same for an override differing only in `IsMesReportableOverride`.
|
||||
- `EntitySerializerTests`: a bundle JSON lacking the two new keys deserializes to `false` / `null`.
|
||||
|
||||
**Step 2: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter RoundTripEquivalence` → FAIL naming the two properties.
|
||||
|
||||
**Step 3: Implement** — carry the field at every listed site:
|
||||
- serializer export: `IsMesReportable: a.IsMesReportable` / `IsMesReportableOverride: o.IsMesReportableOverride`
|
||||
- serializer import + importer entity builds (`:346`, `:603`, `:2168`, `:4357`): `IsMesReportable = al.IsMesReportable` / `IsMesReportableOverride = o.IsMesReportableOverride`
|
||||
- `SyncTemplateAlarmsAsync` (`:2392`): `current.IsMesReportable = alarmDto.IsMesReportable;` and add `current.IsMesReportable` to the audit payload object
|
||||
- `TemplateChildEquality.AlarmsEqual`: `&& e.IsMesReportable == i.IsMesReportable`
|
||||
- `ArtifactDiff` override lambda: `&& e.IsMesReportableOverride == i.IsMesReportableOverride`
|
||||
- `BundleImporter:4904` (`ResolvedAlarm` for validation): `IsMesReportable = al.IsMesReportable`
|
||||
- `TemplateChildEquality` also has an override-equality helper if one exists — grep `PriorityLevelOverride` in `Transport/Import` and extend every comparison you find.
|
||||
|
||||
**Step 4: Run**
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests 2>&1 | tail -3
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter "RoundTripEquivalence|BundleImporterApply|ArtifactDiff" 2>&1 | tail -3
|
||||
```
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit** `feat(transport): carry IsMesReportable / IsMesReportableOverride through bundles (round-trip guard extended)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: CLI flags + README
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 7, Task 8, Task 10, Task 11
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:683-684` (add option), `:709-730` (register on add), `:747-754` (pass), `:758-770` (update option), `:790-797` (pass)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs:551-568`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md:263-313` (alarm add/update option tables), `:808-821` (alarm-override set)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs:31-36`
|
||||
|
||||
**Step 1: Failing tests** — add `InlineData` rows (exact envelope, camelCase, all fields):
|
||||
```csharp
|
||||
[InlineData("template alarm add --template-id 12 --name HighTemp --trigger-type HiLo --priority 500 --mes-reportable", "AddTemplateAlarm", "{\"templateId\":12,\"name\":\"HighTemp\",\"triggerType\":\"HiLo\",\"priorityLevel\":500,\"description\":null,\"triggerConfiguration\":null,\"isLocked\":false,\"isMesReportable\":true}")]
|
||||
[InlineData("instance alarm-override set --instance-id 6 --alarm HighTemp --mes-reportable false", "SetInstanceAlarmOverride", "{\"instanceId\":6,\"alarmCanonicalName\":\"HighTemp\",\"triggerConfigurationOverride\":null,\"priorityLevelOverride\":null,\"isMesReportableOverride\":false}")]
|
||||
```
|
||||
(If the existing serializer emits `triggerConfiguration` differently for a no-config add, copy the exact string the failing assertion prints.)
|
||||
|
||||
**Step 2: Implement**
|
||||
Template add + update (mirror `--locked`):
|
||||
```csharp
|
||||
var mesReportableOption = new Option<bool>("--mes-reportable") { Description = "Report this alarm to MES (IsFlaggedForMES on the alarm-status API)" };
|
||||
mesReportableOption.DefaultValueFactory = _ => false;
|
||||
```
|
||||
register on the command, pass `result.GetValue(mesReportableOption)` as the new last ctor arg (a separate `updateMesReportableOption` for `update`, like `updateLockedOption`).
|
||||
Instance override — nullable means inherit (the `api-method update --trace-all-calls` `Option<bool?>` idiom, `ApiMethodCommands.cs:98-102`):
|
||||
```csharp
|
||||
var setMesReportableOption = new Option<bool?>("--mes-reportable") { Description = "MES-reportable override: true|false; omit to inherit the template value" };
|
||||
```
|
||||
README: add the `--mes-reportable` row to the three option tables and the synopsis lines; note "update replaces the whole entity — omit `--mes-reportable` and it resets to false" on `template alarm update` (that is the existing `--locked` behaviour too).
|
||||
|
||||
**Step 3: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter "EditingParity|UpdateCommandContract"` → PASS.
|
||||
|
||||
**Step 4: Commit** `feat(cli): --mes-reportable on template alarm add/update and instance alarm-override set`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Central UI — template alarm dialog + Alarms table
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 9, Task 11
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateAlarmDialog.razor:38-43` (markup), `:68`, `:84`, `:94`, `:106`, `:128-133`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor:906-934` (table), `:1436-1469` (open/show), `:1518-1540` (save)
|
||||
- Test: create `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/TemplateAlarmDialogMesReportableTests.cs` (structural, copy the shape of `TemplateNativeAlarmSourceEditorTests.cs:16-67`)
|
||||
|
||||
**Step 1: Failing test** — reads `TemplateAlarmDialog.razor` from disk and asserts it contains `@bind="_isMesReportable"`, `id="alarmMesReportable"`, and the label text `MES reportable`; reads `TemplateEdit.razor` and asserts the Alarms table has a `<th>MES</th>` header and a `data-test="alarm-mes-badge"` cell.
|
||||
|
||||
**Step 2: Implement**
|
||||
Dialog markup, directly after the Locked block:
|
||||
```razor
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_isMesReportable" id="alarmMesReportable" />
|
||||
<label class="form-check-label" for="alarmMesReportable">MES reportable</label>
|
||||
<div class="form-text">Returned by the MES alarm-status API as <code>IsFlaggedForMES</code>. Instances can override unless the alarm is locked.</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
Code: `[Parameter] public bool InitialIsMesReportable { get; set; }`, `private bool _isMesReportable;`, seed in `OnInitialized`, pass in `Submit`, and extend the record:
|
||||
```csharp
|
||||
public sealed record AlarmDraft(string Name, AlarmTriggerType TriggerType, int Priority, string? TriggerConfig, bool IsLocked, bool IsMesReportable);
|
||||
```
|
||||
`TemplateEdit.razor`: default draft gets `false`; edit draft passes `alarm.IsMesReportable`; `ShowAlarmDialogAsync` passes `InitialIsMesReportable="initial.IsMesReportable"`; both `proposed`/`alarm` initializers in `SaveAlarmDraftAsync` set `IsMesReportable = draft.IsMesReportable`. Table: header `<th>MES</th>` between Config and Lock; cell
|
||||
```razor
|
||||
<td data-test="alarm-mes-badge">
|
||||
@if (alarm.IsMesReportable) { <span class="badge bg-success-subtle text-success-emphasis border" aria-label="MES reportable">MES</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
```
|
||||
|
||||
**Step 3: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "TemplateAlarmDialogMesReportable|TemplateEdit"` → PASS. `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI` → 0 errors.
|
||||
|
||||
**Step 4: Commit** `feat(ui): MES reportable checkbox on the template alarm dialog + MES column on the Alarms tab`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Central UI — instance override modal (tri-state)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 9, Task 10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor:441-499` (table), `:531-541` (modal field), `:1532` (`OverrideSummary`), `:1564-1583` (`BeginEditOverride`), `:1628-1660` (delta check + save)
|
||||
- Test: create `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureMesReportableTests.cs` (structural on the razor source, plus a unit test of the delta rule if you extract it into a static helper — recommended: `static bool HasOverrideDelta(string? json, int? priority, bool? mes)`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs` — add `AlarmOverride_MesReportable_RoundTrips` beside `AlarmOverride_SetPriority_ThenClear_RoundTrips` (`:195-249`); runs against the rig in Task 14.
|
||||
|
||||
**Step 1: Failing tests** — structural: `data-test="alarm-mes-select"` exists with three `<option>`s; helper test: `(null, null, true)` → delta, `(null, null, null)` → no delta.
|
||||
|
||||
**Step 2: Implement**
|
||||
Modal, a second `col-md-6` beside Priority override:
|
||||
```razor
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">MES reportable</label>
|
||||
<select class="form-select form-select-sm" data-test="alarm-mes-select" @bind="_editingMesReportableText">
|
||||
<option value="">Inherited (@(_editingAlarm.IsMesReportable ? "reportable" : "not reportable"))</option>
|
||||
<option value="true">Reportable</option>
|
||||
<option value="false">Not reportable</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
Code: `private string _editingMesReportableText = "";` seeded in `BeginEditOverride` from `existing?.IsMesReportableOverride?.ToString().ToLowerInvariant() ?? ""`; in `SaveOverrideFromModal`:
|
||||
```csharp
|
||||
bool? mes = _editingMesReportableText switch { "true" => true, "false" => false, _ => null };
|
||||
if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue && mes is null) // ← the delta rule now counts the flag
|
||||
```
|
||||
and pass `mes` to `SetAlarmOverrideAsync(Id, alarmName, overrideJson, priority, currentUser, mes)`.
|
||||
`OverrideSummary`: when `ovr.IsMesReportableOverride is bool m` add `parts.Add(m ? "mesReportable" : "notMesReportable")`. Overrides table: in the *Inherited Config* cell append the `MES` badge when `alarm.IsMesReportable`; in the *Override* cell the summary already renders the new part.
|
||||
|
||||
**Step 3: Run** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter InstanceConfigure` → PASS; `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI` → 0 errors. Playwright test compiles (`dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`) but runs in Task 14.
|
||||
|
||||
**Step 4: Commit** `feat(ui): tri-state MES reportable override on Instance Configure (reportable-only override is a delta)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Documentation
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 13
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/requirements/Component-TemplateEngine.md` (alarm fields + override granularity), `Component-SiteRuntime.md` (Alarms accessor), `Component-ScriptAnalysis.md` (surface list), `Component-InboundAPI.md` (AlarmStatus routing example), `Component-CentralUI.md`, `Component-ConfigurationDatabase.md` (migration list), `Component-CLI.md` if it lists alarm flags
|
||||
- Modify: `CLAUDE.md` — the native-alarm bullet under *Data & Communication*: add one sentence "Computed alarms carry an `IsMesReportable` flag (template checkbox, instance-overridable) exposed as `ScriptAlarm.IsMesReportable` / `Alarms.MesReportableAsync()`; native alarms have no per-alarm row and keep the 900–999 band."
|
||||
- Modify: `docs/plans/2026-06-30-mes-alarm-status-api.md` §6.1 — prefix "**Superseded for COMPUTED alarms 2026-09-11** by `2026-09-11-mes-reportable-alarm-flag-design.md`; still the rule for native alarms."
|
||||
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` — rows 121 (source-level `IsMesReportable` on `TemplateNativeAlarmSource` + override) and 122 (proto field 25 / `DebugAlarmStateDto` field 20 + Alarm Summary badge).
|
||||
- Modify: `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md` — Status → IMPLEMENTED once Task 15 lands.
|
||||
|
||||
**Step 1:** Make the edits. **Step 2:** `grep -rn "severity band\|900–999\|900-999" docs/requirements CLAUDE.md` and make sure each hit now says "native only". **Step 3: Commit** `docs: MES-reportable alarm flag — component docs, CLAUDE.md, register rows 121–122`.
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Rig — rebuild + seed script
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (plus deploy time)
|
||||
**Parallelizable with:** Task 12
|
||||
|
||||
**Files:**
|
||||
- Create: `docker/seed-mes-alarm-demo.sh` (mirror `docker/seed-flow-log-demo.sh`: header comment, `CLI=` prebuilt binary, `A=(…)`, `find_id`, heredoc bodies, idempotent by name)
|
||||
- Modify: `docker/README.md` (add to the seed-script list, if one exists — `grep -n seed-flow-log docker/README.md`)
|
||||
|
||||
**Step 1: Rebuild the rig** (server-side changes + migration; the rig auto-applies migrations at boot):
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/ScadaBridge-mes-reportable
|
||||
dotnet build src/ZB.MOM.WW.ScadaBridge.CLI 2>&1 | tail -1
|
||||
bash docker/deploy.sh 2>&1 | tail -5
|
||||
until curl -sf http://localhost:9000/health/ready >/dev/null; do sleep 2; done
|
||||
docker logs scadabridge-central-a 2>&1 | grep -i "AddAlarmMesReportable" | head -2
|
||||
```
|
||||
Expected: the migration name appears in the central log once.
|
||||
|
||||
**Step 2: Write the seed** — sequence (all via `"$CLI" … "${A[@]}"`):
|
||||
1. `template create --name MesAlarmDemo --description "MES-reportable alarm flag demo"` (skip if `template list | find_id name MesAlarmDemo` succeeds).
|
||||
2. `template attribute add --template-id $T --name Temperature --data-type Double --value 20` (static — no `--data-source`).
|
||||
3. `template alarm add --template-id $T --name HighTemp --trigger-type HiLo --priority 500 --attribute Temperature --hi 80 --hihi 95 --mes-reportable`
|
||||
`template alarm add --template-id $T --name TempSpike --trigger-type ValueMatch --priority 300 --attribute Temperature --match-value 100`
|
||||
`template alarm add --template-id $T --name SensorFault --trigger-type ValueMatch --priority 700 --attribute Temperature --match-value -1`
|
||||
4. `template script add --template-id $T --name AlarmStatus --trigger-type Call --parameters '<schema §2.5 of the design, flat: MachineCode, FlaggedOnly, IncludeAcked, MinSeverity, MaxSeverity, NameFilter>' --return-def '<AlarmStatusResponse schema>' --code "$SITE_CODE"` — body = the script in the design mockup step 3 (`Alarms.MesReportableAsync()` when `FlaggedOnly`, else `CurrentAsync()`; legacy filter order; `IsFlaggedForMES = a.IsMesReportable`). On re-run use `template script update`.
|
||||
5. `instance create --name MesAlarmDemo01 --template-id $T --site-id 1`; `instance alarm-override set --instance-id $I --alarm SensorFault --mes-reportable true`; `instance deploy --id $I`.
|
||||
6. `api-method create --name AlarmStatus --timeout 30 --parameters '{"type":"object","properties":{"MachineCode":{"type":"string"},"AlarmFilter":{"type":"object","properties":{"FlaggedOnly":{"type":"boolean"},"IncludeAcked":{"type":"boolean"},"MinSeverity":{"type":"integer"},"MaxSeverity":{"type":"integer"},"NameFilter":{"type":"string"}}}},"required":["MachineCode"]}' --return-def '<same AlarmStatusResponse schema>' --script "$ROUTER_CODE"` where `$ROUTER_CODE`:
|
||||
```csharp
|
||||
var code = (Parameters.ContainsKey("MachineCode") ? Parameters["MachineCode"]?.ToString() : null) ?? "";
|
||||
var f = Parameters.ContainsKey("AlarmFilter") ? Parameters["AlarmFilter"] as System.Collections.Generic.Dictionary<string, object?> : null;
|
||||
object? G(string k) => f != null && f.TryGetValue(k, out var v) ? v : null;
|
||||
try {
|
||||
return await Route.To(code).Call("AlarmStatus", new {
|
||||
MachineCode = code,
|
||||
FlaggedOnly = G("FlaggedOnly") as bool? ?? false,
|
||||
IncludeAcked = G("IncludeAcked") as bool? ?? true,
|
||||
MinSeverity = G("MinSeverity") is long mn ? (int?)mn : null,
|
||||
MaxSeverity = G("MaxSeverity") is long mx ? (int?)mx : null,
|
||||
NameFilter = G("NameFilter")?.ToString() });
|
||||
} catch (Exception ex) when (ex.Message.Contains("not found on instance")) {
|
||||
return new { WasSuccessful = false, ErrorText = $"Alarm status is not supported on machine '{code}'", Alarms = Array.Empty<object>() };
|
||||
} catch (Exception ex) {
|
||||
return new { WasSuccessful = false, ErrorText = "AlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
|
||||
}
|
||||
```
|
||||
and `api-method create --name SimpleAlarmStatus …` with params `{MachineCode}` and a body that calls the same site script with `FlaggedOnly = true, IncludeAcked = true`.
|
||||
Verify in the api-method design page / `flow-debug run-method` that both compile — memory `inbound-noncompiling-update-keeps-old-handler`: a bad body is silently ignored on update.
|
||||
7. `security api-key create --name mes-alarm-demo --methods AlarmStatus,SimpleAlarmStatus` → print `MES_ALARM_DEMO_KEY=sbk_…` once (mirror `seed-delmia-api.sh:45-46`).
|
||||
8. `site deploy-artifacts --site-id 1` so the site has the method's dependencies, then `instance deploy --id $I` again if the script was updated.
|
||||
|
||||
**Step 3: Run it** `bash docker/seed-mes-alarm-demo.sh` → prints the ids and the key. Run it twice — second run must succeed (idempotent).
|
||||
|
||||
**Step 4: Commit** `feat(docker): seed-mes-alarm-demo.sh — MesAlarmDemo template/instance, AlarmStatus + SimpleAlarmStatus inbound methods`.
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Rig — verify script + live gate + Playwright
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (plus run time)
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `docker/verify-mes-alarm-demo.sh` (mirror `verify-delmia-notifier.sh` assertions: python3 one-liners over CLI JSON / curl JSON; `set -euo pipefail`; a `fail()` helper; PASS/FAIL summary)
|
||||
- Modify: `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md` — add §8 *Rig live gate results* with the measured output.
|
||||
|
||||
**Step 1: Write the verify script** — steps, each asserting on JSON:
|
||||
```bash
|
||||
KEY="${MES_ALARM_DEMO_KEY:?export the key printed by seed-mes-alarm-demo.sh}"
|
||||
I=$("$CLI" instance list "${A[@]}" | find_id uniqueName MesAlarmDemo01)
|
||||
call() { curl -sS -X POST "http://localhost:9000/api/$1" -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d "$2"; }
|
||||
trip() { "$CLI" flow-debug run-value-change --instance-id "$I" --attribute Temperature --value "$1" "${A[@]}" >/dev/null; sleep 2; }
|
||||
# A 96 → AlarmStatus: exactly HighTemp, IsFlaggedForMES true
|
||||
# B 100 → AlarmStatus FlaggedOnly=false: HighTemp + TempSpike (TempSpike false); FlaggedOnly=true: TempSpike absent
|
||||
# C -1 → SimpleAlarmStatus: SensorFault present, IsFlaggedForMES true (instance override wins over template false)
|
||||
# D MinSeverity=600 on AlarmStatus at -1 → SensorFault only
|
||||
# E 20 → AlarmStatus: WasSuccessful true, Alarms []
|
||||
# F MachineCode=DelmiaSim01 → WasSuccessful false, ErrorText contains "not supported on machine"
|
||||
# G MachineCode=NoSuchMachine → WasSuccessful false, ErrorText contains "not found"
|
||||
# H debug snapshot --id $I at step A shows HighTemp Active (cross-check)
|
||||
```
|
||||
Watch the trigger semantics while writing B: a HiLo alarm at 100 is still active (HiHi), so step B expects two rows.
|
||||
|
||||
**Step 2: Run** `MES_ALARM_DEMO_KEY=sbk_… bash docker/verify-mes-alarm-demo.sh` → `PASS 8/8`. If a step fails, use `superpowers-extended-cc:systematic-debugging` before touching code — likely suspects: `Parameters.Get<bool?>` vs `long` coercion in the router (`ParameterValidator.Materialize` yields `long`/`bool`), the 2 s settle after `run-value-change`, or the instance not redeployed after a script update.
|
||||
|
||||
**Step 3: Playwright override round-trip**
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "AlarmOverride_MesReportable_RoundTrips" 2>&1 | tail -3
|
||||
```
|
||||
Expected: PASS (rig login is disabled → fixture login still works, per the cluster-ops skill).
|
||||
|
||||
**Step 4: Record results** in the design doc §8 (commands, row counts, any surprise) and commit `test(docker): verify-mes-alarm-demo.sh — live gate PASS; design §8 results`.
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Finish — sweep, review, merge, memory
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (plus test time)
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Step 1: Full build + targeted sweep of every touched project**
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | grep -E "error|Warn.*CS" | head; \
|
||||
for p in Commons ConfigurationDatabase TemplateEngine ManagementService SiteRuntime ScriptAnalysis Transport Transport.IntegrationTests CLI CentralUI; do \
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.$p.Tests 2>&1 | tail -1; done
|
||||
```
|
||||
Expected: 0 errors; every project `Passed!` (the known env-only Playwright role-negative failures excepted).
|
||||
|
||||
**Step 2: `git diff main --stat` review**, then `git log --oneline main..HEAD` — one commit per task above.
|
||||
|
||||
**Step 3: Merge** via `superpowers-extended-cc:finishing-a-development-branch` (fast-forward `main`, delete the worktree). Do NOT push unless the user says so; QA/prod (`vq03`/`vq04`/`vd03`) are NOT deployed (remote exec is on hold — CLAUDE.md 2026-09-11 note).
|
||||
|
||||
**Step 4: Memory** — write `mes-reportable-alarm-flag-state.md` (status, merged SHA, rig seeded, key name, QA/prod not deployed, follow-ups = register rows 121/122) and add its line to `MEMORY.md`.
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-09-11-mes-reportable-alarm-flag.md",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Worktree",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Commons \u2014 entity, override and resolved-alarm fields",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: Configuration Database \u2014 migration",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Template Engine \u2014 flattening",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Template Engine \u2014 service write paths",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: Management commands + ManagementActor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Site Runtime \u2014 stamp the flag on the alarm snapshot",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: Script surface \u2014 ScriptAlarm.IsMesReportable + Alarms.MesReportableAsync()",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: Transport bundles",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: CLI flags + README",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: Central UI \u2014 template alarm dialog + Alarms table",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: Central UI \u2014 instance override modal (tri-state)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12: Documentation",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7,
|
||||
9,
|
||||
10,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13: Rig \u2014 rebuild + seed script",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14: Rig \u2014 verify script + live gate + Playwright",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15: Finish \u2014 sweep, review, merge, memory",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
12,
|
||||
14
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-09-11T14:46:05Z"
|
||||
}
|
||||
@@ -68,8 +68,8 @@ scadabridge template delete --id <id>
|
||||
scadabridge template attribute add --template-id <id> --name <name> --data-type <type> [--value <value>] [--description <desc>] [--data-source <ref>] [--locked]
|
||||
scadabridge template attribute update --id <id> [--name <name>] [--data-type <type>] [--value <value>] [--description <desc>] [--data-source <ref>] [--locked]
|
||||
scadabridge template attribute delete --id <id>
|
||||
scadabridge template alarm add --template-id <id> --name <name> --trigger-type <type> --priority <n> [--description <desc>] [--trigger-config <json>] [--locked] [--trigger-kind advisory|strict]
|
||||
scadabridge template alarm update --id <id> --name <name> --trigger-type <type> --priority <n> [--description <desc>] [--trigger-config <json>] [--locked] [--trigger-kind advisory|strict]
|
||||
scadabridge template alarm add --template-id <id> --name <name> --trigger-type <type> --priority <n> [--description <desc>] [--trigger-config <json>] [--locked] [--trigger-kind advisory|strict] [--mes-reportable]
|
||||
scadabridge template alarm update --id <id> --name <name> --trigger-type <type> --priority <n> [--description <desc>] [--trigger-config <json>] [--locked] [--trigger-kind advisory|strict] [--mes-reportable]
|
||||
scadabridge template alarm delete --id <id>
|
||||
scadabridge template script add --template-id <id> --name <name> --code <code> --trigger-type <type> [--trigger-config <json>] [--locked] [--parameters <json>] [--return-def <json>] [--trigger-kind advisory|strict]
|
||||
scadabridge template script update --id <id> --name <name> --code <code> --trigger-type <type> [--trigger-config <json>] [--locked] [--parameters <json>] [--return-def <json>] [--trigger-kind advisory|strict]
|
||||
@@ -93,7 +93,7 @@ scadabridge instance set-overrides --id <id> --overrides <json>
|
||||
scadabridge instance attribute-override delete --instance-id <id> --attribute <canonical-name>
|
||||
scadabridge instance native-alarm-source list --instance-id <id>
|
||||
scadabridge instance import-overrides --id <id> --file <path.csv>
|
||||
scadabridge instance alarm-override set --instance-id <id> --alarm <name> [--trigger-config <json>] [--priority <n>]
|
||||
scadabridge instance alarm-override set --instance-id <id> --alarm <name> [--trigger-config <json>] [--priority <n>] [--mes-reportable true|false]
|
||||
scadabridge instance alarm-override delete --instance-id <id> --alarm <name>
|
||||
scadabridge instance alarm-override list --instance-id <id>
|
||||
scadabridge instance native-alarm-source set --instance-id <id> --source <name> [--connection <name>] [--source-ref <ref>] [--filter <expr>]
|
||||
@@ -110,6 +110,12 @@ scadabridge instance delete --id <id>
|
||||
`[["Speed", 5], ["Mode", 7]]`. `--overrides` is a JSON object of attribute name to
|
||||
value, e.g. `{"Speed": "100", "Mode": null}`.
|
||||
|
||||
`instance alarm-override set` replaces the **whole** override row — an omitted flag is not
|
||||
"leave as is", it clears that field back to inherited. `instance alarm-override set
|
||||
--instance-id 6 --alarm X --mes-reportable true` wipes any existing `--priority` /
|
||||
`--trigger-config` override on alarm `X`; pass every override to keep on the same command
|
||||
(`instance alarm-override list` shows the current values).
|
||||
|
||||
### Native Alarm Source Commands
|
||||
|
||||
The `native-alarm-source` subcommands manage the **read-only native alarm mirror** —
|
||||
|
||||
@@ -63,6 +63,7 @@ Central cluster only. Sites have no user interface.
|
||||
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
|
||||
- Manage composition — add/remove feature module instances within templates. **Naming collision detection** provides immediate feedback if composed modules introduce duplicate attribute, alarm, or script names.
|
||||
- Define and edit attributes, alarms, and scripts on templates.
|
||||
- **`TemplateAlarmDialog`** carries an **"MES reportable"** checkbox bound to `TemplateAlarm.IsMesReportable` (default unchecked, 2026-09-11) alongside the existing Name/Trigger Type/Priority/Trigger Definition/Description/Lock fields.
|
||||
- **Member authoring dialogs are hosted, not page-embedded** (M10 residual, 2026-08-01): the template editor's Attribute, Alarm, Native Alarm Source, and Script forms all open through `IDialogService.ShowAsync`, so the single `DialogHost` in `MainLayout` owns the backdrop, focus trap, Escape, and focus restoration. Each form body is its own component beside the page (`TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog`), following the `MoveDataConnectionDialog` pattern — the body renders validation and server errors **inline and stays open**, closing only on a successful save; persistence stays on the page (which owns `TemplateService`) behind an `OnSaveAsync` delegate.
|
||||
- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **hosted dialog** (`TemplateNativeAlarmSourceDialog`):
|
||||
- **Name** — unique within the template (lock/inherit bookkeeping mirrors `TemplateAlarm`).
|
||||
@@ -149,6 +150,7 @@ Central cluster only. Sites have no user interface.
|
||||
- **Type column** — Variable rows display best-effort type info (data type friendly name, scalar/array value rank, writable flag) read from the server during browse.
|
||||
- Set instance-level attribute overrides (non-locked attributes only).
|
||||
- **Bulk override CSV import** (`InstanceConfigure`): a Blazor `InputFile` upload accepts a CSV of `AttributeName, Value, ElementType?` rows (`ElementType` only for `List` attributes). Each row is validated against the instance's flattened attribute schema (name exists + value type-compatible, reusing the existing override validation); the import is **all-or-nothing** — any per-row error aborts the whole upload with a per-row error summary and nothing is applied. On success the rows are upserted through the same ManagementActor add/update-override handlers used by the inline editor. The same import is available from the CLI (`instance import-overrides --file`, see Component-CLI.md).
|
||||
- **Alarm Overrides card** (`InstanceConfigure`) gained an **"MES reportable override"** tri-state select (2026-09-11) in the per-alarm **override modal** (not on the list row), bound to `InstanceAlarmOverride.IsMesReportableOverride` — the options are "Inherited (reportable | not reportable)" (the parenthetical reflects the template alarm's own flag) → `null`, "Reportable" → `true`, and "Not reportable" → `false`, which is a real override pinning the alarm off for this instance rather than a clear. The list row shows the inherited flag as a green **MES** badge and any override in its override summary. Locked alarms are **filtered out of the override list entirely** (the card only lists non-locked alarms), so there is no disabled-row state for them. `instance alarm-override set --mes-reportable true|false` (omit the flag to inherit) is the CLI equivalent.
|
||||
- **Native Alarm Source Overrides card** (`InstanceConfigure`): a card placed **after the Alarm Overrides card**, listing the template's native alarm sources for per-instance binding. Each row offers **inline override** of the three fields that typically vary per physical instance:
|
||||
- **Connection** — a dropdown (same alarm-capable filtering as the template editor).
|
||||
- **Source Reference** — the concrete native key for this instance.
|
||||
|
||||
@@ -358,6 +358,7 @@ A CI grep guard (`AuditLogAppendOnlyGuardTests`) fails the build on any `UPDATE`
|
||||
- Each migration is a versioned, incremental schema change.
|
||||
- New tables are introduced as their own migration — for example, the `Notifications` table for the Notification Outbox ships as a dedicated EF Core migration that creates the table, its `Type`/`Status` value conversions, and its dispatcher and KPI indexes.
|
||||
- The native alarm source tables ship in a dedicated `AddNativeAlarmSources` migration, parallel in shape to the existing `AddInstanceAlarmOverrides` migration: it creates `NativeAlarmSources` and `InstanceNativeAlarmSourceOverrides` with their columns, the `TemplateId` → `Templates` and `InstanceId` → `Instances` cascade-delete foreign keys, and the `(TemplateId, Name)` / `(InstanceId, SourceCanonicalName)` unique indexes.
|
||||
- **`20260911135501_AddAlarmMesReportable`** (2026-09-11) adds `TemplateAlarms.IsMesReportable` (`bit`, default `0`) and `InstanceAlarmOverrides.IsMesReportableOverride` (`bit` NULL, null = inherit the template value). No new tables, no index changes — see `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md`.
|
||||
- The initial `AuditLog` migration creates the monthly partition function `pf_AuditLog_Month` and partition scheme `ps_AuditLog_Month`, then creates the `AuditLog` table aligned to that scheme on `OccurredAtUtc`, along with the indexes listed under Database Schema. The migration also creates the `scadabridge_audit_writer` and `scadabridge_audit_purger` DB roles with the grants described in Database Roles. The ongoing **partition-maintenance job** that rolls the scheme forward each month (creating the next month's partition ahead of time) and switches out expired partitions is owned by the **Audit Log component** (`AuditLogPurgeActor` and its monthly roll-forward step), not by the Configuration Database component — this component is responsible only for the initial schema, roles, and any EF migrations against the table going forward.
|
||||
|
||||
### Development Environment
|
||||
|
||||
@@ -252,6 +252,8 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a
|
||||
- `Route.To("instanceUniqueCode").WaitForAttribute("attributeName", targetValue, timeout)` — Wait, event-driven, until an attribute on a specific instance at any site reaches `targetValue` (value-equality only across the wire), bounded by `timeout`. Returns `true` if matched within the timeout, `false` if it timed out. **The wait is bounded by its own `timeout`, not the generic method-level timeout** — this is the one routed call that may legitimately outlive the method timeout (the site enforces `timeout` and returns `false` when it elapses). A client disconnect still cancels the wait. This is the deliberate exception to the rule below that routed calls inherit the method-level timeout (see "Routing Behavior"): a long event-driven wait is the explicit reason `timeout` governs here.
|
||||
|
||||
> **Reading alarm state via a routed script.** There is deliberately **no** `Route.To(...).GetAlarms(...)` verb. An inbound method that needs a machine's current alarms resolves the instance and calls a **site script** that uses the Site Runtime `Alarms.CurrentAsync()` accessor — `Route.To(code).Call("SimpleAlarmStatus", new { ... })`. Alarm state is per-instance and lives on the site's Instance Actor; keeping the read inside a site script means the filtering/shaping happens where the data is, and central stays a thin router. This is the shape the MES alarm-status endpoints use (`docs/plans/2026-06-30-mes-alarm-status-api.md` §5.1); see [Component-SiteRuntime.md](Component-SiteRuntime.md) → *Alarm State (read-only)* for the accessor.
|
||||
>
|
||||
> **Example — `AlarmStatus`.** The rig demo template `MesAlarmDemo`'s site script (`docker/seed-mes-alarm-demo.sh`) answers `Route.To(MachineCode).Call("AlarmStatus", new { AlarmFilter = new { FlaggedOnly, IncludeAcked, MinSeverity, MaxSeverity, NameFilter } })` with the legacy `AlarmStatusResponse { WasSuccessful, ErrorText, Alarms: AlarmInfo[] }`, setting `IsFlaggedForMES = a.IsMesReportable` per row for **computed** alarms (the template's `TemplateAlarm.IsMesReportable` flag, instance-overridable) and the **900–999 severity band** for **native** alarms, which carry no per-alarm `IsMesReportable` row. `SimpleAlarmStatus` is the same shape with `FlaggedOnly` hard-wired true.
|
||||
|
||||
#### Input/Output
|
||||
- **Input parameters** are available as defined in the method definition.
|
||||
|
||||
@@ -148,7 +148,7 @@ Used by the Template Engine deploy gate:
|
||||
```csharp
|
||||
var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
```
|
||||
This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`, `ExternalSystem.Call(...)`, `Alarms.CurrentAsync()`, and similar API calls against real types, catching undefined-symbol and type-mismatch errors before deployment.
|
||||
This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`, `ExternalSystem.Call(...)`, `Alarms.CurrentAsync()`, `Alarms.MesReportableAsync()` (2026-09-11), and similar API calls against real types, catching undefined-symbol and type-mismatch errors before deployment.
|
||||
|
||||
The surface also mirrors the **`Log` global** (step logs design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D4) as the compile-only `ScriptCompileSurface.CompileLog` — `Debug` / `Info` / `Warn` / `Error(string message, Exception? exception = null)`, including the optional exception parameter, so `Log.Warn("…", ex)` binds at the design-time gate exactly as it does at the site. `Log` needs no `ScriptTrustPolicy` change for the same reason `Alarms` did not: the trust boundary is a deny-list over API roots, not an allow-list of context members.
|
||||
|
||||
|
||||
@@ -510,11 +510,13 @@ Available to all script runs and alarm on-trigger runs:
|
||||
|
||||
### Alarm State (read-only)
|
||||
- `Alarms.CurrentAsync()` — Returns a snapshot of the instance's **current** alarm conditions as `IReadOnlyList<ScriptAlarm>` (`Commons.Types.Scripts`), covering computed alarms and mirrored native (OPC UA A&C / MxAccess Gateway) conditions alike. Served by a **local Ask** (`GetAlarmSnapshotRequest`/`GetAlarmSnapshotResponse`) to the script's own Instance Actor — the same mechanism attribute reads use, never a cross-cluster hop.
|
||||
- `Alarms.MesReportableAsync()` — `CurrentAsync()` filtered to rows where `ScriptAlarm.IsMesReportable` is true (2026-09-11). Native alarms carry no per-alarm config row, so they never satisfy the filter — they still convey MES relevance via the 900–999 severity band (see `docs/plans/2026-06-30-mes-alarm-status-api.md`).
|
||||
- Available to **every** script, unlike the `Alarm` global, which exists only inside an on-trigger handler and describes the single alarm that fired. This accessor is what lets an inbound-API-routed `Call` script answer "what is in alarm on this machine right now?"; before it, native mirrored conditions were reachable only from the Debug View.
|
||||
- **Read-only by design** — native alarms are a read-only mirror of the source (no ack-back), so the accessor exposes no acknowledge/shelve operation.
|
||||
- **Not scope-prefixed**, unlike `Attributes`: alarm identity is not a scope-relative attribute name (computed alarms are keyed by configured name, native conditions by a source-supplied reference), so every scope sees the same list rather than a silently truncated one.
|
||||
- The snapshot is built by the same `BuildAlarmStatesSnapshot()` the Debug View uses, so it **includes placeholder rows** for configured-but-quiet native source bindings (`IsConfiguredPlaceholder`). Callers enumerating real alarms filter on `Active && !IsConfiguredPlaceholder`.
|
||||
- `ScriptAlarm` is a flat projection of the retained `AlarmStateChanged`: `Name`, `SourceReference`, `NativeSourceCanonicalName`, `Active`, `Acknowledged`, `Confirmed?`, `Shelved` (all shelve sub-states collapse to one boolean), `Suppressed`, `Severity` (0–1000), `Kind`, `Message`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `Timestamp`, `AckTime?`, `CurrentValue`, `LimitValue`, `IsConfiguredPlaceholder`. The `Condition` is the authority for active/acked/severity, so computed and native alarms read identically from one filter expression.
|
||||
- `ScriptAlarm` is a flat projection of the retained `AlarmStateChanged`: `Name`, `SourceReference`, `NativeSourceCanonicalName`, `Active`, `Acknowledged`, `Confirmed?`, `Shelved` (all shelve sub-states collapse to one boolean), `Suppressed`, `Severity` (0–1000), `Kind`, `Message`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `Timestamp`, `AckTime?`, `CurrentValue`, `LimitValue`, `IsConfiguredPlaceholder`, `IsMesReportable` (trailing, 2026-09-11 — always `false` on native rows). The `Condition` is the authority for active/acked/severity, so computed and native alarms read identically from one filter expression.
|
||||
- **`IsMesReportable` is stamped in one place.** `InstanceActor.BuildAlarmStatesSnapshot()` sets `AlarmStateChanged.IsMesReportable` from `ResolvedAlarm.IsMesReportable` on every row it produces — retained (fired/active) rows and the fallback/idle rows alike — so `Alarms.CurrentAsync()`/`MesReportableAsync()` and the Debug View snapshot always agree regardless of whether the alarm has fired. The flag is **not** carried on the gRPC stream or `DebugAlarmStateDto` (no proto change in this slice — deferred-work register row 122), so central-side surfaces (Debug View live stream, Alarm Summary) see `false` until that follow-up lands.
|
||||
- The type lives in Commons so the runtime accessor and the Script Analysis compile-only surface (`ScriptCompileSurface.CompileAlarmsAccessor`) project to the **same** type — a script binding at the design-time gate binds identically at the site. Parity is pinned by `CompileSurfaceParityTests`.
|
||||
- Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §5.2.
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ breadcrumb.
|
||||
Override and lock rules apply per entity type at the following granularity:
|
||||
|
||||
- **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. Lock applies to the entire attribute (when locked, no fields can be overridden).
|
||||
- **Alarms**: Priority Level and Trigger Definition (thresholds/ranges/rates) are instance-overridable. Description and On-Trigger Script reference are **not** instance-overridable — `InstanceAlarmOverride` carries only `TriggerConfigurationOverride` and `PriorityLevelOverride`, so an instance cannot re-point an alarm's on-trigger script or reword its description (those are template-level authoring decisions). Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. `TriggerConfigurationOverride` is a **partial merge for `HiLo`** triggers — setpoints left unset in the override keep their inherited values — and a **whole-config replacement for every other trigger type**. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.)
|
||||
- **Alarms**: Priority Level, Trigger Definition (thresholds/ranges/rates) and the MES-reportable flag are instance-overridable — `InstanceAlarmOverride` carries exactly three override fields: `TriggerConfigurationOverride`, `PriorityLevelOverride` and `IsMesReportableOverride`. Description and On-Trigger Script reference are **not** instance-overridable (there is no override column for either), so an instance cannot re-point an alarm's on-trigger script or reword its description — those are template-level authoring decisions. Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. `TriggerConfigurationOverride` is a **partial merge for `HiLo`** triggers — setpoints left unset in the override keep their inherited values — and a **whole-config replacement for every other trigger type**. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.) `TemplateAlarm.IsMesReportable` (bool, default false, "MES reportable" checkbox — `template alarm add|update --mes-reportable`) is instance-overridable via the nullable `InstanceAlarmOverride.IsMesReportableOverride` (null = inherit the template value; `instance alarm-override set --mes-reportable true|false`, omitted = inherit). A locked alarm cannot take this override either, same as every other alarm field. Migration `20260911135501_AddAlarmMesReportable` (Config DB, 2026-09-11); flattening resolves `ResolvedAlarm.IsMesReportable = override ?? template` and ships it in the deployed config JSON — no site schema change. See `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md`.
|
||||
- **Native alarm sources**: An instance overrides a non-locked source via `InstanceNativeAlarmSourceOverride`, keyed by `SourceCanonicalName`. `ConnectionNameOverride`, `SourceReferenceOverride`, and `ConditionFilterOverride` are individually overridable — each is applied only when non-null; a null field **keeps the inherited value**. Name is fixed. Lock applies to the entire source.
|
||||
- **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script.
|
||||
- **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name.
|
||||
|
||||
@@ -553,8 +553,9 @@ public static class InstanceCommands
|
||||
var setAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name (e.g., 'TempLevels' or 'Pump.TempSensor.Heat')", Required = true };
|
||||
var setConfigOption = new Option<string?>("--trigger-config") { Description = "JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace)" };
|
||||
var setPriorityOption = new Option<int?>("--priority") { Description = "Priority override (0-1000)" };
|
||||
var setMesReportableOption = new Option<bool?>("--mes-reportable") { Description = "MES-reportable override: true or false; omit to inherit the template value" };
|
||||
var setCmd = new Command("set") { Description = "Set (upsert) an alarm override on an instance" };
|
||||
setCmd.Add(setIdOption); setCmd.Add(setAlarmOption); setCmd.Add(setConfigOption); setCmd.Add(setPriorityOption);
|
||||
setCmd.Add(setIdOption); setCmd.Add(setAlarmOption); setCmd.Add(setConfigOption); setCmd.Add(setPriorityOption); setCmd.Add(setMesReportableOption);
|
||||
setCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
@@ -563,7 +564,8 @@ public static class InstanceCommands
|
||||
result.GetValue(setIdOption),
|
||||
result.GetValue(setAlarmOption)!,
|
||||
result.GetValue(setConfigOption),
|
||||
result.GetValue(setPriorityOption)));
|
||||
result.GetValue(setPriorityOption),
|
||||
result.GetValue(setMesReportableOption)));
|
||||
});
|
||||
group.Add(setCmd);
|
||||
|
||||
|
||||
@@ -682,6 +682,8 @@ public static class TemplateCommands
|
||||
var triggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
|
||||
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
lockedOption.DefaultValueFactory = _ => false;
|
||||
var mesReportableOption = new Option<bool>("--mes-reportable") { Description = "Report this alarm to MES (IsFlaggedForMES on the alarm-status API)" };
|
||||
mesReportableOption.DefaultValueFactory = _ => false;
|
||||
|
||||
// Typed setpoint flags (alternative to raw --trigger-config; raw wins when both supplied).
|
||||
var attributeOption = new Option<string?>("--attribute") { Description = "Attribute name the trigger watches (all trigger types except Expression)" };
|
||||
@@ -714,6 +716,7 @@ public static class TemplateCommands
|
||||
addCmd.Add(descOption);
|
||||
addCmd.Add(triggerConfigOption);
|
||||
addCmd.Add(lockedOption);
|
||||
addCmd.Add(mesReportableOption);
|
||||
addCmd.Add(attributeOption);
|
||||
addCmd.Add(matchValueOption);
|
||||
addCmd.Add(notEqualsOption);
|
||||
@@ -751,7 +754,8 @@ public static class TemplateCommands
|
||||
result.GetValue(priorityOption)!,
|
||||
result.GetValue(descOption),
|
||||
triggerConfig,
|
||||
result.GetValue(lockedOption)));
|
||||
result.GetValue(lockedOption),
|
||||
result.GetValue(mesReportableOption)));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
@@ -763,6 +767,8 @@ public static class TemplateCommands
|
||||
var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
|
||||
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
updateLockedOption.DefaultValueFactory = _ => false;
|
||||
var updateMesReportableOption = new Option<bool>("--mes-reportable") { Description = "Report this alarm to MES (IsFlaggedForMES on the alarm-status API)" };
|
||||
updateMesReportableOption.DefaultValueFactory = _ => false;
|
||||
// --trigger-kind for update (same semantics as add)
|
||||
var updateTriggerKindOption = new Option<string?>("--trigger-kind")
|
||||
{
|
||||
@@ -777,6 +783,7 @@ public static class TemplateCommands
|
||||
updateCmd.Add(updateDescOption);
|
||||
updateCmd.Add(updateTriggerConfigOption);
|
||||
updateCmd.Add(updateLockedOption);
|
||||
updateCmd.Add(updateMesReportableOption);
|
||||
updateCmd.Add(updateTriggerKindOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
@@ -794,7 +801,8 @@ public static class TemplateCommands
|
||||
result.GetValue(updatePriorityOption)!,
|
||||
result.GetValue(updateDescOption),
|
||||
triggerConfig,
|
||||
result.GetValue(updateLockedOption)));
|
||||
result.GetValue(updateLockedOption),
|
||||
result.GetValue(updateMesReportableOption)));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ scadabridge --url <url> template attribute delete --id <int>
|
||||
Add an alarm definition to a template.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template alarm add --template-id <int> --name <string> --trigger-type <string> --priority <int> [--description <string>] [--trigger-config <json>] [--locked] [--trigger-kind <kind>] [typed-setpoint-flags]
|
||||
scadabridge --url <url> template alarm add --template-id <int> --name <string> --trigger-type <string> --priority <int> [--description <string>] [--trigger-config <json>] [--locked] [--mes-reportable] [--trigger-kind <kind>] [typed-setpoint-flags]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
@@ -277,6 +277,7 @@ scadabridge --url <url> template alarm add --template-id <int> --name <string> -
|
||||
| `--description` | no | Description |
|
||||
| `--trigger-config` | no | Trigger configuration as a JSON string (overrides typed flags when both are supplied) |
|
||||
| `--locked` | no | Lock the alarm in derived templates |
|
||||
| `--mes-reportable` | no | Report this alarm to MES (`IsFlaggedForMES` on the alarm-status API). Defaults to `false` |
|
||||
| `--trigger-kind` | no | Expression-trigger analysis kind: `advisory` (default — non-blocking advisory finding on a blank expression) or `strict` (blank expression is a deploy-blocking error). Ignored for non-`Expression` trigger types. |
|
||||
| `--attribute` | no | Attribute name the trigger watches (all trigger types except `Expression`) |
|
||||
| `--match-value` | no | `ValueMatch`: value to compare against |
|
||||
@@ -298,7 +299,7 @@ Update an alarm definition on a template. An update **replaces** the whole entit
|
||||
required field below must be supplied with its post-update value, even if unchanged.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template alarm update --id <int> --name <string> --trigger-type <string> --priority <int> [--description <string>] [--trigger-config <json>] [--locked] [--trigger-kind <kind>]
|
||||
scadabridge --url <url> template alarm update --id <int> --name <string> --trigger-type <string> --priority <int> [--description <string>] [--trigger-config <json>] [--locked] [--mes-reportable] [--trigger-kind <kind>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
@@ -309,7 +310,8 @@ scadabridge --url <url> template alarm update --id <int> --name <string> --trigg
|
||||
| `--priority` | yes | Alarm priority (0–1000) |
|
||||
| `--description` | no | Description |
|
||||
| `--trigger-config` | no | Trigger configuration JSON |
|
||||
| `--locked` | no | Lock the alarm in derived templates |
|
||||
| `--locked` | no | Lock the alarm in derived templates. Since `update` replaces the whole entity, omitting `--locked` resets it to `false` |
|
||||
| `--mes-reportable` | no | Report this alarm to MES (`IsFlaggedForMES` on the alarm-status API). Since `update` replaces the whole entity, omitting `--mes-reportable` resets it to `false` |
|
||||
| `--trigger-kind` | no | Expression-trigger analysis kind: `advisory` (default) or `strict`. Ignored for non-`Expression` trigger types. **Requires `--trigger-config`** — the kind is written into the trigger-config JSON, so on `update` it has no effect without one (the CLI prints a warning to stderr and continues). |
|
||||
|
||||
#### `template alarm delete`
|
||||
@@ -810,7 +812,7 @@ Label,"Hello, World",
|
||||
Set (upsert) an alarm override on an instance.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> instance alarm-override set --instance-id <int> --alarm <string> [--trigger-config <json>] [--priority <int>]
|
||||
scadabridge --url <url> instance alarm-override set --instance-id <int> --alarm <string> [--trigger-config <json>] [--priority <int>] [--mes-reportable <true|false>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
@@ -819,6 +821,12 @@ scadabridge --url <url> instance alarm-override set --instance-id <int> --alarm
|
||||
| `--alarm` | yes | Alarm canonical name (e.g. `TempLevels` or `Pump.TempSensor.Heat`) |
|
||||
| `--trigger-config` | no | JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace) |
|
||||
| `--priority` | no | Priority override (0–1000) |
|
||||
| `--mes-reportable` | no | MES-reportable override: `true` or `false`; omit to inherit the template value |
|
||||
|
||||
`set` replaces the **whole** override row, so an omitted flag is not "leave as is" — it clears that
|
||||
field back to inherited. `instance alarm-override set --instance-id 6 --alarm X --mes-reportable true`
|
||||
wipes any existing `--priority` / `--trigger-config` override on alarm `X`; pass every override you
|
||||
want to keep on the same command (`instance alarm-override list` shows the current values).
|
||||
|
||||
#### `instance alarm-override delete`
|
||||
|
||||
|
||||
@@ -40,23 +40,28 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Deployment section — Deployer role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
@* Deployment section. The section is gated on RequireFlowDebugger,
|
||||
which is a superset of RequireDeployment (Deployer + the
|
||||
OperationalAudit roles), so every Deployer still sees it and a
|
||||
Viewer/Administrator without Deployer gets a section holding only
|
||||
Flow Debugger. The three Deployer-only pages are gated per item, so
|
||||
Flow Debugger expands/collapses with the group instead of sitting
|
||||
outside it as a lone top-level link. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireFlowDebugger">
|
||||
<Authorized Context="deploymentContext">
|
||||
<NavRailSection Title="Deployment" Key="deployment">
|
||||
<NavRailItem Href="/deployment/topology" Text="Topology" />
|
||||
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
|
||||
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deployerContext">
|
||||
<NavRailItem Href="/deployment/topology" Text="Topology" />
|
||||
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
|
||||
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<NavRailItem Href="/deployment/flow-debugger" Text="Flow Debugger" />
|
||||
</NavRailSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireFlowDebugger">
|
||||
<Authorized Context="flowContext">
|
||||
<NavRailItem Href="/deployment/flow-debugger" Text="Flow Debugger" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@if (context.User.Claims.Any(c => c.Type == JwtTokenService.RoleClaimType && (c.Value == Roles.Administrator || c.Value == Roles.Designer || c.Value == Roles.Deployer)))
|
||||
{
|
||||
<NavRailSection Title="Notifications" Key="notifications">
|
||||
|
||||
+39
-3
@@ -459,6 +459,13 @@
|
||||
<td class="small text-muted text-truncate font-monospace" style="max-width: 280px;"
|
||||
title="@alarm.TriggerConfiguration">
|
||||
@(alarm.TriggerConfiguration ?? "—")
|
||||
@if (alarm.IsMesReportable)
|
||||
{
|
||||
@* The inherited MES-reportable flag; an instance
|
||||
override of it shows in the Override cell's summary. *@
|
||||
<span class="badge bg-success ms-1"
|
||||
data-test="alarm-inherited-mes" title="Template alarm is MES reportable">MES</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (HasOverride(alarm.Name))
|
||||
@@ -538,6 +545,17 @@
|
||||
placeholder="@_editingAlarm.PriorityLevel"
|
||||
@bind="_editingPriorityText" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@* Tri-state: blank = inherit the template alarm's flag.
|
||||
"Not reportable" is a real override, not a clear — it
|
||||
pins a reportable template alarm off for this instance. *@
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">MES reportable override</label>
|
||||
<select class="form-select form-select-sm" data-test="alarm-mes-select" @bind="_editingMesReportableText">
|
||||
<option value="">Inherited (@(_editingAlarm.IsMesReportable ? "reportable" : "not reportable"))</option>
|
||||
<option value="true">Reportable</option>
|
||||
<option value="false">Not reportable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_editingError != null)
|
||||
@@ -837,6 +855,9 @@
|
||||
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
|
||||
private string? _editingInheritedValue; // the inherited config snapshot we diff against on save
|
||||
private string? _editingPriorityText;
|
||||
// Tri-state MES reportable, as the select's string value: "" = inherited,
|
||||
// "true"/"false" = pinned. Parsed back to bool? on save.
|
||||
private string _editingMesReportableText = "";
|
||||
private string? _editingError;
|
||||
private IReadOnlyList<AlarmAttributeChoice> _editingAvailableAttributes = Array.Empty<AlarmAttributeChoice>();
|
||||
|
||||
@@ -1552,6 +1573,8 @@
|
||||
}
|
||||
if (ovr.PriorityLevelOverride.HasValue)
|
||||
parts.Add($"priority={ovr.PriorityLevelOverride.Value}");
|
||||
if (ovr.IsMesReportableOverride is bool m)
|
||||
parts.Add(m ? "mesReportable" : "notMesReportable");
|
||||
|
||||
return parts.Count == 0 ? "(empty)" : string.Join(", ", parts);
|
||||
}
|
||||
@@ -1577,6 +1600,12 @@
|
||||
: (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration);
|
||||
|
||||
_editingPriorityText = existing?.PriorityLevelOverride?.ToString();
|
||||
_editingMesReportableText = existing?.IsMesReportableOverride switch
|
||||
{
|
||||
true => "true",
|
||||
false => "false",
|
||||
_ => "",
|
||||
};
|
||||
_editingAvailableAttributes = _flattenedAttributes;
|
||||
}
|
||||
|
||||
@@ -1621,11 +1650,18 @@
|
||||
: _editingOverrideValue;
|
||||
}
|
||||
|
||||
bool? mesReportable = _editingMesReportableText switch
|
||||
{
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var alarmName = _editingAlarm.Name;
|
||||
|
||||
// No diff + no priority → clear any existing override and close.
|
||||
if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue)
|
||||
// Nothing overridden at all → clear any existing override and close.
|
||||
if (!HasOverrideDelta(overrideJson, priority, mesReportable))
|
||||
{
|
||||
if (_existingAlarmOverrides.ContainsKey(alarmName))
|
||||
{
|
||||
@@ -1647,7 +1683,7 @@
|
||||
else
|
||||
{
|
||||
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync(
|
||||
Id, alarmName, overrideJson, priority, currentUser));
|
||||
Id, alarmName, overrideJson, priority, currentUser, mesReportable));
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_editingError = result.Error;
|
||||
|
||||
+11
@@ -115,6 +115,17 @@ public partial class InstanceConfigure
|
||||
return new CsvOverrideImportOutcome(overrides, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The save-time delta rule for the alarm-override modal: does it carry anything
|
||||
/// worth persisting? When it does not, <c>SaveOverrideFromModal</c> DELETES any
|
||||
/// existing override — so every override term must be represented here or it
|
||||
/// would be silently dropped on save. A <c>false</c> MES-reportable choice
|
||||
/// counts: it pins a reportable template alarm off for this instance, which is a
|
||||
/// real override and not the same as inheriting.
|
||||
/// </summary>
|
||||
internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable)
|
||||
=> !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue;
|
||||
|
||||
/// <summary>
|
||||
/// One validated native-alarm-source retarget from an imported CSV: the source
|
||||
/// binding's name plus the three optional override fields (null = keep the
|
||||
|
||||
+16
-2
@@ -41,6 +41,13 @@
|
||||
<label class="form-check-label" for="alarmLocked">Locked</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_isMesReportable" id="alarmMesReportable" />
|
||||
<label class="form-check-label" for="alarmMesReportable">MES reportable</label>
|
||||
<div class="form-text">Returned by the MES alarm-status API as <code>IsFlaggedForMES</code>. Instances can override this unless the alarm is locked.</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="col-12"><div class="text-danger small" data-test="alarm-form-error">@_error</div></div>
|
||||
@@ -67,6 +74,9 @@
|
||||
[Parameter] public string? InitialTriggerConfig { get; set; }
|
||||
[Parameter] public bool InitialIsLocked { get; set; }
|
||||
|
||||
/// <summary>Seeds the "MES reportable" flag returned by the MES alarm-status API.</summary>
|
||||
[Parameter] public bool InitialIsMesReportable { get; set; }
|
||||
|
||||
/// <summary>Attribute choices offered by the structured trigger editor's picker.</summary>
|
||||
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; }
|
||||
= Array.Empty<AlarmAttributeChoice>();
|
||||
@@ -82,6 +92,7 @@
|
||||
private AlarmTriggerType _triggerType;
|
||||
private string? _triggerConfig;
|
||||
private bool _isLocked;
|
||||
private bool _isMesReportable;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
@@ -92,6 +103,7 @@
|
||||
_triggerType = InitialTriggerType;
|
||||
_triggerConfig = InitialTriggerConfig;
|
||||
_isLocked = InitialIsLocked;
|
||||
_isMesReportable = InitialIsMesReportable;
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
@@ -103,7 +115,7 @@
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var error = await OnSaveAsync(new AlarmDraft(_name, _triggerType, _priority, _triggerConfig, _isLocked));
|
||||
var error = await OnSaveAsync(new AlarmDraft(_name, _triggerType, _priority, _triggerConfig, _isLocked, _isMesReportable));
|
||||
if (error == null)
|
||||
{
|
||||
Context.Close(true);
|
||||
@@ -125,10 +137,12 @@
|
||||
/// <param name="Priority">Priority level.</param>
|
||||
/// <param name="TriggerConfig">Structured trigger configuration payload.</param>
|
||||
/// <param name="IsLocked">Whether instances may override the alarm.</param>
|
||||
/// <param name="IsMesReportable">Whether the MES alarm-status API reports this alarm.</param>
|
||||
public sealed record AlarmDraft(
|
||||
string Name,
|
||||
AlarmTriggerType TriggerType,
|
||||
int Priority,
|
||||
string? TriggerConfig,
|
||||
bool IsLocked);
|
||||
bool IsLocked,
|
||||
bool IsMesReportable);
|
||||
}
|
||||
|
||||
@@ -907,6 +907,7 @@
|
||||
<th>Trigger</th>
|
||||
<th>Priority</th>
|
||||
<th>Config</th>
|
||||
<th>MES</th>
|
||||
<th>Lock</th>
|
||||
<th style="width: 60px;">Actions</th>
|
||||
</tr>
|
||||
@@ -922,6 +923,16 @@
|
||||
config readable instead of merely hidden. *@
|
||||
<td class="small text-muted text-truncate" style="max-width: 200px;"
|
||||
title="@alarm.TriggerConfiguration">@(alarm.TriggerConfiguration ?? "—")</td>
|
||||
<td data-test="alarm-mes-badge">
|
||||
@if (alarm.IsMesReportable)
|
||||
{
|
||||
<span class="badge bg-success" aria-label="MES reportable">MES</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-dark" aria-label="Not MES reportable">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alarm.IsLocked)
|
||||
{
|
||||
@@ -1436,7 +1447,7 @@
|
||||
private Task OpenAddAlarmDialog()
|
||||
=> ShowAlarmDialogAsync(
|
||||
editId: null,
|
||||
new TemplateAlarmDialog.AlarmDraft(string.Empty, default, 500, null, false));
|
||||
new TemplateAlarmDialog.AlarmDraft(string.Empty, default, 500, null, false, false));
|
||||
|
||||
private Task OpenEditAlarmDialog(TemplateAlarm alarm)
|
||||
=> ShowAlarmDialogAsync(
|
||||
@@ -1446,7 +1457,8 @@
|
||||
alarm.TriggerType,
|
||||
alarm.PriorityLevel,
|
||||
alarm.TriggerConfiguration,
|
||||
alarm.IsLocked));
|
||||
alarm.IsLocked,
|
||||
alarm.IsMesReportable));
|
||||
|
||||
private async Task ShowAlarmDialogAsync(int? editId, TemplateAlarmDialog.AlarmDraft initial)
|
||||
{
|
||||
@@ -1461,6 +1473,7 @@
|
||||
InitialPriority="initial.Priority"
|
||||
InitialTriggerConfig="@initial.TriggerConfig"
|
||||
InitialIsLocked="initial.IsLocked"
|
||||
InitialIsMesReportable="initial.IsMesReportable"
|
||||
AvailableAttributes="choices"
|
||||
OnSaveAsync="d => SaveAlarmDraftAsync(editId, d)" />,
|
||||
size: "modal-dialog-scrollable");
|
||||
@@ -1521,6 +1534,7 @@
|
||||
PriorityLevel = draft.Priority,
|
||||
TriggerConfiguration = draft.TriggerConfig?.Trim(),
|
||||
IsLocked = draft.IsLocked,
|
||||
IsMesReportable = draft.IsMesReportable,
|
||||
Description = existing.Description,
|
||||
OnTriggerScriptId = existing.OnTriggerScriptId,
|
||||
};
|
||||
@@ -1536,7 +1550,8 @@
|
||||
TriggerType = draft.TriggerType,
|
||||
PriorityLevel = draft.Priority,
|
||||
TriggerConfiguration = draft.TriggerConfig?.Trim(),
|
||||
IsLocked = draft.IsLocked
|
||||
IsLocked = draft.IsLocked,
|
||||
IsMesReportable = draft.IsMesReportable
|
||||
};
|
||||
|
||||
var addResult = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
|
||||
|
||||
@@ -161,6 +161,18 @@ public class SandboxAlarmsAccessor
|
||||
"Alarms.CurrentAsync() reads the site Instance Actor's live alarm conditions, " +
|
||||
"which aren't available in the central Test Run sandbox — deploy to a site to " +
|
||||
"exercise alarm-reading scripts.");
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>AlarmsAccessor.MesReportableAsync</c> for compile parity; unsupported at
|
||||
/// run time in the central Test Run sandbox.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token used to cancel the read (unused in the sandbox).</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
public Task<IReadOnlyList<ScriptAlarm>> MesReportableAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new ScriptSandboxException(
|
||||
"Alarms.MesReportableAsync() reads the site Instance Actor's live alarm conditions, " +
|
||||
"which aren't available in the central Test Run sandbox — deploy to a site to " +
|
||||
"exercise alarm-reading scripts.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -43,6 +43,11 @@ public class InstanceAlarmOverride
|
||||
/// </summary>
|
||||
public int? PriorityLevelOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the alarm's <c>IsMesReportable</c> flag when set. <c>null</c> = keep inherited.
|
||||
/// </summary>
|
||||
public bool? IsMesReportableOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new alarm override for the specified alarm.
|
||||
/// </summary>
|
||||
|
||||
@@ -16,6 +16,13 @@ public class TemplateAlarm
|
||||
public int PriorityLevel { get; set; }
|
||||
/// <summary>When true, this alarm is locked and cannot be overridden in derived templates.</summary>
|
||||
public bool IsLocked { get; set; }
|
||||
/// <summary>
|
||||
/// When true, the alarm is reported to MES by the alarm-status API
|
||||
/// (<c>IsFlaggedForMES</c> on the legacy contract). Default false. Overridable per
|
||||
/// instance via <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride.IsMesReportableOverride"/>
|
||||
/// unless the alarm is locked.
|
||||
/// </summary>
|
||||
public bool IsMesReportable { get; set; }
|
||||
/// <summary>Type of trigger condition that activates this alarm.</summary>
|
||||
public AlarmTriggerType TriggerType { get; set; }
|
||||
/// <summary>JSON-serialized trigger configuration specific to the <see cref="TriggerType"/>.</summary>
|
||||
|
||||
@@ -34,13 +34,23 @@ public record SetInstanceAreaCommand(int InstanceId, int? AreaId);
|
||||
/// Sets (or upserts) a per-instance alarm override. For HiLo trigger types the
|
||||
/// <c>TriggerConfigurationOverride</c> JSON is merged setpoint-by-setpoint with
|
||||
/// the inherited config; for binary trigger types it replaces the whole config.
|
||||
/// Either field is optional — pass null to leave it unchanged.
|
||||
/// Each override field is optional — null means the instance inherits the template value
|
||||
/// for that field (the row is replaced whole on every call, not merged).
|
||||
/// </summary>
|
||||
/// <param name="InstanceId">ID of the instance the override applies to.</param>
|
||||
/// <param name="AlarmCanonicalName">Canonical (path-qualified for composed members) alarm name.</param>
|
||||
/// <param name="TriggerConfigurationOverride">Trigger configuration JSON override, or null.</param>
|
||||
/// <param name="PriorityLevelOverride">Priority level override, or null.</param>
|
||||
/// <param name="IsMesReportableOverride">
|
||||
/// Per-instance override of the template alarm's MES-reportable flag. Additive trailing
|
||||
/// optional; null (the default) inherits the template value, true/false forces it.
|
||||
/// </param>
|
||||
public record SetInstanceAlarmOverrideCommand(
|
||||
int InstanceId,
|
||||
string AlarmCanonicalName,
|
||||
string? TriggerConfigurationOverride,
|
||||
int? PriorityLevelOverride);
|
||||
int? PriorityLevelOverride,
|
||||
bool? IsMesReportableOverride = null);
|
||||
|
||||
public record DeleteInstanceAlarmOverrideCommand(
|
||||
int InstanceId,
|
||||
|
||||
@@ -42,8 +42,11 @@ public record ResyncInheritedMembersCommand(int TemplateId);
|
||||
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||
public record DeleteTemplateAttributeCommand(int AttributeId);
|
||||
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
// IsMesReportable is an additive trailing optional (default false = "not reported to MES"):
|
||||
// it mirrors TemplateAlarm.IsMesReportable and marks the alarm for inclusion in the MES
|
||||
// alarm-status feed. Additive-only: never reorder the existing parameters.
|
||||
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked, bool IsMesReportable = false);
|
||||
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked, bool IsMesReportable = false);
|
||||
public record DeleteTemplateAlarmCommand(int AlarmId);
|
||||
// Native alarm source bindings (read-only mirror of an OPC UA A&C / MxGateway source's alarms)
|
||||
public record AddTemplateNativeAlarmSourceCommand(int TemplateId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked);
|
||||
|
||||
@@ -107,4 +107,13 @@ public record AlarmStateChanged(
|
||||
/// show the binding node as "no active conditions". Additive; default false.
|
||||
/// </summary>
|
||||
public bool IsConfiguredPlaceholder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the alarm's deployed configuration flags it as MES-reportable
|
||||
/// (<c>ResolvedAlarm.IsMesReportable</c>). Stamped by the Instance Actor on its alarm
|
||||
/// snapshot; always false for native conditions, which have no per-alarm config row.
|
||||
/// NOT carried on the gRPC stream or the debug-snapshot DTO in this slice (no proto
|
||||
/// field on either) — central sees the default. Additive; default false.
|
||||
/// </summary>
|
||||
public bool IsMesReportable { get; init; }
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ public sealed record ResolvedAlarm
|
||||
public int PriorityLevel { get; init; }
|
||||
/// <summary>Gets whether the alarm is locked.</summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>Gets whether the alarm is reported to MES (template value after any instance override).</summary>
|
||||
public bool IsMesReportable { get; init; }
|
||||
/// <summary>Gets the trigger type.</summary>
|
||||
public string TriggerType { get; init; } = string.Empty;
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
/// True for a placeholder row standing in for a CONFIGURED native source binding that
|
||||
/// currently holds no conditions. Scripts enumerating real alarms should filter these out.
|
||||
/// </param>
|
||||
/// <param name="IsMesReportable">
|
||||
/// True when the deployed configuration flags the alarm as MES-reportable (template
|
||||
/// checkbox or instance override). Always false for native conditions.
|
||||
/// </param>
|
||||
public sealed record ScriptAlarm(
|
||||
string Name,
|
||||
string SourceReference,
|
||||
@@ -76,4 +80,5 @@ public sealed record ScriptAlarm(
|
||||
DateTimeOffset? AckTime,
|
||||
string CurrentValue,
|
||||
string LimitValue,
|
||||
bool IsConfiguredPlaceholder);
|
||||
bool IsConfiguredPlaceholder,
|
||||
bool IsMesReportable = false);
|
||||
|
||||
+2142
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAlarmMesReportable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsMesReportable",
|
||||
table: "TemplateAlarms",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsMesReportableOverride",
|
||||
table: "InstanceAlarmOverrides",
|
||||
type: "bit",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsMesReportable",
|
||||
table: "TemplateAlarms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsMesReportableOverride",
|
||||
table: "InstanceAlarmOverrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -611,6 +611,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool?>("IsMesReportableOverride")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int?>("PriorityLevelOverride")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -1477,6 +1480,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.Property<bool>("IsLocked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsMesReportable")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockedInDerived")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
||||
@@ -1175,7 +1175,7 @@ public class ManagementActor : ReceiveActor
|
||||
var result = await svc.SetAlarmOverrideAsync(
|
||||
cmd.InstanceId, cmd.AlarmCanonicalName,
|
||||
cmd.TriggerConfigurationOverride, cmd.PriorityLevelOverride,
|
||||
user.Username);
|
||||
user.Username, cmd.IsMesReportableOverride);
|
||||
return result.IsSuccess
|
||||
? result.Value
|
||||
: throw new ManagementCommandException(result.Error);
|
||||
@@ -3179,7 +3179,8 @@ public class ManagementActor : ReceiveActor
|
||||
PriorityLevel = cmd.PriorityLevel,
|
||||
Description = cmd.Description,
|
||||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||||
IsLocked = cmd.IsLocked
|
||||
IsLocked = cmd.IsLocked,
|
||||
IsMesReportable = cmd.IsMesReportable
|
||||
};
|
||||
var result = await svc.AddAlarmAsync(cmd.TemplateId, alarm, user);
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
@@ -3194,7 +3195,8 @@ public class ManagementActor : ReceiveActor
|
||||
PriorityLevel = cmd.PriorityLevel,
|
||||
Description = cmd.Description,
|
||||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||||
IsLocked = cmd.IsLocked
|
||||
IsLocked = cmd.IsLocked,
|
||||
IsMesReportable = cmd.IsMesReportable
|
||||
};
|
||||
var result = await svc.UpdateAlarmAsync(cmd.AlarmId, alarm, user);
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
|
||||
@@ -154,6 +154,12 @@ public sealed class ScriptCompileSurface
|
||||
/// <returns>The instance's current alarm conditions.</returns>
|
||||
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>AlarmsAccessor.MesReportableAsync</c>.</summary>
|
||||
/// <param name="cancellationToken">Token used to cancel the read.</param>
|
||||
/// <returns>The MES-reportable subset of the instance's current alarm conditions.</returns>
|
||||
public Task<IReadOnlyList<ScriptAlarm>> MesReportableAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.ExternalSystemHelper</c>.</summary>
|
||||
|
||||
@@ -51,6 +51,16 @@ public class InstanceActor : ReceiveActor
|
||||
private readonly Dictionary<string, AlarmState> _alarmStates = new();
|
||||
private readonly Dictionary<string, DateTimeOffset> _alarmTimestamps = new();
|
||||
private readonly Dictionary<string, int> _alarmPriorities = new();
|
||||
|
||||
/// <summary>
|
||||
/// Computed-alarm canonical name → the deployed <c>ResolvedAlarm.IsMesReportable</c>
|
||||
/// flag. Populated alongside <see cref="_alarmPriorities"/> when the Alarm Actor
|
||||
/// children are created and read back in <see cref="BuildAlarmStatesSnapshot"/>, which
|
||||
/// is the single place the flag is stamped — so a fired alarm (served from the retained
|
||||
/// event) and an unfired one (served from the bare fallback projection) cannot drift.
|
||||
/// Native conditions have no entry here and correctly resolve to false.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, bool> _alarmMesReportable = new();
|
||||
private readonly Dictionary<string, IActorRef> _scriptActors = new();
|
||||
private readonly Dictionary<string, IActorRef> _alarmActors = new();
|
||||
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
|
||||
@@ -1568,7 +1578,16 @@ public class InstanceActor : ReceiveActor
|
||||
/// </summary>
|
||||
private List<AlarmStateChanged> BuildAlarmStatesSnapshot()
|
||||
{
|
||||
var states = _latestAlarmEvents.Values.ToList();
|
||||
// The MES-reportable flag is stamped HERE and nowhere else (not at the Alarm
|
||||
// Actor's emit sites), so the retained-event rows below and the fallback rows
|
||||
// that follow always agree on it. A native condition has no _alarmMesReportable
|
||||
// entry and is returned as-is (no record copy) — the default false is the intended
|
||||
// result, and native rows dominate this list on a busy binding.
|
||||
var states = _latestAlarmEvents.Values
|
||||
.Select(e => _alarmMesReportable.TryGetValue(e.AlarmName, out var mes) && mes
|
||||
? e with { IsMesReportable = true }
|
||||
: e)
|
||||
.ToList();
|
||||
|
||||
foreach (var name in _alarmActors.Keys)
|
||||
{
|
||||
@@ -1582,7 +1601,10 @@ public class InstanceActor : ReceiveActor
|
||||
name,
|
||||
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
|
||||
_alarmPriorities.GetValueOrDefault(name, 0),
|
||||
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)));
|
||||
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow))
|
||||
{
|
||||
IsMesReportable = _alarmMesReportable.GetValueOrDefault(name)
|
||||
});
|
||||
}
|
||||
|
||||
// A native binding is "already represented" if any retained event carries its
|
||||
@@ -1980,6 +2002,7 @@ public class InstanceActor : ReceiveActor
|
||||
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
||||
_alarmActors[alarm.CanonicalName] = actorRef;
|
||||
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
|
||||
_alarmMesReportable[alarm.CanonicalName] = alarm.IsMesReportable;
|
||||
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
|
||||
RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration));
|
||||
}
|
||||
|
||||
@@ -398,6 +398,18 @@ public class AlarmsAccessor
|
||||
/// <returns>The instance's current alarm conditions; empty when it holds none.</returns>
|
||||
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
|
||||
=> _ctx.GetAlarmsAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CurrentAsync"/> restricted to alarms whose deployed configuration flags them
|
||||
/// MES-reportable (the template checkbox, or an instance override). Only computed alarms
|
||||
/// can carry the flag — the Instance Actor stamps it from the deployed config — so native
|
||||
/// conditions and their placeholder rows are never included. Callers still filter on
|
||||
/// <c>Active</c> to get only triggered alarms.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancels the wait for the actor's reply.</param>
|
||||
/// <returns>The MES-reportable subset of the instance's current alarm conditions.</returns>
|
||||
public Task<IReadOnlyList<ScriptAlarm>> MesReportableAsync(CancellationToken cancellationToken = default)
|
||||
=> _ctx.GetMesReportableAlarmsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
internal static class ScopeAccessorFactory
|
||||
|
||||
@@ -1272,6 +1272,12 @@ public class ScriptRuntimeContext
|
||||
return response.Alarms.Select(ToScriptAlarm).ToList();
|
||||
}
|
||||
|
||||
/// <summary><see cref="GetAlarmsAsync"/> restricted to rows flagged MES-reportable.</summary>
|
||||
/// <param name="cancellationToken">Cancels the wait for the actor's reply.</param>
|
||||
/// <returns>The MES-reportable subset of the instance's current alarm conditions.</returns>
|
||||
internal async Task<IReadOnlyList<ScriptAlarm>> GetMesReportableAlarmsAsync(CancellationToken cancellationToken = default)
|
||||
=> (await GetAlarmsAsync(cancellationToken)).Where(a => a.IsMesReportable).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Projects a retained <see cref="AlarmStateChanged"/> onto the flat, script-facing
|
||||
/// <see cref="ScriptAlarm"/> shape. <c>Condition</c> is the authority for the
|
||||
@@ -1301,7 +1307,8 @@ public class ScriptRuntimeContext
|
||||
AckTime: a.AckTime,
|
||||
CurrentValue: a.CurrentValue,
|
||||
LimitValue: a.LimitValue,
|
||||
IsConfiguredPlaceholder: a.IsConfiguredPlaceholder);
|
||||
IsConfiguredPlaceholder: a.IsConfiguredPlaceholder,
|
||||
IsMesReportable: a.IsMesReportable);
|
||||
|
||||
/// <summary>
|
||||
/// Site-local tracking-status API for cached operations.
|
||||
|
||||
@@ -359,6 +359,7 @@ public class FlatteningService
|
||||
{
|
||||
TriggerConfiguration = newConfig,
|
||||
PriorityLevel = ovr.PriorityLevelOverride ?? existing.PriorityLevel,
|
||||
IsMesReportable = ovr.IsMesReportableOverride ?? existing.IsMesReportable,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
@@ -453,6 +454,7 @@ public class FlatteningService
|
||||
Description = alarm.Description,
|
||||
PriorityLevel = alarm.PriorityLevel,
|
||||
IsLocked = alarm.IsLocked,
|
||||
IsMesReportable = alarm.IsMesReportable,
|
||||
TriggerType = alarm.TriggerType.ToString(),
|
||||
TriggerConfiguration = triggerConfig,
|
||||
OnTriggerScriptCanonicalName = null, // Resolved later
|
||||
|
||||
@@ -213,6 +213,7 @@ public class InstanceService
|
||||
/// <param name="triggerConfigurationOverride">Override JSON for the trigger configuration.</param>
|
||||
/// <param name="priorityLevelOverride">Override priority level, or null to use template value.</param>
|
||||
/// <param name="user">Username of the user setting the override.</param>
|
||||
/// <param name="isMesReportableOverride">Override for the alarm's MES-reportable flag, or null to use the template value.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the alarm override or failure message.</returns>
|
||||
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
||||
@@ -221,6 +222,7 @@ public class InstanceService
|
||||
string? triggerConfigurationOverride,
|
||||
int? priorityLevelOverride,
|
||||
string user,
|
||||
bool? isMesReportableOverride = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
@@ -254,6 +256,7 @@ public class InstanceService
|
||||
{
|
||||
existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride;
|
||||
existingOverride.PriorityLevelOverride = priorityLevelOverride;
|
||||
existingOverride.IsMesReportableOverride = isMesReportableOverride;
|
||||
await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -268,7 +271,8 @@ public class InstanceService
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
TriggerConfigurationOverride = triggerConfigurationOverride,
|
||||
PriorityLevelOverride = priorityLevelOverride
|
||||
PriorityLevelOverride = priorityLevelOverride,
|
||||
IsMesReportableOverride = isMesReportableOverride
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken);
|
||||
|
||||
@@ -621,6 +621,7 @@ public class TemplateService
|
||||
existing.Description = proposed.Description;
|
||||
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
existing.IsMesReportable = proposed.IsMesReportable;
|
||||
if (template?.IsDerived == true)
|
||||
existing.IsInherited = proposed.IsInherited;
|
||||
else
|
||||
@@ -1107,6 +1108,7 @@ public class TemplateService
|
||||
TriggerType = alarm.TriggerType,
|
||||
TriggerConfiguration = alarm.TriggerConfiguration,
|
||||
OnTriggerScriptId = alarm.OnTriggerScriptId,
|
||||
IsMesReportable = alarm.IsMesReportable,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
});
|
||||
@@ -1384,6 +1386,7 @@ public class TemplateService
|
||||
TriggerType = win.TriggerType,
|
||||
TriggerConfiguration = m.EffectiveTriggerConfiguration,
|
||||
OnTriggerScriptId = win.OnTriggerScriptId,
|
||||
IsMesReportable = win.IsMesReportable,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
}, ct);
|
||||
@@ -1395,7 +1398,8 @@ public class TemplateService
|
||||
existing.Description != win.Description ||
|
||||
existing.OnTriggerScriptId != win.OnTriggerScriptId ||
|
||||
existing.TriggerType != win.TriggerType ||
|
||||
existing.IsLocked != win.IsLocked))
|
||||
existing.IsLocked != win.IsLocked ||
|
||||
existing.IsMesReportable != win.IsMesReportable))
|
||||
{
|
||||
existing.PriorityLevel = win.PriorityLevel;
|
||||
existing.TriggerConfiguration = m.EffectiveTriggerConfiguration;
|
||||
@@ -1403,6 +1407,7 @@ public class TemplateService
|
||||
existing.OnTriggerScriptId = win.OnTriggerScriptId;
|
||||
existing.TriggerType = win.TriggerType;
|
||||
existing.IsLocked = win.IsLocked;
|
||||
existing.IsMesReportable = win.IsMesReportable;
|
||||
await _repository.UpdateTemplateAlarmAsync(existing, ct);
|
||||
counts.Updated++;
|
||||
}
|
||||
|
||||
@@ -491,7 +491,8 @@ public sealed class ArtifactDiff
|
||||
e => e.AlarmCanonicalName,
|
||||
i => i.AlarmCanonicalName,
|
||||
(e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride
|
||||
&& e.PriorityLevelOverride == i.PriorityLevelOverride,
|
||||
&& e.PriorityLevelOverride == i.PriorityLevelOverride
|
||||
&& e.IsMesReportableOverride == i.IsMesReportableOverride,
|
||||
"AlarmOverrides",
|
||||
changes);
|
||||
|
||||
|
||||
@@ -2172,6 +2172,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
TriggerConfiguration = al.TriggerConfiguration,
|
||||
IsLocked = al.IsLocked,
|
||||
LockedInDerived = al.LockedInDerived,
|
||||
IsMesReportable = al.IsMesReportable,
|
||||
});
|
||||
}
|
||||
foreach (var s in dto.Scripts)
|
||||
@@ -2395,6 +2396,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
current.TriggerConfiguration = alarmDto.TriggerConfiguration;
|
||||
current.IsLocked = alarmDto.IsLocked;
|
||||
current.LockedInDerived = alarmDto.LockedInDerived;
|
||||
current.IsMesReportable = alarmDto.IsMesReportable;
|
||||
current.OnTriggerScriptId = null; // re-resolved post-flush.
|
||||
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
@@ -2413,6 +2415,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
current.TriggerConfiguration,
|
||||
current.IsLocked,
|
||||
current.LockedInDerived,
|
||||
current.IsMesReportable,
|
||||
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
@@ -2427,6 +2430,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
TriggerConfiguration = alarmDto.TriggerConfiguration,
|
||||
IsLocked = alarmDto.IsLocked,
|
||||
LockedInDerived = alarmDto.LockedInDerived,
|
||||
IsMesReportable = alarmDto.IsMesReportable,
|
||||
};
|
||||
ex.Alarms.Add(newAlarm);
|
||||
await _auditService.LogAsync(
|
||||
@@ -2445,6 +2449,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
newAlarm.TriggerConfiguration,
|
||||
newAlarm.IsLocked,
|
||||
newAlarm.LockedInDerived,
|
||||
newAlarm.IsMesReportable,
|
||||
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
@@ -4360,6 +4365,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
|
||||
PriorityLevelOverride = o.PriorityLevelOverride,
|
||||
IsMesReportableOverride = o.IsMesReportableOverride,
|
||||
});
|
||||
}
|
||||
foreach (var o in dto.NativeAlarmSourceOverrides)
|
||||
@@ -4913,6 +4919,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// at this single-template-scope validation step the bare name
|
||||
// IS the canonical name, so just pass it through.
|
||||
OnTriggerScriptCanonicalName = string.IsNullOrEmpty(al.OnTriggerScriptName) ? null : al.OnTriggerScriptName,
|
||||
IsMesReportable = al.IsMesReportable,
|
||||
Source = "Template",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ internal static class TemplateChildEquality
|
||||
&& e.TriggerConfiguration == i.TriggerConfiguration
|
||||
&& e.IsLocked == i.IsLocked
|
||||
&& e.LockedInDerived == i.LockedInDerived
|
||||
&& e.IsMesReportable == i.IsMesReportable
|
||||
&& scriptNameById(e.OnTriggerScriptId) == i.OnTriggerScriptName;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -218,7 +218,10 @@ public sealed record TemplateAlarmDto(
|
||||
bool IsLocked,
|
||||
string? OnTriggerScriptName,
|
||||
// Derived-lock flag; additive trailing field (see TemplateAttributeDto).
|
||||
bool LockedInDerived = false);
|
||||
bool LockedInDerived = false,
|
||||
// Whether this alarm is reported to MES; additive trailing field, false on
|
||||
// bundles written before it existed (see TemplateAttributeDto).
|
||||
bool IsMesReportable = false);
|
||||
|
||||
public sealed record TemplateScriptDto(
|
||||
string Name,
|
||||
@@ -395,7 +398,10 @@ public sealed record InstanceAttributeOverrideDto(
|
||||
public sealed record InstanceAlarmOverrideDto(
|
||||
string AlarmCanonicalName,
|
||||
string? TriggerConfigurationOverride,
|
||||
int? PriorityLevelOverride);
|
||||
int? PriorityLevelOverride,
|
||||
// Per-instance override of the alarm's MES-reportable flag; additive trailing
|
||||
// field, null ("keep inherited") on bundles written before it existed.
|
||||
bool? IsMesReportableOverride = null);
|
||||
|
||||
public sealed record InstanceNativeAlarmSourceOverrideDto(
|
||||
string SourceCanonicalName,
|
||||
|
||||
@@ -77,7 +77,8 @@ public sealed class EntitySerializer
|
||||
// name comes through as null and the importer leaves the
|
||||
// FK null on the imported alarm.
|
||||
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
|
||||
LockedInDerived: a.LockedInDerived)).ToList(),
|
||||
LockedInDerived: a.LockedInDerived,
|
||||
IsMesReportable: a.IsMesReportable)).ToList(),
|
||||
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
|
||||
Name: s.Name,
|
||||
Code: s.Code,
|
||||
@@ -285,7 +286,8 @@ public sealed class EntitySerializer
|
||||
AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto(
|
||||
AlarmCanonicalName: o.AlarmCanonicalName,
|
||||
TriggerConfigurationOverride: o.TriggerConfigurationOverride,
|
||||
PriorityLevelOverride: o.PriorityLevelOverride)).ToList(),
|
||||
PriorityLevelOverride: o.PriorityLevelOverride,
|
||||
IsMesReportableOverride: o.IsMesReportableOverride)).ToList(),
|
||||
NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto(
|
||||
SourceCanonicalName: o.SourceCanonicalName,
|
||||
ConnectionNameOverride: o.ConnectionNameOverride,
|
||||
@@ -354,6 +356,7 @@ public sealed class EntitySerializer
|
||||
TriggerConfiguration = al.TriggerConfiguration,
|
||||
IsLocked = al.IsLocked,
|
||||
LockedInDerived = al.LockedInDerived,
|
||||
IsMesReportable = al.IsMesReportable,
|
||||
});
|
||||
}
|
||||
foreach (var s in dto.Scripts)
|
||||
@@ -607,6 +610,7 @@ public sealed class EntitySerializer
|
||||
InstanceId = inst.Id,
|
||||
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
|
||||
PriorityLevelOverride = o.PriorityLevelOverride,
|
||||
IsMesReportableOverride = o.IsMesReportableOverride,
|
||||
});
|
||||
}
|
||||
foreach (var o in dto.NativeAlarmSourceOverrides)
|
||||
|
||||
@@ -33,6 +33,10 @@ public class EditingParityTests
|
||||
[InlineData("instance attribute-override delete --instance-id 6 --attribute Module.Value", "DeleteInstanceAttributeOverride", "{\"instanceId\":6,\"attributeName\":\"Module.Value\"}")]
|
||||
[InlineData("instance native-alarm-source list --instance-id 6", "ListInstanceNativeAlarmSourceOverrides", "{\"instanceId\":6}")]
|
||||
[InlineData("instance alarm-override list --instance-id 6", "ListInstanceAlarmOverrides", "{\"instanceId\":6}")]
|
||||
[InlineData("template alarm add --template-id 12 --name HighTemp --trigger-type HiLo --priority 500 --mes-reportable", "AddTemplateAlarm", "{\"templateId\":12,\"name\":\"HighTemp\",\"triggerType\":\"HiLo\",\"priorityLevel\":500,\"description\":null,\"triggerConfiguration\":null,\"isLocked\":false,\"isMesReportable\":true}")]
|
||||
[InlineData("template alarm update --id 9 --name HighTemp --trigger-type HiLo --priority 500", "UpdateTemplateAlarm", "{\"alarmId\":9,\"name\":\"HighTemp\",\"triggerType\":\"HiLo\",\"priorityLevel\":500,\"description\":null,\"triggerConfiguration\":null,\"isLocked\":false,\"isMesReportable\":false}")]
|
||||
[InlineData("instance alarm-override set --instance-id 6 --alarm HighTemp --mes-reportable false", "SetInstanceAlarmOverride", "{\"instanceId\":6,\"alarmCanonicalName\":\"HighTemp\",\"triggerConfigurationOverride\":null,\"priorityLevelOverride\":null,\"isMesReportableOverride\":false}")]
|
||||
[InlineData("instance alarm-override set --instance-id 6 --alarm HighTemp --priority 7", "SetInstanceAlarmOverride", "{\"instanceId\":6,\"alarmCanonicalName\":\"HighTemp\",\"triggerConfigurationOverride\":null,\"priorityLevelOverride\":7,\"isMesReportableOverride\":null}")]
|
||||
[InlineData("data-connection move --id 4 --site-id 7", "MoveDataConnection", "{\"dataConnectionId\":4,\"targetSiteId\":7}")]
|
||||
public async Task Command_SendsExactManagementEnvelope(string arguments, string command, string payload)
|
||||
{
|
||||
|
||||
+93
@@ -516,4 +516,97 @@ public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixt
|
||||
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tri-state MES reportable round-trip. The override editor's third control is a select whose
|
||||
/// blank option means "inherit the template alarm's <c>IsMesReportable</c>"; "Reportable" /
|
||||
/// "Not reportable" pin it per-instance.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the regression guard for the save-time delta rule: a Save with no config-diff and no
|
||||
/// priority DELETES the override, so a reportable-only choice has to count as a delta on its own
|
||||
/// or it would be silently dropped. The assertion is therefore that choosing ONLY "Reportable"
|
||||
/// (leaving the trigger config and priority untouched) creates the override — badge + a summary
|
||||
/// naming <c>mesReportable</c> — that switching to "Not reportable" persists <c>false</c>
|
||||
/// (verified against the read-back document, since an absent/null field there would mean the
|
||||
/// negative pin was dropped), and that switching back to Inherited removes the override again.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The fixture instance is SHARED, so this clears its own override in-body and again in a
|
||||
/// <c>finally</c>, exactly as <see cref="AlarmOverride_SetPriority_ThenClear_RoundTrips"/> does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task AlarmOverride_MesReportable_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
try
|
||||
{
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var row = page.Locator($"[data-test='alarm-override-row-{_cfg.AlarmName}']");
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']")).ToHaveCountAsync(0);
|
||||
|
||||
// Edit → choose "Reportable" ONLY (no config edit, no priority) → Save.
|
||||
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
|
||||
var mesSelect = page.Locator("[data-test='alarm-mes-select']");
|
||||
await Assertions.Expect(mesSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await mesSelect.SelectOptionAsync("true");
|
||||
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
// Summary part is "mesReportable" (the negative pin renders "notMesReportable").
|
||||
await Assertions.Expect(row).ToContainTextAsync("mesReportable", new() { Timeout = 15_000 });
|
||||
|
||||
// Reopen → switch to the NEGATIVE pin. This is the leg that proves "Not reportable" is
|
||||
// a real write and not a clear: the row must stay overridden and the persisted
|
||||
// isMesReportableOverride must be JSON false, never absent/null.
|
||||
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
|
||||
await Assertions.Expect(mesSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await mesSelect.SelectOptionAsync("false");
|
||||
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
|
||||
|
||||
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(row).ToContainTextAsync("notMesReportable", new() { Timeout = 15_000 });
|
||||
|
||||
using (var negDoc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId))
|
||||
{
|
||||
var negOverrides = negDoc.RootElement.GetProperty("alarmOverrides");
|
||||
var entry = negOverrides.EnumerateArray().Single(o =>
|
||||
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
|
||||
|
||||
Assert.True(entry.TryGetProperty("isMesReportableOverride", out var flag),
|
||||
"alarmOverrides entry is missing isMesReportableOverride.");
|
||||
// JSON false specifically — an absent/null field would mean the negative pin was
|
||||
// dropped and the alarm silently fell back to the template's flag.
|
||||
Assert.Equal(JsonValueKind.False, flag.ValueKind);
|
||||
}
|
||||
|
||||
// Reopen → back to Inherited (blank) → Save. With nothing else set that is now a
|
||||
// no-delta save, which clears the override outright.
|
||||
await row.Locator("[data-test='alarm-edit-btn']").ClickAsync();
|
||||
await Assertions.Expect(mesSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await mesSelect.SelectOptionAsync("");
|
||||
await page.Locator("[data-test='alarm-save-override']").ClickAsync();
|
||||
|
||||
await Assertions.Expect(row.Locator("[data-test='alarm-override-badge']"))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 15_000 });
|
||||
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
|
||||
var overrides = doc.RootElement.GetProperty("alarmOverrides");
|
||||
Assert.DoesNotContain(overrides.EnumerateArray(), o =>
|
||||
o.GetProperty("alarmCanonicalName").GetString() == _cfg.AlarmName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAlarmOverrideAsync(_cfg.InstanceId, _cfg.AlarmName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// MES reportable (tri-state) on the Instance Configure alarm-override editor.
|
||||
///
|
||||
/// <para>The instance-level override is a <c>bool?</c>: null means "inherit the
|
||||
/// template alarm's <c>IsMesReportable</c>", true/false pin it. The modal surfaces
|
||||
/// that as a three-option select (Inherited / Reportable / Not reportable).</para>
|
||||
///
|
||||
/// <para><c>InstanceConfigure</c> is a heavyweight page (~7 injected services with
|
||||
/// their own graphs), so — consistent with the rest of this suite's coverage of it
|
||||
/// (see <c>InstanceConfigureListOverrideTests</c>) — the markup half is a structural
|
||||
/// assertion over the component source. The half that carries the real risk is the
|
||||
/// save-time delta rule, which is extracted as a pure helper and pinned directly:
|
||||
/// the page DELETES the override when it sees no delta, so a reportable-only
|
||||
/// override would be silently dropped on save if the new term were missing.</para>
|
||||
/// </summary>
|
||||
public class InstanceConfigureMesReportableTests
|
||||
{
|
||||
private static string DeploymentComponentsRoot
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 6 && dir is not null; i++)
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||
"Components", "Pages", "Deployment");
|
||||
}
|
||||
}
|
||||
|
||||
private static string InstanceConfigureMarkup
|
||||
=> File.ReadAllText(Path.Combine(DeploymentComponentsRoot, "InstanceConfigure.razor"));
|
||||
|
||||
[Fact]
|
||||
public void OverrideModal_HasTriStateMesReportableSelect()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
|
||||
Assert.Contains("data-test=\"alarm-mes-select\"", markup);
|
||||
|
||||
// Three options: Inherited (empty value) / Reportable (true) / Not reportable (false).
|
||||
var selectStart = markup.IndexOf("data-test=\"alarm-mes-select\"", StringComparison.Ordinal);
|
||||
var selectEnd = markup.IndexOf("</select>", selectStart, StringComparison.Ordinal);
|
||||
Assert.True(selectEnd > selectStart, "MES reportable select is not closed.");
|
||||
|
||||
// Label mirrors the adjacent "Priority override" control.
|
||||
Assert.Contains("MES reportable override</label>", markup);
|
||||
|
||||
var selectBody = markup[selectStart..selectEnd];
|
||||
var optionCount = selectBody.Split("<option").Length - 1;
|
||||
Assert.Equal(3, optionCount);
|
||||
Assert.Contains("value=\"\"", selectBody);
|
||||
Assert.Contains("value=\"true\"", selectBody);
|
||||
Assert.Contains("value=\"false\"", selectBody);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// The new term: a reportable-only override is a real delta in BOTH directions
|
||||
// (false is not "no override" — it pins a reportable template alarm off).
|
||||
[InlineData(null, null, true)]
|
||||
[InlineData(null, null, false)]
|
||||
public void HasOverrideDelta_TreatsMesReportableAloneAsADelta(
|
||||
string? overrideJson, int? priority, bool? mesReportable)
|
||||
=> Assert.True(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null)]
|
||||
[InlineData("", null, null)]
|
||||
[InlineData(" ", null, null)]
|
||||
public void HasOverrideDelta_NothingSet_IsNotADelta(
|
||||
string? overrideJson, int? priority, bool? mesReportable)
|
||||
=> Assert.False(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable));
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"hi\":70}", null, null)]
|
||||
[InlineData(null, 750, null)]
|
||||
public void HasOverrideDelta_ExistingTermsStillCount(
|
||||
string? overrideJson, int? priority, bool? mesReportable)
|
||||
=> Assert.True(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable));
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
||||
|
||||
/// <summary>
|
||||
/// The template alarm authoring surface exposes <c>TemplateAlarm.IsMesReportable</c>:
|
||||
/// an "MES reportable" checkbox in the dialog body and an MES column on the
|
||||
/// template's Alarms tab. <c>TemplateEdit</c> is a heavyweight page (~10 injected
|
||||
/// services with their own graphs), so — like the rest of the TemplateEdit coverage
|
||||
/// in this suite (see <c>TemplateNativeAlarmSourceEditorTests</c>) — these are
|
||||
/// structural assertions over the component source that pin the wiring so it cannot
|
||||
/// silently regress. The persistence behaviour itself is covered by the
|
||||
/// TemplateService / ManagementActor alarm tests.
|
||||
/// </summary>
|
||||
public class TemplateAlarmDialogMesReportableTests
|
||||
{
|
||||
private static string DesignComponentsRoot
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 6 && dir is not null; i++)
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||
"Components", "Pages", "Design");
|
||||
}
|
||||
}
|
||||
|
||||
private static string AlarmDialogMarkup
|
||||
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateAlarmDialog.razor"));
|
||||
|
||||
private static string TemplateEditMarkup
|
||||
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor"));
|
||||
|
||||
[Fact]
|
||||
public void AlarmDialog_HasMesReportableCheckbox()
|
||||
{
|
||||
var markup = AlarmDialogMarkup;
|
||||
Assert.Contains("@bind=\"_isMesReportable\"", markup);
|
||||
Assert.Contains("id=\"alarmMesReportable\"", markup);
|
||||
Assert.Contains("MES reportable", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmDialog_ThreadsMesReportableThroughParameterAndDraft()
|
||||
{
|
||||
var markup = AlarmDialogMarkup;
|
||||
Assert.Contains("InitialIsMesReportable", markup);
|
||||
Assert.Contains("_isMesReportable = InitialIsMesReportable", markup);
|
||||
Assert.Contains("bool IsMesReportable", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmsTab_HasMesColumn()
|
||||
{
|
||||
var markup = TemplateEditMarkup;
|
||||
Assert.Contains("<th>MES</th>", markup);
|
||||
Assert.Contains("data-test=\"alarm-mes-badge\"", markup);
|
||||
Assert.Contains("alarm.IsMesReportable", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmsTab_PassesMesReportableIntoAndOutOfTheDialog()
|
||||
{
|
||||
var markup = TemplateEditMarkup;
|
||||
Assert.Contains("InitialIsMesReportable=\"initial.IsMesReportable\"", markup);
|
||||
Assert.Contains("IsMesReportable = draft.IsMesReportable", markup);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -819,7 +819,9 @@ public class ScriptAnalysisServiceTests
|
||||
" AckDT = a.AckTime,\n" +
|
||||
" AckComment = a.OperatorComment })\n" +
|
||||
" .ToList();\n" +
|
||||
"return new { WasSuccessful = true, Alarms = rows };";
|
||||
"var r = await Alarms.MesReportableAsync();\n" +
|
||||
"var f = r.Where(a => a.IsMesReportable).ToList();\n" +
|
||||
"return new { WasSuccessful = true, Alarms = rows, Flagged = f.Count };";
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest(code));
|
||||
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
|
||||
|
||||
@@ -285,6 +285,29 @@ public class CompatibilityTests
|
||||
Assert.Null(deserialized!.AckTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_AlarmStateChanged_MissingIsMesReportable_DefaultsToFalse()
|
||||
{
|
||||
// A frame minted by a node predating the MES-reportable flag omits the property
|
||||
// entirely; it must deserialize to "not reported to MES", not fail. False is the
|
||||
// safe default — an old site's alarms stay out of the MES alarm-status API until
|
||||
// it is upgraded and stamps the flag itself.
|
||||
var json = """
|
||||
{
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"AlarmName": "Tank01.Level.HiHi",
|
||||
"State": 1,
|
||||
"Priority": 900,
|
||||
"Timestamp": "2026-08-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<AlarmStateChanged>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.False(deserialized!.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_HeartbeatMessage_Succeeds()
|
||||
{
|
||||
|
||||
+42
-1
@@ -3,12 +3,14 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
@@ -111,6 +113,45 @@ public class SchemaConfigurationTests : IDisposable
|
||||
Assert.Equal(new[] { nameof(SiteCall.TerminalAtUtc) }, index.Properties.Select(p => p.Name).ToArray());
|
||||
Assert.Equal("[TerminalAtUtc] IS NOT NULL", index.GetFilter());
|
||||
}
|
||||
|
||||
// MES-reportable alarm flag: TemplateAlarm.IsMesReportable (bool, non-nullable,
|
||||
// defaults false) and the nullable per-instance InstanceAlarmOverride
|
||||
// .IsMesReportableOverride (null = inherit) must both round-trip through the
|
||||
// configuration database. Backed on SQL Server by the AddAlarmMesReportable
|
||||
// migration; pinned here on the model/persist path.
|
||||
[Fact]
|
||||
public async Task TemplateAlarm_And_Override_PersistMesReportableFlags()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("MesAlarmT1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("MesAlarmInst1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var alarm = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
TriggerType = AlarmTriggerType.HiLo,
|
||||
PriorityLevel = 500,
|
||||
IsMesReportable = true
|
||||
};
|
||||
var ovr = new InstanceAlarmOverride("HighTemp")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
IsMesReportableOverride = false
|
||||
};
|
||||
_context.TemplateAlarms.Add(alarm);
|
||||
_context.InstanceAlarmOverrides.Add(ovr);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
Assert.True((await _context.TemplateAlarms.SingleAsync(a => a.Id == alarm.Id)).IsMesReportable);
|
||||
Assert.False((await _context.InstanceAlarmOverrides.SingleAsync(o => o.Id == ovr.Id)).IsMesReportableOverride);
|
||||
}
|
||||
}
|
||||
|
||||
public class SplitQueryBehaviourTests : IDisposable
|
||||
|
||||
@@ -1014,6 +1014,112 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Contains("Valid values:", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddTemplateAlarm_CarriesIsMesReportable()
|
||||
{
|
||||
// The MES-reportable flag rides AddTemplateAlarmCommand as a trailing
|
||||
// optional (additive-only wire contract) and must reach the TemplateAlarm
|
||||
// the handler hands to TemplateService — verified at the repository
|
||||
// boundary, which is where the entity is persisted.
|
||||
var template = new Template("T1") { Id = 1 };
|
||||
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
|
||||
_templateRepo.GetAllTemplatesForAnalysisAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template> { template });
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template> { template });
|
||||
|
||||
TemplateAlarm? added = null;
|
||||
_templateRepo
|
||||
.When(r => r.AddTemplateAlarmAsync(Arg.Any<TemplateAlarm>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => added = ci.Arg<TemplateAlarm>());
|
||||
_services.AddScoped<TemplateService>();
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAlarmCommand(1, "HighTemp", "HiLo", 500, null, null, false, IsMesReportable: true),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(added);
|
||||
Assert.True(added!.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTemplateAlarm_CarriesIsMesReportable()
|
||||
{
|
||||
var template = new Template("T1") { Id = 1 };
|
||||
var existing = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 5,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.HiLo,
|
||||
PriorityLevel = 100,
|
||||
IsMesReportable = false
|
||||
};
|
||||
_templateRepo.GetTemplateAlarmByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template> { template });
|
||||
|
||||
TemplateAlarm? updated = null;
|
||||
_templateRepo
|
||||
.When(r => r.UpdateTemplateAlarmAsync(Arg.Any<TemplateAlarm>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => updated = ci.Arg<TemplateAlarm>());
|
||||
_services.AddScoped<TemplateService>();
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateTemplateAlarmCommand(5, "HighTemp", "HiLo", 500, null, null, false, IsMesReportable: true),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(updated);
|
||||
Assert.True(updated!.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInstanceAlarmOverride_PassesIsMesReportableOverride()
|
||||
{
|
||||
// false is the interesting case: it is distinct from the null "inherit"
|
||||
// default, so a dropped argument would read as "inherit" rather than
|
||||
// "explicitly not reportable".
|
||||
var template = new Template("T1") { Id = 1 };
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 5,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.HiLo,
|
||||
PriorityLevel = 100
|
||||
});
|
||||
var instance = new Instance("Inst1") { Id = 10, TemplateId = 1, SiteId = 1 };
|
||||
|
||||
_templateRepo.GetInstanceByIdAsync(10, Arg.Any<CancellationToken>()).Returns(instance);
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template> { template });
|
||||
_templateRepo.GetAlarmOverrideAsync(10, "HighTemp", Arg.Any<CancellationToken>())
|
||||
.Returns((InstanceAlarmOverride?)null);
|
||||
|
||||
InstanceAlarmOverride? persisted = null;
|
||||
_templateRepo
|
||||
.When(r => r.AddInstanceAlarmOverrideAsync(Arg.Any<InstanceAlarmOverride>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => persisted = ci.Arg<InstanceAlarmOverride>());
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceAlarmOverrideCommand(10, "HighTemp", null, null, IsMesReportableOverride: false),
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(persisted);
|
||||
Assert.False(persisted!.IsMesReportableOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAttribute_ValidScalar_PersistsValueAndKeepsFixedFields()
|
||||
{
|
||||
|
||||
@@ -56,6 +56,8 @@ public class RoslynScriptCompilerTests
|
||||
var scope = Scope;
|
||||
var currentAlarms = await Alarms.CurrentAsync();
|
||||
var viaInstance = await Instance.Alarms.CurrentAsync();
|
||||
var mesReportable = await Alarms.MesReportableAsync();
|
||||
var flaggedAlarms = mesReportable.Where(a => a.IsMesReportable).ToList();
|
||||
""";
|
||||
|
||||
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
@@ -101,7 +103,12 @@ public class RoslynScriptCompilerTests
|
||||
AckComment = a.OperatorComment,
|
||||
}).ToList();
|
||||
|
||||
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
|
||||
// The pre-filtered accessor + the configured flag are the supported shape for
|
||||
// "which alarms does MES care about?" — both must bind at design time.
|
||||
var r = await Alarms.MesReportableAsync();
|
||||
var f = r.Where(a => a.IsMesReportable).ToList();
|
||||
|
||||
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos, Flagged = f.Count };
|
||||
""";
|
||||
|
||||
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// The deployed alarm's <c>IsMesReportable</c> flag must reach the site-local alarm
|
||||
/// snapshot that <c>Alarms.CurrentAsync()</c> and the Debug View query both build (the
|
||||
/// flag does not cross <c>DebugAlarmStateDto</c> to central in this slice). It is stamped in ONE
|
||||
/// place — <c>BuildAlarmStatesSnapshot</c> — so a computed alarm that has fired (served
|
||||
/// from the retained <c>AlarmStateChanged</c>) and one that never has (served from the
|
||||
/// bare Normal fallback) can never disagree about the flag.
|
||||
/// </summary>
|
||||
public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options = new();
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
public InstanceActorMesReportableSnapshotTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-mes-reportable");
|
||||
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) =>
|
||||
ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
null)));
|
||||
|
||||
/// <summary>
|
||||
/// One static attribute driving two computed alarms: <c>HighTemp</c> is flagged
|
||||
/// MES-reportable and fires at 80; <c>TempSpike</c> is not flagged and only matches
|
||||
/// the exact value 100, so it never fires in this test.
|
||||
/// </summary>
|
||||
private static FlattenedConfiguration TwoComputedAlarmsConfig(string instanceName) => new()
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Temperature",
|
||||
Value = "20",
|
||||
DataType = "Double"
|
||||
}
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"hi\":80}",
|
||||
PriorityLevel = 700,
|
||||
IsMesReportable = true
|
||||
},
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempSpike",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}",
|
||||
PriorityLevel = 300,
|
||||
IsMesReportable = false
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private GetAlarmSnapshotResponse AskSnapshot(IActorRef actor, string instanceName)
|
||||
{
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
actor.Tell(new GetAlarmSnapshotRequest(correlationId, instanceName, DateTimeOffset.UtcNow));
|
||||
// Match on the correlation id so a late reply from a timed-out probe can never be
|
||||
// consumed as the "current" snapshot by a later call; keep the inner wait well
|
||||
// inside the AwaitAssert budget so one slow probe doesn't eat the whole retry window.
|
||||
return ExpectMsg<GetAlarmSnapshotResponse>(
|
||||
m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms()
|
||||
{
|
||||
const string instance = "Oven1";
|
||||
var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance));
|
||||
|
||||
// Drive Temperature above the Hi setpoint: HighTemp fires, TempSpike does not.
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-temp", instance, "Temperature", "90", DateTimeOffset.UtcNow));
|
||||
var write = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(write.Success);
|
||||
|
||||
// The alarm child evaluates asynchronously — poll the snapshot until the
|
||||
// retained event for HighTemp has landed.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var pending = AskSnapshot(actor, instance);
|
||||
var firing = Assert.Single(pending.Alarms, a => a.AlarmName == "HighTemp");
|
||||
Assert.Equal(AlarmState.Active, firing.State);
|
||||
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
|
||||
|
||||
var snapshot = AskSnapshot(actor, instance);
|
||||
|
||||
// Both rows are still returned — stamping must not drop or duplicate anything.
|
||||
Assert.Equal(2, snapshot.Alarms.Count);
|
||||
|
||||
// Fired alarm: served from the retained AlarmStateChanged.
|
||||
var fired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp");
|
||||
Assert.Equal(AlarmState.Active, fired.State);
|
||||
Assert.True(fired.IsMesReportable);
|
||||
|
||||
// Never-fired alarm: served from the bare Normal fallback projection.
|
||||
var unfired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike");
|
||||
Assert.Equal(AlarmState.Normal, unfired.State);
|
||||
Assert.False(unfired.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires()
|
||||
{
|
||||
// The fallback branch must carry the flag on its own: an alarm that has never
|
||||
// emitted an event is still MES-reportable per its deployed configuration.
|
||||
const string instance = "Oven2";
|
||||
var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance));
|
||||
|
||||
var snapshot = AskSnapshot(actor, instance);
|
||||
|
||||
Assert.Equal(2, snapshot.Alarms.Count);
|
||||
Assert.True(Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp").IsMesReportable);
|
||||
Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
// then dispose the database before deleting — the master connection anchors the WAL.
|
||||
Shutdown();
|
||||
var path = _localDb.Path;
|
||||
_localDb.Dispose();
|
||||
TestLocalDb.DeleteFiles(path);
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,43 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_ProjectsIsMesReportable()
|
||||
{
|
||||
// The deployed configuration's MES-reportable flag rides the snapshot row and must
|
||||
// reach the script projection verbatim — a row without it reads false.
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var pending = ctx.Alarms.CurrentAsync();
|
||||
RespondWith(probe,
|
||||
NativeAcked() with { IsMesReportable = true },
|
||||
new AlarmStateChanged("CvdReactor01", "HighTemp", AlarmState.Active, 700, ObservedAt));
|
||||
|
||||
var alarms = await pending;
|
||||
Assert.Equal(2, alarms.Count);
|
||||
Assert.True(Assert.Single(alarms, a => a.Name == "Z28061.HeartbeatTimeoutAlarm").IsMesReportable);
|
||||
Assert.False(Assert.Single(alarms, a => a.Name == "HighTemp").IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MesReportableAsync_FiltersToFlaggedRows()
|
||||
{
|
||||
// The convenience accessor is CurrentAsync restricted to the flagged subset, so a
|
||||
// script does not have to repeat the filter (and cannot get it subtly wrong).
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var pending = ctx.Alarms.MesReportableAsync();
|
||||
RespondWith(probe,
|
||||
NativeAcked() with { IsMesReportable = true },
|
||||
NativeAcked() with { AlarmName = "Z28061.UnflaggedAlarm", SourceReference = "Z28061.UnflaggedAlarm" });
|
||||
|
||||
var alarm = Assert.Single(await pending);
|
||||
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.Name);
|
||||
Assert.True(alarm.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList()
|
||||
{
|
||||
|
||||
+132
@@ -495,6 +495,43 @@ public class FlatteningServiceTests
|
||||
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":100}", alarm.TriggerConfiguration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_MesReportable_DerivedChain_BaseValueWins()
|
||||
{
|
||||
// A stale IsInherited placeholder must not be able to un-report an alarm:
|
||||
// the base's IsMesReportable is the resolved value down the chain.
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5,
|
||||
IsMesReportable = true
|
||||
});
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5,
|
||||
IsInherited = true,
|
||||
IsMesReportable = false
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
|
||||
Assert.True(alarm.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_OverriddenAlarmOnDerived_DerivedValueWins()
|
||||
{
|
||||
@@ -563,6 +600,101 @@ public class FlatteningServiceTests
|
||||
Assert.Contains("HighTemp", result.Error);
|
||||
}
|
||||
|
||||
// ── MES reportable flag: template value + instance override ────────────
|
||||
|
||||
[Fact]
|
||||
public void Flatten_MesReportable_TemplateValueCarried()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 1,
|
||||
IsMesReportable = true
|
||||
});
|
||||
template.Alarms.Add(new TemplateAlarm("LowTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"low\":10}",
|
||||
PriorityLevel = 2
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value.Alarms.First(a => a.CanonicalName == "HighTemp").IsMesReportable);
|
||||
Assert.False(result.Value.Alarms.First(a => a.CanonicalName == "LowTemp").IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_MesReportable_OverrideFalseWins()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 1,
|
||||
IsMesReportable = true
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HighTemp")
|
||||
{
|
||||
IsMesReportableOverride = false
|
||||
});
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
|
||||
Assert.False(alarm.IsMesReportable);
|
||||
Assert.Equal("Override", alarm.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_MesReportable_OverrideNullInherits()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 1,
|
||||
IsMesReportable = true
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HighTemp")
|
||||
{
|
||||
PriorityLevelOverride = 7
|
||||
});
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
|
||||
Assert.Equal(7, alarm.PriorityLevel);
|
||||
Assert.True(alarm.IsMesReportable);
|
||||
}
|
||||
|
||||
// ── TemplateEngine-001: deep composition nesting ───────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -220,6 +220,101 @@ public class InstanceServiceTests
|
||||
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAlarmOverrideAsync_StoresIsMesReportableOverride()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template>
|
||||
{
|
||||
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10, IsLocked = false })
|
||||
});
|
||||
_repoMock.Setup(r => r.GetAlarmOverrideAsync(1, "HighTemp", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((InstanceAlarmOverride?)null);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
InstanceAlarmOverride? added = null;
|
||||
_repoMock.Setup(r => r.AddInstanceAlarmOverrideAsync(
|
||||
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InstanceAlarmOverride, CancellationToken>((o, _) => added = o);
|
||||
|
||||
var result = await _sut.SetAlarmOverrideAsync(
|
||||
1, "HighTemp", "{}", null, "admin", isMesReportableOverride: true);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(added);
|
||||
Assert.True(added!.IsMesReportableOverride);
|
||||
Assert.True(result.Value.IsMesReportableOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAlarmOverrideAsync_NullIsMesReportableOverride_ClearsExistingOverride()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template>
|
||||
{
|
||||
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10, IsLocked = false })
|
||||
});
|
||||
|
||||
var existingOverride = new InstanceAlarmOverride("HighTemp")
|
||||
{
|
||||
Id = 5,
|
||||
InstanceId = 1,
|
||||
IsMesReportableOverride = true
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAlarmOverrideAsync(1, "HighTemp", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingOverride);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.SetAlarmOverrideAsync(
|
||||
1, "HighTemp", "{}", null, "admin", isMesReportableOverride: null);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(existingOverride.IsMesReportableOverride);
|
||||
Assert.Null(result.Value.IsMesReportableOverride);
|
||||
_repoMock.Verify(r => r.UpdateInstanceAlarmOverrideAsync(
|
||||
It.Is<InstanceAlarmOverride>(o => o.IsMesReportableOverride == null),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAlarmOverrideAsync_UpdatesIsMesReportableOverrideOnExisting()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template>
|
||||
{
|
||||
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10, IsLocked = false })
|
||||
});
|
||||
|
||||
var existingOverride = new InstanceAlarmOverride("HighTemp")
|
||||
{
|
||||
Id = 5,
|
||||
InstanceId = 1,
|
||||
IsMesReportableOverride = null
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAlarmOverrideAsync(1, "HighTemp", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingOverride);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.SetAlarmOverrideAsync(
|
||||
1, "HighTemp", "{}", 3, "admin", isMesReportableOverride: false);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(existingOverride.IsMesReportableOverride);
|
||||
Assert.Equal(3, existingOverride.PriorityLevelOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enable_ExistingInstance_SetsEnabled()
|
||||
{
|
||||
|
||||
@@ -530,6 +530,65 @@ public class TemplateServiceTests
|
||||
Assert.Contains("TriggerType", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAlarmAsync_CopiesIsMesReportable()
|
||||
{
|
||||
var existing = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
PriorityLevel = 500,
|
||||
IsMesReportable = false
|
||||
};
|
||||
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
PriorityLevel = 500,
|
||||
IsMesReportable = true
|
||||
};
|
||||
var result = await _service.UpdateAlarmAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value.IsMesReportable);
|
||||
Assert.True(existing.IsMesReportable);
|
||||
_repoMock.Verify(r => r.UpdateTemplateAlarmAsync(
|
||||
It.Is<TemplateAlarm>(a => a.IsMesReportable), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAlarmAsync_ClearsIsMesReportable()
|
||||
{
|
||||
var existing = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
PriorityLevel = 500,
|
||||
IsMesReportable = true
|
||||
};
|
||||
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
PriorityLevel = 500,
|
||||
IsMesReportable = false
|
||||
};
|
||||
var result = await _service.UpdateAlarmAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Value.IsMesReportable);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-4: Script Definitions
|
||||
// ========================================================================
|
||||
@@ -1987,4 +2046,108 @@ public class TemplateServiceTests
|
||||
Assert.Contains(added, a => a.TemplateId == 7 && a.Name == "C" && !a.IsInherited); // base's own
|
||||
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited); // child placeholder
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDerivedTemplateAsync_CopiesIsMesReportableOntoPlaceholder()
|
||||
{
|
||||
// Deriving a template (compose → BuildDerivedTemplate) materializes the
|
||||
// base's alarms as IsInherited placeholders; the MES-reportable flag has
|
||||
// to ride along or the derived template reads "not reportable" and an
|
||||
// edit to any other field would persist that false.
|
||||
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||
moduleTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 30,
|
||||
TemplateId = 2,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5,
|
||||
Description = "Too hot",
|
||||
IsMesReportable = true
|
||||
});
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||
|
||||
Template? captured = null;
|
||||
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(captured);
|
||||
var copied = Assert.Single(captured!.Alarms);
|
||||
Assert.True(copied.IsInherited);
|
||||
Assert.True(copied.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconcileDescendants_PropagatesIsMesReportableChange()
|
||||
{
|
||||
// Two shapes in one reconcile: an EXISTING placeholder that drifted
|
||||
// (base flipped false→true after the derive) must be healed, and a base
|
||||
// alarm added later must arrive on the child carrying the flag.
|
||||
var baseT = BaseTemplate(7, "Base");
|
||||
baseT.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 70,
|
||||
TemplateId = 7,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5,
|
||||
IsMesReportable = true
|
||||
});
|
||||
baseT.Alarms.Add(new TemplateAlarm("LowTemp")
|
||||
{
|
||||
Id = 71,
|
||||
TemplateId = 7,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"low\":10}",
|
||||
PriorityLevel = 6,
|
||||
IsMesReportable = true
|
||||
});
|
||||
|
||||
var child = DerivedTemplate(8, "Child", 7);
|
||||
child.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 80,
|
||||
TemplateId = 8,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5,
|
||||
IsInherited = true,
|
||||
IsMesReportable = false // stale placeholder written before the base flipped
|
||||
});
|
||||
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { baseT, child });
|
||||
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { baseT, child });
|
||||
|
||||
var added = new List<TemplateAlarm>();
|
||||
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TemplateAlarm, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
|
||||
var updated = new List<TemplateAlarm>();
|
||||
_repoMock.Setup(r => r.UpdateTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TemplateAlarm, CancellationToken>((a, _) => updated.Add(a)).Returns(Task.CompletedTask);
|
||||
|
||||
await _service.ReconcileDescendantsAsync(7, "admin");
|
||||
|
||||
// Drift detected and healed on the existing placeholder.
|
||||
var healed = Assert.Single(updated, a => a.Name == "HighTemp");
|
||||
Assert.True(healed.IsMesReportable);
|
||||
Assert.True(healed.IsInherited);
|
||||
// Newly propagated member carries the flag.
|
||||
var propagated = Assert.Single(added, a => a.Name == "LowTemp");
|
||||
Assert.Equal(8, propagated.TemplateId);
|
||||
Assert.True(propagated.IsInherited);
|
||||
Assert.True(propagated.IsMesReportable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"threshold\":100}",
|
||||
LockedInDerived = true,
|
||||
IsMesReportable = true,
|
||||
});
|
||||
tpl.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
|
||||
{
|
||||
@@ -809,6 +810,9 @@ public sealed class RoundTripEquivalenceTests : IDisposable
|
||||
{
|
||||
TriggerConfigurationOverride = "{\"hi\":90}",
|
||||
PriorityLevelOverride = 7,
|
||||
// Non-default for a bool? (whose default is null), so the reflection
|
||||
// sweep fails loudly if the DTO drops the field.
|
||||
IsMesReportableOverride = false,
|
||||
});
|
||||
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
|
||||
{
|
||||
|
||||
@@ -396,6 +396,53 @@ public sealed class ArtifactDiffTests
|
||||
ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTemplate_AlarmIsMesReportableChange_IsModified()
|
||||
{
|
||||
var existing = MakeTemplate("T1");
|
||||
existing.Alarms.Add(new TemplateAlarm("A")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
IsMesReportable = false,
|
||||
});
|
||||
var incoming = MakeTemplateDto("T1", alarms:
|
||||
[
|
||||
// every other alarm field identical; only IsMesReportable differs.
|
||||
new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, null,
|
||||
IsMesReportable: true),
|
||||
]);
|
||||
|
||||
var item = _diff.CompareTemplate(incoming, existing);
|
||||
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
ChangeFor(item, "Alarms.A"); // IsMesReportable diverged.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareInstance_AlarmOverrideIsMesReportableChange_IsModified()
|
||||
{
|
||||
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
|
||||
existing.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
|
||||
{
|
||||
TriggerConfigurationOverride = "{\"hi\":90}",
|
||||
PriorityLevelOverride = 7,
|
||||
IsMesReportableOverride = null,
|
||||
});
|
||||
|
||||
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, alarmOverrides:
|
||||
[
|
||||
// identical apart from the MES-reportable override.
|
||||
new InstanceAlarmOverrideDto("HiAlarm", "{\"hi\":90}", 7, IsMesReportableOverride: false),
|
||||
]);
|
||||
|
||||
var item = _diff.CompareInstance(dto, existing);
|
||||
|
||||
Assert.Equal(ConflictKind.Modified, item.Kind);
|
||||
var modified = ChangeFor(item, "AlarmOverrides.HiAlarm");
|
||||
Assert.Equal("<modified>", modified.GetProperty("oldValue").GetString());
|
||||
Assert.Equal("<modified>", modified.GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
// ============ M8: CompareSite ============
|
||||
|
||||
[Fact]
|
||||
@@ -699,7 +746,8 @@ public sealed class ArtifactDiffTests
|
||||
string template = "Tmpl",
|
||||
string site = "site-a",
|
||||
InstanceState state = InstanceState.Enabled,
|
||||
IReadOnlyList<InstanceAttributeOverrideDto>? attributeOverrides = null) =>
|
||||
IReadOnlyList<InstanceAttributeOverrideDto>? attributeOverrides = null,
|
||||
IReadOnlyList<InstanceAlarmOverrideDto>? alarmOverrides = null) =>
|
||||
new(
|
||||
UniqueName: uniqueName,
|
||||
TemplateName: template,
|
||||
@@ -707,7 +755,7 @@ public sealed class ArtifactDiffTests
|
||||
AreaName: null,
|
||||
State: state,
|
||||
AttributeOverrides: attributeOverrides ?? Array.Empty<InstanceAttributeOverrideDto>(),
|
||||
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
||||
AlarmOverrides: alarmOverrides ?? Array.Empty<InstanceAlarmOverrideDto>(),
|
||||
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
||||
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>());
|
||||
}
|
||||
|
||||
@@ -294,6 +294,106 @@ public sealed class EntitySerializerTests
|
||||
Assert.False(Assert.Single(template.Scripts).LockedInDerived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_preserves_IsMesReportable_on_alarm_and_instance_override()
|
||||
{
|
||||
var basic = new Template("Basic") { Id = 1 };
|
||||
basic.Alarms.Add(new TemplateAlarm("High")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
IsMesReportable = true,
|
||||
});
|
||||
|
||||
var instance = new Instance("Pump-01") { Id = 1, TemplateId = 1, SiteId = 1 };
|
||||
instance.AlarmOverrides.Add(new InstanceAlarmOverride("High")
|
||||
{
|
||||
InstanceId = 1,
|
||||
PriorityLevelOverride = 7,
|
||||
IsMesReportableOverride = false,
|
||||
});
|
||||
|
||||
var aggregate = MakeEmptyAggregate() with
|
||||
{
|
||||
Templates = new[] { basic },
|
||||
Sites = new[] { new Site("Site A", "site-a") { Id = 1 } },
|
||||
Instances = new[] { instance },
|
||||
};
|
||||
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
Assert.True(Assert.Single(Assert.Single(dto.Templates).Alarms).IsMesReportable);
|
||||
Assert.False(Assert.Single(Assert.Single(dto.Instances).AlarmOverrides).IsMesReportableOverride);
|
||||
|
||||
var restored = sut.FromBundleContent(dto);
|
||||
Assert.True(Assert.Single(Assert.Single(restored.Templates).Alarms).IsMesReportable);
|
||||
Assert.False(Assert.Single(Assert.Single(restored.Instances).AlarmOverrides).IsMesReportableOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldForm_bundle_json_without_IsMesReportable_deserializes_to_false_and_null()
|
||||
{
|
||||
// A bundle written before the MES-reportable flag existed: the property is
|
||||
// absent from the template alarm and from the instance alarm override. The
|
||||
// DTO fields are additive trailing parameters, so the alarm flag must land
|
||||
// on false and the override on null (= "keep inherited"), never throw.
|
||||
const string json = """
|
||||
{
|
||||
"TemplateFolders": [],
|
||||
"Templates": [
|
||||
{
|
||||
"Name": "Legacy",
|
||||
"FolderName": null,
|
||||
"BaseTemplateName": null,
|
||||
"Description": null,
|
||||
"Attributes": [],
|
||||
"Alarms": [
|
||||
{ "Name": "Alm", "Description": null, "PriorityLevel": 1,
|
||||
"TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false,
|
||||
"OnTriggerScriptName": null, "LockedInDerived": true }
|
||||
],
|
||||
"Scripts": [],
|
||||
"Compositions": []
|
||||
}
|
||||
],
|
||||
"SharedScripts": [],
|
||||
"ExternalSystems": [],
|
||||
"DatabaseConnections": [],
|
||||
"NotificationLists": [],
|
||||
"SmtpConfigs": [],
|
||||
"ApiMethods": [],
|
||||
"Instances": [
|
||||
{
|
||||
"UniqueName": "Pump-01",
|
||||
"TemplateName": "Legacy",
|
||||
"SiteIdentifier": "site-a",
|
||||
"AreaName": null,
|
||||
"State": "Enabled",
|
||||
"AttributeOverrides": [],
|
||||
"AlarmOverrides": [
|
||||
{ "AlarmCanonicalName": "Alm", "TriggerConfigurationOverride": null,
|
||||
"PriorityLevelOverride": 3 }
|
||||
],
|
||||
"NativeAlarmSourceOverrides": [],
|
||||
"ConnectionBindings": []
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var content = JsonSerializer.Deserialize<BundleContentDto>(json, BundleJsonOptions.Default)!;
|
||||
|
||||
var alarm = Assert.Single(Assert.Single(content.Templates).Alarms);
|
||||
Assert.True(alarm.LockedInDerived); // pre-existing trailing field still read
|
||||
Assert.False(alarm.IsMesReportable); // new trailing field defaults
|
||||
|
||||
var over = Assert.Single(Assert.Single(content.Instances).AlarmOverrides);
|
||||
Assert.Equal(3, over.PriorityLevelOverride);
|
||||
Assert.Null(over.IsMesReportableOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_template_folder_preserves_hierarchy()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user