diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor
index ac6a068..9e081ba 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor
@@ -98,6 +98,8 @@
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
private string? _scriptTriggerConfig;
+ private string? _scriptMinTimeValue;
+ private string _scriptMinTimeUnit = "sec";
private string? _scriptParameters;
private string? _scriptReturn;
private bool _scriptIsLocked;
@@ -880,6 +882,47 @@
Changed="@OnScriptTriggerChanged"
AvailableAttributes="@BuildAlarmAttributeChoices()" />
+ @if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_scriptTriggerType))
+ {
+
+
+
+
+
+
+
+
+
+
+ @if (ScriptTriggerIsWhileTrue())
+ {
+
+ This is the re-fire interval for the
+ WhileTrue trigger above.
+
+ @if (DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit) is null)
+ {
+
+ The WhileTrue trigger has no interval set — the script
+ will fire only once. Set a value here to make it re-fire.
+
+ }
+ }
+ else
+ {
+
+ Optional throttle — skips trigger invocations that fire
+ sooner than this.
+
+ }
+
+ }
@@ -1461,6 +1504,19 @@
_scriptTriggerConfig = v.Config;
}
+ ///
+ /// True when the current script trigger is a WhileTrue Conditional/Expression
+ /// trigger — the case where the "Min time between runs" interval is required
+ /// (it is the re-fire cadence).
+ ///
+ private bool ScriptTriggerIsWhileTrue()
+ {
+ var kind = ScriptTriggerConfigCodec.ParseKind(_scriptTriggerType);
+ return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression
+ && ScriptTriggerConfigCodec.Parse(_scriptTriggerConfig, kind).Mode
+ == ScriptTriggerMode.WhileTrue;
+ }
+
private void BeginAddScript()
{
_showScriptForm = true;
@@ -1470,6 +1526,7 @@
_scriptCode = string.Empty;
_scriptTriggerType = null;
_scriptTriggerConfig = null;
+ (_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(null);
_scriptParameters = null;
_scriptReturn = null;
_scriptIsLocked = false;
@@ -1486,6 +1543,7 @@
_scriptCode = script.Code;
_scriptTriggerType = script.TriggerType;
_scriptTriggerConfig = script.TriggerConfiguration;
+ (_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(script.MinTimeBetweenRuns);
_scriptParameters = script.ParameterDefinitions;
_scriptReturn = script.ReturnDefinition;
_scriptIsLocked = script.IsLocked;
@@ -1581,7 +1639,7 @@
ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked,
- MinTimeBetweenRuns = existing.MinTimeBetweenRuns,
+ MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
IsInherited = existing.IsInherited,
LockedInDerived = existing.LockedInDerived,
};
@@ -1606,7 +1664,8 @@
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn,
- IsLocked = _scriptIsLocked
+ IsLocked = _scriptIsLocked,
+ MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit)
};
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
diff --git a/src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs b/src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs
new file mode 100644
index 0000000..68158ea
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs
@@ -0,0 +1,50 @@
+using System.Globalization;
+
+namespace ScadaLink.CentralUI.Components.Shared;
+
+///
+/// Converts a to and from the number+unit pair behind a
+/// duration input (milliseconds / seconds / minutes). A blank or non-positive
+/// number represents "unset" (a null duration).
+///
+internal static class DurationInput
+{
+ /// The unit tokens a duration input offers, smallest first.
+ internal static readonly string[] Units = { "ms", "sec", "min" };
+
+ ///
+ /// Splits a duration into the largest whole unit that represents it exactly.
+ /// A null or non-positive duration yields a blank value and the default
+ /// sec unit.
+ ///
+ internal static (string? Value, string Unit) Split(TimeSpan? duration)
+ {
+ if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
+
+ var ms = (long)d.TotalMilliseconds;
+ if (ms % 60000 == 0) return ((ms / 60000).ToString(CultureInfo.InvariantCulture), "min");
+ if (ms % 1000 == 0) return ((ms / 1000).ToString(CultureInfo.InvariantCulture), "sec");
+ return (ms.ToString(CultureInfo.InvariantCulture), "ms");
+ }
+
+ ///
+ /// Composes a number+unit pair into a duration. A blank, unparseable, or
+ /// non-positive value yields null (unset).
+ ///
+ internal static TimeSpan? Compose(string? value, string unit)
+ {
+ if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
+ || n <= 0)
+ {
+ return null;
+ }
+
+ var factorMs = unit switch
+ {
+ "min" => 60000L,
+ "ms" => 1L,
+ _ => 1000L,
+ };
+ return TimeSpan.FromMilliseconds(n * factorMs);
+ }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs
index 738e11d..142c0d6 100644
--- a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs
+++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs
@@ -81,6 +81,17 @@ internal static class ScriptTriggerConfigCodec
};
}
+ ///
+ /// Whether a trigger type honours the script's MinTimeBetweenRuns.
+ /// True for the auto-firing triggers it throttles — ValueChange, Conditional,
+ /// Expression. False for Interval (its own period is the cadence), Call
+ /// (invoked explicitly, never throttled), and None/Unknown.
+ ///
+ internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
+ ParseKind(triggerType) is ScriptTriggerKind.ValueChange
+ or ScriptTriggerKind.Conditional
+ or ScriptTriggerKind.Expression;
+
/// Canonical TriggerType string for a kind; null for None/Unknown.
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs
new file mode 100644
index 0000000..da72769
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs
@@ -0,0 +1,98 @@
+using ScadaLink.CentralUI.Components.Shared;
+
+namespace ScadaLink.CentralUI.Tests.Shared;
+
+///
+/// Coverage for , the number+unit codec behind the
+/// script form's "Min time between runs" field.
+///
+public class DurationInputTests
+{
+ // ── Split: TimeSpan -> (value, unit) ───────────────────────────────────
+
+ [Fact]
+ public void Split_Null_ReturnsBlankWithSecondsUnit()
+ {
+ var (value, unit) = DurationInput.Split(null);
+
+ Assert.Null(value);
+ Assert.Equal("sec", unit);
+ }
+
+ [Fact]
+ public void Split_Zero_ReturnsBlank()
+ {
+ var (value, _) = DurationInput.Split(TimeSpan.Zero);
+
+ Assert.Null(value);
+ }
+
+ [Fact]
+ public void Split_WholeMinutes_UsesMinuteUnit()
+ {
+ var (value, unit) = DurationInput.Split(TimeSpan.FromMinutes(5));
+
+ Assert.Equal("5", value);
+ Assert.Equal("min", unit);
+ }
+
+ [Fact]
+ public void Split_WholeSeconds_UsesSecondUnit()
+ {
+ var (value, unit) = DurationInput.Split(TimeSpan.FromSeconds(30));
+
+ Assert.Equal("30", value);
+ Assert.Equal("sec", unit);
+ }
+
+ [Fact]
+ public void Split_SubSecond_UsesMillisecondUnit()
+ {
+ var (value, unit) = DurationInput.Split(TimeSpan.FromMilliseconds(250));
+
+ Assert.Equal("250", value);
+ Assert.Equal("ms", unit);
+ }
+
+ // ── Compose: (value, unit) -> TimeSpan? ────────────────────────────────
+
+ [Fact]
+ public void Compose_Blank_ReturnsNull() =>
+ Assert.Null(DurationInput.Compose(null, "sec"));
+
+ [Fact]
+ public void Compose_Zero_ReturnsNull() =>
+ Assert.Null(DurationInput.Compose("0", "sec"));
+
+ [Fact]
+ public void Compose_Negative_ReturnsNull() =>
+ Assert.Null(DurationInput.Compose("-5", "sec"));
+
+ [Fact]
+ public void Compose_SecondsValue_BuildsDuration() =>
+ Assert.Equal(TimeSpan.FromSeconds(30), DurationInput.Compose("30", "sec"));
+
+ [Fact]
+ public void Compose_MinutesValue_BuildsDuration() =>
+ Assert.Equal(TimeSpan.FromMinutes(5), DurationInput.Compose("5", "min"));
+
+ [Fact]
+ public void Compose_MillisecondsValue_BuildsDuration() =>
+ Assert.Equal(TimeSpan.FromMilliseconds(250), DurationInput.Compose("250", "ms"));
+
+ // ── Round-trip ─────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(250)]
+ [InlineData(30000)]
+ [InlineData(300000)]
+ public void RoundTrip_PreservesDuration(long milliseconds)
+ {
+ var original = TimeSpan.FromMilliseconds(milliseconds);
+
+ var (value, unit) = DurationInput.Split(original);
+ var reparsed = DurationInput.Compose(value, unit);
+
+ Assert.Equal(original, reparsed);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs
index 5104ab5..135a735 100644
--- a/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs
@@ -117,6 +117,27 @@ public class ScriptTriggerConfigCodecTests
Assert.Equal(mode, reparsed.Mode);
}
+ // ── SupportsMinTimeBetweenRuns ─────────────────────────────────────────
+
+ [Theory]
+ [InlineData("ValueChange")]
+ [InlineData("Conditional")]
+ [InlineData("Expression")]
+ public void SupportsMinTimeBetweenRuns_TrueForAutoTriggersThatThrottle(string triggerType)
+ {
+ Assert.True(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
+ }
+
+ [Theory]
+ [InlineData("Interval")] // has its own period control
+ [InlineData("Call")] // invoked explicitly — no throttle applies
+ [InlineData(null)] // None — never runs automatically
+ [InlineData("Bogus")] // Unknown trigger type
+ public void SupportsMinTimeBetweenRuns_FalseForIntervalCallNoneAndUnknown(string? triggerType)
+ {
+ Assert.False(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
+ }
+
[Theory]
[InlineData(false)]
[InlineData(true)]