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 @@
+