scenario-form.js: - renderStepRow: non-create dropdown shows only 🆕 outputs by default - «📥 Из облака» button toggles cloud autotest-* instances in same select - toggleCloudInstances(idx): snapshot, flip _showCloud flag, re-render - Button label: 📥 Из облака ↔ ➖ Скрыть облако scenario-list.js: - RUNNING status: yellow background #fef3c7 on both loadScenarios() and runScenario() polling
359 lines
17 KiB
JavaScript
359 lines
17 KiB
JavaScript
// scenario-form.js — модальный редактор сценариев
|
|
// Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams)
|
|
// Зависит от: utils.js (_esc)
|
|
|
|
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 ? {
|
|
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: {} };
|
|
|
|
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 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 = '<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); });
|
|
}
|
|
|
|
function renderStepRow(idx, step) {
|
|
const st = scenarioEditorState;
|
|
const op = step.operation;
|
|
const out = step.output || '';
|
|
const ref = step.instance_ref || '';
|
|
|
|
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') {
|
|
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) {
|
|
let refOpts = '<option value="">— инстанс —</option>';
|
|
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 || '';
|
|
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;
|
|
}
|
|
|
|
// ── Действия ──
|
|
|
|
function onOpChange(idx) {
|
|
scenarioEditorState.steps[idx].params = {};
|
|
scenarioEditorState.steps[idx]._svcOpId = null;
|
|
renderEditor();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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]];
|
|
renderEditor();
|
|
}
|
|
|
|
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') {
|
|
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) {
|
|
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
|
if (cloud) {
|
|
svcId = cloud.serviceId;
|
|
s.instance_uid = ref;
|
|
} else {
|
|
s.instance_ref = ref;
|
|
}
|
|
}
|
|
}
|
|
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 };
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|