feat(ui/templates): structured trigger editor for template scripts
The script add/edit modal exposed a script's trigger as two raw free-text inputs — a type string and hand-written config JSON — with no validation and no parity with the alarm trigger UI. Replace them with a ScriptTriggerEditor component (mirroring AlarmTriggerEditor): a trigger-type dropdown plus type-specific panels for Interval, ValueChange, Conditional, and Call, a grouped attribute picker, and an auto-generated hint. A ScriptTriggerConfigCodec round-trips the TriggerConfiguration JSON the site runtime's ScriptActor consumes, tolerant of legacy keys; an unrecognized stored type is preserved untouched in a read-only panel.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Which kind of trigger a template script has. <see cref="None"/> is no
|
||||
/// trigger; <see cref="Unknown"/> is a stored trigger-type string the runtime
|
||||
/// does not recognize (preserved as-is by the editor).
|
||||
/// </summary>
|
||||
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Unknown }
|
||||
|
||||
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
|
||||
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
|
||||
|
||||
/// <summary>Parsed/editable view of a script trigger's configuration.</summary>
|
||||
internal sealed class ScriptTriggerModel
|
||||
{
|
||||
/// <summary>Interval period in milliseconds.</summary>
|
||||
public long? IntervalMs { get; set; }
|
||||
|
||||
/// <summary>Monitored attribute (ValueChange + Conditional).</summary>
|
||||
public string? AttributeName { get; set; }
|
||||
|
||||
/// <summary>Comparison operator (Conditional) — one of <see cref="ScriptTriggerConfigCodec.Operators"/>.</summary>
|
||||
public string Operator { get; set; } = ">";
|
||||
|
||||
/// <summary>Comparison threshold (Conditional).</summary>
|
||||
public double? Threshold { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip codec for a template script's <c>TriggerType</c> +
|
||||
/// <c>TriggerConfiguration</c>, shared by <see cref="ScriptTriggerEditor"/> (UI
|
||||
/// editing) and consumed at the site by <c>ScriptActor.ParseTriggerConfig</c>.
|
||||
/// Serialized config shapes:
|
||||
/// Interval { intervalMs }
|
||||
/// ValueChange { attributeName }
|
||||
/// Conditional { attributeName, operator, threshold }
|
||||
/// Call { }
|
||||
///
|
||||
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
|
||||
/// older configs survive a round-trip through the editor.
|
||||
/// </summary>
|
||||
internal static class ScriptTriggerConfigCodec
|
||||
{
|
||||
/// <summary>The six comparison operators <c>ScriptActor.EvaluateCondition</c> accepts.</summary>
|
||||
internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" };
|
||||
|
||||
/// <summary>Classifies a raw <c>TriggerType</c> string (case-insensitive).</summary>
|
||||
internal static ScriptTriggerKind ParseKind(string? triggerType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(triggerType)) return ScriptTriggerKind.None;
|
||||
return triggerType.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"interval" => ScriptTriggerKind.Interval,
|
||||
"valuechange" => ScriptTriggerKind.ValueChange,
|
||||
"conditional" => ScriptTriggerKind.Conditional,
|
||||
"call" => ScriptTriggerKind.Call,
|
||||
_ => ScriptTriggerKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
|
||||
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
|
||||
{
|
||||
ScriptTriggerKind.Interval => "Interval",
|
||||
ScriptTriggerKind.ValueChange => "ValueChange",
|
||||
ScriptTriggerKind.Conditional => "Conditional",
|
||||
ScriptTriggerKind.Call => "Call",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses a trigger configuration JSON in the context of the given kind.
|
||||
/// Returns a model with default values on null/empty/malformed input or for
|
||||
/// missing keys — never throws.
|
||||
/// </summary>
|
||||
internal static ScriptTriggerModel Parse(string? json, ScriptTriggerKind kind)
|
||||
{
|
||||
var model = new ScriptTriggerModel();
|
||||
if (string.IsNullOrWhiteSpace(json)) return model;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case ScriptTriggerKind.Interval:
|
||||
model.IntervalMs = TryReadLong(root, "intervalMs");
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.ValueChange:
|
||||
model.AttributeName = TryReadAttributeName(root);
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.Conditional:
|
||||
model.AttributeName = TryReadAttributeName(root);
|
||||
var op = root.TryGetProperty("operator", out var o) ? o.GetString() : null;
|
||||
model.Operator = NormalizeOperator(op);
|
||||
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON — fall through with default model.
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the model to the JSON shape <c>ScriptActor.ParseTriggerConfig</c>
|
||||
/// expects. Returns null for None/Unknown (no structured config to emit).
|
||||
/// </summary>
|
||||
internal static string? Serialize(ScriptTriggerModel model, ScriptTriggerKind kind)
|
||||
{
|
||||
if (kind is ScriptTriggerKind.None or ScriptTriggerKind.Unknown) return null;
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(stream))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
switch (kind)
|
||||
{
|
||||
case ScriptTriggerKind.Interval:
|
||||
if (model.IntervalMs.HasValue)
|
||||
w.WriteNumber("intervalMs", model.IntervalMs.Value);
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.ValueChange:
|
||||
w.WriteString("attributeName", model.AttributeName ?? "");
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.Conditional:
|
||||
w.WriteString("attributeName", model.AttributeName ?? "");
|
||||
w.WriteString("operator", model.Operator);
|
||||
if (model.Threshold.HasValue)
|
||||
w.WriteNumber("threshold", model.Threshold.Value);
|
||||
break;
|
||||
|
||||
// Call → empty object.
|
||||
}
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else ">".</summary>
|
||||
internal static string NormalizeOperator(string? raw)
|
||||
{
|
||||
var op = raw?.Trim();
|
||||
return op != null && Array.IndexOf(Operators, op) >= 0 ? op : ">";
|
||||
}
|
||||
|
||||
private static string? TryReadAttributeName(JsonElement root) =>
|
||||
root.TryGetProperty("attributeName", out var a) ? a.GetString()
|
||||
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
|
||||
: null;
|
||||
|
||||
private static long? TryReadLong(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when p.TryGetInt64(out var i) => i,
|
||||
JsonValueKind.Number => (long)p.GetDouble(),
|
||||
JsonValueKind.String when long.TryParse(
|
||||
p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static double? TryReadDouble(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => p.GetDouble(),
|
||||
JsonValueKind.String when double.TryParse(
|
||||
p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user