diff --git a/site/app.py b/site/app.py index 07661ca..4388d0c 100644 --- a/site/app.py +++ b/site/app.py @@ -18,7 +18,7 @@ from routes.api import bp as api_bp from routes.api_test import bp as api_test_bp # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. -VERSION = "1.0.90" +VERSION = "1.0.91" app = Flask(__name__, template_folder="templates", static_folder="static") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc") diff --git a/site/static/app.js b/site/static/app.js new file mode 100644 index 0000000..9d0551f --- /dev/null +++ b/site/static/app.js @@ -0,0 +1,322 @@ +// 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(); \ No newline at end of file diff --git a/site/templates/index.html b/site/templates/index.html index cba1cf5..87fac99 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -124,326 +124,14 @@ +