feat(cli): add --execution-timeout-seconds + --min-time-between-runs to template script (#54)
Expose TemplateScript.ExecutionTimeoutSeconds and MinTimeBetweenRuns (previously settable only via Transport bundle import) on the CLI and Central UI authoring surfaces. - Commons: add additive trailing optionals MinTimeBetweenRuns (TimeSpan?) and ExecutionTimeoutSeconds (int?) to Add/UpdateTemplateScriptCommand. - ManagementActor: thread both new fields into the built TemplateScript on add/update. - CLI template script add/update: new --min-time-between-runs (duration: ms/s/min, bare number = seconds, 0 = unset, mirroring DurationInput) and --execution-timeout-seconds (int) flags, with client-side duration validation. - Central UI TemplateEdit: add an Execution timeout input (seconds) on the script trigger tab, mirroring the existing Min-time-between-runs control; null/0 = site default. - Tests: TemplateScriptTimingTests pins the option surface + duration parsing; updated the stale 'no UI control' comment on the TemplateService round-trip test.
This commit is contained in:
@@ -130,8 +130,8 @@
|
|||||||
private string? _scriptParameters;
|
private string? _scriptParameters;
|
||||||
private string? _scriptReturn;
|
private string? _scriptReturn;
|
||||||
private bool _scriptIsLocked;
|
private bool _scriptIsLocked;
|
||||||
// Round-tripped from the loaded script so UI edits preserve a timeout set
|
// Per-script execution-timeout override (seconds). Bound to the "Execution timeout"
|
||||||
// via Transport import (no authoring control in the UI — scoped out).
|
// input on the trigger tab; null/0 means "use the site's global default" (#54).
|
||||||
private int? _scriptExecutionTimeoutSeconds;
|
private int? _scriptExecutionTimeoutSeconds;
|
||||||
private string? _scriptFormError;
|
private string? _scriptFormError;
|
||||||
private string _scriptModalTab = "trigger"; // "trigger" | "code" | "parameters" | "return"
|
private string _scriptModalTab = "trigger"; // "trigger" | "code" | "parameters" | "return"
|
||||||
@@ -1397,6 +1397,19 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Execution timeout</label>
|
||||||
|
<div class="input-group" style="max-width: 280px;">
|
||||||
|
<input type="number" min="1" step="1" class="form-control"
|
||||||
|
placeholder="(site default)"
|
||||||
|
@bind="_scriptExecutionTimeoutSeconds" @bind:event="oninput" />
|
||||||
|
<span class="input-group-text">seconds</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
Per-script execution timeout. Leave blank (or 0) to use the
|
||||||
|
site's global default.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: @(_scriptModalTab == "code" ? "block" : "none")">
|
<div style="display: @(_scriptModalTab == "code" ? "block" : "none")">
|
||||||
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
|
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
|
||||||
@@ -2129,6 +2142,12 @@
|
|||||||
_ => "Error"
|
_ => "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()
|
private async Task SaveScript()
|
||||||
{
|
{
|
||||||
if (_selectedTemplate == null) return;
|
if (_selectedTemplate == null) return;
|
||||||
@@ -2150,9 +2169,7 @@
|
|||||||
ReturnDefinition = _scriptReturn,
|
ReturnDefinition = _scriptReturn,
|
||||||
IsLocked = _scriptIsLocked,
|
IsLocked = _scriptIsLocked,
|
||||||
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
|
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
|
||||||
// Round-trip the loaded value — no UI control, so preserve
|
ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds),
|
||||||
// any timeout set via Transport import unchanged.
|
|
||||||
ExecutionTimeoutSeconds = _scriptExecutionTimeoutSeconds,
|
|
||||||
IsInherited = existing.IsInherited,
|
IsInherited = existing.IsInherited,
|
||||||
LockedInDerived = existing.LockedInDerived,
|
LockedInDerived = existing.LockedInDerived,
|
||||||
};
|
};
|
||||||
@@ -2178,7 +2195,8 @@
|
|||||||
ParameterDefinitions = _scriptParameters,
|
ParameterDefinitions = _scriptParameters,
|
||||||
ReturnDefinition = _scriptReturn,
|
ReturnDefinition = _scriptReturn,
|
||||||
IsLocked = _scriptIsLocked,
|
IsLocked = _scriptIsLocked,
|
||||||
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit)
|
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
|
||||||
|
ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds)
|
||||||
};
|
};
|
||||||
|
|
||||||
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
|
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
|
||||||
|
|||||||
@@ -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 UpdateTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked);
|
||||||
public record DeleteTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId);
|
public record DeleteTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId);
|
||||||
public record ListTemplateNativeAlarmSourcesCommand(int TemplateId);
|
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);
|
// MinTimeBetweenRuns/ExecutionTimeoutSeconds are additive trailing optionals (default null = "unset"):
|
||||||
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
// 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 DeleteTemplateScriptCommand(int ScriptId);
|
||||||
public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId);
|
public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId);
|
||||||
public record DeleteTemplateCompositionCommand(int CompositionId);
|
public record DeleteTemplateCompositionCommand(int CompositionId);
|
||||||
|
|||||||
@@ -2170,7 +2170,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
TriggerConfiguration = cmd.TriggerConfiguration,
|
TriggerConfiguration = cmd.TriggerConfiguration,
|
||||||
IsLocked = cmd.IsLocked,
|
IsLocked = cmd.IsLocked,
|
||||||
ParameterDefinitions = cmd.ParameterDefinitions,
|
ParameterDefinitions = cmd.ParameterDefinitions,
|
||||||
ReturnDefinition = cmd.ReturnDefinition
|
ReturnDefinition = cmd.ReturnDefinition,
|
||||||
|
MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
|
||||||
|
ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds
|
||||||
};
|
};
|
||||||
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
|
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
|
||||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||||
@@ -2185,7 +2187,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
TriggerConfiguration = cmd.TriggerConfiguration,
|
TriggerConfiguration = cmd.TriggerConfiguration,
|
||||||
IsLocked = cmd.IsLocked,
|
IsLocked = cmd.IsLocked,
|
||||||
ParameterDefinitions = cmd.ParameterDefinitions,
|
ParameterDefinitions = cmd.ParameterDefinitions,
|
||||||
ReturnDefinition = cmd.ReturnDefinition
|
ReturnDefinition = cmd.ReturnDefinition,
|
||||||
|
MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
|
||||||
|
ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds
|
||||||
};
|
};
|
||||||
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
|
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
|
||||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #54: <c>template script add/update</c> must expose <c>--min-time-between-runs</c>
|
||||||
|
/// (a duration with ms/s/min units) and <c>--execution-timeout-seconds</c> (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
|
||||||
|
/// <see cref="TemplateCommands.TryParseMinTimeBetweenRuns"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class TemplateScriptTimingTests
|
||||||
|
{
|
||||||
|
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||||
|
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||||
|
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||||
|
private static readonly Option<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -505,14 +505,11 @@ public class TemplateServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateScript_UiEditPath_PreservesExistingExecutionTimeoutSeconds()
|
public async Task UpdateScript_UiEditPath_PreservesExistingExecutionTimeoutSeconds()
|
||||||
{
|
{
|
||||||
// M2.5 (#9): ExecutionTimeoutSeconds has no authoring control in the UI.
|
// #54: the UI now has an ExecutionTimeoutSeconds input, but it still loads
|
||||||
// A UI-style update (proposed.ExecutionTimeoutSeconds == null) must NOT
|
// (round-trips) the existing value into that input on edit, so leaving it
|
||||||
// overwrite a timeout previously set via Transport import.
|
// 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
|
||||||
// The fix is in TemplateEdit.razor: it round-trips the loaded value, so
|
// — guarding the UI edit path against silently blanking a configured timeout.
|
||||||
// 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.
|
|
||||||
var existing = new TemplateScript("OnStart", "return true;")
|
var existing = new TemplateScript("OnStart", "return true;")
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user