feat(admin): edit domain settings inline and copy saved configurations
deploy-preland / deploy zoomstage.online (push) Successful in 57s
deploy-preland / deploy zoomstage.online (push) Successful in 57s
This commit is contained in:
1 parent
84f70b1c71
commit
dd6d221dd7
19 files changed
+402
-409
No files matched your search
@@ -9,3 +9,7 @@
|
||||
.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0}
|
||||
/* #23: compact numeric percentages; note rows span the whole matrix without sticky cells. */
|
||||
.overview-percent{display:block;margin:6px 0;line-height:1.35}.overview-percent:first-child{margin-top:0}.overview-percent:last-child{margin-bottom:0}.overview-raw{font-size:12px;overflow-wrap:anywhere}#overview-table td.utm-note-cell{position:static;left:auto;width:auto;min-width:0;z-index:auto;background:#fafafa;white-space:normal}.utm-note-title{display:block;font-size:12px;color:#707070;margin-bottom:6px}.utm-note-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 8px}.utm-note-textarea{width:100%;max-width:100%;min-width:0;resize:vertical;min-height:90px}.utm-note-status{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.utm-note-status:empty{display:none}.utm-note-edit .toolbar{margin-bottom:0}.utm-note-cell .btn{white-space:normal}.utm-note-read:has(.utm-note-text[hidden]){display:flex;align-items:center}
|
||||
/* #25: one editable matrix and independent saved-settings copy. */
|
||||
.container{width:min(1440px,100%)}.overview-table{min-width:1280px}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){width:auto}.overview-table td{position:static!important}.overview-table tr[data-overview-utm] th{background:#fafafa}.overview-table .matrix-variant{display:grid;grid-template-columns:44px 58px 48px 1fr 20px;align-items:center;gap:6px;margin-top:7px}.matrix-variant .form-control{height:30px;min-height:30px;min-width:0;padding:3px 5px;font-size:13px}.matrix-labels{display:grid;grid-template-columns:44px 58px 48px 1fr;gap:6px;color:#78818e;font-size:11px}.matrix-default{display:flex;gap:4px;align-items:center;font-size:12px;font-weight:400;white-space:nowrap}.matrix-default input{margin:0}.matrix-percent{font-size:12px;white-space:nowrap}.matrix-percent.positive{font-weight:600}.matrix-remove{padding:0;border:0;background:none;color:#7a8594;font-size:19px;cursor:pointer;line-height:24px}.matrix-remove:hover{color:#a94442}.matrix-variants+.btn{font-size:12px;border:0;background:none;padding:6px 0 0;color:#337ab7}.matrix-footer td{background:#f8f9fb}.matrix-footer .toolbar{margin:0;gap:7px}.matrix-status{font-size:12px;font-weight:400;margin:7px 0}.matrix-dirty{color:#9c6413}.matrix-environment{font-size:12px;font-weight:400}.copy-grid{display:grid;grid-template-columns:320px minmax(0,1fr);gap:32px}.copy-grid>label{font-weight:600}.copy-grid select{margin-top:8px}.copy-hint{display:block;margin-top:10px;font-size:12px;font-weight:400}.copy-grid .target-options{margin-top:8px}.copy-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;border-top:1px solid #e1e5eb;margin-top:20px;padding-top:16px}.copy-bottom p{margin:0;font-size:13px}.copy-bottom .btn{flex-shrink:0}#copy-reason{margin:12px 0 0}.utm-note-read{display:flex;align-items:center;justify-content:space-between;gap:14px}.utm-note-text{margin:0}.utm-note-cell .btn{flex-shrink:0}.overview-wrapper{overscroll-behavior-x:contain}.overview-table caption{font-size:12px}@media(max-width:600px){.overview-table{min-width:1240px}.copy-grid{grid-template-columns:1fr;gap:20px}.copy-bottom{align-items:stretch;flex-direction:column}.copy-bottom .btn{align-self:flex-start}.overview-table th:first-child{width:90px}.matrix-footer .toolbar{flex-wrap:nowrap}.matrix-footer .btn{font-size:12px}.utm-note-read{align-items:flex-start}.utm-note-cell .btn{max-width:150px}.overview-table .form-control{font-size:13px}}
|
||||
|
||||
#notice{position:static;box-shadow:none}
|
||||
+96
-147
@@ -3,9 +3,8 @@
|
||||
'use strict';
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const state = {csrf:'',targets:[],source:'',reads:new Map(),fields:[],jobs:[],loading:0};
|
||||
const state = {sessionGeneration:0,csrf:'',targets:[],source:'',reads:new Map(),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'})+' МСК';};
|
||||
@@ -17,28 +16,6 @@
|
||||
return {key:keys[0],value:keys.length?config.utm[keys[0]]:null,conflict:keys.length>1?keys:[]};
|
||||
}
|
||||
function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)&&(!enums[marker]||Object.hasOwn(enums[marker],digit))?digit:null;}
|
||||
function overviewCell(config,marker){
|
||||
const cell=el('td'),group=groupFor(config,marker);
|
||||
if(group.conflict.length){cell.append(el('p',{class:'overview-error',text:'Конфликт ключей: '+group.conflict.join(', ')}));return cell;}
|
||||
if(!group.value||typeof group.value!=='object'||Array.isArray(group.value)){cell.append(el('span',{class:'text-muted',text:group.key?'Некорректная группа':'Группа отсутствует'}));return cell;}
|
||||
const value=group.value,defaultDigit=digitFor(marker,value.default),weights=value.weights;
|
||||
if(!defaultDigit)cell.append(el('p',{class:'overview-error',text:'Некорректный default: '+String(value.default??'не задан')}));
|
||||
if(!weights||typeof weights!=='object'||Array.isArray(weights)){cell.append(el('p',{class:'overview-error',text:'Веса отсутствуют или имеют неверный тип.'}));return cell;}
|
||||
const variants=[],seen=new Set();let invalid=false;
|
||||
for(const [raw,weight]of Object.entries(weights)){
|
||||
const digit=digitFor(marker,raw);
|
||||
if(!digit||typeof weight!=='number'||!Number.isFinite(weight)||weight<0){invalid=true;cell.append(el('p',{class:'overview-error',text:'Недопустимый вариант/вес: '+raw+' = '+JSON.stringify(weight)}));continue;}
|
||||
if(seen.has(digit)){invalid=true;cell.append(el('p',{class:'overview-error',text:'Конфликт semantic и numeric вариантов: '+raw+' / '+digit}));}
|
||||
seen.add(digit);variants.push({digit,weight});
|
||||
}
|
||||
if(invalid){for(const [raw,weight]of Object.entries(weights))cell.append(el('div',{class:'overview-raw',text:raw+' = '+JSON.stringify(weight)}));return cell;}
|
||||
if(defaultDigit&&!seen.has(defaultDigit))variants.push({digit:defaultDigit,weight:0});
|
||||
variants.sort((a,b)=>Number(a.digit)-Number(b.digit));
|
||||
const maximum=Math.max(0,...variants.map(v=>v.weight)),sum=maximum>0?variants.reduce((total,v)=>total+v.weight/maximum,0):0;
|
||||
for(const variant of variants){const percent=sum>0?Math.round((variant.weight/maximum)/sum*10000)/100:0;const line=el('div',{class:'overview-percent'},el(percent>0?'strong':'span',{class:'overview-value',text:variant.digit+' ('+percent+'%)'}));if(variant.digit===defaultDigit)line.append(el('span',{class:'overview-default',text:' - default'}));cell.append(line);}
|
||||
if(!variants.length)cell.append(el('span',{class:'text-muted',text:'Вариантов нет'}));
|
||||
return cell;
|
||||
}
|
||||
const utmNotes={entries:new Map(),loading:false,error:'',serial:0};
|
||||
function noteEntry(marker){
|
||||
if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=Math.max(1,state.targets.length);return entry;}
|
||||
@@ -68,7 +45,7 @@
|
||||
entry.reload.hidden=!entry.conflict;entry.reload.disabled=entry.saving||entry.loading;
|
||||
entry.retry.hidden=!utmNotes.error;entry.retry.disabled=utmNotes.loading;
|
||||
const error=entry.error||utmNotes.error;entry.status.className='utm-note-status '+(error?'overview-error':'text-muted');
|
||||
entry.status.textContent=entry.saving?'Сохранение заметки…':entry.loading?'Загрузка сохранённой заметки…':error?error:utmNotes.loading?'Загрузка заметок…':entry.note?.saved_at?'Сохранено '+formatDate(entry.note.saved_at):entry.note?'': 'Заметка ещё не загружена.';
|
||||
entry.status.textContent=entry.saving?'Сохранение заметки…':entry.loading?'Загрузка сохранённой заметки…':error?error:utmNotes.loading?'Загрузка заметок…':entry.note?'': 'Заметка ещё не загружена.';
|
||||
}
|
||||
function validNote(note){return note&&typeof note.text==='string'&&typeof note.version==='string'&&(note.saved_at===null||typeof note.saved_at==='string');}
|
||||
async function loadNotes(){
|
||||
@@ -107,32 +84,87 @@
|
||||
}}else if(utm!==undefined)visit(['utm'],utm);
|
||||
return values;
|
||||
}
|
||||
const matrix={entries:new Map(),structure:'',copySource:'',copyTargets:new Set(),copyBusy:false,copyEpoch:0,copyError:'',copyResults:[],operationTargets:new Set()};
|
||||
function matrixEntry(id){if(!matrix.entries.has(id))matrix.entries.set(id,{id,epoch:0,data:null,dirty:false,saving:false,error:'',conflict:false,cells:new Map(),groups:new Map()});return matrix.entries.get(id);}
|
||||
function matrixDirty(){return [...matrix.entries.values()].some(e=>e.dirty||e.saving)||[...utmNotes.entries.values()].some(e=>e.editing||e.saving);}
|
||||
function matrixChanged(entry){entry.epoch++;const snapshot=overview.entries.get(entry.id);if(snapshot?.status==='loading'&&snapshot.data)overview.entries.set(entry.id,{...snapshot,status:'success'});overview.pending.delete(entry.id);entry.dirty=true;entry.error='';entry.validation='';try{matrixChanges(entry);}catch(error){entry.validation=error.message;}updateMatrixStatus(entry);renderCopy();}
|
||||
function updateMatrixStatus(entry){
|
||||
if(!entry.statusNode)return;entry.statusNode.textContent=entry.saving?'Сохранение…':entry.error||entry.validation|| (entry.dirty?'Есть изменения':entry.data?'Сохранено':'Чтение…');entry.statusNode.className='matrix-status '+(entry.error||entry.validation?'overview-error':entry.dirty?'matrix-dirty':'text-muted');
|
||||
entry.save.disabled=!entry.dirty||!!entry.validation||entry.saving||entry.conflict||!entry.data||!state.targets.find(t=>t.id===entry.id)?.enabled||!state.targets.find(t=>t.id===entry.id)?.verified;entry.cancel.disabled=entry.saving||!entry.dirty;entry.reload.disabled=entry.saving;entry.reload.textContent=entry.conflict?'Загрузить сохранённое':'Обновить';
|
||||
if(entry.footerError){entry.footerError.textContent=entry.error||entry.validation||'';entry.footerError.hidden=!entry.footerError.textContent;}for(const cell of entry.cells.values())for(const input of cell.querySelectorAll('input,select,button'))input.disabled=entry.saving||input.dataset.exhausted==='true';
|
||||
}
|
||||
function matrixGroup(entry,marker,cell){
|
||||
cell.replaceChildren();const group=groupFor(entry.data.config,marker),raw=group.value;
|
||||
if(group.conflict.length){cell.append(el('p',{class:'overview-error',text:'Конфликт ключей: '+group.conflict.join(', ')}));return;}
|
||||
if(!group.key){cell.append(el('span',{class:'text-muted',text:'Группа отсутствует'}),button('+ Добавить группу',()=>{const draft={key:marker,original:null,rows:[],defaultRow:null,added:true};entry.groups.set(marker,draft);renderGroup(draft);addVariant(draft,'1',1);draft.defaultRow=draft.rows[0];draft.rows[0].radio.checked=true;draft.touched=true;matrixChanged(entry);}));return;}
|
||||
if(!raw||typeof raw!=='object'||Array.isArray(raw)||!raw.weights||typeof raw.weights!=='object'||Array.isArray(raw.weights)||!digitFor(marker,raw.default)||Object.entries(raw.weights).some(([key,w])=>!digitFor(marker,key)||typeof w!=='number'||!Number.isFinite(w)||w<0)||new Set(Object.keys(raw.weights).map(k=>digitFor(marker,k))).size!==Object.keys(raw.weights).length){cell.append(el('p',{class:'overview-error',text:'Некорректная группа. Редактирование недоступно.'}));return;}
|
||||
const draft={key:group.key,original:structuredClone(raw),rows:[],defaultRow:null,added:false};entry.groups.set(marker,draft);renderGroup(draft);for(const [key,w]of Object.entries(raw.weights))addVariant(draft,key,w);const defaultDigit=digitFor(marker,raw.default);if(!draft.rows.some(row=>digitFor(marker,row.variant.value)===defaultDigit))addVariant(draft,String(raw.default),0);draft.defaultRow=draft.rows.find(row=>digitFor(marker,row.variant.value)===defaultDigit);draft.defaultRow.radio.checked=true;percentages(draft);
|
||||
function renderGroup(draft){draft.marker=marker;cell.replaceChildren();draft.list=el('div',{class:'matrix-variants'});cell.append(el('div',{class:'matrix-labels','aria-hidden':'true'},el('span',{text:'Вариант'}),el('span',{text:'Вес'}),el('span',{text:'Доля'}),el('span',{text:'Default'})),draft.list,button('+ Вариант',()=>{const candidates=enums[marker]?Object.keys(enums[marker]):Array.from({length:9},(_,i)=>String(i+1));const next=candidates.find(v=>!draft.rows.some(r=>digitFor(marker,r.variant.value)===v));if(!next)return;const semantic=Object.keys(draft.original?.weights||{}).some(v=>Object.values(enums[marker]||{}).includes(v));addVariant(draft,semantic?enums[marker][next]:next,0);draft.touched=true;matrixChanged(entry);}));draft.addControl=cell.lastElementChild;}
|
||||
function addVariant(draft,key,weight){const wrapper=el('div',{class:'matrix-variant'}),values=enums[marker]?Object.keys(enums[marker]):Array.from({length:9},(_,i)=>String(i+1));const semantic=Object.values(enums[marker]||{}).includes(key);const variant=el('select',{class:'form-control','aria-label':marker+' вариант '+entry.id},...values.map(v=>el('option',{value:semantic?enums[marker][v]:v,text:v})));variant.value=key;const input=numberInput(weight,0);input.setAttribute('aria-label',marker+' вес '+key+' '+entry.id);const percent=el('span',{class:'matrix-percent'}),radio=el('input',{type:'radio',name:'default-'+entry.id+'-'+marker,'aria-label':marker+' default '+key+' '+entry.id});const row={wrapper,variant,weight:input,percent,radio};draft.rows.push(row);const remove=button('×',()=>{draft.rows.splice(draft.rows.indexOf(row),1);wrapper.remove();if(draft.defaultRow===row){draft.defaultRow=null;}percentages(draft);draft.touched=true;matrixChanged(entry);});remove.className='matrix-remove';remove.setAttribute('aria-label','Удалить вариант '+marker+' '+key+' '+entry.id);wrapper.append(variant,input,percent,el('label',{class:'matrix-default'},radio,el('span',{text:'default'})),remove);draft.list.append(wrapper);for(const control of [variant,input])control.addEventListener('input',()=>{percentages(draft);draft.touched=true;matrixChanged(entry);});radio.addEventListener('change',()=>{draft.defaultRow=row;draft.touched=true;matrixChanged(entry);});percentages(draft);}
|
||||
}
|
||||
function percentages(group){if(group.addControl){const values=enums[group.marker]?Object.keys(enums[group.marker]):Array.from({length:9},(_,i)=>String(i+1)),exhausted=values.every(v=>group.rows.some(r=>digitFor(group.marker,r.variant.value)===v));group.addControl.dataset.exhausted=String(exhausted);group.addControl.disabled=exhausted;}const max=Math.max(0,...group.rows.map(r=>Number(r.weight.value)||0)),sum=max?group.rows.reduce((n,r)=>n+(Number(r.weight.value)||0)/max,0):0;for(const row of group.rows){const p=sum?Math.round((Number(row.weight.value)||0)/max/sum*10000)/100:0;row.percent.textContent=p+'%';row.percent.classList.toggle('positive',p>0);}}
|
||||
function matrixChanges(entry){const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?enums[marker][digit]:digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;}
|
||||
function populateMatrix(entry){entry.groups.clear();entry.hostTouched=false;entry.validation='';for(const [key,cell]of entry.cells){cell.replaceChildren();if(key==='host'){entry.host=el('input',{class:'form-control',value:entry.data.config.host??'','data-host':'','aria-label':'Основной сайт '+entry.id});entry.host.addEventListener('input',()=>{entry.hostTouched=true;matrixChanged(entry);});cell.append(entry.host);}else matrixGroup(entry,key,cell);}updateMatrixStatus(entry);}
|
||||
function renderOverview(){
|
||||
const table=$('overview-table'),head=el('tr',{},el('th',{scope:'col',text:'Параметр'}));
|
||||
for(const target of state.targets){const entry=overview.entries.get(target.id),th=el('th',{scope:'col','data-overview-target':target.id},el('div',{class:'overview-domain',text:target.domain}),el('div',{class:'text-muted',text:target.environment+(target.enabled?'':' · запись отключена')+(target.verified?'':' · соединение не проверено')}));
|
||||
let text=entry?.status==='loading'?'Чтение…':entry?.status==='error'?'Ошибка чтения: '+entry.error:entry?.data?'Прочитано':'Не прочитано';
|
||||
if(entry?.data){text+=(entry.status!=='success'?' Показан прежний снимок. ':' ')+formatDate(entry.readAt)+' · версия '+entry.data.version.slice(0,12);}
|
||||
th.append(el('p',{class:'overview-status'+(entry?.status==='error'?' overview-error':''),text}));
|
||||
const refresh=button('Обновить',()=>refreshOverviewTarget(target.id));refresh.setAttribute('aria-label','Обновить сводку '+target.domain);refresh.disabled=entry?.status==='loading';
|
||||
th.append(el('div',{class:'toolbar'},refresh,button('Редактировать',async()=>{await openTarget(target.id);if(state.source===target.id){$('source-target').value=target.id;$('settings').scrollIntoView({behavior:'smooth',block:'start'});}})));head.append(th);
|
||||
const focused=document.activeElement,selection=focused&&'selectionStart'in focused?{start:focused.selectionStart,end:focused.selectionEnd}:null;const signature=state.targets.map(t=>t.id).join('|'),table=$('overview-table');table.style.minWidth=Math.max(760,110+state.targets.length*292)+'px';if(matrix.structure!==signature||!table.querySelector('thead tr')){matrix.structure=signature;const head=el('tr',{},el('th',{scope:'col',text:'Параметр'})),body=table.querySelector('tbody');body.replaceChildren();table.querySelector('thead').replaceChildren(head);const hostrow=el('tr',{'data-overview-extra':'host'},el('th',{scope:'row',text:'Основной сайт'}));body.append(hostrow);for(const target of state.targets){const entry=matrixEntry(target.id);entry.statusNode=el('p',{class:'matrix-status',role:'status'});entry.reload=button('Обновить',()=>{if(entry.dirty&&!confirm('Загрузить сохранённые настройки и сбросить черновик '+target.domain+'?'))return;return refreshOverviewTarget(target.id,true);});entry.reload.setAttribute('aria-label','Обновить настройки '+target.id);head.append(el('th',{'data-overview-target':target.id},el('div',{class:'overview-domain',text:target.domain}),el('div',{class:'text-muted matrix-environment',text:target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено')}),entry.statusNode,entry.reload));const cell=entry.cells.get('host')||el('td',{'data-matrix-target':target.id});entry.cells.set('host',cell);hostrow.append(cell);}
|
||||
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{'aria-hidden':'true'}));for(const target of state.targets){const cell=matrixEntry(target.id).cells.get(marker)||el('td',{'data-matrix-target':target.id,'data-marker':marker});matrixEntry(target.id).cells.set(marker,cell);row.append(cell);}body.append(noteEntry(marker).row,row);}
|
||||
const footer=el('tr',{class:'matrix-footer'},el('th',{scope:'row',text:'Сохранение'}));for(const target of state.targets){const entry=matrixEntry(target.id);entry.save=button('Сохранить',()=>saveMatrix(target.id),'primary');entry.save.setAttribute('aria-label','Сохранить настройки '+target.id);entry.cancel=button('Отмена',()=>{if(entry.conflict)return refreshOverviewTarget(target.id,true);entry.epoch++;entry.dirty=false;entry.error='';entry.validation='';populateMatrix(entry);renderCopy();});entry.cancel.setAttribute('aria-label','Отменить настройки '+target.id);entry.footerError=el('p',{class:'overview-error matrix-footer-error',role:'alert','aria-live':'polite',hidden:true});footer.append(el('td',{},el('div',{class:'toolbar'},entry.save,entry.cancel),entry.footerError));if(entry.data&&!entry.host)populateMatrix(entry);else updateMatrixStatus(entry);}body.append(footer);if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}
|
||||
}
|
||||
const active=document.activeElement,noteFocus=active?.matches('.utm-note-textarea')?{node:active,start:active.selectionStart,end:active.selectionEnd,direction:active.selectionDirection,scroll:active.scrollTop}:null;
|
||||
table.querySelector('thead').replaceChildren(head);const body=table.querySelector('tbody');body.replaceChildren();
|
||||
const extraByTarget=new Map(),extraKeys=new Map();
|
||||
for(const target of state.targets){const extra=overviewExtra(overview.entries.get(target.id)?.data?.config);extraByTarget.set(target.id,extra);for(const [key,value]of extra)extraKeys.set(key,value.label);}
|
||||
const orderedKeys=[...extraKeys.keys()].sort((a,b)=>{const rank=key=>key==='host'?0:1;return rank(a)-rank(b)||a.localeCompare(b,'ru');});
|
||||
for(const key of orderedKeys){const row=el('tr',{'data-overview-extra':key},el('th',{scope:'row',text:extraKeys.get(key)}));for(const target of state.targets){const entry=overview.entries.get(target.id),value=extraByTarget.get(target.id)?.get(key);row.append(el('td',{class:'overview-plain',text:value?value.text:entry?.data?'Поле отсутствует':'Нет прочитанного конфига'}));}body.append(row);}
|
||||
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{'aria-hidden':'true',text:''}));for(const target of state.targets){const entry=overview.entries.get(target.id);row.append(entry?.data?overviewCell(entry.data.config,marker):el('td',{class:'text-muted',text:entry?.status==='loading'?'Загрузка…':'Нет прочитанного конфига'}));}body.append(noteEntry(marker).row,row);}
|
||||
if(noteFocus?.node.isConnected&&!noteFocus.node.disabled){noteFocus.node.focus({preventScroll:true});noteFocus.node.setSelectionRange(noteFocus.start,noteFocus.end,noteFocus.direction);noteFocus.node.scrollTop=noteFocus.scroll;}
|
||||
if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState();
|
||||
for(const target of state.targets){const entry=matrixEntry(target.id),snapshot=overview.entries.get(target.id);const title=entry.statusNode?.parentElement;if(title){title.querySelector('.overview-domain').textContent=target.domain;title.querySelector('.matrix-environment').textContent=target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено');}if(snapshot?.data&&entry.data!==snapshot.data&&!entry.dirty&&!entry.saving){entry.data=snapshot.data;populateMatrix(entry);}if(snapshot?.status==='error'&&!entry.error){entry.error='Ошибка чтения: '+snapshot.error;}updateMatrixStatus(entry);}renderExtras();renderCopy();if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState();
|
||||
}
|
||||
function refreshOverviewTarget(id){
|
||||
const request=++overview.serial,previous=overview.entries.get(id);overview.entries.set(id,{...previous,status:'loading',request});renderOverview();
|
||||
const pending=(async()=>{try{const data=await api('config?target='+encodeURIComponent(id));if(overview.entries.get(id)?.request!==request||!state.csrf)return;overview.entries.set(id,{status:'success',request,data,readAt:new Date().toISOString()});}catch(error){if(overview.entries.get(id)?.request!==request||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});}finally{if(overview.entries.get(id)?.request===request){overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;
|
||||
function renderExtras(){const body=$('overview-table').querySelector('tbody'),values=new Map(),keys=new Map();for(const t of state.targets){const v=overviewExtra(matrixEntry(t.id).data?.config);v.delete('host');values.set(t.id,v);for(const [key,item]of v)keys.set(key,item.label);}body.querySelectorAll('[data-matrix-extra]').forEach(n=>n.remove());for(const [key,label]of keys){const row=el('tr',{'data-matrix-extra':key},el('th',{text:label}));for(const t of state.targets)row.append(el('td',{class:'overview-plain',text:values.get(t.id).get(key)?.text||'Поле отсутствует'}));body.append(row);}}
|
||||
function refreshOverviewTarget(id,force=false){const entry=matrixEntry(id),epoch=++entry.epoch,request=++overview.serial,previous=overview.entries.get(id);overview.entries.set(id,{...previous,status:'loading',request});renderOverview();const pending=(async()=>{try{const data=await api('config?target='+encodeURIComponent(id));if(entry.epoch!==epoch||matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{status:'success',request,data});if(entry.dirty&&!force){if(entry.data?.version!==data.version){entry.conflict=true;entry.error='Настройки изменены на сервере. Черновик сохранён. Загрузите сохранённое или отмените изменения.';}}else{entry.data=data;entry.dirty=false;entry.conflict=false;entry.error='';populateMatrix(entry);}}catch(error){if(entry.epoch!==epoch||matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});entry.error=error.message;}finally{if(entry.epoch===epoch&&matrix.entries.get(id)===entry){overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;}
|
||||
async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;run(loadNotes);try{await Promise.allSettled(state.targets.map(t=>refreshOverviewTarget(t.id)));}finally{control.disabled=false;}}
|
||||
async function waitMatrixJob(job,isCurrent){
|
||||
let current=typeof job==='object'?job:null,id=typeof job==='string'?job:job.id;
|
||||
while(!current||current.status!=='completed'){
|
||||
await new Promise(resolve=>setTimeout(resolve,600));if(!isCurrent())return null;
|
||||
current=(await api('jobs?id='+encodeURIComponent(id))).job;if(!isCurrent())return null;
|
||||
}
|
||||
if(!isCurrent())return null;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(current);renderJobs();return current;
|
||||
}
|
||||
async function saveMatrix(id){
|
||||
const entry=matrixEntry(id),generation=state.sessionGeneration,isCurrent=()=>generation===state.sessionGeneration&&!!state.csrf&&matrix.entries.get(id)===entry;
|
||||
if(entry.saving||entry.conflict||!entry.dirty)return;
|
||||
try{
|
||||
const changes=matrixChanges(entry);if(!changes.length){entry.dirty=false;updateMatrixStatus(entry);renderCopy();return;}
|
||||
entry.epoch++;entry.saving=true;entry.error='';updateMatrixStatus(entry);renderCopy();
|
||||
const result=await api('apply',{targets:[{id,version:entry.data.version}],changes});if(!isCurrent())return;
|
||||
const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;
|
||||
const outcome=job.results?.find(r=>(typeof r.target==='object'?r.target.id:r.target)===id);
|
||||
if(outcome?.status!=='success'){entry.conflict=outcome?.status==='conflict';throw Error(outcome?.message||'Сохранение не подтверждено. Проверьте журнал.');}
|
||||
entry.saving=false;if(!isCurrent())return;await refreshOverviewTarget(id,true);if(!isCurrent())return;
|
||||
if(state.source===id){await fetchRead(id);if(!isCurrent())return;await loadSets();if(!isCurrent())return;await loadBackups();}
|
||||
}catch(error){if(isCurrent()){entry.error=error.message;entry.conflict=entry.conflict||error.status===409;}}
|
||||
finally{if(isCurrent()){entry.saving=false;updateMatrixStatus(entry);renderCopy();}}
|
||||
}
|
||||
function renderCopy(){
|
||||
$('copy-all').disabled=matrix.copyBusy;$('copy-none').disabled=matrix.copyBusy;
|
||||
const resultBox=$('copy-results');resultBox.replaceChildren();if(matrix.copyError)resultBox.append(el('p',{class:'overview-error',text:matrix.copyError}));for(const result of matrix.copyResults){const id=typeof result.target==='object'?result.target.id:result.target;resultBox.append(el('p',{class:'status-'+result.status,text:targetName(id)+' — '+(statuses[result.status]||result.status)+(result.message?' · '+result.message:'')}));}
|
||||
const source=$('copy-source'),ids=state.targets.map(t=>t.id);if([...source.options].slice(1).map(o=>o.value).join('|')!==ids.join('|'))source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:t.domain})));for(const option of [...source.options]){const target=state.targets.find(t=>t.id===option.value);if(target)option.textContent=target.domain;}source.value=matrix.copySource;source.disabled=matrix.copyBusy;const box=$('copy-targets');box.replaceChildren();for(const target of state.targets){if(target.id===matrix.copySource){matrix.copyTargets.delete(target.id);continue;}const input=el('input',{type:'checkbox',checked:matrix.copyTargets.has(target.id),disabled:matrix.copyBusy||!target.enabled||!target.verified,'aria-label':'Копировать в '+target.domain});input.addEventListener('change',()=>{if(input.checked)matrix.copyTargets.add(target.id);else matrix.copyTargets.delete(target.id);renderCopy();});box.append(el('label',{},input,target.domain));}const from=matrix.entries.get(matrix.copySource),targets=[...matrix.copyTargets].map(id=>matrixEntry(id));let reason=matrix.copyBusy?'Копирование…':!from?'Выберите исходный домен.':!state.targets.find(t=>t.id===from.id)?.enabled||!state.targets.find(t=>t.id===from.id)?.verified?'Источник отключён или соединение не проверено.':!from.data?'Дождитесь чтения исходного домена.':overview.entries.get(from.id)?.status!=='success'?'Обновите настройки источника после ошибки чтения.':from.dirty||from.saving?'Сначала сохраните или отмените изменения источника.':from.conflict?'Обновите настройки источника.':!targets.length?'Выберите домены получателей.':targets.some(e=>!state.targets.find(t=>t.id===e.id)?.enabled||!state.targets.find(t=>t.id===e.id)?.verified)?'Получатель отключён или соединение не проверено.':targets.some(e=>e.dirty||e.saving)?'Сначала сохраните или отмените изменения получателей.':targets.some(e=>!e.data||e.conflict||overview.entries.get(e.id)?.status!=='success')?'Обновите настройки получателей.':'';$('copy-reason').textContent=reason;$('copy-submit').disabled=!!reason;$('copy-submit').textContent='Копировать в '+targets.length+' '+(targets.length%10===1&&targets.length%100!==11?'домен':targets.length%10>=2&&targets.length%10<=4&&(targets.length%100<12||targets.length%100>14)?'домена':'доменов');}
|
||||
async function copySettings(){
|
||||
if($('copy-submit').disabled)return;
|
||||
const from=matrixEntry(matrix.copySource),ids=[...matrix.copyTargets],generation=state.sessionGeneration,operation=++matrix.copyEpoch;
|
||||
const entries=new Map([from.id,...ids].map(id=>[id,matrix.entries.get(id)]));
|
||||
const sameOperation=()=>generation===state.sessionGeneration&&!!state.csrf&&matrix.copyEpoch===operation;
|
||||
const isCurrent=()=>sameOperation()&&[...entries].every(([id,entry])=>matrix.entries.get(id)===entry);
|
||||
matrix.copyError='';matrix.copyResults=[];matrix.copyBusy=true;for(const entry of entries.values()){entry.epoch++;entry.saving=true;updateMatrixStatus(entry);}renderCopy();
|
||||
try{
|
||||
const result=await api('copy',{source:{id:from.id,version:from.data.version},targets:ids.map(id=>({id,version:entries.get(id).data.version}))});if(!isCurrent())return;
|
||||
const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;
|
||||
matrix.copyResults=job.results||[];
|
||||
for(const outcome of job.results||[]){
|
||||
if(!isCurrent())return;const id=typeof outcome.target==='object'?outcome.target.id:outcome.target,entry=entries.get(id);if(!entry)continue;
|
||||
if(outcome.status==='success'){entry.saving=false;await refreshOverviewTarget(id);if(!isCurrent())return;}
|
||||
else{entry.error=outcome.message||'Не удалось скопировать настройки.';entry.conflict=outcome.status==='conflict';updateMatrixStatus(entry);}
|
||||
}
|
||||
if(!isCurrent())return;notice('Копирование завершено. Результат каждого домена показан в журнале.','info');
|
||||
if(state.source&&ids.includes(state.source)){await fetchRead(state.source);if(!isCurrent())return;await loadSets();if(!isCurrent())return;await loadBackups();}
|
||||
}catch(error){if(isCurrent()){matrix.copyError=error.message;notice(error.message);if(error.status===409){from.conflict=true;from.error=error.message;updateMatrixStatus(from);}}}
|
||||
finally{if(sameOperation()){matrix.copyBusy=false;for(const [id,entry]of entries)if(matrix.entries.get(id)===entry){entry.saving=false;updateMatrixStatus(entry);}renderCopy();}}
|
||||
}
|
||||
async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;run(loadNotes);try{await Promise.allSettled(state.targets.map(target=>refreshOverviewTarget(target.id)));}finally{control.disabled=false;}}
|
||||
function syncOverview(){
|
||||
const ids=new Set(state.targets.map(target=>target.id));for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
|
||||
const ids=new Set(state.targets.map(target=>target.id));for(const id of matrix.entries.keys())if(!ids.has(id)){matrix.entries.get(id).epoch++;matrix.entries.delete(id);matrix.copyTargets.delete(id);matrix.operationTargets.delete(id);}if(!ids.has(matrix.copySource))matrix.copySource='';for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
|
||||
if(builder.target&&!ids.has(builder.target))builder.target='';
|
||||
const select=$('builder-target');select.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));select.value=builder.target;renderOverview();if(builder.target&&builder.entries.get(builder.target)?.domain!==state.targets.find(t=>t.id===builder.target)?.domain)run(()=>openBuilder(builder.target,true));else renderBuilder();run(refreshOverview);
|
||||
}
|
||||
@@ -192,117 +224,33 @@
|
||||
|
||||
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;};
|
||||
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;for(const entry of matrix.entries.values())updateMatrixStatus(entry);renderCopy();}}));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'}};
|
||||
const generation=state.sessionGeneration;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;
|
||||
if(!response.ok||!body.ok){if(response.status===401&&generation===state.sessionGeneration)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(){utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';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 showLogin(){state.sessionGeneration++;matrix.copyEpoch++;matrix.copyBusy=false;for(const entry of matrix.entries.values())entry.epoch++;matrix.entries.clear();matrix.structure='';matrix.copySource='';matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.clear();matrix.operationTargets.clear();utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';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 labelControl(text,input){const label=el('label',{text});label.append(input);return label;}
|
||||
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)){
|
||||
const catalogGroups=readCurrent()?.catalog?.groups?.[marker];
|
||||
if(Array.isArray(catalogGroups))section.append(el('p',{class:'alert alert-success',text:'Каталог основного сайта проверен. Доступные группы: '+(catalogGroups.map(item=>String(item.number)+(item.name?' — '+String(item.name):'')).join('; ')||'нет')+'.'}));
|
||||
else 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'){
|
||||
if(key==='requisites'){
|
||||
// По просьбе владельца (#22/comment3770) редактирование реквизитов отключено.
|
||||
// Данные config сохраняются; реквизитов нет в сводке и state.fields (#24).
|
||||
// typed(container,[key],value,'Реквизиты');
|
||||
continue;
|
||||
}
|
||||
typed(container,[key],value,key==='host'?'Основной сайт':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'&&key!=='chb')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 openTarget(id){if(id!==state.source)matrix.operationTargets=new Set(id?[id]:[]);state.source=id;$('sets-body').replaceChildren();$('backups').replaceChildren();renderTargetOptions();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение конфигурации для наборов и резерва…';try{await fetchRead(id);await Promise.all([loadSets(),loadBackups()]);$('read-state').textContent='Домен прочитан.';}finally{state.loading--;}} function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:matrix.operationTargets.has(target.id)&&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)matrix.operationTargets.add(target.id);else matrix.operationTargets.delete(target.id);if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}}));}}
|
||||
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();syncOverview();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()){
|
||||
async function applyChanges(changes,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();}
|
||||
try{const result=await api('apply',{targets,changes});notice('Задание принято. Проверяйте результат каждой цели в журнале.','info');await showJob(result.job);}finally{}
|
||||
}
|
||||
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))));
|
||||
@@ -316,21 +264,22 @@
|
||||
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);run(()=>refreshOverviewTarget(result.target));}if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}updateSummary();}}
|
||||
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','copy'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success'){state.reads.delete(result.target);run(()=>refreshOverviewTarget(result.target));}if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}}}
|
||||
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});if(state.targets.includes(t)){t.verified=true;renderConnections();renderOverview();}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')));}}
|
||||
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});if(state.targets.includes(t)){t.verified=true;renderConnections();renderOverview();}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='';}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,remember:form.remember.checked});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');}));
|
||||
$('logout').addEventListener('click',()=>run(async()=>{if(matrixDirty()&&!confirm('Есть несохранённые изменения настроек или заметок. Выйти?'))return;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?'Проверить и добавить':'Загрузить и добавить';});
|
||||
$('reload').addEventListener('click',()=>run(()=>openTarget(state.source)));
|
||||
$('copy-source').addEventListener('change',e=>{matrix.copySource=e.target.value;matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.delete(e.target.value);renderCopy();});
|
||||
$('copy-all').addEventListener('click',()=>{matrix.copyTargets=new Set(state.targets.filter(t=>t.id!==matrix.copySource&&t.enabled&&t.verified).map(t=>t.id));renderCopy();});
|
||||
$('copy-none').addEventListener('click',()=>{matrix.copyTargets.clear();renderCopy();});
|
||||
$('copy-submit').addEventListener('click',()=>run(copySettings)); $('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='';}});
|
||||
window.addEventListener('beforeunload',event=>{if(matrixDirty()){event.preventDefault();event.returnValue='';}});
|
||||
run(async()=>{try{await loadState();}catch(error){if(error.status===401)showLogin();else throw error;}});
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog,LinkBuilder,Notes};
|
||||
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog,LinkBuilder,Notes,Copy};
|
||||
header('X-Content-Type-Options: nosniff');header('X-Frame-Options: DENY');header('Referrer-Policy: same-origin');header('Cache-Control: no-store');
|
||||
$path=parse_url($_SERVER['REQUEST_URI']??'/',PHP_URL_PATH);
|
||||
if(!str_starts_with((string)$path,'/api/')) {require dirname(__DIR__).'/views/dashboard.php';exit;}
|
||||
@@ -59,6 +59,10 @@ try {
|
||||
$catalog=Catalog::read($store,$read['config']);$warnings=[];if($catalog===null)foreach(['degr','tp','cons'] as $key)if(isset($read['config']->utm->$key))$warnings[]="{$key}: существование группы на основном сайте не проверено.";
|
||||
reply(['config'=>$read['config'],'version'=>$read['version'],'warnings'=>$warnings,'catalog'=>$catalog,'sets'=>$sets->list($target,$read['config'])]);
|
||||
}
|
||||
if($path==='/api/copy'){
|
||||
if($method!=='POST')throw new Problem('Метод копирования не поддерживается.',405);
|
||||
reply(['job'=>(new Copy($store))->create($data)],202);
|
||||
}
|
||||
if($path==='/api/apply'&&$method==='POST'){$data['targets']=array_map(static fn($t)=>(array)$t,(array)($data['targets']??[]));$data['changes']=array_map(static fn($c)=>(array)$c,(array)($data['changes']??[]));reply(['job'=>$jobs->create('apply',$data)],202);}
|
||||
if($path==='/api/jobs'&&$method==='GET')reply(isset($_GET['id'])?['job'=>$jobs->get((string)$_GET['id'])]:['jobs'=>$jobs->all()]);
|
||||
if($path==='/api/jobs/retry'&&$method==='POST')reply(['job'=>$jobs->retry((string)($data['id']??''),(array)($data['targets']??[]))],202);
|
||||
|
||||
Reference in new issue
Block a user