Files
app-autotest/site/static/js/scenario-form.js
T
naeel eb517594bc v1.2.4: collapsible help section in scenario editor modal
scenario-form.js: <details> block between name and steps:
- How steps work (operation → target)
- Create: service + output name
- Non-create: instance from scenario or cloud
- Parameters: key=value format with example
- Buttons: ↑↓ reorder, ✕ delete
2026-07-31 11:49:04 +04:00

250 lines
13 KiB
JavaScript

// scenario-form.js — модальный редактор сценариев
// Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams)
// Зависит от: utils.js (_esc), instances.js (currentSvcId)
let scenarioEditorState = null; // {defId, version, name, steps, services:[], opsCache:{}}
// step: {service_id, operation, output, instance_ref, instance_uid, params: [["k","v"],...]}
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: Object.entries(s.params || {})
})),
services: [], opsCache: {}
} : { defId: null, version: 1, name: '', steps: [], services: [], opsCache: {} };
// Загрузить список сервисов и облачные инстансы
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();
}
function openModal(html) {
let modal = document.getElementById('scenario-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'scenario-modal';
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();
}
function renderEditor() {
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> key = value (символические коды, как в API).<br>
&nbsp;&nbsp;Пример: <code>durationMs</code> = <code>5000</code><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);
}
function renderStepRow(idx, step) {
const st = scenarioEditorState;
const op = step.operation;
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];
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>`;
// 1. Операция ВСЕГДА первая
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>`;
// 2. В зависимости от операции
if (op === 'create') {
// Сервис
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) {
// Инстанс — облачные + предыдущие output'ы
let refOpts = '<option value="">— инстанс —</option>';
// output'ы из create-шагов
prevOutputs.forEach(o => { refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`; });
// Облачные инстансы
if (st.cloudInstances && st.cloudInstances.length) {
refOpts += '<option disabled>── облако ──</option>';
st.cloudInstances.forEach(i => {
if (i.explainedStatus === 'deleted') return;
const sel = i.instanceUid == ref ? 'selected' : '';
refOpts += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(i.displayName)}${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')})</option>`;
});
}
html += `<div><select id="step-${idx}-ref" style="padding:4px;font-size:12px;max-width:500px;">${refOpts}</select></div>`;
}
// Параметры
html += `<div style="margin-top:4px;" id="step-${idx}-params">`;
const params = step.params || [];
params.forEach(([k, v], pi) => {
html += `<div style="display:flex;gap:4px;align-items:center;margin:2px 0;">`;
html += `<input type="text" id="step-${idx}-pk-${pi}" value="${_esc(k)}" placeholder="param" style="width:110px;padding:2px;font-size:10px;">`;
html += `<span>=</span>`;
html += `<input type="text" id="step-${idx}-pv-${pi}" value="${_esc(v)}" placeholder="value" style="width:140px;padding:2px;font-size:10px;">`;
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 3px;" onclick="removeParam(${idx},${pi});renderEditor();">✕</button>`;
html += `</div>`;
});
html += `<button class="btn btn-sm" style="font-size:9px;margin-top:2px;" onclick="addParam(${idx});renderEditor();">+ Параметр</button>`;
html += '</div></div>';
return html;
}
function onOpChange(idx) { renderEditor(); }
function addStep() {
scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: [] });
renderEditor();
}
function removeStep(idx) {
scenarioEditorState.steps.splice(idx, 1);
renderEditor();
}
function moveStep(idx, dir) {
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);
}
function collectFormSteps() {
const st = scenarioEditorState;
const name = (document.getElementById('scenario-name')?.value || '').trim();
const steps = [];
st.steps.forEach((_, idx) => {
const opEl = document.getElementById('step-' + idx + '-op');
const operation = (opEl?.value || '').trim();
let svcId = 0;
const s = { operation, params: {} };
if (operation === 'create') {
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 {
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;
});
steps.push(s);
});
return { name, steps };
}
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';
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();
if (r.status === 409) {
alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.');
return;
}
if (d.error) { alert(d.error); return; }
if (isNew && d.id) st.defId = d.id;
if (d.version) st.version = d.version;
closeModal();
} catch (e) {
alert('Ошибка сохранения: ' + e.message);
}
}