// window.APP — переменные из Jinja2 (заполняются в index.html) // window.APP = { version, stand, hasUserToken }; /* * === АРХИТЕКТУРА ФРОНТЕНДА === * * Один HTML-файл, ванильный JS (без фреймворков). * Три колонки: инфраструктура | сервисы | инстансы+операции+параметры. * * Глобальное состояние: * svcInstances — кеш списка инстансов из /api/operations/{svcId} * selectedInst — UID выбранного инстанса (null если не выбран) * selectedOp — {opId, opName, svcId} выбранная операция * pollTimer — setInterval таймер поллинга статуса операции * currentSvcId — ID текущего выбранного сервиса (по умолчанию 1 = Болванка) * AUTOTEST_PREFIX — префикс имён autotest-инстансов * * Потоки: * CREATE: startCreate() → showParams(18,'create') → executeOp(pp) → poll → refresh * MODIFY: toggleInstance() → runOp('modify',id) → showParams(id,'modify') → validate → executeOp * БЕЗ ПАРАМЕТРОВ (delete/suspend/resume/redeploy): runOp() → confirm → executeOp({}) * * Хелперы: * _esc(s) — HTML-экранирование (" → " & → & < → <) * validateJson() — проверка JSON.parse для map-полей (onblur + перед отправкой) * showStages() — отрисовка этапов выполнения операции (⏳/✅/❌) */ let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null; let currentSvcId=(window.APP&&window.APP.firstServiceId)||1; // динамический — обновляется при selectService() let currentSvcName=''; // имя текущего сервиса (колонка «тип инстанса») let currentSvcShort=''; // краткое имя (redis, postgres) для displayName let busy=false; // блокировка: запрет операций пока идёт другая const AUTOTEST_PREFIX='autotest-'; // Первый сервис из списка (рендерится Jinja2) selectService(currentSvcId); async function selectService(svcId){ if(busy) return; // операция выполняется — не менять сервис currentSvcId=svcId; selectedInst=null; selectedOp=null; stopPoll(); document.getElementById('params-area').style.display='none'; document.querySelectorAll('.stages-box').forEach(e=>e.remove()); // Подсветить активный сервис в боковой панели document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active')); const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`); if(svcEl) svcEl.classList.add('active'); try{ const r=await fetch('/api/operations/'+svcId); const d=await r.json(); svcInstances=d.instances||[]; currentSvcName=d.svc||''; currentSvcShort=d.svcShort||''; // Обновить текст кнопки создания const btnSpan=document.querySelector('#create-btn-area span'); if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса'); let html=''; svcInstances.forEach(i=>{ const status=i.status||i.explainedStatus||'?'; const sc=status==='running'?'badge-success':''; html+=`
${_esc(i.displayName)} ${_esc(i.svc||'')} ${_esc(status)}
`; html+=`
`; }); document.getElementById('inst-list').innerHTML=html; }catch(e){document.getElementById('inst-list').innerHTML='Ошибка загрузки';} } async function toggleInstance(iuid){ if(busy) return; // операция выполняется — ничего не делать selectedInst=iuid; stopPoll(); document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.toggle('active',e.dataset.iuid===iuid)); document.getElementById('params-area').style.display='none'; const opsEl=document.getElementById('ops-'+iuid); if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;} document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); // Операции для ИНСТАНСА, не для выбранного сервиса const inst=svcInstances.find(x=>x.instanceUid===iuid); const svcId=inst?inst.serviceId:currentSvcId; try{ const r=await fetch('/api/operations/'+svcId); const d=await r.json(); let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); if(inst&&(inst.status||inst.explainedStatus)==='not created'){ ops=ops.filter(o=>o.operation==='delete'); } opsEl.innerHTML=ops.map(o=> `` ).join(''); opsEl.classList.add('open'); }catch(e){opsEl.innerHTML='Ошибка загрузки';opsEl.classList.add('open');} } function opClass(opName){ if(opName==='modify') return 'op-btn-modify'; if(opName==='suspend') return 'op-btn-suspend'; if(opName==='resume') return 'op-btn-resume'; if(opName==='delete') return 'op-btn-delete'; if(opName==='redeploy') return 'op-btn-redeploy'; if(opName==='reconcile') return 'op-btn-reconcile'; return ''; } async function startCreate(){ if(busy) return; selectedInst=null; stopPoll(); document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); // Найти svcOperationId для create у текущего сервиса 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 } } function makeCreateDisplayName(){ const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0'); return currentSvcShort ? currentSvcShort+'-'+rand : rand; } function runOp(opName,opId){ if(busy) return; stopPoll(); showParams(opId,opName); } function findInstName(){ const i=svcInstances.find(x=>x.instanceUid===selectedInst); return i?i.displayName:selectedInst; } 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'; 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-параметров 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())}
`:''; form.innerHTML=createHeader+opHeader+(opName==='create'?`
${AUTOTEST_PREFIX}
`:'') + params.map(p=>{ const req=p.isRequired; const hasDfl=p.defaultValue!==null&&p.defaultValue!==undefined&&p.defaultValue!==''; const labelCls=req?(hasDfl?'req':'req-nodfl'):''; const dfl=hasDfl?p.defaultValue:(req?'':''); const vl=p.valueList; const isMap=(p.dataType||'').startsWith('map'); let input; if(vl&&Array.isArray(vl)){ input=``; }else if(p.refSvcId){ // Параметр ссылается на другой сервис — выпадающий список инстансов const refInsts=allInst.filter(i=>i.serviceId===p.refSvcId&&i.explainedStatus!=='deleted'); let opts=refInsts.map(i=>``).join(''); if(!dfl) opts=''+opts; input=``; }else if(p.dataType==='boolean'){ input=``; }else if(p.dataDescriptor&&isMap){ // Раскрыть dataDescriptor — вложенные поля вместо JSON-строки const dd=p.dataDescriptor; let subHtml=''; let dflObj={}; try{dflObj=JSON.parse(dfl||'{}');}catch(e){} Object.keys(dd).forEach(subKey=>{ const sub=dd[subKey]; const subDfl=dflObj[subKey]!==undefined?dflObj[subKey]:(sub.defaultValue||''); const subVl=sub.valueList; const subReq=sub.isRequired; const subNoDfl=subReq&&!subDfl&&subDfl!==0&&subDfl!==false; const subLblCls=subNoDfl?'req-nodfl':''; let si; if(subVl&&Array.isArray(subVl)){ si=``; }else if(sub.dataType==='boolean'){ si=``; }else{ si=``; } subHtml+=`
${si}
`; }); input=`
${subHtml}
`; return `
${input}`; }else{ const dt=isMap?'map':''; input=``; if(isMap) input+=``; } return `
${input}
`; }).join(''); const btn=document.getElementById('btn-test'); btn.style.display='inline-flex'; btn.textContent='Запустить'; btn.onclick=()=>{ // Проверить ВСЕ map-поля перед отправкой 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={}; const nested={}; // {parentKey: {subKey: value, ...}} document.querySelectorAll('#params-form [name^="p_"]').forEach(el=>{ let v=el.value; const dt=el.dataset.type||''; if(dt==='mapchild'){ const name=el.name.replace('p_',''); const dot=name.indexOf('.'); const parentKey=name.substring(0,dot); const subKey=name.substring(dot+1); if(!nested[parentKey]) nested[parentKey]={}; nested[parentKey][subKey]=v; return; } if(dt==='array'||dt.startsWith('array')) v=JSON.stringify([v]); if(dt==='map'||dt==='map-fixed') v=v||'{}'; pp[el.name.replace('p_','')]=v; }); // Сериализовать вложенные объекты в JSON строки for(const pk in nested) pp[pk]=JSON.stringify(nested[pk]); executeOp(pp); }; }).catch(e=>{ document.getElementById('params-form').innerHTML=`Ошибка загрузки: ${_esc(e.message)}`; }); } // === Хелперы === function _esc(s){ /* HTML-экранирование: " → " & → & < → < */ return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/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'); const title=displayName || opName; btn.textContent=opName+'...'; document.getElementById('test-status').textContent=' ⏳ '+title+' выполняется...'; } async function executeOp(params){ busy=true; 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(); 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); // Создать динамический stages-box после инстанса 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{ 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(); if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` FAIL ${_esc(d.error||'')}`;btn.disabled=false;return;} // CMDB delete — мгновенное удаление, не надо поллинга 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; } // start polling const opUid=d.opUid; const instanceName=d.displayName||displayName||findInstName(); 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++; if(_pollErrors>=5){ stopPoll(); setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName); } } },2000); }catch(e){ busy=false; document.getElementById('test-status').textContent=' Ошибка: '+e.message; btn.disabled=false; } } 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; } function stopPoll(){ if(pollTimer){clearInterval(pollTimer);pollTimer=null;} } async function refreshInstances(){ try{ const r=await fetch('/api/operations/'+currentSvcId); const d=await r.json(); const newInsts=d.instances||[]; svcInstances=newInsts; let html=''; svcInstances.forEach(i=>{ const status=i.status||i.explainedStatus||'?'; const sc=status==='running'?'badge-success':''; html+=`
${_esc(i.displayName)} ${_esc(i.svc||'')} ${_esc(status)}
`; html+=`
`; }); document.getElementById('inst-list').innerHTML=html; }catch(e){} } // === История тестов === let historyOpen=false; async function toggleHistory(){ const body=document.getElementById('history-body'); historyOpen=!historyOpen; body.style.display=historyOpen?'block':'none'; if(historyOpen) await loadHistory(); } async function loadHistory(){ const body=document.getElementById('history-body'); if(!body||body.style.display==='none') return; try{ const r=await fetch('/api/history'); const rows=await r.json(); if(!rows||!rows.length){body.innerHTML='Нет записей'; return;} let html=''; html+=''; rows.forEach(r=>{ const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?'; const cls=r.status==='OK'?'badge-success':''; html+=``; }); html+='
ВремяОперацияИнстансСтатусДлит.
${time} ${_esc(r.op_name||'?')} ${_esc(r.display_name||r.instance_uid||'?')} ${_esc(r.status||'?')} ${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}
'; body.innerHTML=html; }catch(e){body.innerHTML='Ошибка загрузки';} } // === Панель логов (скрыта, показывается по кнопке) === let logPollTimer=null; function startLogPoll(){ if(logPollTimer) return; logPollTimer=setInterval(async()=>{ const el=document.getElementById('log-panel'); if(!el||el.style.display==='none') return; // не дёргать если скрыта try{ const r=await fetch('/api/log'); const lines=await r.json(); el.innerHTML=lines.map(l=>`
${_esc(l)}
`).join(''); el.scrollTop=el.scrollHeight; }catch(e){} },2000); } function toggleLog(){ const el=document.getElementById('log-panel'); if(!el) return; if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); } else el.style.display='none'; } // === Сценарии (автотесты) === let scenarioOpen=false, scenarioPollTimer=null; async function toggleScenario(){ const body=document.getElementById('scenario-body'); scenarioOpen=!scenarioOpen; body.style.display=scenarioOpen?'block':'none'; if(!scenarioOpen){ stopScenarioPoll(); return; } await loadScenarios(); } async function loadScenarios(){ const body=document.getElementById('scenario-body'); try{ const [sr, srHistory]=await Promise.all([ fetch('/api/scenarios').then(r=>r.json()), fetch('/api/scenario/status').then(r=>r.json()), ]); let html=''; if(sr.length){ html+='
'; sr.forEach(s=>{ html+=``; }); html+='
'; }else{ html+='Нет сценариев в БД'; } // Последний запуск — только для текущей версии приложения const curVer=window.APP&&window.APP.version||''; const verHistory=srHistory&&srHistory.filter(r=>r.app_version===curVer); if(verHistory && verHistory.length){ const last=verHistory[0]; const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱'; const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?'; html+=`
Последний: ${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}
`; if(last.error_log) html+=`
${_esc(last.error_log)}
`; } body.innerHTML=html; }catch(e){body.innerHTML='Ошибка загрузки';} } async function runScenario(defId){ if(busy){ return; } busy=true; stopScenarioPoll(); const body=document.getElementById('scenario-body'); body.innerHTML=`
⏳ Запуск...
`; try{ const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({definition_id:defId})}); const d=await r.json(); if(d.error){ body.innerHTML=`Ошибка: ${_esc(d.error)}`; busy=false; return; } const runId=d.run_id; if(!runId){ body.innerHTML=`Ошибка: нет run_id в ответе`; busy=false; return; } // Поллинг конкретного запуска scenarioPollTimer=setInterval(async()=>{ try{ const sr=await fetch('/api/scenario/run/'+runId); if(!sr.ok){ return; } const last=await sr.json(); if(!last||last.error) return; const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱'; const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?'; let html=`
${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}
`; if(last.error_log) html+=`
${_esc(last.error_log)}
`; body.innerHTML=html; if(last.status!=='RUNNING'){ stopScenarioPoll(); busy=false; await refreshInstances(); if(historyOpen) await loadHistory(); } }catch(e){} },3000); }catch(e){ body.innerHTML=`Ошибка: ${_esc(e.message)}`; busy=false; } } function stopScenarioPoll(){ if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; } } startLogPoll();