feat(triggers): add WhileTrue fire mode for Conditional/Expression script triggers
Conditional and Expression script triggers gain an optional `mode` field in their TriggerConfiguration JSON: - OnTrue (default): unchanged edge/per-change firing. An absent mode field parses as OnTrue, so every existing trigger config behaves identically. - WhileTrue: fires on the false->true edge, then re-fires on a periodic timer while the condition holds; stops on the true->false edge. The re-fire cadence is the script's MinTimeBetweenRuns; with none configured the trigger degrades to a single edge fire and logs a warning. ScriptActor tracks condition truth state and manages a dedicated "whiletrue-trigger" timer. ScriptTriggerConfigCodec and ScriptTriggerEditor round-trip the mode and expose an OnTrue/WhileTrue selector for the two trigger kinds. Design: docs/plans/2026-05-18-whiletrue-trigger-mode-design.md Tests: 7 ScriptActor runtime tests (edge fire, timer re-fire, stop, re-arm, no-MinTimeBetweenRuns degrade, OnTrue regressions) + 14 codec / editor tests. SiteRuntime suite 206 green, CentralUI suite 295 green.
This commit is contained in:
@@ -12,6 +12,13 @@ namespace ScadaLink.CentralUI.Components.Shared;
|
||||
/// </summary>
|
||||
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Expression, Unknown }
|
||||
|
||||
/// <summary>
|
||||
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
|
||||
/// as the condition becomes true; <see cref="WhileTrue"/> re-fires on a timer
|
||||
/// (cadence = the script's MinTimeBetweenRuns) while the condition stays true.
|
||||
/// </summary>
|
||||
internal enum ScriptTriggerMode { OnTrue, WhileTrue }
|
||||
|
||||
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
|
||||
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
|
||||
|
||||
@@ -32,6 +39,9 @@ internal sealed class ScriptTriggerModel
|
||||
|
||||
/// <summary>Boolean C# expression (Expression).</summary>
|
||||
public string? Expression { get; set; }
|
||||
|
||||
/// <summary>Fire mode (Conditional + Expression). Defaults to <see cref="ScriptTriggerMode.OnTrue"/>.</summary>
|
||||
public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -41,9 +51,12 @@ internal sealed class ScriptTriggerModel
|
||||
/// Serialized config shapes:
|
||||
/// Interval { intervalMs }
|
||||
/// ValueChange { attributeName }
|
||||
/// Conditional { attributeName, operator, threshold }
|
||||
/// Conditional { attributeName, operator, threshold, mode }
|
||||
/// Call { }
|
||||
/// Expression { expression }
|
||||
/// Expression { expression, mode }
|
||||
///
|
||||
/// <c>mode</c> (Conditional + Expression) is <c>OnTrue</c> or <c>WhileTrue</c>;
|
||||
/// an absent or unrecognized value parses as <c>OnTrue</c>.
|
||||
///
|
||||
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
|
||||
/// older configs survive a round-trip through the editor.
|
||||
@@ -109,10 +122,12 @@ internal static class ScriptTriggerConfigCodec
|
||||
var op = root.TryGetProperty("operator", out var o) ? o.GetString() : null;
|
||||
model.Operator = NormalizeOperator(op);
|
||||
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
|
||||
model.Mode = ReadMode(root);
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.Expression:
|
||||
model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
|
||||
model.Mode = ReadMode(root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -152,10 +167,12 @@ internal static class ScriptTriggerConfigCodec
|
||||
w.WriteString("operator", model.Operator);
|
||||
if (model.Threshold.HasValue)
|
||||
w.WriteNumber("threshold", model.Threshold.Value);
|
||||
w.WriteString("mode", model.Mode.ToString());
|
||||
break;
|
||||
|
||||
case ScriptTriggerKind.Expression:
|
||||
w.WriteString("expression", model.Expression ?? "");
|
||||
w.WriteString("mode", model.Mode.ToString());
|
||||
break;
|
||||
|
||||
// Call → empty object.
|
||||
@@ -165,6 +182,18 @@ internal static class ScriptTriggerConfigCodec
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional <c>mode</c> property; an absent or unrecognized value
|
||||
/// (case-insensitive) yields <see cref="ScriptTriggerMode.OnTrue"/>.
|
||||
/// </summary>
|
||||
private static ScriptTriggerMode ReadMode(JsonElement root)
|
||||
{
|
||||
var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
|
||||
return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
|
||||
? ScriptTriggerMode.WhileTrue
|
||||
: ScriptTriggerMode.OnTrue;
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else ">".</summary>
|
||||
internal static string NormalizeOperator(string? raw)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
ScriptActor.ParseTriggerConfig consumes:
|
||||
Interval { intervalMs }
|
||||
ValueChange { attributeName }
|
||||
Conditional { attributeName, operator, threshold }
|
||||
Conditional { attributeName, operator, threshold, mode }
|
||||
Expression { expression, mode }
|
||||
Call { } *@
|
||||
|
||||
<div class="border rounded bg-white p-3">
|
||||
@@ -65,6 +66,12 @@
|
||||
break;
|
||||
}
|
||||
|
||||
@* ── Fire mode (Conditional + Expression) ──────────────────────────── *@
|
||||
@if (_kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
|
||||
{
|
||||
@RenderMode();
|
||||
}
|
||||
|
||||
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
||||
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange
|
||||
or ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
|
||||
@@ -273,6 +280,37 @@
|
||||
await Emit();
|
||||
}
|
||||
|
||||
// ── Fire mode (Conditional + Expression) ───────────────────────────────
|
||||
|
||||
private RenderFragment RenderMode() => __builder =>
|
||||
{
|
||||
<div class="mt-3">
|
||||
<label for="script-trigger-mode" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Fire mode
|
||||
</label>
|
||||
<select id="script-trigger-mode" class="form-select form-select-sm"
|
||||
style="max-width: 420px;"
|
||||
@bind="_model.Mode" @bind:after="OnModeChanged">
|
||||
<option value="@ScriptTriggerMode.OnTrue">
|
||||
Once — when the condition becomes true
|
||||
</option>
|
||||
<option value="@ScriptTriggerMode.WhileTrue">
|
||||
Repeatedly — while the condition stays true
|
||||
</option>
|
||||
</select>
|
||||
@if (_model.Mode == ScriptTriggerMode.WhileTrue)
|
||||
{
|
||||
<div class="form-text">
|
||||
Re-runs on a timer while the condition holds, at the script's
|
||||
<strong>Min time between runs</strong> interval — set that field below
|
||||
the script editor, or the trigger fires only once.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
private async Task OnModeChanged() => await Emit();
|
||||
|
||||
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
|
||||
|
||||
private RenderFragment RenderAttributePicker(string label) => __builder =>
|
||||
@@ -341,11 +379,15 @@
|
||||
|
||||
ScriptTriggerKind.Conditional =>
|
||||
_model.Threshold is { } t
|
||||
? $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
|
||||
? (_model.Mode == ScriptTriggerMode.WhileTrue
|
||||
? $"Re-runs while {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
|
||||
: $"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.",
|
||||
|
||||
ScriptTriggerKind.Expression =>
|
||||
"Runs once each time this expression becomes true.",
|
||||
_model.Mode == ScriptTriggerMode.WhileTrue
|
||||
? "Re-runs while this expression stays true."
|
||||
: "Runs once each time this expression becomes true.",
|
||||
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user