diff --git a/site/app.py b/site/app.py index c92df55..6fbbeb2 100644 --- a/site/app.py +++ b/site/app.py @@ -19,7 +19,7 @@ from routes.api_scenario_run import bp_run as api_scenario_run_bp from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. -VERSION = "1.2.4" +VERSION = "1.2.5" app = Flask(__name__, template_folder="templates", static_folder="static") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc") diff --git a/site/static/js/scenario-form.js b/site/static/js/scenario-form.js index a30a395..6772a36 100644 --- a/site/static/js/scenario-form.js +++ b/site/static/js/scenario-form.js @@ -1,9 +1,10 @@ // scenario-form.js — модальный редактор сценариев // Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams) -// Зависит от: utils.js (_esc), instances.js (currentSvcId) +// Зависит от: utils.js (_esc) -let scenarioEditorState = null; // {defId, version, name, steps, services:[], opsCache:{}} -// step: {service_id, operation, output, instance_ref, instance_uid, params: [["k","v"],...]} +let scenarioEditorState = null; +// {defId, version, name, steps:[], services:[], cloudInstances:[], opsCache:{}, paramDefsCache:{}} +// step: {service_id, operation, output, instance_ref, instance_uid, params:{name:value}} async function showScenarioEditor(def) { scenarioEditorState = def ? { @@ -12,12 +13,12 @@ async function showScenarioEditor(def) { service_id: s.service_id || '', operation: s.operation || '', output: s.output || '', instance_ref: s.instance_ref || '', instance_uid: s.instance_uid || '', - params: Object.entries(s.params || {}) + params: s.params || {} // {name: value} — символические имена })), - services: [], opsCache: {} - } : { defId: null, version: 1, name: '', steps: [], services: [], opsCache: {} }; + services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} + } : { defId: null, version: 1, name: '', steps: [], + services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} }; - // Загрузить список сервисов и облачные инстансы try { const [svcR, instR] = await Promise.all([ fetch('/api/services').then(r => r.json()), @@ -47,36 +48,142 @@ function closeModal() { loadScenarios(); } +// ── Снапшот параметров перед ре-рендером ── + +function snapshotStepParams(idx) { + const st = scenarioEditorState; + const container = document.getElementById('step-' + idx + '-params'); + if (!container || !container.innerHTML) return; + const numericParams = collectParams('#step-' + idx + '-params'); + const defs = st.paramDefsCache[st.steps[idx]._svcOpId]; + if (!defs) return; + // Обратный маппинг: numericId → name + const idToName = {}; + defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; }); + const result = {}; + for (const [id, val] of Object.entries(numericParams)) { + const name = idToName[parseInt(id)] || id; + if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') { + result[name] = val; + } + } + st.steps[idx].params = result; +} + +function snapshotAllParams() { + if (!scenarioEditorState) return; + scenarioEditorState.steps.forEach((_, idx) => snapshotStepParams(idx)); +} + +// ── Резолв операции + загрузка параметров ── + +async function resolveStepSvcOpId(idx) { + const st = scenarioEditorState; + const step = st.steps[idx]; + const op = step.operation; + if (!op) return null; + let svcId = step.service_id; + if (op !== 'create') { + // Определить svcId через выбранный инстанс + const refEl = document.getElementById('step-' + idx + '-ref'); + const ref = (refEl?.value || '').trim(); + if (ref) { + const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref); + if (cloud) { svcId = cloud.serviceId; } + else { + // output-ref: ищем в предыдущих create-шагах + for (let i = 0; i < idx; i++) { + if (st.steps[i].output === ref) { svcId = st.steps[i].service_id; break; } + } + } + } + } + if (!svcId) return null; + + // Кеш операций + if (!st.opsCache[svcId]) { + try { + const r = await fetch('/api/operations/' + svcId); + const d = await r.json(); + st.opsCache[svcId] = d.operations || []; + } catch (e) { return null; } + } + const ops = st.opsCache[svcId]; + const found = ops.find(o => o.operation === op); + return found ? found.svcOperationId : null; +} + +async function loadStepParams(idx) { + const st = scenarioEditorState; + const step = st.steps[idx]; + if (!step.operation) return; + + const svcOpId = await resolveStepSvcOpId(idx); + if (!svcOpId) return; + step._svcOpId = svcOpId; + + // Кеш параметров + if (!st.paramDefsCache[svcOpId]) { + try { + const r = await fetch('/api/params/' + svcOpId); + const d = await r.json(); + st.paramDefsCache[svcOpId] = d.params || d || []; + } catch (e) { return; } + } + const defs = st.paramDefsCache[svcOpId]; + const savedParams = step.params || {}; + + // Подготовить allInst для refSvcId-параметров + let allInst = st.cloudInstances || []; + const needRefs = defs.some(p => p.refSvcId); + if (needRefs && !allInst.length) { + try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {} + } + + // Рендер: клон def с подстановкой сохранённых значений в defaultValue + const container = document.getElementById('step-' + idx + '-params'); + if (!container) return; + let html = ''; + defs.forEach(p => { + const clone = Object.assign({}, p); + const savedVal = savedParams[p.name]; + if (savedVal !== undefined) clone.defaultValue = savedVal; + html += renderParamRow(clone, allInst); + }); + container.innerHTML = html; +} + +// ── Рендер ── + function renderEditor() { + snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой const st = scenarioEditorState; let html = '
'; html += '

' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '

'; - // Название html += '

