diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 37057dc..dc445fc 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -874,13 +874,12 @@ -
- - -
-
- - +
+ +
@@ -1456,6 +1455,13 @@ else { _toast.ShowError(result.Error); } } + /// Applies the structured trigger editor's type + config atomically. + private void OnScriptTriggerChanged(ScriptTriggerValue v) + { + _scriptTriggerType = v.TriggerType; + _scriptTriggerConfig = v.Config; + } + private void BeginAddScript() { _showScriptForm = true; diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs new file mode 100644 index 0000000..43cb0fd --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs @@ -0,0 +1,190 @@ +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace ScadaLink.CentralUI.Components.Shared; + +/// +/// Which kind of trigger a template script has. is no +/// trigger; is a stored trigger-type string the runtime +/// does not recognize (preserved as-is by the editor). +/// +internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Unknown } + +/// A script's trigger as the editor emits it: a type string + config JSON. +public sealed record ScriptTriggerValue(string? TriggerType, string? Config); + +/// Parsed/editable view of a script trigger's configuration. +internal sealed class ScriptTriggerModel +{ + /// Interval period in milliseconds. + public long? IntervalMs { get; set; } + + /// Monitored attribute (ValueChange + Conditional). + public string? AttributeName { get; set; } + + /// Comparison operator (Conditional) — one of . + public string Operator { get; set; } = ">"; + + /// Comparison threshold (Conditional). + public double? Threshold { get; set; } +} + +/// +/// Round-trip codec for a template script's TriggerType + +/// TriggerConfiguration, shared by (UI +/// editing) and consumed at the site by ScriptActor.ParseTriggerConfig. +/// Serialized config shapes: +/// Interval { intervalMs } +/// ValueChange { attributeName } +/// Conditional { attributeName, operator, threshold } +/// Call { } +/// +/// Parsing also accepts the legacy aliases attribute and value so +/// older configs survive a round-trip through the editor. +/// +internal static class ScriptTriggerConfigCodec +{ + /// The six comparison operators ScriptActor.EvaluateCondition accepts. + internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" }; + + /// Classifies a raw TriggerType string (case-insensitive). + 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 + }; + } + + /// Canonical TriggerType string for a kind; null for None/Unknown. + internal static string? KindToString(ScriptTriggerKind kind) => kind switch + { + ScriptTriggerKind.Interval => "Interval", + ScriptTriggerKind.ValueChange => "ValueChange", + ScriptTriggerKind.Conditional => "Conditional", + ScriptTriggerKind.Call => "Call", + _ => null + }; + + /// + /// 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. + /// + 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; + } + + /// + /// Serializes the model to the JSON shape ScriptActor.ParseTriggerConfig + /// expects. Returns null for None/Unknown (no structured config to emit). + /// + 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()); + } + + /// Returns if it is a recognized operator, else ">". + 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 + }; + } +} diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor new file mode 100644 index 0000000..0f5a3ae --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor @@ -0,0 +1,339 @@ +@namespace ScadaLink.CentralUI.Components.Shared +@using System.Globalization + +@* Structured editor for a template script's trigger. Owns both the trigger-type + selector and the type-specific configuration, emitting (via Changed) the + canonical TriggerType string + the TriggerConfiguration JSON that + ScriptActor.ParseTriggerConfig consumes: + Interval { intervalMs } + ValueChange { attributeName } + Conditional { attributeName, operator, threshold } + Call { } *@ + +
+ + @* ── Trigger type ──────────────────────────────────────────────────── *@ +
+ + +
+ + @* ── Type-specific configuration ───────────────────────────────────── *@ + @switch (_kind) + { + case ScriptTriggerKind.Interval: + @RenderInterval(); + break; + case ScriptTriggerKind.ValueChange: +
@RenderAttributePicker("Monitored attribute")
+ break; + case ScriptTriggerKind.Conditional: + @RenderConditional(); + break; + case ScriptTriggerKind.Call: +
+ No automatic trigger — this script runs only when another script + invokes it via Instance.CallScript("..."). +
+ break; + case ScriptTriggerKind.Unknown: +
+ Unrecognized trigger type @_rawType. Its stored + configuration is shown below and left untouched — pick a known + trigger type above to reconfigure it. +
@(string.IsNullOrWhiteSpace(TriggerConfig) ? "(empty)" : TriggerConfig)
+
+ break; + } + + @* ── Hint ──────────────────────────────────────────────────────────── *@ + @if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional) + { +
@BuildHint()
+ } +
+ +@code { + // ── Parameters ───────────────────────────────────────────────────────── + + [Parameter] public string? TriggerType { get; set; } + [Parameter] public string? TriggerConfig { get; set; } + + /// Raised whenever the type or config changes — emits both atomically. + [Parameter] public EventCallback Changed { get; set; } + + /// Flattened attribute list (direct + inherited + composed) for the picker. + [Parameter] public IReadOnlyList AvailableAttributes { get; set; } = + Array.Empty(); + + // ── Internal state ───────────────────────────────────────────────────── + + private ScriptTriggerKind _kind; + private string _kindValue = "None"; + private string? _rawType; + private ScriptTriggerModel _model = new(); + + // Last type/config seen on Parameters — distinguishes an external change + // (re-parse) from this component's own echo (skip). + private bool _seen; + private string? _lastType; + private string? _lastConfig; + + // Text mirrors — @bind needs settable backing fields; kept in sync with the + // model so blank inputs round-trip blank rather than as 0. + private string _attributeName = string.Empty; + private string _operator = ">"; + private string? _thresholdText; + private string? _intervalText; + private string _intervalUnit = "ms"; + + // ── Parse / serialize lifecycle ──────────────────────────────────────── + + protected override void OnParametersSet() + { + if (_seen && _lastType == TriggerType && _lastConfig == TriggerConfig) return; + _seen = true; + _lastType = TriggerType; + _lastConfig = TriggerConfig; + + _rawType = TriggerType; + _kind = ScriptTriggerConfigCodec.ParseKind(TriggerType); + _kindValue = _kind.ToString(); + _model = ScriptTriggerConfigCodec.Parse(TriggerConfig, _kind); + SyncMirrors(); + } + + private void SyncMirrors() + { + _attributeName = _model.AttributeName ?? string.Empty; + _operator = _model.Operator; + _thresholdText = _model.Threshold?.ToString("R", CultureInfo.InvariantCulture); + (_intervalText, _intervalUnit) = SplitInterval(_model.IntervalMs); + } + + /// Chooses the largest whole unit (min/sec/ms) that represents the period exactly. + private static (string?, string) SplitInterval(long? ms) + { + if (ms is not { } v) return (null, "ms"); + if (v >= 60000 && v % 60000 == 0) return ((v / 60000).ToString(CultureInfo.InvariantCulture), "min"); + if (v >= 1000 && v % 1000 == 0) return ((v / 1000).ToString(CultureInfo.InvariantCulture), "sec"); + return (v.ToString(CultureInfo.InvariantCulture), "ms"); + } + + private static long UnitFactor(string unit) => unit switch + { + "min" => 60000, + "sec" => 1000, + _ => 1 + }; + + /// Serializes the current model and raises once. + private async Task Emit() + { + var type = ScriptTriggerConfigCodec.KindToString(_kind); + var config = ScriptTriggerConfigCodec.Serialize(_model, _kind); + _lastType = type; + _lastConfig = config; + await Changed.InvokeAsync(new ScriptTriggerValue(type, config)); + } + + // ── Trigger type ─────────────────────────────────────────────────────── + + private async Task OnKindChanged() + { + if (!Enum.TryParse(_kindValue, out var newKind) + || newKind == ScriptTriggerKind.Unknown) + { + _kindValue = _kind.ToString(); + return; + } + + // Carry the attribute name across a ValueChange <-> Conditional switch. + var preservedAttr = _model.AttributeName; + _kind = newKind; + _model = new ScriptTriggerModel(); + if (newKind is ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional) + _model.AttributeName = preservedAttr; + SyncMirrors(); + await Emit(); + } + + // ── Interval ─────────────────────────────────────────────────────────── + + private RenderFragment RenderInterval() => __builder => + { +
+
+ + +
+
+ +
+
+ }; + + private async Task OnIntervalChanged() + { + _model.IntervalMs = + long.TryParse(_intervalText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) && n > 0 + ? n * UnitFactor(_intervalUnit) + : null; + await Emit(); + } + + // ── Conditional ──────────────────────────────────────────────────────── + + private RenderFragment RenderConditional() => __builder => + { +
@RenderAttributePicker("Monitored attribute")
+
+
+ + +
+
+ + +
+
+ }; + + private async Task OnOperatorChanged() + { + _model.Operator = ScriptTriggerConfigCodec.NormalizeOperator(_operator); + await Emit(); + } + + private async Task OnThresholdChanged() + { + _model.Threshold = + double.TryParse(_thresholdText, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) + ? v + : null; + await Emit(); + } + + // ── Attribute picker (ValueChange + Conditional) ─────────────────────── + + private RenderFragment RenderAttributePicker(string label) => __builder => + { + + + }; + + private async Task OnAttributeChanged() + { + _model.AttributeName = _attributeName; + await Emit(); + } + + private static int SourceOrder(string source) => source switch + { + "Direct" => 0, + "Inherited" => 1, + "Composed" => 2, + _ => 3 + }; + + // ── Hint ─────────────────────────────────────────────────────────────── + + private string BuildHint() + { + var attr = string.IsNullOrWhiteSpace(_model.AttributeName) + ? "the selected attribute" + : $"\"{_model.AttributeName}\""; + + return _kind switch + { + ScriptTriggerKind.Interval => + _model.IntervalMs is { } ms + ? $"Runs every {_intervalText} {UnitLabel(_intervalUnit)} ({ms} ms)." + : "Runs on a fixed timer — set the period above.", + + ScriptTriggerKind.ValueChange => + $"Runs whenever {attr} changes value.", + + ScriptTriggerKind.Conditional => + _model.Threshold is { } t + ? $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}." + : $"Runs when {attr} changes and meets the configured condition — set a threshold above.", + + _ => string.Empty + }; + } + + private static string UnitLabel(string unit) => unit switch + { + "min" => "minute(s)", + "sec" => "second(s)", + _ => "millisecond(s)" + }; + + private static string OperatorLabel(string op) => op switch + { + ">" => "greater than", + ">=" => "at least", + "<" => "less than", + "<=" => "at most", + "==" => "equals", + "!=" => "not equal", + _ => "" + }; +}