Files
app-autotest/site/static/js/scenario-form.js
T

571 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<div class="modal-backdrop" onclick="closeModal()"></div><div class="modal-content" id="modal-content"></div>';
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 = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
// Заголовок
html += '<h3 style="margin:0 0 12px;">' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '</h3>';
// Поле названия
html += '<div style="margin-bottom:10px;"><label style="font-size:12px;font-weight:600;">Название</label><br>';
html += `<input type="text" id="scenario-name" value="${_esc(st.name)}" style="width:100%;padding:6px;font-size:13px;" placeholder="my_test">`;
html += '</div>';
// Справка «Как заполнять»
html += `<details style="margin-bottom:10px;font-size:11px;color:var(--muted);border:1px solid var(--brand-gray);border-radius:4px;padding:6px 8px;">
<summary style="cursor:pointer;font-weight:600;">📖 Как заполнять</summary>
<div style="margin-top:6px;line-height:1.6;">
<b>Шаг:</b> выбери <b>операцию</b> → укажи цель.<br>
<b>Create:</b> выбери сервис + задай <b>output</b> (имя, например <code>d1</code>).<br>
<b>Остальные:</b> выбери инстанс — 🆕 из create-шагов или ☁ из облака.<br>
<b>Параметры:</b> разверни «Параметры» — значения загружаются из API автоматически.<br>
<b>[↑][↓]</b> — порядок, <b>[✕]</b> — удалить.
</div>
</details>`;
// Шаги
html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;">Шаги</div>';
st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); });
// Кнопка добавления шага
html += `<button class="btn btn-sm" style="margin-top:6px;" onclick="addStep()">+ Добавить шаг</button>`;
// Кнопки Сохранить / Отмена
html += '<div style="margin-top:14px;display:flex;gap:8px;">';
html += `<button class="btn btn-sm" style="background:var(--brand-primary);color:#fff;padding:6px 16px;" onclick="saveScenario()">Сохранить</button>`;
html += `<button class="btn btn-sm" style="padding:6px 16px;" onclick="closeModal()">Отмена</button>`;
html += '</div></div>';
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 = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`;
// Заголовок шага: номер + кнопки [↑][↓][✕]
html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">`;
html += `<span style="font-size:11px;font-weight:600;">Шаг ${idx+1}</span>`;
// Кнопка вверх (кроме первого)
if (idx > 0) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},-1);renderEditor();">↑</button>`;
// Кнопка вниз (кроме последнего)
if (idx < st.steps.length - 1) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},1);renderEditor();">↓</button>`;
// Кнопка удаления
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;margin-left:auto;color:var(--destructive);" onclick="removeStep(${idx})">✕</button>`;
html += `</div>`;
// Выпадающий список операций
const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
let opOpts = '<option value="">— операция —</option>';
allOps.forEach(o => { opOpts += `<option value="${o}" ${o == op ? 'selected' : ''}>${o}</option>`; });
html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select></div>`;
// Разное содержимое в зависимости от типа операции
if (op === 'create') {
// CREATE: выбор сервиса + output name
let svcOpts = '<option value="">— сервис —</option>';
st.services.forEach(s => {
svcOpts += `<option value="${s.svcId}" ${s.svcId == step.service_id ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`;
});
html += `<div style="display:flex;gap:6px;align-items:center;"><select id="step-${idx}-svc" style="padding:4px;font-size:12px;">${svcOpts}</select></div>`;
html += `<div style="margin-top:4px;"><input type="text" id="step-${idx}-output" value="${_esc(out)}" placeholder="имя для ссылок (например: d1)" style="width:220px;padding:3px;font-size:11px;"></div>`;
} else if (op) {
// НЕ-CREATE: выбор инстанса (из create-шагов + облако)
let refOpts = '<option value="">— инстанс —</option>';
// Инстансы из предыдущих create-шагов
prevOutputs.forEach(o => {
refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`;
});
// Облачные autotest-* инстансы (только если раскрыты)
if (step._showCloud && st.cloudInstances) {
refOpts += '<option disabled>── облако ──</option>';
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 += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(dn)}${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')})</option>`;
});
}
const btnLabel = step._showCloud ? '➖ Скрыть облако' : '📥 Из облака';
html += `<div style="display:flex;gap:4px;align-items:center;"><select id="step-${idx}-ref" style="padding:4px;font-size:12px;max-width:500px;">${refOpts}</select>`;
html += `<button class="btn btn-sm" style="font-size:10px;padding:0 6px;" onclick="toggleCloudInstances(${idx})">${btnLabel}</button></div>`;
}
// Параметры — свёрнутый <details>, заполняется асинхронно в loadStepParams()
html += `<details style="margin-top:4px;" id="step-${idx}-params-box"><summary style="font-size:11px;cursor:pointer;">Параметры</summary><div id="step-${idx}-params" style="margin-top:4px;"></div></details>`;
html += '</div>';
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);
}
}