feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 11:33:56 +03:00
1 parent 5b6b2d2053
commit 9515c1c62b
52 files changed
+2459 -34

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
/* Local Bootstrap 3 compatible surface; indpic table/controls ported from
* api/modules/cpanel/views/settings/index.php at 591a67cc8302 (preland#22). */
*{box-sizing:border-box}body{margin:0;color:#333;background:#f7f7f7;font:14px/1.42857143 "Helvetica Neue",Helvetica,Arial,sans-serif}.container{width:min(1170px,100%);padding:0 15px;margin:0 auto}.navbar{background:#222;color:#ddd;min-height:52px}.nav-inner{display:flex;gap:24px;align-items:center;min-height:52px}.nav-inner strong{font-size:18px}.nav-inner button{margin-left:auto}h1{font-size:30px;font-weight:500;margin:24px 0 20px}h2{font-size:24px;font-weight:500;margin:0 0 18px}h3{font-size:18px;font-weight:500}p{margin:0 0 12px}.panel{padding:20px;background:white;border:1px solid #ddd;border-radius:4px;margin:20px 0}.login-panel{max-width:420px;margin:60px auto}.login-panel label{display:block;margin:15px 0}.form-control{display:block;width:100%;min-height:34px;padding:6px 12px;font:inherit;line-height:1.42857143;color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:inset 0 1px 1px #0001}input[type=checkbox]{margin:0 5px 0 0;accent-color:#337ab7}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid #337ab7;outline-offset:2px}button,input,select{font:inherit}.btn{display:inline-block;padding:6px 12px;border:1px solid transparent;border-radius:4px;font-weight:400;text-align:center;cursor:pointer;background:white;color:#333}.btn-default{border-color:#ccc}.btn-default:hover{background:#e6e6e6}.btn-primary{background:#337ab7;border-color:#2e6da4;color:white}.btn-danger{background:#d9534f;color:white}.btn-xs{padding:1px 5px;font-size:12px}.btn:disabled{opacity:.5;cursor:not-allowed}.toolbar,.target-options,.utm-inline-options,.utm-inline-option{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.toolbar{margin:12px 0}.grow{flex:1;min-width:200px}.target-options label{padding:8px 10px;border:1px solid #ddd;border-radius:4px}.text-muted{color:#707070}.alert{border:1px solid #ddd;border-radius:4px;padding:15px;margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.alert-danger{background:#f2dede;color:#a94442;border-color:#ebccd1}.alert-success{background:#dff0d8;color:#3c763d}.alert-warning{background:#fcf8e3;color:#8a6d3b}.alert-info{background:#d9edf7;color:#31708f}.tabs{display:flex;flex-wrap:wrap;gap:24px;border-bottom:1px solid #ddd;padding-bottom:12px}a,.utm-default-action,.utm-group-save{color:#337ab7}.utm-default-action,.utm-group-save{border:0;background:none;cursor:pointer;padding:0;text-decoration:underline}.utm-default-value{font-weight:bold;color:#3c763d}.table-responsive{overflow-x:auto;max-width:100%}.table{width:100%;border-collapse:collapse;margin-bottom:12px}.table th{text-align:left;background:#f5f5f5}.table td,.table th{padding:8px;vertical-align:middle;border:1px solid #ddd}.table-hover tbody tr:hover{background:#f5f5f5}.indpic-sets-table{table-layout:fixed;min-width:900px}.indpic-sets-table th:first-child{width:170px}.indpic-default-column{width:105px}.indpic-check-column{width:110px}.indpic-actions-column{width:100px}.indpic-file-control{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.indpic-file-control input{max-width:230px;min-width:0}.indpic-add-form{display:flex;align-items:end;flex-wrap:wrap;gap:10px;margin-top:14px}.indpic-number-input{width:96px}.indpic-add-form .checkbox{margin:0 4px 7px}.indpic-add-form input[name=name]{width:170px}.indpic-add-form input[type=file]{max-width:300px}.utm-group-number{font-size:18px;font-weight:600}.utm-group-name{margin:6px 0}.utm-inline-options{gap:24px;margin-bottom:12px}.utm-inline-option{gap:6px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.field{margin:12px 0}.field-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}.field-head label{font-weight:600}.nested{border-left:3px solid #eee;padding-left:15px;min-width:0}.field-block{margin:18px 0;padding-top:12px;border-top:1px solid #eee}.weight-table{max-width:560px}.weight-table input,.weight-table select{width:100%;min-width:75px}.mixed{font-size:12px;color:#8a6d3b;margin-left:8px}.job{margin:16px 0;border-top:1px solid #ddd;padding-top:12px}.job .table{min-width:480px}.status-success{color:#3c763d}.status-error,.status-conflict{color:#a94442}.status-unknown{color:#8a6d3b}.connection{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.connection span{flex:1;min-width:160px}.break{overflow-wrap:anywhere}dialog{border:1px solid #999;border-radius:6px;box-shadow:0 5px 15px #0008;padding:0;width:960px;max-width:calc(100% - 40px)}dialog::backdrop{background:#0007}.modal-header{position:relative;text-align:center;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header h2{font-size:18px;margin:0 34px}.close{position:absolute;right:15px;top:10px;background:none;border:0;font-size:25px;color:#777;cursor:pointer}.modal-body{padding:15px;max-height:calc(100vh - 180px);overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}[hidden]{display:none!important}@media(max-wLine truncated
#notice{position:sticky;top:8px;z-index:100;box-shadow:0 2px 6px #0002}
+143
View File
@@ -0,0 +1,143 @@
/* CPanel indpic UI adapted from api 591a67cc8302, settings/index.php and
* _utm-default.php. PublicUtmCodec labels retained; no runtime HTML injection. */
'use strict';
(() => {
const $ = id => document.getElementById(id);
const state = {csrf:'',targets:[],source:'',reads:new Map(),fields:[],jobs:[],loading:0};
const markers=['indpic','theme','degr','reg','banch','tp','cons','focus'];
const aliases={reg:'registration',banch:'bchoice'};
const enums={theme:{'1':'dark','2':'light'},reg:{'1':'before','2':'after'},banch:{'1':'b','2':'a'},focus:{'1':'face','2':'center'}};
const descriptions={theme:{'1':'тёмная','2':'светлая'},reg:{'1':'ввод имейл → подтверждение PIN → оплата','2':'ввод имейл → оплата → подтверждение PIN'},banch:{'1':'нет pop-up','2':'есть pop-up'},focus:{'1':'лицо, ближайшее к центру','2':'геометрический центр'}};
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
const statuses={queued:'Ожидание',running:'Выполняется',success:'Сохранено и проверено',error:'Ошибка',conflict:'Конфликт версии — перечитайте домен',unknown:'Неизвестный результат — проверьте состояние'};
function el(tag,attrs={},...children){const n=document.createElement(tag);for(const [k,v] of Object.entries(attrs)){if(k==='class')n.className=v;else if(k==='text')n.textContent=v;else if(k.startsWith('on'))n.addEventListener(k.slice(2),v);else if(k in n && k!=='list')n[k]=v;else n.setAttribute(k,v);}for(const c of children.flat())if(c!==undefined&&c!==null)n.append(c instanceof Node?c:document.createTextNode(String(c)));return n;}
const button=(label,action,kind='default')=>{const control=el('button',{type:'button',class:'btn btn-'+kind,text:label});control.addEventListener('click',()=>run(async()=>{control.disabled=true;try{await action();}finally{control.disabled=false;}}));return control;};
function notice(message,type='danger'){const n=$('notice');n.className='alert alert-'+type;n.textContent=message;n.hidden=!message;}
async function api(path,data,method='POST'){
const options={method:data===undefined?'GET':method,credentials:'same-origin',headers:{'Accept':'application/json'}};
if(data!==undefined){options.headers['X-CSRF-Token']=state.csrf;if(data instanceof FormData)options.body=data;else{options.headers['Content-Type']='application/json';options.body=JSON.stringify(data);}}
let response;try{response=await fetch('/api/'+path,options);}catch(_){throw new Error('Связь с админкой прервана. Откройте журнал: операция могла сохраниться на сервере.');}
let body;try{body=await response.json();}catch(_){throw new Error('Сервер вернул непонятный ответ. Проверьте журнал перед повтором.');}
if(!response.ok||!body.ok){if(response.status===401)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body;
}
async function run(action){try{await action();}catch(error){notice(error.message);}}
function showLogin(){for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';state.fields=[];state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
function selected(){return [...$('targets').querySelectorAll('input:checked')].map(n=>n.value);}
function targetName(id){const t=state.targets.find(t=>t.id===id);return t? t.domain+' ('+t.environment+')':id;}
function valueAt(object,path){return path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);}
function equivalent(a,b){return JSON.stringify(a)===JSON.stringify(b);}
function readCurrent(){return state.reads.get(state.source);}
function chosenChanges(){return state.fields.filter(f=>f.check.checked).map(f=>({path:f.path,value:f.get()}));}
function updateSummary(){
const fields=state.fields.filter(f=>f.check.checked), ids=selected();
$('change-summary').textContent=fields.length?'Выбранные поля: '+fields.map(f=>f.path.join('.')).join(', '):'Поля не выбраны.';
$('target-summary').textContent='Цели: '+(ids.map(targetName).join(', ')||'не выбраны');
$('apply').disabled=!fields.length||!ids.length||state.loading>0||ids.some(id=>!state.reads.has(id));
for(const f of state.fields){const values=ids.map(id=>valueAt(state.reads.get(id)?.config,f.path));f.mixed.textContent=values.length>1&&values.some(v=>!equivalent(v,values[0]))?'Разные значения':'';}
}
function bindField(container,path,get,controls){
const check=el('input',{type:'checkbox','aria-label':'Применить поле '+path.join('.')});
const mixed=el('span',{class:'mixed'});const head=el('div',{class:'field-head'},check,el('span',{text:path.join('.')}),mixed);
container.prepend(head);const field={path,get,check,mixed};state.fields.push(field);
check.addEventListener('change',updateSummary);
for(const control of controls)for(const event of ['input','change'])control.addEventListener(event,()=>{check.checked=true;updateSummary();});
return field;
}
function labelControl(text,input){const label=el('label',{text});label.append(input);return label;}
function numberInput(value,min=undefined,max=undefined){const n=el('input',{class:'form-control',type:'number',value:String(value),step:'any'});if(min!==undefined)n.min=min;if(max!==undefined)n.max=max;return n;}
function typed(container,path,value,label){
if(value!==null&&typeof value==='object'){
const block=el('div',{class:'field-block'},el('h3',{text:label||path.at(-1)}));container.append(block);
if(Array.isArray(value)){
// Arrays are one explicit atomic field: row operations preserve order and types.
const list=el('div',{class:'nested'}),rows=[];block.append(list);let field;
const row=(v)=>{const wrap=el('div',{class:'toolbar'});let control,get;
if(v!==null&&typeof v==='object'){
const subtree=el('div',{class:'nested grow'});const entry={value:structuredClone(v)};
const renderObject=(parent,obj)=>{for(const [k,val] of Object.entries(obj)){if(val!==null&&typeof val==='object'){const child=el('div',{class:'nested'},el('strong',{text:k}));parent.append(child);renderObject(child,val);}else{const inp=scalar(val,k);parent.append(labelControl(k,inp));inp.addEventListener('change',()=>{obj[k]=scalarValue(inp,val);field.check.checked=true;updateSummary();});}}};renderObject(subtree,entry.value);control=subtree;get=()=>entry.value;
}else{control=scalar(v,label||path.at(-1));get=()=>scalarValue(control,v);}
const item={get,wrap};rows.push(item);wrap.append(control,button('Удалить строку',()=>{rows.splice(rows.indexOf(item),1);wrap.remove();field.check.checked=true;updateSummary();}));list.append(wrap);
control.addEventListener('input',()=>{field.check.checked=true;updateSummary();});};
field=bindField(block,path,()=>rows.map(r=>r.get()),[]);value.forEach(row);
block.append(button('Добавить строку',()=>{row(typeof value[0]==='number'?0:typeof value[0]==='boolean'?false:'');field.check.checked=true;updateSummary();}));
}else{const nested=el('div',{class:'nested'});block.append(nested);for(const [key,v]of Object.entries(value))typed(nested,[...path,key],v,key);if(!Object.keys(value).length)block.append(el('p',{class:'text-muted',text:'Пустой объект. Поля отсутствуют и не создаются автоматически.'}));}
}else{
const block=el('div',{class:'field'});const control=scalar(value,label||path.at(-1));block.append(labelControl(label||path.at(-1),control));container.append(block);bindField(block,path,()=>scalarValue(control,value),[control]);
}
}
function scalar(value,label){let input;if(typeof value==='boolean'){input=el('input',{type:'checkbox',checked:value});}else if(typeof value==='number')input=numberInput(value);else if(value===null){input=el('select',{class:'form-control'},el('option',{value:'null',text:'null (пустое значение)'}));}else input=el('input',{class:'form-control',type:'text',value:String(value)});input.setAttribute('aria-label',label);return input;}
function scalarValue(input,original){if(typeof original==='boolean')return input.checked;if(typeof original==='number'){if(input.value===''||!Number.isFinite(Number(input.value)))throw new Error('Введите число: '+input.getAttribute('aria-label'));return Number(input.value);}if(original===null)return null;return input.value;}
function optionLabel(marker,value){const digit=Object.entries(enums[marker]||{}).find(([,v])=>v===value)?.[0]||value;return digit+(descriptions[marker]?.[digit]?' — '+descriptions[marker][digit]:'');}
function utmGroup(container,key,group,marker){
const section=el('section',{class:'field-block'},el('h2',{text:marker+'= UTM'}));container.append(section);
const groupFieldsStart=state.fields.length;
if(!group||typeof group!=='object'||Array.isArray(group)){typed(section,['utm',key],group,key);return;}
if(['degr','tp','cons'].includes(marker))section.append(el('p',{class:'alert alert-warning',text:'Существование группы на основном сайте не проверено. Допустим ручной номер 1–9.'}));
if(Object.hasOwn(group,'default')){
const original=group.default, current=String(original), semantic=!!enums[marker]&&Object.values(enums[marker]).includes(current);
const select=el('select',{class:'form-control','aria-label':marker+' default'});const values=enums[marker]?Object.keys(enums[marker]).map(d=>semantic?enums[marker][d]:d):Array.from({length:9},(_,i)=>String(i+1));
if(!values.includes(current))values.unshift(current);for(const v of values)select.append(el('option',{value:v,text:optionLabel(marker,v)}));select.value=current;
const block=el('div',{class:'field'});block.append(labelControl('Default',select));section.append(block);
const field=bindField(block,['utm',key,'default'],()=>typeof original==='number'?Number(select.value):select.value,[select]);
if(enums[marker]){const inline=el('div',{class:'utm-inline-options'});for(const v of values){const item=el('div',{class:'utm-inline-option'},el('span',{text:optionLabel(marker,v)}));const choose=el('button',{type:'button',class:'utm-default-action',text:current===v?'default':'назначить',onclick:()=>{select.value=v;field.check.checked=true;for(const b of inline.querySelectorAll('button'))b.textContent=b===choose?'default':'назначить';updateSummary();}});item.append(choose);inline.append(item);}section.append(inline);}
}
if(Object.hasOwn(group,'weights')&&group.weights&&typeof group.weights==='object'&&!Array.isArray(group.weights)){
const block=el('div',{class:'field'}),table=el('table',{class:'table table-bordered weight-table'}),body=el('tbody');table.append(el('thead',{},el('tr',{},el('th',{text:'Вариант'}),el('th',{text:'Вес ≥ 0'}),el('th',{text:'Действия'}))),body);block.append(el('h3',{text:'Веса вариантов'}),el('div',{class:'table-responsive'},table));section.append(block);const rows=[];
const field=bindField(block,['utm',key,'weights'],()=>{const weights={};for(const row of rows){const variant=row.variant.value;if(Object.hasOwn(weights,variant))throw new Error(marker+': вариант '+variant+' указан дважды.');if(!Number.isFinite(Number(row.weight.value))||row.weight.value===''||Number(row.weight.value)<0)throw new Error(marker+': вес должен быть неотрицательным числом.');weights[variant]=Number(row.weight.value);}return weights;},[]);
const addRow=(variant,weight)=>{const tr=el('tr');const variants=enums[marker]?Object.keys(enums[marker]):Array.from({length:9},(_,i)=>String(i+1));const semantic=!!enums[marker]&&Object.values(enums[marker]).includes(variant);const all=variants.map(d=>semantic?enums[marker][d]:d);if(!all.includes(variant))all.unshift(variant);const v=el('select',{class:'form-control','aria-label':marker+' вариант'},all.map(n=>el('option',{value:n,text:optionLabel(marker,n)})));v.value=variant;const w=numberInput(weight,0);w.setAttribute('aria-label',marker+' вес '+variant);const row={variant:v,weight:w};rows.push(row);tr.append(el('td',{},v),el('td',{},w),el('td',{},button('Удалить',()=>{rows.splice(rows.indexOf(row),1);tr.remove();field.check.checked=true;updateSummary();})));body.append(tr);for(const input of [v,w])input.addEventListener('input',()=>{field.check.checked=true;updateSummary();});};
for(const [variant,weight]of Object.entries(group.weights))addRow(variant,weight);
block.append(button('Добавить вариант',()=>{addRow('1',0);field.check.checked=true;updateSummary();}),el('p',{class:'text-muted',text:marker==='cons'?'При выборе rnd и нулевой сумме весов используется default.':'При выборе rnd и нулевой сумме весов используется первый вариант weights (в порядке config.json); при пустом списке — default.'}));
}
for(const [k,v]of Object.entries(group))if(!['default','weights'].includes(k))typed(section,['utm',key,k],v,k);
const fields=state.fields.slice(groupFieldsStart);section.prepend(button('Выбрать группу '+marker,()=>{fields.forEach(f=>f.check.checked=true);updateSummary();}));
}
function renderConfig(){state.fields=[];const container=$('config-fields');container.replaceChildren();const read=readCurrent();if(!read)return;const config=read.config;
for(const [key,value]of Object.entries(config))if(key!=='utm')typed(container,[key],value,key==='host'?'Основной сайт':key==='requisites'?'Реквизиты':key);
if(config.utm&&typeof config.utm==='object'){
for(const marker of markers){const key=Object.hasOwn(config.utm,marker)?marker:aliases[marker]&&Object.hasOwn(config.utm,aliases[marker])?aliases[marker]:marker==='banch'&&Object.hasOwn(config.utm,'chob')?'chob':null;if(key)utmGroup(container,key,config.utm[key],marker);else container.append(el('div',{class:'field-block'},el('h2',{text:marker+'= UTM'}),el('p',{class:'text-muted',text:'Группа отсутствует в этом config.json; автоматически не добавляется.'})));}
for(const [key,value]of Object.entries(config.utm))if(!markers.includes(key)&&!Object.values(aliases).includes(key)&&key!=='chob')typed(container,['utm',key],value,key);
}
$('read-state').textContent='Прочитано '+formatDate(new Date())+' · версия '+read.version.slice(0,12);
$('warnings').replaceChildren(...(read.warnings||[]).map(w=>el('p',{class:'alert alert-warning',text:typeof w==='string'?w:JSON.stringify(w)})));
updateSummary();
}
async function fetchRead(id){const data=await api('config?target='+encodeURIComponent(id));state.reads.set(id,data);return data;}
async function openTarget(id,force=false){if(!force&&state.fields.some(f=>f.check.checked)&&!confirm('При смене домена несохранённый выбор полей будет сброшен. Продолжить?')){$('source-target').value=state.source;return;}state.source=id;state.fields=[];$('config-fields').replaceChildren();$('sets-body').replaceChildren();$('backups').replaceChildren();$('warnings').replaceChildren();state.reads.clear();renderTargetOptions();updateSummary();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение текущей конфигурации…';updateSummary();try{await fetchRead(id);renderConfig();await Promise.all([loadSets(),loadBackups()]);notice('','info');}finally{state.loading--;updateSummary();}}
function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:target.id===state.source&&target.enabled,disabled:!target.enabled});const status=el('span',{class:'text-muted'});const label=el('label',{},input,targetName(target.id),status);box.append(label);input.addEventListener('change',()=>run(async()=>{if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;updateSummary();try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}updateSummary();}));}}
async function loadState(){const data=await api('state');state.csrf=data.csrf;state.targets=data.targets||[];$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;const source=$('source-target');source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));source.value=state.source;renderTargetOptions();renderConnections();await loadJobs();}
function safeTargets(){const ids=selected();if(!ids.length)throw new Error('Выберите хотя бы один домен.');if(state.loading||ids.some(id=>!state.reads.has(id)))throw new Error('Сначала перечитайте выбранные домены и дождитесь загрузки.');return ids;}
async function applyChanges(changes=chosenChanges(),ids=safeTargets()){
if(!changes.length)throw new Error('Выберите поля для применения.');
const targets=ids.map(id=>{const read=state.reads.get(id);if(!read)throw new Error('Сначала прочитайте конфигурацию '+targetName(id));return {id,version:read.version};});
$('apply').disabled=true;try{const result=await api('apply',{targets,changes});state.fields.filter(f=>f.check.checked).forEach(f=>f.check.checked=false);notice('Задание принято. Проверяйте результат каждой цели в журнале.','info');await showJob(result.job);}finally{updateSummary();}
}
async function loadSets(){if(!state.source)return;const targetId=state.source;const data=await api('sets?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;const current=readCurrent();if(current)current.sets=data.sets;const body=$('sets-body');body.replaceChildren();for(const set of (data.sets||[]).filter(s=>s.exists||s.registered||s.in_use)){const tr=el('tr',{class:'indpic-set-row'});const name=el('input',{class:'form-control utm-group-name',value:set.name||'',maxLength:255,'aria-label':'Название Set '+set.number});const namecell=el('td',{},el('span',{class:'utm-group-number',text:String(set.number)}),name,el('button',{type:'button',class:'utm-group-save',text:set.registered?'Сохранить':'Проверить и добавить',onclick:()=>run(async()=>{if(set.registered){await api('sets/name',{target:state.source,number:set.number,name:name.value});notice('Название сохранено.','success');}else{const result=await api('sets/register',{targets:[state.source],number:set.number,name:name.value});await showJob(result.job,true);}})}));const def=el('td');if(set.default)def.append(el('span',{class:'utm-default-value',text:'default'}));else def.append(el('button',{type:'button',class:'utm-default-action',text:'назначить',onclick:()=>run(()=>applyChanges([{path:['utm','indpic','default'],value:String(set.number)}],[state.source]))}));
const file=el('input',{type:'file',accept:'.zip,application/zip','aria-label':'ZIP Set '+set.number});const zip=el('td',{},el('div',{class:'indpic-file-control'},file,button('Загрузить ZIP',()=>uploadSet(file.files[0],set.number,name.value,set.in_use))));
const actions=el('td');if(!set.default&&!set.in_use&&set.registered)actions.append(button('Удалить',async()=>{if(confirm('Удалить Set '+set.number+' из реестра? Файлы на хосте останутся.')){await api('sets/delete',{target:state.source,number:set.number});await loadSets();}},'danger'));else actions.append(el('span',{class:'text-muted',text:set.in_use?'Используется':'Не в реестре'}));
tr.append(namecell,def,el('td',{},button('Проверить',async()=>{const r=await api('sets/check',{targets:safeTargets(),number:set.number});await showJob(r.job,true);})),zip,actions);body.append(tr);
}if(!(data.sets||[]).some(s=>s.exists||s.registered||s.in_use))body.append(el('tr',{},el('td',{colSpan:5,text:'Наборы ещё не зарегистрированы.'})));
}
async function uploadSet(file,number,name,inUse=false){if(!file)throw new Error('Выберите ZIP-файл.');const ids=safeTargets();const existing=ids.filter(id=>(state.reads.get(id)?.sets||[]).some(s=>String(s.number)===String(number)&&(s.exists||s.in_use)));if((inUse||existing.length)&&!confirm('Заменить существующий набор '+number+' на выбранных доменах? Он может использоваться как default или с ненулевым весом. Default и веса не изменятся.'))return;const form=new FormData();form.set('file',file);form.set('number',number);form.set('name',name);form.set('targets',JSON.stringify(safeTargets()));const r=await api('sets/upload',form);await showJob(r.job,true);}
function modal(title,text){$('result-title').textContent=title;$('result-body').textContent=text;if(!$('indpic-result-modal').open)$('indpic-result-modal').showModal();}
function renderJobs(){const box=$('jobs');box.replaceChildren();if(!state.jobs.length)box.append(el('p',{class:'text-muted',text:'Заданий пока нет.'}));for(const job of state.jobs){const block=el('div',{class:'job'},el('strong',{class:'break',text:'Задание '+job.id+' · '+(job.status==='completed'?'Завершено':statuses[job.status]||job.status)}));if(job.created_at)block.append(el('p',{class:'text-muted',text:formatDate(job.created_at)}));if(job.changes)block.append(el('p',{class:'text-muted break',text:'Поля: '+job.changes.map(c=>Array.isArray(c.path)?c.path.join('.'):String(c.path)).join(', ')}));const table=el('table',{class:'table table-bordered'},el('thead',{},el('tr',{},el('th',{text:'Домен'}),el('th',{text:'Результат'}),el('th',{text:'Повтор'}))));const body=el('tbody');const retry=[];for(const result of job.results||[]){const id=typeof result.target==='object'?result.target.id:result.target;const tr=el('tr');const outcome=el('td',{class:'status-'+result.status},el('strong',{text:statuses[result.status]||result.status}),el('div',{class:'break',text:[result.message,...(result.warnings||[])].filter(Boolean).join(' · ')}));const action=el('td');if(['error','conflict','unknown'].includes(result.status)){const check=el('input',{type:'checkbox',value:id,'aria-label':'Повтор '+targetName(id),checked:false});retry.push(check);action.append(check);}tr.append(el('td',{text:targetName(id)}),outcome,action);body.append(tr);}table.append(body);block.append(el('div',{class:'table-responsive'},table));if(retry.length)block.append(button('Повторить выбранные неуспешные цели',async()=>{const ids=retry.filter(c=>c.checked).map(c=>c.value);if(!ids.length)throw new Error('Отметьте неуспешные цели для повтора.');if((job.results||[]).some(r=>ids.includes(r.target)&&r.status==='conflict')){throw new Error('Конфликт нельзя повторить со старой версией. Перечитайте домен, проверьте значения и примените поля заново.');}const r=await api('jobs/retry',{id:job.id,targets:ids});await showJob(r.job);}));box.append(block);}}
async function loadJobs(){const r=await api('jobs');state.jobs=(r.jobs||[]).slice().sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||'')));renderJobs();for(const job of state.jobs)if(job.status!=='completed'&&!pollTimers.has(job.id))pollTimers.set(job.id,setTimeout(()=>run(()=>pollJob(job.id,false)),1400));}
const pollTimers=new Map();
async function showJob(job,dialog=false){const id=typeof job==='string'?job:job.id;if(typeof job==='object'){state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();}if(dialog)modal('Проверка / публикация indpic','Задание '+id+' принято. Ожидание проверки…');await pollJob(id,dialog);}
async function pollJob(id,dialog){pollTimers.delete(id);const r=await api('jobs?id='+encodeURIComponent(id));const job=r.job;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();if(dialog&&$('indpic-result-modal').open){modal('Результат проверки indpic',(job.results||[]).map(v=>targetName(v.target)+'\n'+(statuses[v.status]||v.status)+'\n'+(v.message||'')).join('\n\n')||'Ожидание…');}if(job.status!=='completed'){clearTimeout(pollTimers.get(id));pollTimers.set(id,setTimeout(()=>run(()=>pollJob(id,dialog)),1400));}else{clearTimeout(pollTimers.get(id));pollTimers.delete(id);const configChanged=['apply','restore'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success')state.reads.delete(result.target);if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}updateSummary();}}
async function loadBackups(){const box=$('backups');box.replaceChildren();if(!state.source)return;const targetId=state.source;const r=await api('backups?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;for(const backup of r.backups||[]){box.append(el('div',{class:'toolbar'},el('span',{class:'break',text:(backup.created_at?formatDate(backup.created_at):'')+' · '+backup.id}),button('Восстановить',async()=>{if(!confirm('Восстановить резерв '+backup.id+' на '+targetName(state.source)+'?'))return;const read=readCurrent();if(!read)throw new Error('Перед восстановлением перечитайте текущий домен.');const result=await api('restore',{target:state.source,id:backup.id,version:read.version});await showJob(result.job);})));}if(!(r.backups||[]).length)box.append(el('p',{class:'text-muted',text:'Резервных копий пока нет.'}));}
function renderConnections(){const list=$('connection-list');list.replaceChildren();for(const t of state.targets){list.append(el('div',{class:'connection'},el('span',{class:'break',text:t.name+' · '+targetName(t.id)+' · '+t.type+' · '+(t.enabled?'включено':'отключено')+(t.verified?' · соединение проверено':' · требуется проверка')+(t.has_secret?' · секрет сохранён':'')}),button('Изменить',()=>{const form=$('connection-form');form.reset();for(const key of ['id','name','domain','environment','type','host','port','user','root','host_key'])form.elements[key].value=t[key]??'';form.elements.enabled.checked=!!t.enabled;form.elements.id.readOnly=true;$('connection-title').textContent='Изменить подключение';form.elements.name.focus();}),button('Проверить соединение',async()=>{const result=await api('targets/test',{id:t.id});modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');}),button('Удалить',async()=>{if(confirm('Удалить подключение '+t.domain+'? Сайт и файлы останутся.')){await api('targets',{id:t.id},'DELETE');if(state.source===t.id){state.source='';state.fields=[];$('config-fields').replaceChildren();}await loadState();}},'danger')));}}
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;try{const data=await api('login',{username:form.username.value,password:form.password.value});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
$('logout').addEventListener('click',()=>run(async()=>{await api('logout',{});for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();showLogin();notice('Сеанс завершён.','info');}));
$('source-target').addEventListener('change',event=>run(()=>openTarget(event.target.value)));
$('reload').addEventListener('click',()=>run(async()=>{if(!state.source)throw new Error('Выберите домен.');if(state.fields.some(f=>f.check.checked)&&!confirm('Перечитать домен и сбросить несохранённый выбор полей?'))return;await openTarget(state.source,true);}));
$('select-all-fields').addEventListener('click',()=>{state.fields.forEach(f=>f.check.checked=true);updateSummary();});$('clear-fields').addEventListener('click',()=>{state.fields.forEach(f=>f.check.checked=false);updateSummary();});
$('apply').addEventListener('click',()=>run(()=>applyChanges()));
$('set-add').elements.sftp.addEventListener('change',event=>{const form=$('set-add');form.querySelector('.zip-field').hidden=event.target.checked;form.elements.file.required=!event.target.checked;form.querySelector('button').textContent=event.target.checked?'Проверить и добавить':'Загрузить и добавить';});
$('set-add').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const number=Number(form.elements.number.value);if(form.elements.sftp.checked){const r=await api('sets/register',{targets:safeTargets(),number,name:form.elements.name.value});await showJob(r.job,true);}else await uploadSet(form.elements.file.files[0],number,form.elements.name.value,false);});});
$('connection-new').addEventListener('click',()=>{$('connection-form').reset();$('connection-form').elements.id.readOnly=false;$('connection-title').textContent='Добавить подключение';});
$('connection-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const target={};for(const key of ['id','name','domain','environment','type','host','user','root','host_key'])target[key]=form.elements[key].value;target.port=Number(form.elements.port.value);target.enabled=form.elements.enabled.checked;const data={target};if(form.elements.secret.value)data.secret=form.elements.secret.value;await api('targets',data);form.elements.secret.value='';await loadState();notice('Подключение сохранено. Проверьте соединение перед записью.','success');});});
$('refresh-history').addEventListener('click',()=>run(async()=>{await Promise.all([loadJobs(),loadBackups()]);notice('Журнал обновлён.','info');}));
for(const id of ['result-close','result-done'])$(id).addEventListener('click',()=>$('indpic-result-modal').close());
window.addEventListener('beforeunload',event=>{if(state.fields.some(f=>f.check.checked)){event.preventDefault();event.returnValue='';}});
run(async()=>{try{await loadState();}catch(error){if(error.status===401)showLogin();else throw error;}});
})();