// 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 таймер поллинга статуса операции * SVC_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; const SVC_ID=1; const AUTOTEST_PREFIX='autotest-'; selectService(SVC_ID); async function selectService(svcId){ selectedInst=null; selectedOp=null; stopPoll(); document.getElementById('params-card').style.display='none'; document.getElementById('stages-box').style.display='none'; const r=await fetch('/api/operations/'+svcId); const d=await r.json(); svcInstances=d.instances||[]; let html=''; svcInstances.forEach(i=>{ const status=i.status||i.explainedStatus||'?'; const sc=status==='running'?'badge-success':''; html+=`
${i.displayName} ${status}
`; html+=`
`; }); html+=`
+ Создать новый инстанс
`; document.getElementById('inst-list').innerHTML=html; } async function toggleInstance(iuid){ selectedInst=iuid; stopPoll(); document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.toggle('active',e.dataset.iuid===iuid)); document.getElementById('params-card').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 r=await fetch('/api/operations/'+SVC_ID); const d=await r.json(); const ops=d.operations.filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); opsEl.innerHTML=ops.map(o=> `` ).join(''); 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 ''; } function startCreate(){ selectedInst=null; stopPoll(); document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); showParams(18,'create'); } function makeCreateDisplayName(){ const suffix = Date.now().toString(36); return suffix; } function runOp(opName,opId){ stopPoll(); selectedOp={opId,opName,svcId:SVC_ID}; if(opName==='modify'){ showParams(opId,opName); }else{ // confirm and execute immediately if(!confirm(`Запустить ${opName} для ${findInstName()}?`)) return; executeOp({}); } } function findInstName(){ const i=svcInstances.find(x=>x.instanceUid===selectedInst); return i?i.displayName:selectedInst; } function showParams(opId,opName){ selectedOp={opId,opName,svcId:SVC_ID}; document.getElementById('params-card').style.display='block'; document.getElementById('stages-box').style.display='none'; document.getElementById('test-status').textContent=''; const isCreate=opName==='create'; const qs=isCreate?'' : `?instanceUid=${selectedInst}`; fetch('/api/params/'+opId+qs).then(r=>r.json()).then(data=>{ const params=data.params||data; const form=document.getElementById('params-form'); const displayNameValue=isCreate?makeCreateDisplayName():''; form.innerHTML=(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.dataType==='boolean'){ 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={}; document.querySelectorAll('#params-form [name^="p_"]').forEach(el=>{ let v=el.value; const dt=el.dataset.type||''; if(dt==='array'||dt.startsWith('array')) v=JSON.stringify([v]); if(dt==='map'||dt==='map-fixed') v=v||'{}'; pp[el.name.replace('p_','')]=v; }); executeOp(pp); }; }); } // === Хелперы === function _esc(s){ /* HTML-экранирование: " → " & → & < → < */ return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/${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){ 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-card').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); document.getElementById('stages-box').style.display='block'; document.getElementById('stages-box').innerHTML=''; try{ const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ serviceId:SVC_ID, operation:selectedOp.opName, svcOperationId:selectedOp.opId, params, instanceUid:selectedInst||'', displayName:isCreate ? displayName : '' })}); const d=await r.json(); if(d.status==='FAIL'){document.getElementById('test-status').innerHTML=` FAIL ${d.error||''}`;btn.disabled=false;return;} // start polling const opUid=d.opUid; const instanceName=d.displayName||displayName||findInstName(); pollTimer=setInterval(async()=>{ const sr=await fetch('/api/test/status/'+opUid); const sd=await sr.json(); 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); if(sd.status==='OK'){ await refreshInstances(); } } },2000); }catch(e){ 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} ${s.stage} — ${(s.duration||0).toFixed(1)}s
`; }); document.getElementById('stages-box').innerHTML=html; } function stopPoll(){ if(pollTimer){clearInterval(pollTimer);pollTimer=null;} } async function refreshInstances(){ const r=await fetch('/api/operations/'+SVC_ID); 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+=`
${i.displayName} ${status}
`; html+=`
`; }); html+=`
+ Создать новый инстанс
`; document.getElementById('inst-list').innerHTML=html; } // === Панель логов (скрыта, показывается по кнопке) === 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=>`
${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'; } startLogPoll();