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
+10
View File
@@ -68,6 +68,16 @@ removed later.
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit. the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
Export round-trips the same shape. Export round-trips the same shape.
- **Native alarm section (Gitea #642)** — the Edit-tag modal carries a shared `NativeAlarmEditor`
below the per-driver editor / JSON textarea: a "Native alarm (Part 9 condition)" switch plus
`alarmType`, `severity` (1..1000) and `historizeToAveva` (default/yes/no), writing the
driver-agnostic `alarm` object of `TagConfig`. Offered only for `IAlarmSource` drivers (MTConnect,
AbCip, FOCAS, OpcUaClient, Galaxy — `NativeAlarmModel.SupportsNativeAlarms`); on any other driver an
existing `alarm` object is shown with a "no effect" note and a Remove button, never silently
dropped. Switching it off REMOVES the `alarm` key. Validation is advisory only (warnings) — the
deploy gate has no `alarm` rule and the server clamps severity, so the editor is never stricter.
Every edit re-parses the latest `ConfigJson` (`NativeAlarmModel.Edit`), so it composes with the
driver editor in either order. Not yet on the manual-entry grid (use JSON/CSV there).
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` → - **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery` universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
@@ -9,6 +9,7 @@
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors @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.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IRawTreeService Svc @inject IRawTreeService Svc
@@ -90,6 +91,9 @@
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div> <div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
} }
</div> </div>
<NativeAlarmEditor ConfigJson="@_form.TagConfig"
ConfigJsonChanged="@(v => _form.TagConfig = v)"
DriverType="@DriverType" />
} }
@if (!string.IsNullOrWhiteSpace(_error)) @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);
}
}
@@ -90,14 +90,72 @@ public sealed class NativeAlarmModel
// Re-attach (no-op when it was already the same node; required when we created a fresh one). // Re-attach (no-op when it was already the same node; required when we created a fresh one).
_root["alarm"] = alarm; _root["alarm"] = alarm;
} }
else
{
// Toggled off (#642): the alarm object must go, or a "disabled" tag would still deploy as a condition.
_root.Remove("alarm");
}
return TagConfigJson.Serialize(_root); return TagConfigJson.Serialize(_root);
} }
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary> /// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns> /// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
/// <remarks>Deliberately never errors: the deploy gate has no <c>alarm</c> rule and
/// <c>TagConfigIntent.ParseAlarm</c> accepts any alarmType string and any severity (clamped 1..1000 at
/// publish), so rejecting here would be stricter than the gate. Out-of-range input is advisory —
/// see <see cref="Warnings"/>.</remarks>
public string? Validate() => null; public string? Validate() => null;
/// <summary>Advisory messages for values the server will accept but silently normalise.</summary>
/// <returns>Zero or more warning strings.</returns>
public IEnumerable<string> Warnings()
{
if (!IsAlarm) { yield break; }
if (Severity is < 1 or > 1000)
{
yield return $"Severity {Severity} is outside 1..1000; the server clamps it.";
}
if (!KnownAlarmTypes.Contains(AlarmType))
{
yield return $"Alarm type '{AlarmType}' is not one of {string.Join("/", KnownAlarmTypes)}; the server treats it as AlarmCondition.";
}
}
/// <summary>The Part 9 condition subtypes the server distinguishes (OffNormal/Discrete; the rest map to the base condition).</summary>
public static readonly IReadOnlyList<string> KnownAlarmTypes =
["AlarmCondition", "OffNormalAlarm", "DiscreteAlarm", "LimitAlarm"];
/// <summary>Driver types that implement <c>IAlarmSource</c> (so a TagConfig <c>alarm</c> object is
/// meaningful). The alarm object itself is driver-agnostic; this only gates the editor's visibility.</summary>
private static readonly HashSet<string> AlarmDriverTypes = new(StringComparer.OrdinalIgnoreCase)
{
Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
Core.Abstractions.DriverTypeNames.Galaxy,
};
/// <summary>True when the driver type can emit native alarms.</summary>
/// <param name="driverType">Driver type name.</param>
/// <returns>Whether the native-alarm editor applies.</returns>
public static bool SupportsNativeAlarms(string? driverType)
=> driverType is not null && AlarmDriverTypes.Contains(driverType);
/// <summary>
/// Order-safe edit: re-parses the LATEST <paramref name="json"/> (so a keystroke in the per-driver
/// editor is never overwritten by a stale cached copy), applies <paramref name="mutate"/>, serialises.
/// Only the <c>alarm</c> key is touched; every other key is preserved verbatim.
/// </summary>
/// <param name="json">Current TagConfig JSON.</param>
/// <param name="mutate">Edit to apply to the freshly-parsed model.</param>
/// <returns>The new TagConfig JSON.</returns>
public static string Edit(string? json, Action<NativeAlarmModel> mutate)
{
var m = FromJson(json);
mutate(m);
return m.ToJson();
}
/// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the /// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the
/// TagConfig has no <c>alarm</c> object yet. Galaxy's <c>AlarmExtension</c> is a boolean /// TagConfig has no <c>alarm</c> object yet. Galaxy's <c>AlarmExtension</c> is a boolean
/// off-normal condition, so <c>OffNormalAlarm</c> is the natural Part 9 subtype.</summary> /// off-normal condition, so <c>OffNormalAlarm</c> is the natural Part 9 subtype.</summary>
@@ -0,0 +1,106 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>Gitea #642 — the shared NativeAlarmEditor's pure-model behaviour: remove-on-off, composition
/// with the per-driver editors' models, validation parity with the deploy gate, driver-type gating.</summary>
public sealed class NativeAlarmEditorModelTests
{
[Fact]
public void ToJson_with_IsAlarm_false_removes_the_alarm_key_and_keeps_the_rest()
{
var m = NativeAlarmModel.FromJson("""{"FullName":"X.Y","alarm":{"alarmType":"LimitAlarm","severity":300,"extra":1}}""");
m.IsAlarm = false;
var o = JsonNode.Parse(m.ToJson())!.AsObject();
o.ContainsKey("alarm").ShouldBeFalse();
o["FullName"]!.GetValue<string>().ShouldBe("X.Y");
}
[Fact]
public void Toggle_off_then_on_again_starts_from_defaults()
{
var off = NativeAlarmModel.Edit("""{"alarm":{"severity":300}}""", m => m.IsAlarm = false);
var on = NativeAlarmModel.Edit(off, m => m.IsAlarm = true);
var back = NativeAlarmModel.FromJson(on);
back.IsAlarm.ShouldBeTrue();
back.Severity.ShouldBe(500);
back.AlarmType.ShouldBe("AlarmCondition");
}
[Fact]
public void Edit_preserves_unknown_keys_at_both_levels()
{
var json = NativeAlarmModel.Edit("""{"FullName":"A","zz":1,"alarm":{"alarmType":"OffNormalAlarm","custom":"k"}}""",
m => m.Severity = 900);
var o = JsonNode.Parse(json)!.AsObject();
o["zz"]!.GetValue<int>().ShouldBe(1);
o["alarm"]!["custom"]!.GetValue<string>().ShouldBe("k");
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(900);
}
[Fact]
public void Alarm_and_mtconnect_driver_editor_compose_in_either_order()
{
const string start = """{"fullName":"Xact"}""";
// alarm first, then driver editor (driver model re-parses the alarm-bearing JSON)
var a1 = NativeAlarmModel.Edit(start, m => { m.IsAlarm = true; m.Severity = 800; });
var d1 = MTConnectTagConfigModel.FromJson(a1);
d1.FullName = "Yact";
var r1 = d1.ToJson();
// driver editor first, then alarm
var d2 = MTConnectTagConfigModel.FromJson(start);
d2.FullName = "Yact";
var r2 = NativeAlarmModel.Edit(d2.ToJson(), m => { m.IsAlarm = true; m.Severity = 800; });
foreach (var r in new[] { r1, r2 })
{
var o = JsonNode.Parse(r)!.AsObject();
o["fullName"]!.GetValue<string>().ShouldBe("Yact");
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(800);
}
}
[Theory]
[InlineData("Modbus")]
[InlineData("S7")]
[InlineData("AbCip")]
[InlineData("AbLegacy")]
[InlineData("TwinCAT")]
[InlineData("FOCAS")]
[InlineData("OpcUaClient")]
[InlineData("Sql")]
[InlineData("Mqtt")]
[InlineData("MTConnect")]
public void Every_typed_driver_model_preserves_an_alarm_object(string driverType)
{
const string json = """{"alarm":{"alarmType":"LimitAlarm","severity":123}}""";
var round = TagConfigRoundTrip.Through(driverType, json);
NativeAlarmModel.FromJson(round).Severity.ShouldBe(123);
}
[Fact]
public void Validate_is_not_stricter_than_the_deploy_gate()
{
// The gate/parser accepts any severity (clamped 1..1000 at publish) and any alarmType string.
var m = NativeAlarmModel.FromJson("""{"alarm":{"alarmType":"Whatever","severity":5000}}""");
m.Validate().ShouldBeNull();
m.Warnings().ShouldNotBeEmpty(); // advisory only
}
[Fact]
public void SupportsNativeAlarms_covers_exactly_the_IAlarmSource_drivers()
{
foreach (var t in new[] { DriverTypeNames.MTConnect, DriverTypeNames.AbCip, DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeTrue(t);
foreach (var t in new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.Mqtt, null })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeFalse(t);
}
}
@@ -0,0 +1,23 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>Test helper: FromJson→ToJson through the driver's typed TagConfig model.</summary>
internal static class TagConfigRoundTrip
{
public static string Through(string driverType, string json) => driverType switch
{
DriverTypeNames.Modbus => ModbusTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.S7 => S7TagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.AbCip => AbCipTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.AbLegacy => AbLegacyTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.TwinCAT => TwinCATTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.FOCAS => FocasTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.OpcUaClient => OpcUaClientTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
_ => throw new ArgumentOutOfRangeException(nameof(driverType)),
};
}