feat(scenarios): add timeoutSeconds to scenarios and steps

- add timeoutSeconds field to scenario and step entities
- add timeoutSeconds to create/update DTOs for scenario and step
- enforce timeout via scheduler: abort run if step exceeds limit
- expose timeoutSeconds in MCP create/update scenario and step tools
- add client-side type, API, i18n, and form support for timeoutSeconds
- add integration tests for timeout persistence via REST and MCP
This commit is contained in:
2026-04-17 13:46:30 +03:00
parent 8dbd319bba
commit a50dce2755
19 changed files with 402 additions and 12 deletions
+4 -2
View File
@@ -193,7 +193,7 @@ export const scenarios = {
body: JSON.stringify({ name, description, environmentId }),
});
},
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId'>>): Promise<Scenario> {
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>): Promise<Scenario> {
return request(`/scenarios/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
@@ -254,7 +254,7 @@ export const runs = {
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<
PaginatedResponse<
ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }
ScenarioRun & { scenario: { id: string; name: string } }
>
> {
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
@@ -299,12 +299,14 @@ export const scenarioCredentials = {
export interface CreateStepPayload {
title?: string;
execCode?: string;
timeoutSeconds?: number | null;
}
export interface UpdateStepPayload {
title?: string;
order?: number;
execCode?: string;
timeoutSeconds?: number | null;
}
export const steps = {
+2
View File
@@ -67,6 +67,7 @@ export interface ScenarioStep {
order: number;
title: string | null;
execCode: string | null;
timeoutSeconds: number | null;
createdAt: string;
updatedAt: string;
}
@@ -85,6 +86,7 @@ export interface Scenario {
name: string;
description?: string;
environmentId?: string | null;
timeoutSeconds?: number | null;
environment?: Pick<Environment, 'id' | 'name'>;
steps?: ScenarioStep[];
scenarioCredentials?: ScenarioCredential[];
+7 -1
View File
@@ -185,6 +185,9 @@
"export_modal_title": "Export Scenario",
"export_include_environment": "Include Environment",
"export_include_credentials": "Include Credentials",
"form_timeout": "Scenario Timeout (seconds)",
"form_timeout_placeholder": "Default: 600",
"form_timeout_hint": "Max seconds for the entire scenario run (default: 600)",
"field_name": "Name",
"field_id": "ID"
},
@@ -222,7 +225,10 @@
"form_session_placeholder": "e.g. my-session",
"form_session_required": "Session name is required",
"form_exec_code": "Exec code",
"form_exec_code_placeholder": "return await page.title();"
"form_exec_code_placeholder": "return await page.title();",
"form_timeout": "Step Timeout (seconds)",
"form_timeout_placeholder": "Default: 60",
"form_timeout_hint": "Max seconds for this step (default: 60)"
},
"runs": {
"title": "Runs",
+1 -1
View File
@@ -33,7 +33,7 @@ const STATUS_LABEL: Record<ScenarioRunStatus, string> = {
};
type AllRunRow = ScenarioRun & {
scenario: { id: number; name: string };
scenario: { id: string; name: string };
};
export function AllRunsPage() {
@@ -16,6 +16,7 @@ export function CreateStepPage() {
const [scenario, setScenario] = useState<Scenario | null>(null);
const [title, setTitle] = useState('');
const [execCode, setExecCode] = useState('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -33,6 +34,7 @@ export function CreateStepPage() {
await steps.create(id!, {
title: title.trim() || undefined,
execCode: execCode.trim() || undefined,
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : undefined,
});
toast.success(t('steps.created'));
navigate(`/scenarios/${id}`);
@@ -76,6 +78,15 @@ export function CreateStepPage() {
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
</div>
<Input
label={t('steps.form_timeout')}
placeholder={t('steps.form_timeout_placeholder')}
type="number"
min={1}
value={timeoutSeconds}
onChange={(e) => setTimeoutSeconds(e.target.value)}
hint={t('steps.form_timeout_hint')}
/>
</div>
<div className={styles.formActions}>
@@ -17,6 +17,7 @@ export function EditScenarioPage() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [environmentId, setEnvironmentId] = useState<string>('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
const [envs, setEnvs] = useState<Environment[]>([]);
const [nameError, setNameError] = useState('');
const [loading, setLoading] = useState(true);
@@ -31,6 +32,7 @@ export function EditScenarioPage() {
setName(data.name);
setDescription(data.description || '');
setEnvironmentId(data.environmentId ?? '');
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
})
.finally(() => setLoading(false));
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
@@ -48,6 +50,7 @@ export function EditScenarioPage() {
name: name.trim(),
description: description.trim() || undefined,
environmentId: environmentId || null,
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null,
});
toast.success(t('scenarios.updated'));
navigate(`/scenarios/${id}`);
@@ -114,6 +117,15 @@ export function EditScenarioPage() {
...envs.map((env) => ({ value: env.id, label: env.name })),
]}
/>
<Input
label={t('scenarios.form_timeout')}
placeholder={t('scenarios.form_timeout_placeholder')}
type="number"
min={1}
value={timeoutSeconds}
onChange={(e) => setTimeoutSeconds(e.target.value)}
hint={t('scenarios.form_timeout_hint')}
/>
</div>
<div className={styles.formActions}>
+13 -1
View File
@@ -17,6 +17,7 @@ export function EditStepPage() {
const [step, setStep] = useState<ScenarioStep | null>(null);
const [title, setTitle] = useState('');
const [execCode, setExecCode] = useState('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -28,6 +29,7 @@ export function EditStepPage() {
setStep(st);
setTitle(st.title ?? '');
setExecCode(st.execCode ?? '');
setTimeoutSeconds(st.timeoutSeconds != null ? String(st.timeoutSeconds) : '');
})
.finally(() => setLoading(false));
}, [id, stepId]);
@@ -39,6 +41,7 @@ export function EditStepPage() {
await steps.update(id!, stepId!, {
title: title.trim() || undefined,
execCode: execCode.trim() || undefined,
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null,
});
toast.success(t('steps.updated'));
navigate(`/scenarios/${id}`);
@@ -64,7 +67,7 @@ export function EditStepPage() {
label: scenario?.name ?? `#${id}`,
onClick: () => navigate(`/scenarios/${id}`),
},
{ label: t('steps.edit_title', { order: step ? step.order + 1 : stepId }) },
{ label: t('steps.edit_title', { order: step ? step.order : stepId }) },
]}
/>
</div>
@@ -85,6 +88,15 @@ export function EditStepPage() {
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
</div>
<Input
label={t('steps.form_timeout')}
placeholder={t('steps.form_timeout_placeholder')}
type="number"
min={1}
value={timeoutSeconds}
onChange={(e) => setTimeoutSeconds(e.target.value)}
hint={t('steps.form_timeout_hint')}
/>
</div>
<div className={styles.formActions}>
@@ -288,7 +288,7 @@ export function ScenarioDetailPage() {
</span>
),
},
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order + 1, width: 60 },
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
{
key: 'title',
header: t('scenarios.step_title'),