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
@@ -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).
_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);
}
/// <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>
/// <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;
/// <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
/// 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>