// operations.js — запуск операций: create, modify, delete, поллинг, этапы // // Это основной модуль ручного режима тестирования. // Жизненный цикл операции: // 1. Пользователь выбирает инстанс → жмёт кнопку операции (runOp) // 2. Загружаются параметры с ТЕКУЩИМИ значениями (showParams) // 3. Пользователь правит параметры → жмёт «Запустить» (executeOp) // 4. POST /api/test → возвращает opUid → начинается поллинг (pollTimer) // 5. Каждые 2 секунды GET /api/test/status/{opUid} → этапы + статус // 6. При завершении → обновить список инстансов + историю // // Глобальные переменные (используются из instances.js): // pollTimer — ID setInterval для поллинга let pollTimer=null; /** * Сгенерировать короткое имя для нового инстанса. * Формат: {svcShort}-{random3} (напр. "dummy-a3f"). * random3 — base36 (0-9a-z), 3 символа = 46656 вариантов. * * @returns {string} displayName suffix (без префикса autotest-) */ function makeCreateDisplayName(){ const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0'); return currentSvcShort ? currentSvcShort+'-'+rand : rand; } /** * Найти displayName выбранного инстанса. * * @returns {string} displayName или instanceUid (если не найден) */ function findInstName(){ const i=svcInstances.find(x=>x.instanceUid===selectedInst); return i?i.displayName:selectedInst; } /** * Остановить поллинг текущей операции. */ function stopPoll(){ if(pollTimer){clearInterval(pollTimer);pollTimer=null;} } /** * Начать создание нового инстанса (кнопка «+ Создать»). * * Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}. * Если операция create не найдена — fallback на svcOperationId=18 (стандартный). */ async function startCreate(){ if(busy) return; selectedInst=null; // create — нет выбранного инстанса stopPoll(); // Снять выделение со всех инстансов document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); try{ const r=await fetch('/api/operations/'+currentSvcId); const d=await r.json(); const createOp=d.operations.find(o=>o.operation==='create'); const opId=createOp?createOp.svcOperationId:18; // fallback showParams(opId,'create'); }catch(e){ showParams(18,'create'); // fallback при ошибке } } /** * Запустить операцию над выбранным инстансом (кнопка modify/delete/...). * * @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy" * @param {number} opId — svcOperationId */ function runOp(opName,opId){ if(busy) return; stopPoll(); showParams(opId,opName); } /** * Показать форму параметров для операции. * * Алгоритм: * 1. Загрузить параметры: GET /api/params/{opId}[?instanceUid=xxx] * - Для create: без instanceUid → шаблонные defaults * - Для не-create: с instanceUid → текущие значения из state.params * 2. Отрисовать форму через renderParamRow (из params-render.js) * 3. Показать кнопку «Запустить» * * @param {number} opId — svcOperationId * @param {string} opName — "create"/"modify"/... */ function showParams(opId,opName){ // Сохраняем выбранную операцию selectedOp={opId,opName,svcId:currentSvcId}; // Показать панель параметров, скрыть старые этапы document.getElementById('params-area').style.display='block'; document.querySelectorAll('.stages-box').forEach(e=>e.remove()); document.getElementById('test-status').textContent=''; const isCreate=opName==='create'; // Для create — шаблонные значения, для не-create — с instanceUid const qs=isCreate?'' : `?instanceUid=${selectedInst}`; fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{ if(data.error){ document.getElementById('params-form').innerHTML= `Ошибка: ${_esc(data.error)}`; return; } const params=data.params||data; if(!Array.isArray(params)){ document.getElementById('params-form').innerHTML= `Неверный формат параметров`; return; } // Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов) let allInst=[]; const needRefs=params.some(p=>p.refSvcId); if(needRefs){ try{ const r=await fetch('/api/instances/list'); const list=await r.json(); allInst=list||[]; }catch(e){} } const form=document.getElementById('params-form'); const displayNameValue=isCreate?makeCreateDisplayName():''; // Заголовок формы const createHeader=isCreate ? `
Создание: ${_esc(currentSvcName)}
` : ''; const opHeader=!isCreate ? `
${_esc(opName)} → ${_esc(findInstName())}
` : ''; // Форма: заголовок + displayName (только create) + параметры form.innerHTML=createHeader+opHeader + (opName==='create' ? `
${AUTOTEST_PREFIX}
` : '') + params.map(p=>renderParamRow(p,allInst)).join(''); // Кнопка «Запустить» const btn=document.getElementById('btn-test'); btn.style.display='inline-flex'; btn.textContent='Запустить'; btn.onclick=()=>{ // Валидация JSON-полей перед отправкой let bad=[]; document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{ if(!validateJson(el,true)) bad.push(el); }); if(bad.length>0){ document.getElementById('test-status').innerHTML= `Ошибка ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`; return; } const pp=collectParams(); // собрать значения из формы executeOp(pp); // отправить }; }).catch(e=>{ document.getElementById('params-form').innerHTML= `Ошибка загрузки: ${_esc(e.message)}`; }); } /** * Установить состояние «завершено» — показать результат операции. * * @param {string} statusText — "OK"/"FAIL"/"TIMEOUT" * @param {string} statusClass — CSS-класс бейджа * @param {string} statusError — текст ошибки * @param {number} statusDuration— длительность в секундах * @param {string} instanceName — displayName инстанса */ function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){ busy=false; // разблокировать UI // Закрыть панели операций document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open')); const btn=document.getElementById('btn-test'); btn.disabled=false; btn.textContent='Готово'; btn.onclick=null; // убрать обработчик const opName=selectedOp?.opName||'операция'; const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s'; const extra=_esc(statusError||''); const instName=_esc(instanceName||findInstName()||selectedInst||''); document.getElementById('test-status').innerHTML= ` ${statusText} ${opName} ${durationText} ${instName} ${extra}`; } /** * Установить состояние «выполняется». */ function setRunningState(opName, displayName){ const btn=document.getElementById('btn-test'); btn.textContent=opName+'...'; document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...'; } /** * Отправить операцию на выполнение (POST /api/test) и начать поллинг. * * Алгоритм: * 1. POST /api/test → {status: "RUNNING", opUid} * 2. Если CMDB-delete → сразу OK (без поллинга) * 3. Иначе → setInterval каждые 2 секунды: * GET /api/test/status/{opUid} → этапы + статус * 4. При OK/FAIL → остановить поллинг, обновить список инстансов и историю * 5. При 5 ошибках подряд → TIMEOUT (связь потеряна) * * @param {Object} params — {numeric_param_id: value} */ async function executeOp(params){ busy=true; // заблокировать UI stopPoll(); const isCreate=selectedOp?.opName==='create'; const displayNameInput=document.getElementById('param-displayname'); const displayNameSuffix=(displayNameInput?.value||'').trim(); const displayName=isCreate ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` : findInstName(); // Подготовка UI document.getElementById('params-area').style.display='block'; document.getElementById('params-form').innerHTML=''; // очистить форму const btn=document.getElementById('btn-test'); btn.style.display='inline-flex'; btn.textContent=selectedOp.opName+'...'; btn.disabled=true; setRunningState(selectedOp.opName, displayName); // Создать контейнер для этапов (после кнопки create или после панели операций) const instId=selectedInst||''; const afterEl=instId?document.getElementById('ops-'+instId):null; document.querySelectorAll('.stages-box').forEach(e=>e.remove()); const stagesBox=document.createElement('div'); stagesBox.className='stages-box'; stagesBox.style.cssText='max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;'; if(afterEl){afterEl.after(stagesBox);} else{document.getElementById('create-btn-area').after(stagesBox);} try{ // POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName const r=await fetch('/api/test',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ serviceId:currentSvcId, operation:selectedOp.opName, svcOperationId:selectedOp.opId, params, instanceUid:selectedInst||'', displayName:isCreate?displayName:'' }) }); const d=await r.json(); // FAIL на старте — нечего поллить if(d.status==='FAIL'){ busy=false; document.getElementById('test-status').innerHTML= ` FAIL ${_esc(d.error||'')}`; btn.disabled=false; return; } // CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){ busy=false; setFinishedState('OK','badge-success','',0,d.displayName); try{await refreshInstances();}catch(e){} if(historyOpen) try{await loadHistory();}catch(e){} return; } const opUid=d.opUid; const instanceName=d.displayName||displayName; // Поллинг: каждые 2 секунды let _pollErrors=0; pollTimer=setInterval(async()=>{ try{ const sr=await fetch('/api/test/status/'+opUid); const sd=await sr.json(); _pollErrors=0; // сброс счётчика ошибок showStages(sd.stages||[]); // обновить этапы if(sd.status!=='RUNNING'){ stopPoll(); setFinishedState( sd.status, sd.status==='OK'?'badge-success':'badge', sd.error||sd.errorLog, sd.duration, sd.displayName||instanceName ); await refreshInstances(); // обновить список инстансов if(historyOpen) await loadHistory(); // обновить историю } }catch(e){ _pollErrors++; // 5 ошибок подряд (10 секунд) → считаем связь потерянной if(_pollErrors>=5){ stopPoll(); setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName); } } },2000); }catch(e){ busy=false; document.getElementById('test-status').textContent=' Ошибка: '+e.message; btn.disabled=false; } } /** * Показать этапы выполнения операции (stages из API). * * Каждый этап: иконка (✅/❌/⏳) + название + длительность. * * @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...] */ function showStages(stages){ if(!stages||!stages.length) return; let html='
Этапы
'; stages.forEach(s=>{ const done=!!s.dtFinish; // этап завершён? const icon=done?(s.isSuccessful?'✅':'❌'):'⏳'; html+=`
${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s
`; }); // Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом) const boxes=document.querySelectorAll('.stages-box'); if(boxes.length) boxes[boxes.length-1].innerHTML=html; }