';
// Справка «Как заполнять»
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);
}
}