'; html += ``; html += '
'; - // Справка html += `
📖 Как заполнять
Шаг: выбери операцию → укажи цель.
- Create: выбери сервис + задай output (имя, например d1). Другие шаги смогут ссылаться на него.
- Остальные операции: выбери инстанс — 🆕 из create-шагов этого сценария или ☁ из облака.
- Параметры: key = value (символические коды, как в API).
-   Пример: durationMs = 5000
- [↑][↓] — порядок шагов, [✕] — удалить шаг. + Create: выбери сервис + задай output (имя, например d1).
+ Остальные: выбери инстанс — 🆕 из create-шагов или ☁ из облака.
+ Параметры: разверни «Параметры» — значения загружаются из API автоматически.
+ [↑][↓] — порядок, [✕] — удалить.
`; - // Шаги html += '
Шаги
'; st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); }); html += ``; - // Кнопки html += '
'; html += ``; html += ``; html += '
'; openModal(html); + + // Асинхронно загрузить параметры для шагов с выбранной операцией + st.steps.forEach((s, idx) => { if (s.operation) loadStepParams(idx); }); } function renderStepRow(idx, step) { @@ -85,7 +192,6 @@ function renderStepRow(idx, step) { const out = step.output || ''; const ref = step.instance_ref || ''; - // Output refs из предыдущих create-шагов const prevOutputs = []; for (let i = 0; i < idx; i++) { const s = st.steps[i]; @@ -100,15 +206,12 @@ function renderStepRow(idx, step) { html += ``; html += ``; - // 1. Операция ВСЕГДА первая const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy']; let opOpts = ''; allOps.forEach(o => { opOpts += ``; }); html += `
`; - // 2. В зависимости от операции if (op === 'create') { - // Сервис let svcOpts = ''; st.services.forEach(s => { svcOpts += ``; @@ -116,11 +219,8 @@ function renderStepRow(idx, step) { html += `
`; html += `
`; } else if (op) { - // Инстанс — облачные + предыдущие output'ы let refOpts = ''; - // output'ы из create-шагов prevOutputs.forEach(o => { refOpts += ``; }); - // Облачные инстансы if (st.cloudInstances && st.cloudInstances.length) { refOpts += ''; st.cloudInstances.forEach(i => { @@ -132,54 +232,49 @@ function renderStepRow(idx, step) { html += `
`; } - // Параметры - html += `
`; - const params = step.params || []; - params.forEach(([k, v], pi) => { - html += `
`; - html += ``; - html += `=`; - html += ``; - html += ``; - html += `
`; - }); - html += ``; - html += '
'; + // Параметры — свёрнутый
, заполняется асинхронно в loadStepParams + html += `
Параметры
`; + + html += ''; return html; } -function onOpChange(idx) { renderEditor(); } +// ── Действия ── + +function onOpChange(idx) { + // Сброс params при смене операции (несовместимы) + scenarioEditorState.steps[idx].params = {}; + scenarioEditorState.steps[idx]._svcOpId = null; + renderEditor(); +} function addStep() { - scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: [] }); + snapshotAllParams(); + scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: {} }); renderEditor(); } function removeStep(idx) { + snapshotAllParams(); scenarioEditorState.steps.splice(idx, 1); renderEditor(); } function moveStep(idx, dir) { + snapshotAllParams(); const steps = scenarioEditorState.steps; const target = idx + dir; if (target < 0 || target >= steps.length) return; [steps[idx], steps[target]] = [steps[target], steps[idx]]; -} - -function addParam(idx) { - scenarioEditorState.steps[idx].params.push(['', '']); -} - -function removeParam(idx, pi) { - scenarioEditorState.steps[idx].params.splice(pi, 1); + renderEditor(); } function collectFormSteps() { + snapshotAllParams(); const st = scenarioEditorState; const name = (document.getElementById('scenario-name')?.value || '').trim(); const steps = []; - st.steps.forEach((_, idx) => { + st.steps.forEach((step, idx) => { const opEl = document.getElementById('step-' + idx + '-op'); const operation = (opEl?.value || '').trim(); let svcId = 0; @@ -195,25 +290,30 @@ function collectFormSteps() { const refEl = document.getElementById('step-' + idx + '-ref'); const ref = (refEl?.value || '').trim(); if (ref) { - // Это UUID облачного инстанса — ищем его service_id const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref); if (cloud) { svcId = cloud.serviceId; s.instance_uid = ref; } else { - // Это output-имя из предыдущего шага s.instance_ref = ref; } } } s.service_id = svcId; - // Параметры - st.steps[idx].params.forEach((__, pi) => { - const k = document.getElementById('step-' + idx + '-pk-' + pi)?.value?.trim(); - const v = document.getElementById('step-' + idx + '-pv-' + pi)?.value || ''; - if (k) s.params[k] = v; - }); + // Собрать параметры через collectParams + обратный маппинг numeric→name + const defs = st.paramDefsCache[step._svcOpId]; + if (defs) { + const numericParams = collectParams('#step-' + idx + '-params'); + const idToName = {}; + defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; }); + for (const [id, val] of Object.entries(numericParams)) { + const pname = idToName[parseInt(id)] || id; + if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') { + s.params[pname] = val; + } + } + } steps.push(s); }); return { name, steps }; @@ -224,7 +324,7 @@ async function saveScenario() { if (!name) { alert('Введите название'); return; } if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; } for (let i = 0; i < steps.length; i++) { - if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис'); return; } + if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис или инстанс'); return; } if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; } } const st = scenarioEditorState;