@@ -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 ──────────────────────────────────────────────────── *@
+
+
+ Trigger type
+
+
+ — none (never runs automatically) —
+ Interval — run on a fixed timer
+ Value change — run when an attribute changes
+ Conditional — run when a condition is met
+ Call — run only when invoked by another script
+ @if (_kind == ScriptTriggerKind.Unknown)
+ {
+
+ @_rawType (unknown)
+
+ }
+
+
+
+ @* ── 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 =>
+ {
+
+
+ Run every
+
+
+
+
+ milliseconds
+ seconds
+ minutes
+
+
+
+ };
+
+ 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")
+
+
+ Operator
+
+ @foreach (var op in ScriptTriggerConfigCodec.Operators)
+ {
+ @op — @OperatorLabel(op)
+ }
+
+
+
+ Threshold
+
+
+
+ };
+
+ 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 =>
+ {
+ @label
+
+ — select attribute —
+ @{
+ var groups = AvailableAttributes
+ .GroupBy(c => c.Source)
+ .OrderBy(g => SourceOrder(g.Key))
+ .ToList();
+ }
+ @foreach (var grp in groups)
+ {
+
+ @foreach (var choice in grp.OrderBy(c => c.CanonicalName, StringComparer.Ordinal))
+ {
+ @choice.CanonicalName (@choice.DataType)
+ }
+
+ }
+ @* Keep a saved-but-missing attribute selectable so it stays visible. *@
+ @if (!string.IsNullOrEmpty(_attributeName)
+ && !AvailableAttributes.Any(c => string.Equals(c.CanonicalName, _attributeName, StringComparison.Ordinal)))
+ {
+
+ @_attributeName (not on this template)
+
+ }
+
+ };
+
+ 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",
+ _ => ""
+ };
+}