feat(adminui): typed native-alarm section in the raw tag modal (#642)

NativeAlarmModel: IsAlarm=false now removes the alarm key; advisory Warnings, driver gating, order-safe Edit.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:52:55 -04:00
parent a8b07ae12d
commit 6951faa08f
6 changed files with 305 additions and 0 deletions
@@ -9,6 +9,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IRawTreeService Svc
@@ -90,6 +91,9 @@
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
</div>
<NativeAlarmEditor ConfigJson="@_form.TagConfig"
ConfigJsonChanged="@(v => _form.TagConfig = v)"
DriverType="@DriverType" />
}
@if (!string.IsNullOrWhiteSpace(_error))
@@ -0,0 +1,104 @@
@* Shared native-alarm section (Gitea #642): authors the driver-agnostic TagConfig `alarm` object
(alarmType / severity / historizeToAveva) for a raw tag. A thin shell over NativeAlarmModel — all rules
live there. It sits OUTSIDE the per-driver typed editor (Galaxy has none) and writes the same ConfigJson.
Order-safety: every change goes through NativeAlarmModel.Edit(ConfigJson, ...), which re-parses the
LATEST json, so it can never clobber a key the driver editor just wrote; the driver models in turn
preserve the `alarm` key (pinned by NativeAlarmEditorModelTests).
Plain input/select + @onchange (no InputNumber/EditForm binding — see the AdminUI binding pitfalls). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@if (_m.IsAlarm || _supported)
{
<div class="mb-3 border rounded p-2" data-testid="native-alarm-editor">
@if (!_supported)
{
@* Never hide an authored alarm silently: show it and offer removal rather than dropping it. *@
<div class="text-warning small">
This tag carries an <span class="mono">alarm</span> object, but driver type
<span class="mono">@DriverType</span> does not raise native alarms, so it has no effect.
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" id="native-alarm-remove"
@onclick="@(() => Update(m => m.IsAlarm = false))">Remove alarm object</button>
}
else
{
<div class="form-check form-switch">
<input id="native-alarm-enabled" type="checkbox" class="form-check-input"
checked="@_m.IsAlarm"
@onchange="@(e => Update(m => m.IsAlarm = e.Value is true))" />
<label class="form-check-label" for="native-alarm-enabled">Native alarm (Part 9 condition)</label>
</div>
@if (_m.IsAlarm)
{
<div class="row g-2 mt-1">
<div class="col-md-4">
<label class="form-label" for="native-alarm-type">Alarm type</label>
<select id="native-alarm-type" class="form-select form-select-sm" value="@_m.AlarmType"
@onchange="@(e => Update(m => m.AlarmType = e.Value?.ToString() ?? "AlarmCondition"))">
@foreach (var t in NativeAlarmModel.KnownAlarmTypes) { <option value="@t">@t</option> }
@if (!NativeAlarmModel.KnownAlarmTypes.Contains(_m.AlarmType))
{
<option value="@_m.AlarmType">@_m.AlarmType (custom)</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="native-alarm-severity">Severity (1..1000)</label>
<input id="native-alarm-severity" type="number" min="1" max="1000" class="form-control form-control-sm"
value="@_m.Severity"
@onchange="@(e => Update(m => m.Severity = int.TryParse(e.Value?.ToString(), out var s) ? s : 500))" />
</div>
<div class="col-md-4">
<label class="form-label" for="native-alarm-historize">Historize to AVEVA</label>
<select id="native-alarm-historize" class="form-select form-select-sm"
value="@HistorizeValue(_m.HistorizeToAveva)"
@onchange="@(e => Update(m => m.HistorizeToAveva = ParseHistorize(e.Value?.ToString())))">
<option value="default">Default (yes)</option>
<option value="yes">Yes</option>
<option value="no">No (live alerts only)</option>
</select>
</div>
</div>
@foreach (var w in _m.Warnings())
{
<div class="text-warning small mt-1">@w</div>
}
}
}
</div>
}
@code {
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Two-way config callback.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>Driver type of the owning device; gates whether the editor is offered.</summary>
[Parameter] public string? DriverType { get; set; }
private NativeAlarmModel _m = new();
private bool _supported;
private string? _lastConfigJson;
protected override void OnParametersSet()
{
_supported = NativeAlarmModel.SupportsNativeAlarms(DriverType);
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = NativeAlarmModel.FromJson(ConfigJson);
}
private static string HistorizeValue(bool? v) => v is null ? "default" : v.Value ? "yes" : "no";
private static bool? ParseHistorize(string? v) => v switch { "yes" => true, "no" => false, _ => null };
private async Task Update(Action<NativeAlarmModel> apply)
{
var json = NativeAlarmModel.Edit(ConfigJson, apply);
_lastConfigJson = json;
_m = NativeAlarmModel.FromJson(json);
await ConfigJsonChanged.InvokeAsync(json);
}
}