diff --git a/admin/README.md b/admin/README.md index d3d3405..897d3c2 100644 --- a/admin/README.md +++ b/admin/README.md @@ -1,22 +1,33 @@ # Админка прелендингов — preland#22 Отдельное PHP-приложение для `admin.zoomstage.online`. Формы редактируют фактические -`config.json`, выбранные поля применяются к одному или нескольким доменам. ZIP indpic +`config.json` в общей таблице; сохранённые настройки можно скопировать в выбранные домены. ZIP indpic проверяется по контракту CPanel и доставляется на выбранные хостинги. Изменение контента не требует Git/push/deploy; `admin/` не входит в публичную поставку прелендингов. -Редактирование реквизитов отключено по поручению владельца: `requisites` скрыты в сводке и редакторе, не входят в «Выбрать все поля». Данные config сохраняются. +Реквизиты и устаревший `utm.chb` скрыты и защищены от записи. Неизвестные поля показаны только для чтения и сохраняются. Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории, копии и фактический статус — в базе знаний, основной реестр — preland#22. ## Сводка и конструктор ссылки -Первый экран читает сохранённые конфиги всех подключений: основной сайт, -дополнительные поля, default и все веса UTM. У каждой колонки своё время чтения, -версия, обновление и ошибка; при отказе прежний снимок явно помечается. Обновление -сводки не сбрасывает черновик редактора и не меняет его версию для CAS. +Первый экран — редактор фактических конфигов всех подключений. Основной сайт, +варианты, веса и default редактируются в соответствующей колонке. Доля вычисляется +автоматически:3:1 →75%/25%; нулевые веса дают0%. Default выбирается radio обычного +начертания. Отсутствующая группа добавляется только явно. «Сохранить» и «Отмена» +работают отдельно для каждого домена; ошибка и конфликт сохраняют черновик. +Перечитывание другой колонки сохраняет DOM, фокус и курсор. После конфликта нужно +явно загрузить сохранённое либо отменить черновик. Версии CAS остаются внутренними; +даты заметок и версии конфигов в таблице не показываются. +Под таблицей — копирование сохранённых host и восьми канонических UTM из одного +источника в несколько получателей. Источник исключён из получателей. Черновики +источника и выбранных получателей сначала нужно сохранить или отменить. Неизвестные +поля, реквизиты, заметки и файлы ZIP не копируются. Отсутствующая группа источника +оставляет группу получателя. Источник и каждая цель проверяются по своей версии; +сохранение/копирование считаются завершёнными после результата задания и перечитывания. +Ошибки отдельных целей видны в колонке и журнале; успешные цели не повторяются. Конструктор работает отдельно: значения или «Рандом» в восьми списках, затем «Сформировать и сохранить». Последний выбор и точный URL сохраняются на сервере отдельно по ID домена, восстанавливаются после перезагрузки. Изменение списка скрывает @@ -33,10 +44,7 @@ ## Компактная сводка и заметки — preland#23 / #24 -Ячейка UTM содержит только номер и долю заданного веса: `1 (100%)`, -`2 (0%) - default`. Положительные номер и доля выделены жирным; суффикс ` - default` всегда обычный. Проценты округляются -до двух знаков без лишних нулей; при всех нулевых весах у всех вариантов `0%`. -Таблица не меняет default/веса и не подставляет другому варианту `100%`. +С версии preland#25 ячейки содержат inline-редактор, описанный выше. В блоке каждой UTM верхняя строка содержит название один раз и общую заметку в объединённой ячейке доменов. Ниже — пустая первая ячейка и значения доменов. @@ -99,16 +107,16 @@ Imagick не нужен. Замена реквизитов сбрасывает ## Работа через формы -1. Выбрать исходный домен: данные и SHA-версия читаются с его хостинга. -2. Изменить поля. Отмечаются только введённые поля; можно явно выбрать группу/всю форму. -3. Отметить домены и нажать «Применить к выбранным». Каждый домен использует собственную - прочитанную версию и сохраняет свои остальные значения. После сохранения перечитать домен. -4. В журнале проверить отдельные статусы. Успешные цели не повторяются. Конфликт требует - перечитать данные; после неизвестного результата повтор сначала сверяет фактический хеш. -5. В indpic выбрать ZIP либо проверить уже загруженную SFTP-папку. Новый набор сам не - меняет default/веса. Чтобы назначить новый номер indpic default, он должен присутствовать - в таблице весов — можно с весом0. Это действующее правило resolver, а не вес трафика. - +1. Изменить основной сайт или UTM в нужной колонке, проверить проценты и default, + нажать «Сохранить» под колонкой. Остальные поля сохраняются; ошибка оставляет черновик. +2. Для копирования выбрать источник и получателей под таблицей. Сначала сохранить + либо отменить их черновики, затем нажать кнопку с числом получателей. +3. Проверить итог каждой цели в колонках и журнале. При конфликте явно перечитать + сохранённые настройки; неизвестный результат требует проверки журнала и фактического состояния. +4. Раздел indpic имеет собственные «Открытый домен» и checkbox целей ZIP/проверки. + Выбор копирования настроек на эти цели не влияет. Резерв относится к открытому домену indpic. +5. Загрузить ZIP или проверить SFTP-папку, при необходимости назначить новый default + в основной таблице: его номер должен присутствовать в весах, допускается вес0. Номера — 1–9, ZIP содержит папку номера и ровно90 изображений установленных форматов/ размеров; лимит8МиБ на файл,64МиБ распакованного набора. При нулевой сумме весов rnd использует первый ключ weights по порядку config.json; только cons использует default. @@ -160,3 +168,24 @@ Git-снимком. Для отката приложения вернуть пр Фактические production-команды, версии, ограничения, свидетельства UI и статус подключения четырёх доменов фиксируются в preland#22 и KB; этот README сам не доказывает завершение выпуска. + +## Browser проверки preland#25 + +`ui-matrix.cjs` выполняет полный цикл на четырёх disposable local-целях a/b/c/d: +inline input/default/add/remove/missing, Save/Cancel, параллельные черновики, +задержанный GET, настоящий CAS-конфликт и offline/recovery, copy двух получателей, +partial failure/source changed, aliases/unknown/requisites, независимые ZIP-цели, +ошибочный ZIP, конструктор/заметки и1440/390. Скрипту нужны writable mount fixture +в `/fixture` и output в `/output`; он возвращает исходные config-файлы в finally. +`ui-regression.cjs` — совместимая точка входа этого же сценария, отдельно повторять её +не требуется. `ui-utm.cjs` отдельно проверяет настоящую ZIP-доставку в a+b, variant3 +с весом0/default и обратное восстановление через controls; нужен `VALID_ZIP` набора3. +`ui-notes.cjs`, `ui-link-builder.cjs`, `ui-verified-refresh.cjs`, `ui-remember.cjs`, +`ui-requisites.cjs` и `ui-smoke.cjs` сохраняют отдельные сценарии своих подсистем. +Ни один browser write-test не предназначен для production. + + +Отдельный `ui-session-jobs.cjs` удерживает ответ настоящего завершённого Save/Copy +через выход и повторный вход: старое продолжение не сбрасывает новый черновик, +не подменяет результаты/notice и не снимает busy новой операции. Серверные задания +при выходе продолжаются; отменяются только устаревшие UI-продолжения. diff --git a/admin/composer.json b/admin/composer.json index ee55d4c..498de88 100644 --- a/admin/composer.json +++ b/admin/composer.json @@ -4,5 +4,5 @@ "license": "proprietary", "require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"}, "config": {"sort-packages": true}, - "scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php"], "test:worker": "php tests/backend-worker.php"} + "scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"} } diff --git a/admin/public/assets/admin.css b/admin/public/assets/admin.css index 27c8aae..a641b2f 100644 --- a/admin/public/assets/admin.css +++ b/admin/public/assets/admin.css @@ -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} diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js index 2186803..398809b 100644 --- a/admin/public/assets/admin.js +++ b/admin/public/assets/admin.js @@ -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;}}); })(); diff --git a/admin/public/index.php b/admin/public/index.php index 7012d77..82e03fc 100644 --- a/admin/public/index.php +++ b/admin/public/index.php @@ -1,7 +1,7 @@ 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); diff --git a/admin/src/Copy.php b/admin/src/Copy.php new file mode 100644 index 0000000..de00764 --- /dev/null +++ b/admin/src/Copy.php @@ -0,0 +1,100 @@ +['reg','registration'],'banch'=>['banch','chob','bchoice']]; + private const MAPS = ['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']]; + + public function __construct(private Store $store) {} + + public function create(array $data): array + { + if (count($data)!==2 || array_diff(array_keys($data),['source','targets'])) throw new Problem('Передайте только источник и получателей копирования.'); + $source=$this->selection($data['source']??null); + $targets=$data['targets']??null; + if (!is_array($targets) || !array_is_list($targets) || !$targets || count($targets)>100) throw new Problem('Выберите от 1 до 100 получателей.'); + $selected=[];$seen=[$source['id']=>true]; + foreach ($targets as $entry) { + $target=$this->selection($entry); + if (isset($seen[$target['id']])) throw new Problem('Источник нельзя выбирать получателем; получатели не должны повторяться.'); + $seen[$target['id']]=true;$this->available($target['id']);$selected[]=$target; + } + $target=$this->available($source['id']); + $read=(new Transport($this->store,$target))->readConfig(); + if (!hash_equals($read['version'],$source['version'])) throw new Problem('Настройки источника изменились. Перечитайте источник перед копированием.',409); + $changes=self::project($read['config']); + if (!$changes) throw new Problem('В источнике нет host или UTM для копирования.'); + // Jobs сериализует каждое значение отдельно: JSON-объекты и массивы сохраняют типы. + return (new Jobs($this->store))->create('copy',['source'=>$source,'targets'=>$selected,'changes'=>$changes]); + } + + private function selection(mixed $value): array + { + if (!$value instanceof \stdClass) throw new Problem('Источник и получатель должны быть объектами с ID и версией.'); + $value=get_object_vars($value); + if (count($value)!==2 || array_diff(array_keys($value),['id','version']) || !is_string($value['id']??null) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$value['id']) || !is_string($value['version']??null) || !preg_match('/^[a-f0-9]{64}$/D',$value['version'])) throw new Problem('Укажите корректные ID и версию сохранённых настроек.'); + return $value; + } + + private function available(string $id): array + { + $target=$this->store->target($id); + if (empty($target['enabled'])) throw new Problem('Подключение отключено.'); + if (empty($target['verified'])) throw new Problem('Сначала проверьте подключение.'); + return $target; + } + + /** Проверяет неоднозначность всех alias, включая пару chob/bchoice и значения null. */ + public static function assertAliases(\stdClass $config): void + { + if (!property_exists($config,'utm')) return; + if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.'); + foreach (self::ALIASES as $canonical=>$aliases) { + $found=0;foreach ($aliases as $alias) if (property_exists($config->utm,$alias)) $found++; + if ($found>1) throw new Problem("Конфликт UTM-ключей {$canonical}. Явно устраните неоднозначность."); + } + } + + private static function project(\stdClass $config): array + { + self::assertAliases($config); + $projection=new \stdClass();$changes=[]; + if (property_exists($config,'host')) { + if (!is_string($config->host)) throw new Problem('Адрес сайта источника должен быть строкой.'); + $projection->host=$config->host;$changes[]=['path'=>['host'],'value'=>$config->host]; + } + $projection->utm=new \stdClass(); + foreach (self::GROUPS as $canonical) { + $group=null;$present=false; + foreach (self::ALIASES[$canonical]??[$canonical] as $alias) { + if (isset($config->utm) && property_exists($config->utm,$alias)) {$group=$config->utm->$alias;$present=true;break;} + } + // Отсутствующая группа источника никогда не удаляет группу получателя. + if (!$present) continue; + if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights??null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$canonical} источника."); + $number=static function(mixed $value) use($canonical): mixed { + if (!is_string($value) && !is_int($value)) throw new Problem('Вариант UTM источника должен быть номером.'); + $digit=array_search((string)$value,self::MAPS[$canonical]??[],true); + return $digit===false?$value:(string)$digit; + }; + $weights=new \stdClass(); + foreach (get_object_vars($group->weights) as $variant=>$weight) { + $variant=(string)$number($variant); + if (property_exists($weights,$variant)) throw new Problem('Конфликт semantic и numeric вариантов UTM источника.'); + $weights->$variant=$weight; + } + $default=$number($group->default); + $projection->utm->$canonical=(object)['default'=>$default,'weights'=>$weights]; + // Меняем листья, чтобы сохранить неизвестные свойства группы получателя. + $changes[]=['path'=>['utm',$canonical,'default'],'value'=>$default]; + $changes[]=['path'=>['utm',$canonical,'weights'],'value'=>$weights]; + } + // Наличие физических наборов и каталог проверяются отдельно на каждом получателе. + Config::validate($projection,static fn(int $number): bool=>true); + return $changes; + } +} diff --git a/admin/src/Jobs.php b/admin/src/Jobs.php index fe08cb2..203ee69 100644 --- a/admin/src/Jobs.php +++ b/admin/src/Jobs.php @@ -5,7 +5,7 @@ final class Jobs { public function __construct(private Store $store) {} public function create(string $type,array $payload): array { - if(!in_array($type,['apply','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.'); + if(!in_array($type,['apply','copy','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.'); $targets=$payload['targets']??(isset($payload['target'])?[$payload['target']]:[]);if(!$targets||count($targets)>100)throw new Problem('Выберите от 1 до 100 подключений.'); $results=[];$seen=[];foreach($targets as $t){$id=is_array($t)?($t['id']??''):$t;if(!is_string($id)||!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id))throw new Problem('Некорректная цель задания.');if(isset($seen[$id]))continue;$seen[$id]=true;$results[]=['target'=>$id,'status'=>'queued','message'=>'Ожидает выполнения.'];} if(isset($payload['changes']))foreach($payload['changes'] as &$change){if(array_key_exists('value',$change)){$change['value_json']=json_encode($change['value'],JSON_THROW_ON_ERROR);unset($change['value']);}}unset($change); @@ -31,14 +31,15 @@ final class Jobs $target=$this->store->target($result['target']);if(empty($target['enabled']))throw new Problem('Подключение отключено.'); if(empty($target['verified']))throw new Problem('Сначала проверьте подключение.'); $transport=new Transport($this->store,$target);$p=$job['payload'];$type=$job['type']; - if(in_array($type,['apply','restore'],true)){ + if(in_array($type,['apply','copy','restore'],true)){ $current=$transport->readConfig();$expected=$p['version']??''; - if($type==='apply')foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??''; + if(in_array($type,['apply','copy'],true))foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??''; // После обрыва/рестарта сперва сверяем ожидаемый хеш уже подготовленного контента. $desired=$job['results'][$index]['desired_hash']??null; if($desired && hash_equals($desired,$current['version'])) {$job['results'][$index]=['target'=>$target['id'],'status'=>'success','message'=>'Сохранённое состояние подтверждено после повторного чтения.','version'=>$current['version']];$save();continue;} if(!is_string($expected)||!hash_equals($current['version'],$expected))throw new Conflict(); if(isset($p['changes']))foreach($p['changes'] as &$change){if(isset($change['value_json']))$change['value']=json_decode($change['value_json'],false,64,JSON_THROW_ON_ERROR);}unset($change); + if($type==='copy')Copy::assertAliases($current['config']); $next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]); $checked=[];$warnings=Config::validate($next,function($number)use($target,&$checked){if(array_key_exists($number,$checked))return $checked[$number];try{(new Sets($this->store))->check($target,$number);return $checked[$number]=true;}catch(\Throwable){return $checked[$number]=false;}},Catalog::read($this->store,$next)); $content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save(); diff --git a/admin/tests/copy.php b/admin/tests/copy.php new file mode 100644 index 0000000..cd04960 --- /dev/null +++ b/admin/tests/copy.php @@ -0,0 +1,63 @@ +$path){$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);} +function fixture(string $id,string $json): void {global $paths;file_put_contents($paths[$id].'/config.json',$json);} +function readCopy(string $id): array {global $store;return (new Transport($store,$store->target($id)))->readConfig();} +function selection(string $id): object {return (object)['id'=>$id,'version'=>readCopy($id)['version']];} +function copyBody(array $ids=['a']): array {return ['source'=>selection('source'),'targets'=>array_map('selection',$ids)];} +$source='{"host":"saved.example","requisites":["source"],"extra":{"source":true},"utm":{"registration":{"default":"after","weights":{"before":3,"after":1},"source_only":true},"theme":{"default":2,"weights":{"1":0,"2":7}},"bchoice":{"default":"a","weights":{"b":2,"a":1}},"focus":{"default":"face","weights":{"face":0,"center":0}},"unknown":{"raw":"source"}}}'; +$target='{"host":"old.example","requisites":["target"],"extra":{"empty":{},"array":[],"flag":false,"zero":0},"utm":{"reg":{"default":1,"weights":{"1":1},"target_only":{"keep":true}},"theme":{"default":"dark","weights":{"dark":1}},"chob":{"default":"b","weights":{"b":1}},"indpic":{"default":"9","weights":{"9":0}},"unknown":{"raw":"target"}}}'; +// Missing source indpic must preserve it; use a valid recipient without indpic for initial success. +$target=str_replace(',"indpic":{"default":"9","weights":{"9":0}}','',$target); +fixture('source',$source);foreach(['a','b','c'] as $id)fixture($id,$target); +$port=random_int(19000,29000);$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='copy-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('copy-fixture-password',PASSWORD_DEFAULT); +$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);$cookie=''; +function copyRequest(array $body,?string $csrf=null,string $path='/api/copy'): array {global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>'POST','header'=>implode("\r\n",$headers),'content'=>json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);preg_match('~HTTP/\S+ (\d+)~',$http_response_header[0]??'',$match);foreach($http_response_header as $header)if(str_starts_with(strtolower($header),'set-cookie: '))$cookie=explode(';',substr($header,12))[0];return [(int)($match[1]??0),json_decode((string)$raw,true,64,JSON_THROW_ON_ERROR)];} +try{ + $deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(20000);}while(microtime(true)<$deadline); + [$status]=copyRequest(copyBody());copyCheck($status===401,'copy requires authentication'); + [$status,$body]=copyRequest(['username'=>'copy-admin','password'=>'copy-fixture-password'],null,'/api/login');$csrf=$body['csrf']; + [$status]=copyRequest(copyBody());copyCheck($status===403,'copy requires CSRF'); + [$status,$body]=copyRequest(copyBody(['a','b']),$csrf);copyCheck($status===202&&isset($body['job']['id']),'HTTP copy queues server-owned saved snapshot');$job=$body['job'];$jobs=new Jobs($store); + fixture('source',str_replace('saved.example','changed.example',$source)); + $staleTarget=readCopy('b');fixture('b',str_replace('old.example','foreign.example',$target)); + $jobs->run($job['id']);$done=$jobs->get($job['id']);copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','copy isolates success and target CAS conflict'); + $a=readCopy('a')['config'];copyCheck($a->host==='saved.example'&&readCopy('b')['config']->host==='foreign.example','queued source snapshot is immutable and conflict target untouched'); + copyCheck($a->utm->reg->default==='2'&&$a->utm->reg->weights->{'1'}===3&&$a->utm->reg->weights->{'2'}===1&&$a->utm->reg->target_only->keep===true,'source semantic registration projects to target numeric reg and preserves target group metadata'); + copyCheck($a->utm->theme->default==='light'&&$a->utm->theme->weights->dark===0&&$a->utm->theme->weights->light===7&&$a->utm->chob->default==='a'&&!property_exists($a->utm,'bchoice'),'target semantic schema and alias preserved'); + copyCheck($a->utm->focus->default==='1'&&$a->utm->focus->weights->{'1'}===0&&$a->utm->focus->weights->{'2'}===0,'missing recipient group added canonically and all-zero weights retained'); + copyCheck($a->requisites===['target']&&$a->extra->empty instanceof stdClass&&$a->extra->array===[]&&$a->extra->flag===false&&$a->extra->zero===0&&$a->utm->unknown->raw==='target'&&!property_exists($a->utm->reg,'source_only'),'noncopy fields and JSON types preserved, source unknown fields excluded'); + copyCheck(count($store->backups('a'))===1&&$store->backupContent('a',$store->backups('a')[0]['id'])===$target&&$store->backups('b')===[],'successful target gets exact backup and conflict gets none'); + $old=copyBody();fixture('source',str_replace('changed.example','newer.example',file_get_contents($paths['source'].'/config.json')));$before=count(glob($store->dir.'/jobs/*.json'));[$status]=copyRequest($old,$csrf);copyCheck($status===409&&count(glob($store->dir.'/jobs/*.json'))===$before,'source version conflict returns HTTP 409 before enqueue'); + $valid=copyBody();$invalid=[[],array_merge($valid,['config'=>(object)[]]),array_replace($valid,['source'=>['source']]),array_replace($valid,['targets'=>(object)['a'=>selection('a')]]),array_replace($valid,['targets'=>[]]),array_replace($valid,['targets'=>[selection('a'),selection('a')]]),array_replace($valid,['targets'=>[selection('source')]]),array_replace($valid,['targets'=>array_fill(0,101,selection('a'))])]; + foreach(['id'=>1,'version'=>123,'extra'=>true] as $key=>$value){$bad=clone $valid['source'];$bad->$key=$value;$invalid[]=array_replace($valid,['source'=>$bad]);$bad=clone $valid['targets'][0];$bad->$key=$value;$invalid[]=array_replace($valid,['targets'=>[$bad]]);} + foreach(['','abc',str_repeat('G',64)] as $version){$bad=clone $valid['source'];$bad->version=$version;$invalid[]=array_replace($valid,['source'=>$bad]);} + foreach($invalid as $i=>$request){[$status]=copyRequest($request,$csrf);copyCheck($status===422,'strict copy JSON rejects invalid shape/types/unknown keys/duplicates '.$i);} + foreach(['source','a'] as $id)foreach(['enabled','verified'] as $field){$registry=$store->read('targets.json');$registry[$id][$field]=false;$store->write('targets.json',$registry);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'copy rejects '.$field.' false for '.$id);$registry[$id][$field]=true;$store->write('targets.json',$registry);} + foreach(['{"utm":{"chob":{"default":"a","weights":{"a":1}},"bchoice":{"default":"a","weights":{"a":1}}}}','{"utm":{"reg":null}}','{"utm":{"theme":{"default":"dark","weights":{"dark":1,"1":2}}}}','{"utm":{"focus":{"default":true,"weights":{"1":1}}}}','{"host":null}','{"utm":[]}'] as $json){fixture('source',$json);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'source invalid/ambiguous canonical structure rejected before job');} + fixture('source','{"utm":{"reg":{"default":"before","weights":{"before":1}}}}');fixture('c','{"host":"keep.example","utm":{"focus":{"default":2,"weights":{"2":0},"meta":[]}},"x":{}}'); + [$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);$c=readCopy('c')['config'];copyCheck($c->host==='keep.example'&&$c->utm->focus->default===2&&$c->utm->focus->meta===[]&&$c->x instanceof stdClass,'missing source host/groups leave recipient values and types unchanged'); + fixture('source','{"utm":{"indpic":{"default":"1","weights":{"1":1}}}}');fixture('c','{"utm":{}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobId=$body['job']['id'];$before=readCopy('c')['content'];$backupCount=count($store->backups('c'));$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='error'&&readCopy('c')['content']===$before&&count($store->backups('c'))===$backupCount,'copy validates physical indpic on recipient and fails before write/backup'); + mkdir($paths['c'].'/1',0700);foreach(IndpicSetStorage::expectedDimensions() as $name=>$size){$image=new Imagick();$image->newImage($size['width'],$size['height'],'#777777');$image->setImageFormat(pathinfo($name,PATHINFO_EXTENSION));file_put_contents($paths['c'].'/1/'.$name,$image->getImagesBlob());$image->clear();} + $jobs->retry($jobId,['c']);$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='success','failed copy retries same snapshot after missing physical set is repaired'); + fixture('source','{"host":"both.example","utm":{"tp":{"default":"2","weights":{"2":3}},"cons":{"default":1,"weights":{"1":0}},"chb":null}}'); + [$status,$body]=copyRequest(copyBody(['a','c']),$csrf);$both=$body['job']['id'];$cBefore=readCopy('c');$jobs->run($both);$done=$jobs->get($both);$c=readCopy('c'); + copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='success'&&readCopy('a')['config']->utm->tp->weights->{'2'}===3&&$c['config']->utm->cons->default===1,'one saved snapshot copies to two successful recipients including tp/cons'); + copyCheck($c['config']->utm->indpic->default==='1'&&$c['config']->utm->indpic->weights->{'1'}===1&&!property_exists($c['config']->utm,'chb')&&is_dir($paths['c'].'/1'),'absent source indpic preserves valid recipient group/set; source chb is excluded'); + $backupCount=count($store->backups('c'));$record=$store->read('jobs/'.$both.'.json');$record['status']='running';$record['results'][1]=['target'=>'c','status'=>'running','desired_hash'=>$c['version']];$store->write('jobs/'.$both.'.json',$record);$jobs->run($both); + copyCheck($jobs->get($both)['results'][1]['status']==='success'&&readCopy('c')['content']===$c['content']&&count($store->backups('c'))===$backupCount,'copy desired-hash recovery confirms already-written config without second write or backup'); + $rejected=false;try{$jobs->retry($both,['a','c']);}catch(Problem $e){$rejected=$e->status===422;} + copyCheck($rejected&&count($store->backups('c'))===$backupCount,'successful copy recipients cannot be retried and do not acquire duplicate backups'); + fixture('source','{"host":"catalog.example","utm":{"degr":{"default":"2","weights":{"2":1}}}}');fixture('c','{"host":"old.example","utm":{}}');$store->write('catalog.json',['host'=>'catalog.example','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[],'cons'=>[]]]);[$status,$body]=copyRequest(copyBody(['c']),$csrf);$before=readCopy('c')['content'];$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&readCopy('c')['content']===$before,'copy validates target result against fresh catalog for copied host'); + fixture('source','{"host":"safe.example"}');fixture('c','{"utm":{"chb":null}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&property_exists(readCopy('c')['config']->utm,'chb'),'copy preserves obsolete chb write guard rather than silently deleting it'); + fixture('c','{"utm":{"chob":{"default":"a","weights":{"a":1}},"bchoice":{"default":"b","weights":{"b":1}}}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error','ambiguous target aliases rejected per domain even if source group absent'); + fixture('c','{"host":"safe.example"}');$backup=$store->backup('c','{"utm":{"chb":null}}');$restore=$jobs->create('restore',['target'=>'c','version'=>readCopy('c')['version'],'id'=>$backup]);$jobs->run($restore['id']);copyCheck($jobs->get($restore['id'])['results'][0]['status']==='error'&&readCopy('c')['config']->host==='safe.example','restore retains chb publication guard after copy integration'); + echo "Copy HTTP/jobs contract complete\n"; +}finally{proc_terminate($server);proc_close($server);fclose($log);} diff --git a/admin/tests/ui-link-builder.cjs b/admin/tests/ui-link-builder.cjs index 4bef1a3..9f72ded 100644 --- a/admin/tests/ui-link-builder.cjs +++ b/admin/tests/ui-link-builder.cjs @@ -28,11 +28,11 @@ const fs=require('node:fs'); const nextReg=selectionA.selection.reg==='rnd'?'1':'rnd';await page.locator('#builder-fields select[data-marker=reg]').selectOption(nextReg); assert.equal(await page.locator('#builder-result').isVisible(),false,'old saved URL hidden for unsaved constructor changes'); const updated=await generate();const saved=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(saved.selection.reg,nextReg);for(const marker of Object.keys(selectionA.selection))if(marker!=='reg')assert.equal(saved.selection[marker],selectionA.selection[marker]);assert.notEqual(updated,first); - await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт',{exact:true}).waitFor();await page.getByLabel('Основной сайт',{exact:true}).fill('draft-only.example.org'); - const version=await page.locator('#read-state').innerText(); - await page.getByRole('button',{name:'Обновить сводку',exact:true}).click(); + await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт a',{exact:true}).waitFor();await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-only.example.org'); + await page.locator('#read-state').getByText('Домен прочитан.',{exact:true}).waitFor();const version=await page.locator('#read-state').innerText(); + await page.getByRole('button',{name:'Обновить настройки',exact:true}).click(); await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false); - assert.equal(await page.getByLabel('Основной сайт',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('#change-summary').innerText(),/host/); + assert.equal(await page.getByLabel('Основной сайт a',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/Есть изменения/); assert.equal((await read('a')).config.host,before[0].config.host,'overview/constructor did not save editor draft'); await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-desktop.png',fullPage:true}); await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await page.locator('#builder-target').selectOption('a');await page.locator('#builder-url').waitFor({state:'visible'});assert.equal(await page.locator('#builder-url').inputValue(),updated);assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),'rnd');assert.equal(await page.locator('#builder-fields select[data-marker=reg]').inputValue(),nextReg); diff --git a/admin/tests/ui-matrix.cjs b/admin/tests/ui-matrix.cjs new file mode 100644 index 0000000..b34f1fd --- /dev/null +++ b/admin/tests/ui-matrix.cjs @@ -0,0 +1,38 @@ +/* preland#25: real UI, actual CAS/jobs and isolated fixture. Browser plugin not available. */ +const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs'); +(async()=>{ + const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')])); + const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);let csrf=''; + const host=id=>page.locator('[data-matrix-target="'+id+'"] input[data-host]'),cell=(id,m)=>page.locator('[data-matrix-target="'+id+'"][data-marker="'+m+'"]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),header=id=>page.locator('[data-overview-target="'+id+'"]'); + const request=async(route,data)=>{const response=data===undefined?await page.request.get('/api/'+route):await page.request.post('/api/'+route,{data,headers:{'X-CSRF-Token':csrf}});const result=await response.json();assert.ok(result.ok,result.error);return result;}; + const get=id=>request('config?target='+id);const readFile=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const writeFile=(id,c)=>fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c)); + const saved=async id=>{await page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent==='Сохранено',id,{timeout:60000});}; + const refresh=async()=>{await page.locator('#overview-refresh').click();await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);}; + try{ + const d=readFile('d');delete d.utm.cons;writeFile('d',d); + await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await saved(id);csrf=(await request('state')).csrf; + assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('#overview [class*=status]').filter({hasText:/версия|МСК/}).count(),0);assert.equal(await page.locator('#overview').getByText(/requisites|chb/).count(),0); + const weights=cell('a','theme').locator('input[type=number]');await weights.nth(0).fill('3');await weights.nth(1).fill('1');assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['75%','25%']);await weights.nth(0).fill('0');await weights.nth(1).fill('0');assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['0%','0%']);await cancel('a').click();assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['25%','75%']); + await cell('a','degr').getByRole('button',{name:'+ Вариант',exact:true}).click();assert.equal(await cell('a','degr').locator('.matrix-variant').count(),3);await cell('a','degr').getByRole('button',{name:'Удалить вариант degr 3 a',exact:true}).click();assert.equal(await cell('a','degr').locator('.matrix-variant').count(),2);await cancel('a').click(); + const radio=cell('a','theme').locator('input[type=radio]:checked');const def=await radio.getAttribute('aria-label');await cell('a','theme').getByRole('button',{name:'Удалить вариант theme '+def.split(' ')[2]+' a',exact:true}).click();assert.equal(await cell('a','theme').locator('input[type=radio]:checked').count(),0);assert.match(await header('a').innerText(),/выберите default/);assert.equal(await save('a').isDisabled(),true);await cell('a','theme').locator('input[type=radio]').check();assert.equal(await save('a').isEnabled(),true);await cancel('a').click(); + await cell('d','cons').getByRole('button',{name:'+ Добавить группу',exact:true}).click();assert.equal(await cell('d','cons').locator('input[type=radio]:checked').count(),1);assert.equal(readFile('d').utm.cons,undefined);await save('d').click();await saved('d');assert.deepEqual(readFile('d').utm.cons,{default:'1',weights:{'1':1}}); + console.log('PASS edit/default/missing'); // Two independent drafts and note cursor remain through foreign-column save and read. + await host('a').fill('draft-a.invalid');const handle=await host('a').elementHandle();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});await cell('b','theme').locator('input[type=number]').first().fill('7');await save('b').click();await saved('b');assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);assert.equal(readFile('b').utm.theme.weights.dark,7);assert.equal(readFile('b').utm.theme.default,'light');assert.deepEqual(readFile('b').custom,JSON.parse(original.get('b')).custom);assert.deepEqual(readFile('b').requisites,JSON.parse(original.get('b')).requisites);await refresh();assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);await cancel('a').click(); + console.log('PASS foreign save'); // A late force GET must not erase input typed after reload starts. + let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);let held=false;const delayed=async route=>{if(!held){held=true;const response=await route.fetch();started();await gate;await route.fulfill({response});}else await route.continue();};await page.route('**/api/config?target=a',delayed);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click({noWaitAfter:true});await seen;await host('a').fill('after-get.invalid');release();await page.waitForTimeout(200);assert.equal(await host('a').inputValue(),'after-get.invalid');await page.unroute('**/api/config?target=a',delayed);await cancel('a').click(); + console.log('PASS late get'); // Real remote version conflict, explicit reload and offline recovery preserve drafts. + await host('a').fill('my-draft.invalid');const external=readFile('a');external.custom.external='concurrent';writeFile('a',external);await save('a').click();await header('a').getByText(/измен|верс|конфликт/i).waitFor();assert.equal(await host('a').inputValue(),'my-draft.invalid');await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await host('a').inputValue(),external.host); + await cell('b','theme').locator('input[type=number]').first().fill('5');await context.setOffline(true);await save('b').click();await header('b').getByText(/Связь/).waitFor();assert.equal(await cell('b','theme').locator('input[type=number]').first().inputValue(),'5');await context.setOffline(false);await save('b').click();await saved('b');assert.equal(readFile('b').utm.theme.weights.dark,5); + console.log('PASS conflict/offline'); // Source and recipient drafts block copying. Copy selected recipients only, semantic aliases preserved. + await page.locator('#copy-source').selectOption('a');assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),0);await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.getByLabel('Копировать в c.invalid',{exact:true}).check();await host('a').fill('source-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/источника/);await cancel('a').click();await host('b').fill('target-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/получателей/);await cancel('b').click(); + const beforeD=fs.readFileSync(root+'/d/config.json','utf8'),beforeB=readFile('b'),source=(await get('a')).config;await page.locator('#copy-submit').click();assert.equal(await page.locator('#copy-all').isDisabled(),true);assert.equal(await page.locator('#copy-none').isDisabled(),true);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await saved('b');await saved('c');const copiedB=readFile('b');assert.deepEqual(copiedB.utm.theme.weights,{dark:source.utm.theme.weights['1'],light:source.utm.theme.weights['2']});assert.deepEqual(copiedB.requisites,beforeB.requisites);assert.deepEqual(copiedB.custom,beforeB.custom);assert.equal(fs.readFileSync(root+'/d/config.json','utf8'),beforeD); + // Real target CAS partial failure and source CAS preflight failure. + const staleC=readFile('c');staleC.custom.concurrent='target';writeFile('c',staleC);const partialResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();const partial=(await(await partialResponse).json()).job;await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});const partialJob=(await request('jobs?id='+partial.id)).job;assert.deepEqual(partialJob.results.map(r=>r.status),['success','conflict']);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.equal(await page.locator('#copy-results .status-conflict').count(),1);assert.equal(readFile('c').custom.concurrent,'target');await header('c').getByRole('button',{name:'Обновить настройки c',exact:true}).click();await saved('c');const staleSource=readFile('a');staleSource.custom.concurrent='source';writeFile('a',staleSource);const sourceResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();assert.equal((await sourceResponse).status(),409);await page.locator('#copy-results .overview-error').waitFor();await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');console.log('PASS actual partial copy and source version conflict'); await page.locator('#copy-none').click();assert.equal(await page.locator('#copy-targets input:checked').count(),0);await page.locator('#copy-all').click();assert.equal(await page.locator('#copy-targets input:checked').count(),3);await page.locator('#copy-none').click(); + console.log('PASS copy'); // Operations keep their own selection independent from copy UI. + await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();await page.locator('#copy-source').selectOption('c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['a','b']);await page.locator('#source-target').selectOption('c');assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['c']);await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor(); + const unchangedBeforeZip=(await get('a')).version;await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Invalid ZIP #25');await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not ZIP')});const zipResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));await page.locator('#set-add button').click();const invalidZip=await(await zipResponse).json();if(invalidZip.ok){await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.locator('#result-body').getByText(/Ошибка/).waitFor({timeout:60000});await page.getByRole('button',{name:'Готово',exact:true}).click();}else await page.locator('#notice.alert-danger').waitFor();assert.equal((await get('a')).version,unchangedBeforeZip);console.log('PASS actual invalid ZIP preserves runtime'); await page.locator('#builder-target').selectOption('a');await page.locator('#builder-fields select').first().waitFor();await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.match(await page.locator('#builder-url').inputValue(),/^https:\/\/a.invalid/); + // Notes preserve the same textarea node, caret and draft during matrix refresh. + const note=page.locator('[data-utm-note=reg]');await note.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const textarea=note.getByLabel('Заметка reg',{exact:true});await textarea.fill('Заметка #25\nПроверка курсора');const noteHandle=await textarea.elementHandle();await textarea.evaluate(n=>{n.focus();n.setSelectionRange(3,7);document.querySelector('#overview-refresh').click();});await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);assert.deepEqual(await noteHandle.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:3,end:7});await note.getByRole('button',{name:'Отменить заметку reg',exact:true}).click(); + await page.locator('#overview').screenshot({path:out+'/matrix-desktop.png'});await page.locator('#copy-panel').screenshot({path:out+'/copy-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/matrix-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#copy-panel').screenshot({path:out+'/copy-mobile.png'});assert.deepEqual(errors,[]);console.log('PASS inline weights/default/add/remove/missing/save/cancel; multi drafts/late GET/CAS/offline; saved copy selected-only/aliases/unknown preservation; independent operations/builder/notes/mobile/no JS errors'); + }finally{await context.setOffline(false);await browser.close();for(const [id,content]of original)fs.writeFileSync(root+'/'+id+'/config.json',content);} +})().catch(e=>{console.error(e);process.exit(1);}); diff --git a/admin/tests/ui-notes.cjs b/admin/tests/ui-notes.cjs index 07aaa1c..d4935c1 100644 --- a/admin/tests/ui-notes.cjs +++ b/admin/tests/ui-notes.cjs @@ -23,13 +23,12 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st const save=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();const body=await(await response).json();assert.ok(body.ok,body.error);await textarea().waitFor({state:'hidden'});return body.note;}; try{ await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'}); - await page.locator('#overview-table tr[data-overview-utm=reg] td .overview-percent').first().waitFor({timeout:6000}); + await page.locator('#overview-table tr[data-overview-utm=reg] td .matrix-percent').first().waitFor({timeout:6000}); for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);} const state=await request('state');csrf=state.csrf;const snapshots=await Promise.all(state.targets.map(async t=>{const r=await page.request.get('/api/config?target='+t.id).then(r=>r.json());return {id:t.id,...r};}));const jobsBefore=(await request('jobs')).jobs.map(j=>j.id).sort(); const enumMap={theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}}; const alias={reg:['registration'],banch:['chob','bchoice']}; - for(let index=0;indexObject.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);items.sort((a,b)=>Number(a[0])-Number(b[0]));const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index);const expected=items.map(([v,w])=>v+' ('+(sum>0?Number((w/sum*100).toFixed(2)):0)+'%)'+(v===def?' - default':''));assert.deepEqual(await cell.locator('.overview-percent').allTextContents(),expected);assert.doesNotMatch(await cell.innerText(),/Default:|вес|тёмная|светлая|имейл|pop-up|центр/);for(let i=0;iNumber(getComputedStyle(n).fontWeight));if(/\(0%\)/.test(expected[i]))assert.ok(weight<=400,'zero percentages use regular weight');else assert.ok(weight>=600,'positive percentages use bold weight');}for(const suffix of await cell.locator('.overview-default').all()){assert.equal(await suffix.innerText(),' - default');assert.ok(await suffix.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default suffix is regular');}}} - await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'}); + for(let index=0;indexObject.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>(sum>0?Number((w/sum*100).toFixed(2)):0)+'%');assert.deepEqual(await cell.locator('.matrix-percent').allTextContents(),expected);assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'}); await enterEdit();const draft='Заметка UI — '+Date.now()+'\n\nТолько обычный текст';await textarea().fill(draft);const handle=await textarea().elementHandle();await textarea().evaluate(n=>{document.querySelector('#overview-refresh').click();n.focus();n.setSelectionRange(7,11);});await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);assert.equal(await textarea().inputValue(),draft);assert.deepEqual(await handle.evaluate(n=>({connected:n.isConnected,focused:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focused:true,start:7,end:11}); await save();assert.equal((await request('utm-notes')).notes.reg.text,draft);assert.equal(await row().locator('img').count(),0);await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await row().getByText(draft,{exact:true}).waitFor(); const newer=draft+'\nСохранено после начала старого GET'; diff --git a/admin/tests/ui-regression.cjs b/admin/tests/ui-regression.cjs index 24b2f8b..9d265e2 100644 --- a/admin/tests/ui-regression.cjs +++ b/admin/tests/ui-regression.cjs @@ -1,76 +1,3 @@ -/* Real HTTP + rendered regression: stale version, independent partial result, - * invalid ZIP recovery, safe DOM. Run only against disposable Docker fixtures. - * No browser network mocking. Browser plugin not available. */ -const {chromium}=require('playwright-core'); -const assert=require('node:assert/strict'); -const fs=require('node:fs'); -(async()=>{ - const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']}); - const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}}); - const errors=[],consoleMessages=[];page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});});page.on('dialog',d=>d.accept()); - await page.goto(process.env.ADMIN_URL||'http://admin'); - await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin'); - await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password'); - await page.getByRole('button',{name:'Войти',exact:true}).click(); - await page.locator('#workspace').waitFor({state:'visible'}); - const state=await page.request.get('/api/state').then(r=>r.json()); - const api=async(path,data)=>{const response=await page.request.post('/api/'+path,{data,headers:{'X-CSRF-Token':state.csrf}});const result=await response.json();assert.equal(result.ok,true,result.error);return result;}; - const read=async(id)=>page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json()); - const poll=async(id)=>{for(let n=0;n<120;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error||'GET jobs вернул ошибку без описания');assert.ok(r.job,'GET jobs не вернул задание '+id);if(r.job.status==='completed')return r.job;await new Promise(r=>setTimeout(r,250));}throw Error('job timeout '+id);}; - const ids=state.targets.filter(t=>t.enabled).slice(0,2).map(t=>t.id);assert.equal(ids.length,2,'two disposable targets required'); - const originals=await Promise.all(ids.map(read)); - const externalHost='external-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10)+'.example.org'; - let primaryError=null; - const restore=async()=>{const targets=await Promise.all(ids.map(async id=>({id,version:(await read(id)).version})));for(let i=0;i!document.querySelector('#targets').textContent.includes('чтение версии')); - const altered=await api('apply',{targets:[{id:ids[1],version:originals[1].version}],changes:[{path:['host'],value:externalHost}]}); - assert.equal((await poll(altered.job.id)).results[0].status,'success'); - await page.getByLabel('Основной сайт',{exact:true}).fill('partial-check.example.org'); - const responsePromise=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST'); - await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click(); - const submitted=await (await responsePromise).json(); - const result=await poll(submitted.job.id); - assert.deepEqual(result.results.map(r=>r.status),['success','conflict']); - const job=page.locator('.job').filter({hasText:submitted.job.id}); - await job.getByText('Конфликт версии — перечитайте домен',{exact:true}).waitFor(); - assert.equal((await read(ids[0])).config.host,'partial-check.example.org'); - assert.equal((await read(ids[1])).config.host,externalHost,'stale UI did not overwrite external writer'); - await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-conflict-partial.png',fullPage:true}); - await page.reload(); - await page.locator('#workspace').waitFor({state:'visible'}); - await page.locator('.job').filter({hasText:submitted.job.id}).waitFor(); - await page.locator('#source-target').selectOption(ids[0]); - await page.getByLabel('Основной сайт',{exact:true}).waitFor(); - await page.locator('#set-add input[name=number]').fill(process.env.ZIP_NUMBER||'9'); - await page.locator('#set-add input[name=name]').fill('Тест ZIP'); - await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not a ZIP')}); - const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload')); - await page.locator('#set-add button').click(); - const invalidResult=await (await uploadResponse).json(); - if(invalidResult.ok){const invalidJob=await poll(invalidResult.job.id);assert.equal(invalidJob.results[0].status,'error');await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.getByRole('button',{name:'Готово',exact:true}).click();} - else await page.locator('#notice.alert-danger').waitFor({state:'visible'}); - assert.equal((await read(ids[0])).config.host,'partial-check.example.org','invalid ZIP kept working config'); - if(process.env.VALID_ZIP){ - await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP); - const response=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload')); - await page.locator('#set-add button').click();const accepted=await(await response).json();assert.equal(accepted.ok,true,accepted.error); - const done=await poll(accepted.job.id);assert.equal(done.results[0].status,'success',done.results[0].message); - await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor(); - await page.getByRole('button',{name:'Готово',exact:true}).click(); - } - await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-zip-recovery.png',fullPage:true}); - fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-regression-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2)); - assert.deepEqual(errors,[]);console.log('PASS real partial success/conflict/external state/persistent history/invalid ZIP'+(process.env.VALID_ZIP?'/valid ZIP recovery':'')); - }catch(error){primaryError=error;}finally{ - const cleanupErrors=[]; - try{await restore();}catch(error){cleanupErrors.push(error);} - try{await browser.close();}catch(error){cleanupErrors.push(error);} - if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'Основная ошибка browser-сценария; отдельно — ошибки восстановления fixture / закрытия browser',{cause:primaryError}); - if(primaryError)throw primaryError; - if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'Ошибка восстановления fixture / закрытия browser'); - } -})().catch(e=>{console.error(e);process.exit(1);}); +/* preland#25 consolidates partial copy/CAS/offline/ZIP-error regressions in the + * actual new matrix flow. This compatibility entry executes that full scenario. */ +require('./ui-matrix.cjs'); diff --git a/admin/tests/ui-remember.cjs b/admin/tests/ui-remember.cjs index 8c59025..87b5bd4 100644 --- a/admin/tests/ui-remember.cjs +++ b/admin/tests/ui-remember.cjs @@ -22,10 +22,10 @@ const fs=require('node:fs');const os=require('node:os');const path=require('node const read=async()=>{const r=await page.request.get('/api/config?target='+target).then(r=>r.json());assert.ok(r.ok,r.error);return r;};const before=await read(); assert.ok(Array.isArray(before.config.requisites)&&before.config.requisites.length,'fixture requisites required'); await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0,'requisites overview hidden'); - await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor(); + await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт '+target,{exact:true}).waitFor(); assert.equal(await page.locator('#config-fields').getByLabel('Реквизиты',{exact:true}).count(),0,'requisites editor hidden'); assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites absent from editable fields'); - await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click(); + assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0,'old generic editor removed'); const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config,'runtime unchanged'); await page.screenshot({path:output+'/remember-requisites-editor.png',fullPage:true}); await context.close();context=await launch();page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(origin);await page.locator('#workspace').waitFor({state:'visible'});assert.equal(await page.locator('#login-panel').isVisible(),false,'reopen authenticates without password'); diff --git a/admin/tests/ui-requisites.cjs b/admin/tests/ui-requisites.cjs index cbec5c7..71b6f40 100644 --- a/admin/tests/ui-requisites.cjs +++ b/admin/tests/ui-requisites.cjs @@ -1,11 +1,3 @@ -/* preland#24: requisites/chb hidden; ordinary unknown fields retained; no writes. */ -const {chromium}=require('playwright-core');const assert=require('node:assert/strict'); -(async()=>{ - const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']}); - try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'}); - const target=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+target).then(r=>r.json());const before=await read();assert.ok(before.ok,before.error);await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites not in editable fields'); - const observed={requisites:await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),chb:await page.locator('#overview-table tr[data-overview-extra^="utm.chb"]').count(),chbEditor:await page.locator('#config-fields input[aria-label^="Применить поле utm.chb"]').count()};console.log('Observed hidden-field surface',observed);assert.deepEqual(observed,{requisites:0,chb:0,chbEditor:0}); - if(before.config.utm.custom_visible){assert.equal(await page.locator('#overview-table tr[data-overview-extra="utm.custom_visible"]').count(),1);assert.ok(await page.locator('#config-fields input[aria-label^="Применить поле utm.custom_visible"]').count());} - await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites|utm\.chb/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);console.log('PASS requisites/chb hidden / unknown visible / select all excludes / config unchanged'); - }finally{await browser.close();} -})().catch(error=>{console.error(error);process.exit(1);}); +/* preland#25: protection surface now uses only the inline matrix, unknown fields readonly. */ +const {chromium}=require('playwright-core'),assert=require('node:assert/strict'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+id).then(r=>r.json()),before=await read();await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('[data-matrix-extra^=requisites],[data-matrix-extra^="utm.chb"]').count(),0);if(before.config.utm.custom_visible){await page.locator('[data-matrix-extra="utm.custom_visible"]').waitFor();assert.equal(await page.locator('[data-matrix-extra="utm.custom_visible"] input').count(),0);}const after=await read();assert.deepEqual(after.config,before.config);assert.equal(after.version,before.version);console.log('PASS requisites/chb hidden / unknown fields readonly / unchanged runtime');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-session-jobs.cjs b/admin/tests/ui-session-jobs.cjs new file mode 100644 index 0000000..989433f --- /dev/null +++ b/admin/tests/ui-session-jobs.cjs @@ -0,0 +1,10 @@ +/* preland#25: real accepted jobs, delayed completed HTTP response across logout/login. */ +const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs'); +(async()=>{const root='/fixture',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),context=await browser.newContext({baseURL:'http://host.docker.internal:18093'}),page=await context.newPage();page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);const errors=[];page.on('pageerror',e=>errors.push(e.message)); +const login=async()=>{await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();}; +const logout=async()=>{await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});}; +const host=id=>page.getByLabel('Основной сайт '+id,{exact:true}); +const holdJob=async()=>{let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r);const handler=async route=>{if(held)return route.continue();held=true;const url=route.request().url();let body;for(let n=0;n<160;n++){body=await page.request.get(url).then(r=>r.json());assert.ok(body.ok,body.error);if(body.job.status==='completed')break;await new Promise(r=>setTimeout(r,100));}assert.equal(body.job.status,'completed');ready(body.job);await gate;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});};await page.route('**/api/jobs?id=*',handler);return {captured,release,stop:()=>page.unroute('**/api/jobs?id=*',handler)};}; +try{await page.goto('/');await login();let held=await holdJob();const weight=page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').first();await weight.fill(String(Number(await weight.inputValue())+1));await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await held.captured;await logout();await login();await host('a').fill('new-session-draft.invalid');const node=await host('a').elementHandle(),notice=await page.locator('#notice').innerText();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});held.release();await page.waitForTimeout(400);assert.equal(await host('a').inputValue(),'new-session-draft.invalid','old Save completion cannot reset a new session draft');assert.deepEqual(await node.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:2,end:5});assert.equal(await page.locator('#notice').innerText(),notice);await held.stop();await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();console.log('PASS old accepted Save completion ignored after logout/login'); +held=await holdJob();await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await held.captured;await logout();await login();await host('b').fill('new-session-recipient-draft.invalid');await page.locator('#copy-source').selectOption('c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true,'new login is not blocked by old copyBusy');const current=await holdJob();await page.locator('#copy-submit').click();await current.captured;const currentNotice=await page.locator('#notice').innerText();held.release();await page.waitForTimeout(400);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');assert.equal(await page.locator('#copy-source').isDisabled(),true,'old finally cannot unlock new copy');assert.equal(await page.locator('#copy-results').innerText(),'','old outcomes cannot replace pending new operation');assert.equal(await page.locator('#notice').innerText(),currentNotice);current.release();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.match(await page.locator('#copy-results').innerText(),/d.invalid/);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');await current.stop();await held.stop();assert.deepEqual(errors,[]);console.log('PASS old accepted Copy completion/finally ignored; new copyBusy/outcomes/notice/draft preserved'); +}finally{await browser.close();for(const [id,text]of original)fs.writeFileSync(root+'/'+id+'/config.json',text);}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-smoke.cjs b/admin/tests/ui-smoke.cjs index b3d29e4..aee0ea1 100644 --- a/admin/tests/ui-smoke.cjs +++ b/admin/tests/ui-smoke.cjs @@ -1,47 +1,3 @@ -/* Browser plugin not available; run only inside the supplied Docker Playwright image. - * Required env: ADMIN_URL, ADMIN_USER, ADMIN_PASSWORD, UI_OUTPUT; optional SECOND_TARGET. - */ -const { chromium } = require('playwright-core'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -(async () => { - const browser = await chromium.launch({headless:true,executablePath:process.env.CHROMIUM_PATH || '/usr/bin/chromium',args:['--no-sandbox']}); - const page = await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}}); - const errors=[],consoleMessages=[]; page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});}); - await page.goto(process.env.ADMIN_URL || 'http://admin'); - assert.match(await page.title(), /Прелендинги/); - await page.getByLabel('Логин', {exact:true}).fill(process.env.ADMIN_USER || 'admin'); - await page.getByLabel('Пароль', {exact:true}).fill(process.env.ADMIN_PASSWORD || 'test-password'); - await page.getByRole('button',{name:'Войти',exact:true}).click(); - await page.locator('#workspace').waitFor({state:'visible'}); - await page.locator('#source-target').selectOption({index:1}); - await page.getByLabel('Основной сайт', {exact:true}).waitFor(); - const host = page.getByLabel('Основной сайт', {exact:true}); - const original = await host.inputValue(); - const secondOriginal=process.env.SECOND_TARGET?await page.request.get('/api/config?target='+encodeURIComponent(process.env.SECOND_TARGET)).then(r=>r.json()).then(r=>r.config.host):null; - await host.fill('ui-check.example.org'); - assert.match(await page.locator('#change-summary').innerText(), /host/); - assert.equal(await page.locator('#targets input:checked').count(),1); - if (process.env.SECOND_TARGET) await page.locator('#targets input[value="'+process.env.SECOND_TARGET+'"]').check(); - const apply = async () => { - const pending=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST'); - await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click(); - const accepted=await(await pending).json();assert.equal(accepted.ok,true,accepted.error); - await page.locator('.job').filter({hasText:accepted.job.id}).getByText('Сохранено и проверено',{exact:true}).first().waitFor(); - }; - await apply(); - await page.getByRole('button',{name:'Перечитать',exact:true}).click(); - await page.waitForFunction(()=>document.querySelector('[aria-label="Основной сайт"]')?.value==='ui-check.example.org'); - await host.fill(original); - await apply(); - if(process.env.SECOND_TARGET){await page.locator('#source-target').selectOption(process.env.SECOND_TARGET);await host.waitFor();await host.fill(secondOriginal);await apply();} - assert.equal(await page.locator('#config-fields textarea').count(),0,'JSON textarea absent from config editor'); - assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия'); - await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-desktop.png',fullPage:true}); - await page.setViewportSize({width:390,height:844}); - await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-mobile.png',fullPage:true}); - assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,'no page-level horizontal overflow'); - fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2)); - assert.deepEqual(errors,[]);assert.deepEqual(consoleMessages.filter(m=>!m.text.includes('status of 401')),[],'no unexpected browser console errors'); - await browser.close(); console.log('PASS real login/read/edit/apply/reload/restore + mobile + console'); -})().catch(e=>{console.error(e);process.exit(1);}); +/* preland#25: readonly smoke + real local draft/cancel. Writes are covered by ui-matrix.cjs. */ +const {chromium}=require('playwright-core'),assert=require('node:assert/strict'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=(await page.request.get('/api/state').then(r=>r.json())).targets[0].id,host=page.getByLabel('Основной сайт '+id,{exact:true});await host.waitFor();const original=await host.inputValue();await host.fill('draft-smoke.invalid');assert.match(await page.locator('[data-overview-target="'+id+'"] .matrix-status').innerText(),/Есть изменения/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await host.inputValue(),original);const theme=page.locator('[data-matrix-target="'+id+'"][data-marker=theme]'),add=theme.getByRole('button',{name:'+ Вариант',exact:true});assert.equal(await add.isDisabled(),true);const checked=theme.locator('.matrix-variant').filter({has:page.locator('input[type=radio]:checked')});await checked.locator('button').click();assert.equal(await add.isEnabled(),true);assert.match(await page.locator('.matrix-footer td').first().innerText(),/выберите default/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await add.isDisabled(),true);assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS login/nonblank/matrix input-cancel/ZIP table/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-utm.cjs b/admin/tests/ui-utm.cjs index 0d95c9a..8f2bc3c 100644 --- a/admin/tests/ui-utm.cjs +++ b/admin/tests/ui-utm.cjs @@ -1,85 +1,8 @@ -/* Real UI UTM regression. Disposable Docker fixtures a/b only. - * Browser plugin not available. No route mocks or host-installed runtimes. - * VALID_ZIP must contain set3; PUBLIC_A/B are optional fixture origins. */ -const {chromium}=require('playwright-core'); -const assert=require('node:assert/strict'); -const fs=require('node:fs'); -(async()=>{ - const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']}); - const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}}); - const output=process.env.UI_OUTPUT||'/tmp'; - const errors=[],consoleMessages=[]; - page.on('pageerror',e=>errors.push(e.message)); - page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});}); - page.on('dialog',dialog=>dialog.accept()); - const read=async id=>{const r=await page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.config,'config missing');return r;}; - const poll=async id=>{for(let n=0;n<480;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.job,'job missing: '+id);if(r.job.status==='completed')return r.job;await new Promise(resolve=>setTimeout(resolve,250));}throw Error('job timeout: '+id);}; - const group=()=>page.locator('section.field-block').filter({has:page.getByRole('heading',{name:'indpic= UTM',exact:true})}); - const open=async id=>{await page.locator('#source-target').selectOption(id);await page.getByLabel('indpic default',{exact:true}).waitFor();await page.waitForFunction(()=>document.querySelector('#read-state').textContent.includes('версия'));}; - const chooseBoth=async()=>{await page.locator('#targets input[value="b"]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));}; - const submit=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();const accepted=await(await response).json();assert.ok(accepted.ok,accepted.error);assert.ok(accepted.job);const job=await poll(accepted.job.id);const rendered=page.locator('.job').filter({hasText:job.id});await rendered.getByText('Завершено',{exact:false}).first().waitFor();return {job,rendered};}; - const setWeights=async weights=>{const section=group();const rows=section.locator('.weight-table tbody tr');while(await rows.count())await rows.last().getByRole('button',{name:'Удалить',exact:true}).click();for(const [variant,weight]of Object.entries(weights)){await section.getByRole('button',{name:'Добавить вариант',exact:true}).click();const row=rows.last();await row.getByLabel('indpic вариант',{exact:true}).selectOption(variant);await row.locator('input[type=number]').fill(String(weight));}}; - let originals=[],primaryError=null; - try{ - await page.goto(process.env.ADMIN_URL||'http://admin'); - await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin'); - await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password'); - await page.getByRole('button',{name:'Войти',exact:true}).click(); - await page.locator('#workspace').waitFor({state:'visible'}); - const state=await page.request.get('/api/state').then(r=>r.json());assert.ok(state.ok,state.error); - for(const id of ['a','b'])assert.ok(state.targets.some(t=>t.id===id&&t.enabled),'enabled fixture '+id+' required'); - originals=await Promise.all(['a','b'].map(read)); - for(const original of originals){assert.ok(!Object.hasOwn(original.config.utm.indpic.weights,'3'),'original fixture must omit weights3 for rejection scenario');assert.notEqual(String(original.config.utm.indpic.default),'3');} - await open('a');await chooseBoth(); - // Delivery is a real two-target ZIP form submission, not an API shortcut. - assert.ok(process.env.VALID_ZIP,'VALID_ZIP=/fixture/valid-set3.zip required'); - await page.locator('#set-add input[name=number]').fill('3'); - await page.locator('#set-add input[name=name]').fill('UI UTM — набор 3'); - await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP); - const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload')); - await page.locator('#set-add button').click(); - const upload=await(await uploadResponse).json();assert.ok(upload.ok,upload.error); - const uploaded=await poll(upload.job.id);assert.deepEqual(uploaded.results.map(r=>r.status),['success','success']); - await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor(); - await page.getByRole('button',{name:'Готово',exact:true}).click(); - // Wrong path must fail clearly, without altering either runtime config. - await page.getByLabel('indpic default',{exact:true}).selectOption('3'); - const rejected=await submit();assert.deepEqual(rejected.job.results.map(r=>r.status),['error','error']); - for(const result of rejected.job.results)assert.match(result.message,/Добавьте вариант 3 в таблицу весов indpic.*вес 0/); - await rejected.rendered.getByText(/Добавьте вариант 3 в таблицу весов indpic/).first().waitFor(); - assert.deepEqual((await read('a')).config,originals[0].config);assert.deepEqual((await read('b')).config,originals[1].config); - await page.screenshot({path:output+'/admin-utm-rejected-default.png',fullPage:true}); - // Correct path: a zero-weight row and explicit default3, only these two fields. - await group().getByRole('button',{name:'Добавить вариант',exact:true}).click(); - const added=group().locator('.weight-table tbody tr').last(); - await added.getByLabel('indpic вариант',{exact:true}).selectOption('3'); - await added.locator('input[type=number]').fill('0'); - // Rejected submission cleared explicit field selection; select default again. - await page.getByLabel('indpic default',{exact:true}).selectOption('3'); - assert.equal(await page.locator('#targets input:checked').count(),2); - assert.match(await page.locator('#change-summary').innerText(),/utm.indpic.weights/); - const applied=await submit();assert.deepEqual(applied.job.results.map(r=>r.status),['success','success']); - const actuals=await Promise.all(['a','b'].map(read)); - for(let i=0;i<2;i++){const expected=structuredClone(originals[i].config);expected.utm.indpic.default=typeof originals[0].config.utm.indpic.default==='number'?3:'3';expected.utm.indpic.weights={...originals[0].config.utm.indpic.weights,'3':0};assert.deepEqual(actuals[i].config,expected,'only selected indpic fields changed on '+['a','b'][i]);} - fs.writeFileSync(output+'/admin-utm-before-restore.json',JSON.stringify({job:applied.job.id,configs:{a:actuals[0].config,b:actuals[1].config}},null,2)); - await page.getByRole('button',{name:'Перечитать',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[aria-label="indpic default"]')?.value==='3'); - assert.equal(await group().locator('.weight-table tbody tr').filter({has:page.locator('option[value="3"]:checked')}).count(),1); - await page.screenshot({path:output+'/admin-utm-applied.png',fullPage:true}); - const publicEvidence=[]; - for(const origin of [process.env.PUBLIC_A,process.env.PUBLIC_B].filter(Boolean)){ - const response=await page.request.get(new URL('/?indpic=abcd3zzzz',origin).href);assert.equal(response.status(),200,'public HTML '+origin);const html=await response.text();assert.match(html,/data-source="[^\"]*\/3\/01_desk_1-source\.avif/,'public resolver selects uploaded set3'); - const image=await page.request.get(new URL('/3/01_desk_1-source.jpg',origin).href);assert.equal(image.status(),200,'public uploaded JPEG '+origin);const bytes=await image.body();assert.equal(bytes[0],0xff);assert.equal(bytes[1],0xd8);publicEvidence.push({origin,htmlStatus:response.status(),imageStatus:image.status(),bytes:bytes.length}); - } - fs.writeFileSync(output+'/admin-utm-public.json',JSON.stringify(publicEvidence,null,2)); - assert.deepEqual(errors,[]); - }catch(error){primaryError=error;}finally{ - const cleanupErrors=[]; - // The restoration itself uses actual form row removal/addition/default controls. - for(let i=0;i{console.error(error);process.exit(1);}); +/* preland#25: UTM inline editor and two-target ZIP regression, disposable fixtures only. */ +const {chromium}=require('playwright-core'),assert=require('node:assert/strict'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));const originals=new Map(); +const cell=id=>page.locator('[data-matrix-target="'+id+'"][data-marker=indpic]');const saved=async id=>page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent==='Сохранено',id,{timeout:90000});const save=async id=>{await page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}).click();await saved(id);};const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;}; +try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b']){await saved(id);originals.set(id,(await read(id)).config);assert.ok(!Object.hasOwn(originals.get(id).utm.indpic.weights,'3'),'fixture originally omits3');} +await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();assert.ok(process.env.VALID_ZIP,'VALID_ZIP with set3 required');await page.locator('#set-add input[name=number]').fill('3');await page.locator('#set-add input[name=name]').fill('UI #25 — набор 3');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add button').click();await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();for(const id of ['a','b']){assert.deepEqual((await read(id)).config,originals.get(id),'ZIP leaves config unchanged');assert.equal(await cell(id).locator('input[type=radio][aria-label="indpic default 3 '+id+'"]').count(),0,'missing variant cannot be silently selected as default');await cell(id).getByRole('button',{name:'+ Вариант',exact:true}).click();const row=cell(id).locator('.matrix-variant').last();await row.locator('select').selectOption('3');await row.locator('input[type=number]').fill('0');await row.locator('input[type=radio]').check();await save(id);const expected=structuredClone(originals.get(id));expected.utm.indpic.weights['3']=0;expected.utm.indpic.default=typeof expected.utm.indpic.default==='number'?3:'3';assert.deepEqual((await read(id)).config,expected);await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await saved(id);assert.equal(await cell(id).locator('input[type=radio]:checked').evaluate(n=>n.closest('.matrix-variant').querySelector('select').value),'3');} +assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(n=>n.map(i=>i.value)),['a','b']);assert.deepEqual(errors,[]);console.log('PASS actual two-target ZIP / new variant3=0+explicit default / per-column Save+reread / otherfields preserved'); +}finally{for(const [id,config]of originals){const rows=cell(id).locator('.matrix-variant');for(const row of await rows.all()){if(await row.locator('select').inputValue()==='3')await row.locator('button').click();}const def=String(config.utm.indpic.default);for(const row of await cell(id).locator('.matrix-variant').all())if(await row.locator('select').inputValue()===def)await row.locator('input[type=radio]').check();const button=page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});if(await button.isEnabled()){await save(id);assert.deepEqual((await read(id)).config,config);}}await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-verified-refresh.cjs b/admin/tests/ui-verified-refresh.cjs index dc09c1c..b5a8d21 100644 --- a/admin/tests/ui-verified-refresh.cjs +++ b/admin/tests/ui-verified-refresh.cjs @@ -14,12 +14,12 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st await page.reload();await page.locator('#workspace').waitFor({state:'visible'});current=await state();csrf=current.csrf; const row=page.locator('#connection-list .connection').filter({hasText:original.domain}),header=page.locator('#overview-table th[data-overview-target="'+original.id+'"]');await header.waitFor();assert.match(await row.innerText(),/требуется проверка/);assert.match(await header.innerText(),/соединение не проверено/); const before=await read(original.id); - await page.locator('#source-target').selectOption(original.id);await page.getByLabel('Основной сайт',{exact:true}).waitFor();await page.getByLabel('Основной сайт',{exact:true}).fill('unsaved-verification.example.org');await page.locator('#targets input[value=b]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии')); - const versionText=await page.locator('#read-state').innerText(),selected=await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value));assert.equal(selected.length,2); + await page.locator('#source-target').selectOption(original.id);await page.getByLabel('Основной сайт a',{exact:true}).waitFor();await page.getByLabel('Основной сайт a',{exact:true}).fill('unsaved-verification.example.org');await page.locator('#targets input[value=b]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии')); + await page.locator('#read-state').getByText('Домен прочитан.',{exact:true}).waitFor();const versionText=await page.locator('#read-state').innerText(),selected=await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value));assert.equal(selected.length,2); await page.locator('#builder-target').selectOption(original.id);const theme=page.locator('#builder-fields select[data-marker=theme]');await theme.waitFor();await theme.selectOption((await theme.inputValue())==='rnd'?'1':'rnd');const constructorDraft=await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value])); await row.getByRole('button',{name:'Проверить соединение',exact:true}).click();await page.locator('#indpic-result-modal').waitFor({state:'visible'});assert.match(await page.locator('#result-body').innerText(),/проверен/i);assert.equal((await state()).targets.find(t=>t.id===original.id).verified,true,'real backend verified flag true'); assert.match(await row.innerText(),/соединение проверено/,'connection row refreshed without reload');assert.doesNotMatch(await row.innerText(),/требуется проверка/);assert.doesNotMatch(await header.innerText(),/соединение не проверено/,'overview header refreshed without reload'); - assert.equal(await page.getByLabel('Основной сайт',{exact:true}).inputValue(),'unsaved-verification.example.org');assert.equal(await page.locator('#read-state').innerText(),versionText);assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value)),selected);assert.deepEqual(await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value])),constructorDraft);assert.match(await page.locator('#change-summary').innerText(),/host/); + assert.equal(await page.getByLabel('Основной сайт a',{exact:true}).inputValue(),'unsaved-verification.example.org');assert.equal(await page.locator('#read-state').innerText(),versionText);assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value)),selected);assert.deepEqual(await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value])),constructorDraft);assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/Есть изменения/); const after=await read(original.id);assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);assert.deepEqual(errors,[]); await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/verified-refresh.png',fullPage:true});fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/verified-refresh-evidence.json',JSON.stringify({target:original.id,serverVerified:true,configVersion:after.version,selected,constructorDraftPreserved:true,pageErrors:errors},null,2));console.log('PASS real targets/test modal + connection/overview verified refresh + editor/selected2/CAS/constructor preserved/config unchanged'); }catch(error){primaryError=error;}finally{ diff --git a/admin/views/dashboard.php b/admin/views/dashboard.php index 53e2a5a..6c82c7a 100644 --- a/admin/views/dashboard.php +++ b/admin/views/dashboard.php @@ -7,12 +7,10 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.
-