// scenario-form.js — модальный редактор сценариев // // Это САМЫЙ СЛОЖНЫЙ модуль фронтенда (~358 строк). // Редактор позволяет создавать/редактировать сценарии через модальное окно. // // СОСТОЯНИЕ РЕДАКТОРА: scenarioEditorState = { // defId, version, name, steps: [{service_id, operation, output, instance_ref, instance_uid, params, _svcOpId}], // services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} // } // // ЖИЗНЕННЫЙ ЦИКЛ РЕДАКТИРОВАНИЯ: // 1. showScenarioEditor(def) — инициализация, загрузка сервисов/инстансов, renderEditor() // 2. renderEditor() — отрисовка модала с шагами // 3. Пользователь меняет операцию → onOpChange → renderEditor (перерисовка) // 4. Для каждого шага с операцией — loadStepParams (GET /api/params/{opId}) // 5. Параметры рендерятся через params-render.js // 6. Перед перерисовкой — snapshotAllParams (сохранить несохранённые правки) // 7. Сохранение — collectFormSteps → POST/PUT /api/scenario/definitions // // ОБРАТНЫЙ МАППИНГ ПАРАМЕТРОВ: // В форме параметры хранятся с числовыми ID (как и в API). // В БД сценария — с символьными именами. // При сохранении: numeric_id → name (через paramDefsCache) // При загрузке: name → numeric_id (подстановка в defaultValue) let scenarioEditorState = null; /** * Открыть редактор сценария. * * @param {Object|null} def — определение сценария или null (новый) */ async function showScenarioEditor(def) { // Инициализация состояния scenarioEditorState = def ? { defId: def.id, version: def.version, name: def.name, steps: (def.steps || []).map(s => ({ service_id: s.service_id || '', operation: s.operation || '', output: s.output || '', instance_ref: s.instance_ref || '', instance_uid: s.instance_uid || '', params: s.params || {} // {name: value} — символьные имена })), services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} } : { defId: null, version: 1, name: '', steps: [], services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} }; // Загрузить справочные данные: сервисы + инстансы (для select'ов) try { const [svcR, instR] = await Promise.all([ fetch('/api/services').then(r => r.json()), fetch('/api/instances/list').then(r => r.json()) ]); scenarioEditorState.services = svcR || []; scenarioEditorState.cloudInstances = instR || []; } catch (e) { scenarioEditorState.services = []; scenarioEditorState.cloudInstances = []; } renderEditor(); } /** * Открыть модальное окно с HTML-контентом. * * @param {string} html — HTML-контент модала */ function openModal(html) { let modal = document.getElementById('scenario-modal'); if (!modal) { // Создать модал при первом вызове modal = document.createElement('div'); modal.id = 'scenario-modal'; // backdrop — закрытие по клику вне модала modal.innerHTML = ''; document.body.appendChild(modal); } modal.style.display = 'block'; document.getElementById('modal-content').innerHTML = html; } /** * Закрыть модальное окно и обновить список сценариев. */ function closeModal() { const modal = document.getElementById('scenario-modal'); if (modal) modal.style.display = 'none'; loadScenarios(); // обновить список (могли измениться) } // ═══════════════════════════════════════════════════════ // Снапшот параметров — защита от потери правок // ═══════════════════════════════════════════════════════ /** * Сохранить параметры ОДНОГО шага перед перерисовкой. * * Зачем: renderEditor() пересоздаёт ВЕСЬ HTML → все несохранённые правки теряются. * Эта функция собирает текущие значения из DOM и сохраняет в step.params. * * @param {number} idx — индекс шага */ function snapshotStepParams(idx) { const st = scenarioEditorState; const container = document.getElementById('step-' + idx + '-params'); if (!container || !container.innerHTML) return; // Собрать значения из формы (числовые ID → значение) const numericParams = collectParams('#step-' + idx + '-params'); const defs = st.paramDefsCache[st.steps[idx]._svcOpId]; if (!defs) return; // Обратный маппинг: numeric_id → 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)); } // ═══════════════════════════════════════════════════════ // Резолв операции + загрузка параметров // ═══════════════════════════════════════════════════════ /** * Найти svcOperationId для операции в шаге. * * Для create: использует service_id шага. * Для не-create: определяет service_id через выбранный инстанс * (облачный UUID → serviceId, или output-ref → service_id предыдущего create-шага). * * @param {number} idx — индекс шага * @returns {number|null} svcOperationId или null */ 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') { // Для не-create: определяем svcId через выбранный инстанс const refEl = document.getElementById('step-' + idx + '-ref'); const ref = (refEl?.value || '').trim(); if (ref) { // Проверяем: это облачный UUID? 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; } /** * Загрузить и отрисовать параметры для шага. * * Алгоритм: * 1. resolveStepSvcOpId — найти ID операции * 2. GET /api/params/{svcOpId} — шаблон параметров * 3. Подставить сохранённые значения (step.params) в defaultValue * 4. Отрисовать через renderParamRow * * @param {number} idx — индекс шага */ 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); // shallow copy const savedVal = savedParams[p.name]; // ищем по символическому имени if (savedVal !== undefined) clone.defaultValue = savedVal; html += renderParamRow(clone, allInst); }); container.innerHTML = html; } // ═══════════════════════════════════════════════════════ // Рендер редактора // ═══════════════════════════════════════════════════════ /** * Главная функция рендера — перерисовывает ВЕСЬ модал. * * Вызывается при: * - Открытии редактора * - Изменении операции (onOpChange) * - Добавлении/удалении/перемещении шагов * - Переключении облачных инстансов */ function renderEditor() { snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой const st = scenarioEditorState; let html = '
'; // Заголовок html += '

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

