Files
app-autotest/site/static/js/operations.js
T

343 lines
16 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.
// 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=
`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;
return;
}
const params=data.params||data;
if(!Array.isArray(params)){
document.getElementById('params-form').innerHTML=
`<span style="color:var(--destructive);">Неверный формат параметров</span>`;
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
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>`
: '';
const opHeader=!isCreate
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)}${_esc(findInstName())}</div>`
: '';
// Форма: заголовок + displayName (только create) + параметры
form.innerHTML=createHeader+opHeader
+ (opName==='create'
? `<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`
: '')
+ 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=
`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
return;
}
const pp=collectParams(); // собрать значения из формы
executeOp(pp); // отправить
};
}).catch(e=>{
document.getElementById('params-form').innerHTML=
`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
});
}
/**
* Установить состояние «завершено» — показать результат операции.
*
* @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=
` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${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=
` <span class="badge">FAIL</span> ${_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='<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
stages.forEach(s=>{
const done=!!s.dtFinish; // этап завершён?
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳';
html+=`<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)}${(s.duration||0).toFixed(1)}s</div>`;
});
// Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом)
const boxes=document.querySelectorAll('.stages-box');
if(boxes.length) boxes[boxes.length-1].innerHTML=html;
}