diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor
index 1e094026..dce0f573 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor
@@ -130,8 +130,8 @@
private string? _scriptParameters;
private string? _scriptReturn;
private bool _scriptIsLocked;
- // Round-tripped from the loaded script so UI edits preserve a timeout set
- // via Transport import (no authoring control in the UI — scoped out).
+ // Per-script execution-timeout override (seconds). Bound to the "Execution timeout"
+ // input on the trigger tab; null/0 means "use the site's global default" (#54).
private int? _scriptExecutionTimeoutSeconds;
private string? _scriptFormError;
private string _scriptModalTab = "trigger"; // "trigger" | "code" | "parameters" | "return"
@@ -1397,6 +1397,19 @@
}
}
+
+
+
+
+ seconds
+
+
+ Per-script execution timeout. Leave blank (or 0) to use the
+ site's global default.
+
+
"Error"
};
+ // Normalizes the execution-timeout input: a null or non-positive value means
+ // "use the site default", so it is stored as null (matching the Site Runtime's
+ // own ≤0-means-default handling and the entity's documented contract).
+ private static int? NormalizeExecutionTimeout(int? seconds)
+ => seconds is > 0 ? seconds : null;
+
private async Task SaveScript()
{
if (_selectedTemplate == null) return;
@@ -2150,9 +2169,7 @@
ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked,
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
- // Round-trip the loaded value — no UI control, so preserve
- // any timeout set via Transport import unchanged.
- ExecutionTimeoutSeconds = _scriptExecutionTimeoutSeconds,
+ ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds),
IsInherited = existing.IsInherited,
LockedInDerived = existing.LockedInDerived,
};
@@ -2178,7 +2195,8 @@
ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked,
- MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit)
+ MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
+ ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds)
};
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs
index 7493d77c..0063f8c1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs
@@ -29,8 +29,13 @@ public record AddTemplateNativeAlarmSourceCommand(int TemplateId, string Name, s
public record UpdateTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked);
public record DeleteTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId);
public record ListTemplateNativeAlarmSourcesCommand(int TemplateId);
-public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
-public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
+// MinTimeBetweenRuns/ExecutionTimeoutSeconds are additive trailing optionals (default null = "unset"):
+// MinTimeBetweenRuns mirrors the TemplateScript.MinTimeBetweenRuns TimeSpan throttle/re-fire interval;
+// ExecutionTimeoutSeconds mirrors the per-script TemplateScript.ExecutionTimeoutSeconds override (seconds;
+// null/non-positive falls back to the site's global default). Both were previously settable only via
+// Transport bundle import — these fields close the CLI/UI authoring gap (#54). Additive-only: never reorder.
+public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null, TimeSpan? MinTimeBetweenRuns = null, int? ExecutionTimeoutSeconds = null);
+public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null, TimeSpan? MinTimeBetweenRuns = null, int? ExecutionTimeoutSeconds = null);
public record DeleteTemplateScriptCommand(int ScriptId);
public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId);
public record DeleteTemplateCompositionCommand(int CompositionId);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index d9e9e2c3..821c16a6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -2170,7 +2170,9 @@ public class ManagementActor : ReceiveActor
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked,
ParameterDefinitions = cmd.ParameterDefinitions,
- ReturnDefinition = cmd.ReturnDefinition
+ ReturnDefinition = cmd.ReturnDefinition,
+ MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
+ ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds
};
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
@@ -2185,7 +2187,9 @@ public class ManagementActor : ReceiveActor
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked,
ParameterDefinitions = cmd.ParameterDefinitions,
- ReturnDefinition = cmd.ReturnDefinition
+ ReturnDefinition = cmd.ReturnDefinition,
+ MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
+ ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds
};
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs
new file mode 100644
index 00000000..7d4f7a0f
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs
@@ -0,0 +1,120 @@
+using System.CommandLine;
+using ZB.MOM.WW.ScadaBridge.CLI.Commands;
+
+namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
+
+///
+/// #54: template script add/update must expose --min-time-between-runs
+/// (a duration with ms/s/min units) and --execution-timeout-seconds (an int)
+/// so the per-script throttle/re-fire interval and the execution-timeout override —
+/// previously settable only via Transport bundle import — are CLI-authorable. These
+/// tests pin the option surface and the duration-parsing semantics of
+/// .
+///
+public class TemplateScriptTimingTests
+{
+ private static readonly Option Url = new("--url") { Recursive = true };
+ private static readonly Option Username = new("--username") { Recursive = true };
+ private static readonly Option Password = new("--password") { Recursive = true };
+ private static readonly Option Format = CliOptions.CreateFormatOption();
+
+ private static Command ScriptGroup()
+ => TemplateCommands.Build(Url, Format, Username, Password)
+ .Subcommands.Single(c => c.Name == "script");
+
+ // ---- option surface ----
+
+ [Fact]
+ public void ScriptAdd_HasTimingOptions()
+ {
+ var add = ScriptGroup().Subcommands.Single(c => c.Name == "add");
+ var names = add.Options.Select(o => o.Name).ToArray();
+ Assert.Contains("--min-time-between-runs", names);
+ Assert.Contains("--execution-timeout-seconds", names);
+ }
+
+ [Fact]
+ public void ScriptUpdate_HasTimingOptions()
+ {
+ var update = ScriptGroup().Subcommands.Single(c => c.Name == "update");
+ var names = update.Options.Select(o => o.Name).ToArray();
+ Assert.Contains("--min-time-between-runs", names);
+ Assert.Contains("--execution-timeout-seconds", names);
+ }
+
+ // ---- --min-time-between-runs parsing ----
+
+ [Fact]
+ public void MinTime_Absent_IsUnsetNoError()
+ {
+ Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(null, out var d, out var err));
+ Assert.Null(d);
+ Assert.Null(err);
+ }
+
+ [Fact]
+ public void MinTime_Blank_IsUnsetNoError()
+ {
+ Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(" ", out var d, out var err));
+ Assert.Null(d);
+ Assert.Null(err);
+ }
+
+ [Theory]
+ [InlineData("500ms", 500)]
+ [InlineData("250MS", 250)]
+ public void MinTime_Milliseconds_Parsed(string value, int expectedMs)
+ {
+ Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err));
+ Assert.Null(err);
+ Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d);
+ }
+
+ [Theory]
+ [InlineData("5")] // bare number → seconds
+ [InlineData("5s")]
+ [InlineData("5sec")]
+ [InlineData("5SEC")]
+ public void MinTime_Seconds_BareDefaultsToSeconds(string value)
+ {
+ Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err));
+ Assert.Null(err);
+ Assert.Equal(TimeSpan.FromSeconds(5), d);
+ }
+
+ [Theory]
+ [InlineData("2m")]
+ [InlineData("2min")]
+ [InlineData("2MIN")]
+ public void MinTime_Minutes_Parsed(string value)
+ {
+ Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err));
+ Assert.Null(err);
+ Assert.Equal(TimeSpan.FromMinutes(2), d);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("0s")]
+ [InlineData("0min")]
+ public void MinTime_Zero_IsUnset(string value)
+ {
+ // 0 means "no throttle" → null, mirroring DurationInput.Compose's non-positive handling.
+ Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err));
+ Assert.Null(err);
+ Assert.Null(d);
+ }
+
+ [Theory]
+ [InlineData("abc")]
+ [InlineData("5h")] // hours not supported
+ [InlineData("5days")]
+ [InlineData("-3")]
+ [InlineData("1.5s")] // non-integer
+ public void MinTime_Invalid_ReturnsError(string value)
+ {
+ Assert.False(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err));
+ Assert.Null(d);
+ Assert.NotNull(err);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs
index d8211da8..91b2c884 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs
@@ -505,14 +505,11 @@ public class TemplateServiceTests
[Fact]
public async Task UpdateScript_UiEditPath_PreservesExistingExecutionTimeoutSeconds()
{
- // M2.5 (#9): ExecutionTimeoutSeconds has no authoring control in the UI.
- // A UI-style update (proposed.ExecutionTimeoutSeconds == null) must NOT
- // overwrite a timeout previously set via Transport import.
- //
- // The fix is in TemplateEdit.razor: it round-trips the loaded value, so
- // proposed.ExecutionTimeoutSeconds will equal the existing value, not null.
- // This test proves that when the round-trip is working, the service
- // preserves the timeout end-to-end.
+ // #54: the UI now has an ExecutionTimeoutSeconds input, but it still loads
+ // (round-trips) the existing value into that input on edit, so leaving it
+ // untouched re-sends the same value rather than null. This test proves the
+ // service preserves the timeout end-to-end when the loaded value is re-sent
+ // — guarding the UI edit path against silently blanking a configured timeout.
var existing = new TemplateScript("OnStart", "return true;")
{
Id = 1,