From 56d23f913d3ed1a1e5a5ae4821ec0d75a8f38753 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Sat, 11 Apr 2026 00:12:21 +0300 Subject: [PATCH] feat(scenarios): require environment for scenario runs - require environmentId when creating runs in API and MCP tools - pass selected environment data into step execution helpers - prompt for environment selection before running scenarios in UI --- client/src/api/client.ts | 7 +- client/src/api/types.ts | 1 + client/src/i18n/locales/en.json | 6 +- client/src/pages/run/RunsPage.tsx | 65 +++++++++++++++-- .../src/pages/scenario/ScenarioDetailPage.tsx | 54 ++++++++++++-- client/src/pages/scenario/ScenariosPage.tsx | 71 ++++++++++++++++--- server/src/mcp/mcp.service.ts | 8 ++- .../scenario/dto/create-scenario-run.dto.ts | 7 ++ server/src/scenario/scenario-run.entity.ts | 3 + .../scenario/scenario-scheduler.service.ts | 17 ++++- server/src/scenario/scenario.controller.ts | 8 ++- server/src/scenario/scenario.module.ts | 2 + server/src/scenario/scenario.service.ts | 16 ++++- 13 files changed, 237 insertions(+), 28 deletions(-) create mode 100644 server/src/scenario/dto/create-scenario-run.dto.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index e671eef..05d4edb 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -192,8 +192,11 @@ export const scenarios = { remove(id: string): Promise { return request(`/scenarios/${id}`, { method: 'DELETE' }); }, - run(id: string): Promise { - return request(`/scenarios/${id}/run`, { method: 'POST' }); + run(id: string, environmentId: string): Promise { + return request(`/scenarios/${id}/run`, { + method: 'POST', + body: JSON.stringify({ environmentId }), + }); }, getRun(scenarioId: string, runId: string): Promise { return request(`/scenarios/${scenarioId}/run/${runId}`); diff --git a/client/src/api/types.ts b/client/src/api/types.ts index e30b0e4..97c6cf7 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -98,6 +98,7 @@ export type LogLevel = 'log' | 'warn' | 'error'; export interface ScenarioRun { id: string; scenarioId: string; + environmentId: string; scenario?: Pick; status: ScenarioRunStatus; stepRuns?: ScenarioRunStep[]; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 2ee8d9a..ec815a8 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -145,7 +145,11 @@ "cred_form_cred_required": "Select a credential", "cred_form_alias": "Alias", "cred_form_alias_placeholder": "e.g. api_key", - "cred_form_alias_required": "Alias is required" + "cred_form_alias_required": "Alias is required", + "run_modal_title": "Select environment", + "run_modal_env_label": "Environment", + "run_modal_no_env": "No environments found. Create an environment before running a scenario.", + "run_modal_env_required": "Please select an environment" }, "theme": { "switch_to_light": "Switch to light theme", diff --git a/client/src/pages/run/RunsPage.tsx b/client/src/pages/run/RunsPage.tsx index 214efcb..029f6c1 100644 --- a/client/src/pages/run/RunsPage.tsx +++ b/client/src/pages/run/RunsPage.tsx @@ -2,13 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Play, ClipboardList, Activity } from 'lucide-react'; -import { scenarios, runs } from '../../api'; -import type { Scenario, ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api'; +import { environments, scenarios, runs } from '../../api'; +import type { + Environment, + Scenario, + ScenarioRun, + ScenarioRunStep, + ScenarioRunStatus, +} from '../../api'; import { AutoRefreshIndicator, Badge, Breadcrumbs, Button, + Modal, + Select, Table, Timestamp, UuidBadge, @@ -45,6 +53,10 @@ export function RunsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [pulseKey, setPulseKey] = useState(0); + const [runModalOpen, setRunModalOpen] = useState(false); + const [running, setRunning] = useState(false); + const [envs, setEnvs] = useState([]); + const [selectedEnvId, setSelectedEnvId] = useState(''); const pollRef = useRef | null>(null); const hasDataRef = useRef(false); @@ -72,13 +84,31 @@ export function RunsPage() { }; }, [load]); + useEffect(() => { + environments + .list(1, 200) + .then((res) => { + setEnvs(res.data); + setSelectedEnvId((prev) => prev || res.data[0]?.id || ''); + }) + .catch(() => {}); + }, []); + const handleRun = async () => { + if (!selectedEnvId) { + toast.error(t('scenarios.run_modal_env_required')); + return; + } + setRunning(true); try { - const run = await scenarios.run(id!); + const run = await scenarios.run(id!, selectedEnvId); toast.success('Scenario run started'); navigate(`/scenarios/${id}/runs/${run.id}`); + setRunModalOpen(false); } catch (err) { toast.error((err as Error).message); + } finally { + setRunning(false); } }; @@ -119,7 +149,7 @@ export function RunsPage() { />
- @@ -136,6 +166,33 @@ export function RunsPage() { pageSizeOptions={[10, 20, 50]} onRowClick={(r) => navigate(`/scenarios/${id}/runs/${r.id}`)} /> + + !running && setRunModalOpen(false)} + footer={( + <> + + + + )} + > + {envs.length === 0 ? ( +

{t('scenarios.run_modal_no_env')}

+ ) : ( + setSelectedEnvId(e.target.value)} + options={envs.map((env) => ({ value: env.id, label: env.name }))} + /> + )} +
); } diff --git a/client/src/pages/scenario/ScenariosPage.tsx b/client/src/pages/scenario/ScenariosPage.tsx index 3658beb..46b8533 100644 --- a/client/src/pages/scenario/ScenariosPage.tsx +++ b/client/src/pages/scenario/ScenariosPage.tsx @@ -3,12 +3,13 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Play, Plus, Trash2, Download, ClipboardList } from 'lucide-react'; import { parse as yamlParse } from 'yaml'; -import { scenarios } from '../../api'; -import type { Scenario } from '../../api'; +import { environments, scenarios } from '../../api'; +import type { Environment, Scenario } from '../../api'; import { Breadcrumbs, Button, Modal, + Select, Table, type TableColumn, Timestamp, @@ -26,13 +27,19 @@ export function ScenariosPage() { const [error, setError] = useState(null); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); + const [runScenarioId, setRunScenarioId] = useState(null); + const [running, setRunning] = useState(false); + const [envs, setEnvs] = useState([]); + const [selectedEnvId, setSelectedEnvId] = useState(''); const fileInputRef = useRef(null); const load = useCallback(() => { - scenarios - .list() - .then((res) => setItems(res.data)) + Promise.all([scenarios.list(), environments.list(1, 200)]) + .then(([scenarioRes, envRes]) => { + setItems(scenarioRes.data); + setEnvs(envRes.data); + }) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, []); @@ -41,13 +48,25 @@ export function ScenariosPage() { load(); }, [load]); - const handleRun = async (id: string) => { + const openRunModal = (id: string) => { + setRunScenarioId(id); + if (!selectedEnvId && envs.length > 0) { + setSelectedEnvId(envs[0].id); + } + }; + + const handleRun = async () => { + if (!runScenarioId || !selectedEnvId) return; + setRunning(true); try { - const run = await scenarios.run(id); + const run = await scenarios.run(runScenarioId, selectedEnvId); toast.success('Scenario run started'); - navigate(`/scenarios/${id}/runs/${run.id}`); + navigate(`/scenarios/${runScenarioId}/runs/${run.id}`); + setRunScenarioId(null); } catch (err) { toast.error((err as Error).message); + } finally { + setRunning(false); } }; @@ -94,7 +113,14 @@ export function ScenariosPage() { align: 'right', render: (s) => ( - + + + )} + > + {envs.length === 0 ? ( +

{t('scenarios.run_modal_no_env')}

+ ) : ( +