feat(adminui): native-alarm HistorizeToAveva opt-out

This commit is contained in:
Joseph Doherty
2026-06-16 16:27:31 -04:00
parent 72d414ada7
commit 6a8020e7e7
8 changed files with 393 additions and 15 deletions
@@ -136,6 +136,27 @@
<ValidationMessage For="@(() => _form.TagConfig)" />
</div>
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@
@if (HasNativeAlarm)
{
<div class="mb-3">
<label class="form-label">Native alarm</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="tag-alarm-historize"
checked="@AlarmHistorizeToAveva"
@onchange="OnAlarmHistorizeChanged" />
<label class="form-check-label" for="tag-alarm-historize">Historize to AVEVA</label>
</div>
<div class="form-text">
When unchecked, this alarm's transitions are NOT written to the AVEVA historian
(the live alerts feed is unaffected). Checked is the default.
</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
@@ -234,6 +255,27 @@
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
};
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
private bool HasNativeAlarm => NativeAlarmModel.FromJson(_form.TagConfig).IsAlarm;
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
// explicit false leaves it unchecked.
private bool AlarmHistorizeToAveva => NativeAlarmModel.FromJson(_form.TagConfig).HistorizeToAveva != false;
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
// Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip).
private void OnAlarmHistorizeChanged(ChangeEventArgs e)
{
var model = NativeAlarmModel.FromJson(_form.TagConfig);
if (!model.IsAlarm) { return; }
var isChecked = e.Value is bool b && b;
model.HistorizeToAveva = isChecked ? null : false;
_form.TagConfig = model.ToJson();
}
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
@@ -0,0 +1,102 @@
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Typed working model for the optional native-alarm <c>alarm</c> sub-object inside a tag's
/// <c>TagConfig</c> JSON. A tag whose <c>TagConfig</c> carries an <c>alarm</c> object is materialised
/// as an OPC UA Part 9 condition (rather than a value variable); the fields here mirror what the
/// server's <c>Phase7Composer.ExtractTagAlarm</c> / <c>DeploymentArtifact.ExtractTagAlarm</c> parse.
///
/// <para>
/// <see cref="HistorizeToAveva"/> is the per-condition opt-out of the DURABLE AVEVA historian write
/// (<c>bool?</c>; absent ⇒ <c>null</c> ⇒ historize). The server's <c>HistorianAdapterActor</c> gates
/// the sink write on <c>historizeToAveva is not false</c>, so <c>null</c> and <c>true</c> both
/// historize and only an explicit <c>false</c> suppresses the durable row — the live <c>/alerts</c>
/// fan-out is unaffected. This mirrors the scripted-alarm <c>HistorizeToAveva</c> opt-out posture.
/// </para>
///
/// <para>
/// <see cref="FromJson"/> / <see cref="ToJson"/> operate over the WHOLE <c>TagConfig</c> JSON (root +
/// nested <c>alarm</c>), preserving every unrecognised key at both levels so a load→save can't drop a
/// field this editor doesn't expose.
/// </para>
/// </summary>
public sealed class NativeAlarmModel
{
/// <summary>True iff the TagConfig carried an <c>alarm</c> object (the tag is an alarm condition).</summary>
public bool IsAlarm { get; set; }
/// <summary>OPC UA Part 9 condition subtype (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition).</summary>
public string AlarmType { get; set; } = "AlarmCondition";
/// <summary>1..1000 OPC UA severity.</summary>
public int Severity { get; set; } = 500;
/// <summary>Per-condition opt-out of the durable AVEVA historian write. <c>null</c> ⇒ absent ⇒
/// historize (default-on at the server gate); <c>true</c> ⇒ historize; <c>false</c> ⇒ suppress the
/// durable row (live alerts fan-out unaffected).</summary>
public bool? HistorizeToAveva { get; set; }
private JsonObject _root = new();
/// <summary>Loads a model from a TagConfig JSON string. When no <c>alarm</c> object is present the
/// model is flagged non-alarm and the alarm fields keep their defaults. Retains every original key
/// at the root and inside <c>alarm</c> so a load→save preserves fields this editor doesn't expose.</summary>
public static NativeAlarmModel FromJson(string? json)
{
var root = TagConfigJson.ParseOrNew(json);
var model = new NativeAlarmModel { _root = root };
if (root.TryGetPropertyValue("alarm", out var aNode) && aNode is JsonObject alarm)
{
model.IsAlarm = true;
model.AlarmType = TagConfigJson.GetString(alarm, "alarmType") is { } t && t.Length > 0
? t : "AlarmCondition";
model.Severity = TagConfigJson.GetInt(alarm, "severity", 500);
model.HistorizeToAveva = ReadNullableBool(alarm, "historizeToAveva");
}
return model;
}
/// <summary>Serialises this model back to a TagConfig JSON string. When <see cref="IsAlarm"/> is set,
/// writes the alarm fields over the preserved <c>alarm</c> sub-object (creating it if absent); a
/// <c>null</c> <see cref="HistorizeToAveva"/> REMOVES the key so the absent ⇒ historize default holds.</summary>
public string ToJson()
{
if (IsAlarm)
{
var alarm = _root.TryGetPropertyValue("alarm", out var n) && n is JsonObject existing
? existing
: new JsonObject();
TagConfigJson.Set(alarm, "alarmType", AlarmType);
TagConfigJson.Set(alarm, "severity", Severity);
// null REMOVES the key (absent ⇒ historize default-on at the server gate); a concrete
// true/false is written through. JsonValue.Create(bool) yields a JSON boolean literal.
if (HistorizeToAveva is { } h)
{
alarm["historizeToAveva"] = JsonValue.Create(h);
}
else
{
alarm.Remove("historizeToAveva");
}
// Re-attach (no-op when it was already the same node; required when we created a fresh one).
_root["alarm"] = alarm;
}
return TagConfigJson.Serialize(_root);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate() => null;
/// <summary>Reads a nullable bool: <c>null</c> when absent/null/non-bool; otherwise the bool value.</summary>
private static bool? ReadNullableBool(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b)
? b
: null;
}