'; // Поле названия html += '

'; html += ``; html += '
'; // Справка «Как заполнять» html += `
📖 Как заполнять
Шаг: выбери операцию → укажи цель.
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); }); } /** * Отрисовать ОДНУ строку шага в редакторе. * * @param {number} idx — индекс шага * @param {Object} step — {service_id, operation, output, instance_ref, instance_uid, params} * @returns {string} HTML */ function renderStepRow(idx, step) { const st = scenarioEditorState; const op = step.operation; const out = step.output || ''; const ref = step.instance_ref || ''; // Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select) const prevOutputs = []; for (let i = 0; i < idx; i++) { const s = st.steps[i]; if (s.operation === 'create' && s.output) prevOutputs.push(s.output); } let html = `
`; // Заголовок шага: номер + кнопки [↑][↓][✕] html += `
`; html += `Шаг ${idx+1}`; // Кнопка вверх (кроме первого) if (idx > 0) html += ``; // Кнопка вниз (кроме последнего) if (idx < st.steps.length - 1) html += ``; // Кнопка удаления html += ``; html += `
`; // Выпадающий список операций const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy']; let opOpts = ''; allOps.forEach(o => { opOpts += ``; }); html += `
`; // Разное содержимое в зависимости от типа операции if (op === 'create') { // CREATE: выбор сервиса + output name let svcOpts = ''; st.services.forEach(s => { svcOpts += ``; }); html += `
`; html += `
`; } else if (op) { // НЕ-CREATE: выбор инстанса (из create-шагов + облако) let refOpts = ''; // Инстансы из предыдущих create-шагов prevOutputs.forEach(o => { refOpts += ``; }); // Облачные autotest-* инстансы (только если раскрыты) if (step._showCloud && st.cloudInstances) { refOpts += ''; st.cloudInstances.forEach(i => { const dn = i.displayName || ''; // Только autotest-*, не deleted if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return; const sel = i.instanceUid == ref ? 'selected' : ''; refOpts += ``; }); } const btnLabel = step._showCloud ? '➖ Скрыть облако' : '📥 Из облака'; html += `
`; html += `
`; } // Параметры — свёрнутый
, заполняется асинхронно в loadStepParams() html += `
Параметры
`; html += '
'; return html; } // ═══════════════════════════════════════════════════════ // Действия редактора // ═══════════════════════════════════════════════════════ /** * Изменение операции в выпадающем списке → сохранить выбор + сброс + перерисовка. * * ВАЖНО: сначала читаем новое значение из DOM-селекта и сохраняем в step.operation, * иначе renderEditor() перерисует старый селект (предыдущее значение). */ function onOpChange(idx) { // Считать новое значение из DOM ДО перерисовки const opEl = document.getElementById('step-' + idx + '-op'); const newOp = (opEl?.value || '').trim(); const step = scenarioEditorState.steps[idx]; step.operation = newOp; // сохранить выбранную операцию step.params = {}; // сброс параметров (для новой операции — другие) step._svcOpId = null; // сброс кеша svcOperationId // Для не-create: автоматически показать облачные инстансы if (newOp && newOp !== 'create') { step._showCloud = true; } renderEditor(); } /** * Переключить показ облачных инстансов в select'е. */ function toggleCloudInstances(idx) { snapshotAllParams(); scenarioEditorState.steps[idx]._showCloud = !scenarioEditorState.steps[idx]._showCloud; renderEditor(); } /** * Добавить новый (пустой) шаг. */ function addStep() { snapshotAllParams(); scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: {}, _showCloud: false }); renderEditor(); } /** * Удалить шаг по индексу. */ function removeStep(idx) { snapshotAllParams(); scenarioEditorState.steps.splice(idx, 1); renderEditor(); } /** * Переместить шаг вверх/вниз. * * @param {number} idx — индекс шага * @param {number} dir — -1 (вверх) или +1 (вниз) */ function moveStep(idx, dir) { snapshotAllParams(); const steps = scenarioEditorState.steps; const target = idx + dir; if (target < 0 || target >= steps.length) return; // Swap [steps[idx], steps[target]] = [steps[target], steps[idx]]; renderEditor(); } /** * Собрать ВСЕ шаги из DOM для сохранения. * * Для каждого шага: * - Извлечь service_id (из select для create, из инстанса для не-create) * - Извлечь output/instance_ref/instance_uid * - Собрать параметры через collectParams + обратный маппинг numeric→name * * @returns {{name: string, steps: Array}} */ function collectFormSteps() { snapshotAllParams(); // финальный снапшот const st = scenarioEditorState; const name = (document.getElementById('scenario-name')?.value || '').trim(); const steps = []; st.steps.forEach((step, idx) => { const opEl = document.getElementById('step-' + idx + '-op'); const operation = (opEl?.value || '').trim(); let svcId = 0; const s = { operation, params: {} }; if (operation === 'create') { // CREATE: service_id из select const svcEl = document.getElementById('step-' + idx + '-svc'); svcId = parseInt(svcEl?.value) || 0; const outEl = document.getElementById('step-' + idx + '-output'); const output = (outEl?.value || '').trim(); if (output) s.output = output; } else { // НЕ-CREATE: определяем instance_ref или instance_uid 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; s.instance_uid = ref; // облачный UUID } else { s.instance_ref = ref; // output-ссылка } } } s.service_id = svcId; // Собрать параметры через 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 }; } /** * Сохранить сценарий — POST (новый) или PUT (существующий). * * Валидация перед отправкой: * - Название не пустое * - Хотя бы один шаг * - У каждого шага выбран сервис/инстанс и операция */ async function saveScenario() { const { name, steps } = collectFormSteps(); // Валидация 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].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; } } const st = scenarioEditorState; const isNew = !st.defId; const url = isNew ? '/api/scenario/definitions' : '/api/scenario/definitions/' + st.defId; const method = isNew ? 'POST' : 'PUT'; // Для PUT нужна version (оптимистичная блокировка) const body = isNew ? { name, steps } : { name, steps, version: st.version }; try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const d = await r.json(); // 409 Conflict — кто-то уже изменил if (r.status === 409) { alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.'); return; } if (d.error) { alert(d.error); return; } // Обновить состояние (id и version могли измениться) if (isNew && d.id) st.defId = d.id; if (d.version) st.version = d.version; closeModal(); } catch (e) { alert('Ошибка сохранения: ' + e.message); } }