feat(admin): edit domain settings inline and copy saved configurations
deploy-preland / deploy zoomstage.online (push) Successful in 57s

This commit is contained in:
Panfilov Konstantin committed 2026-10-26 03:06:11 +03:00
1 parent 84f70b1c71
commit dd6d221dd7
19 files changed
+402 -409

No files matched your search

+49 -20
View File
@@ -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-продолжения.
+1 -1
View File
@@ -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"}
}
+4
View File
@@ -9,3 +9,7 @@
.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0}
/* #23: compact numeric percentages; note rows span the whole matrix without sticky cells. */
.overview-percent{display:block;margin:6px 0;line-height:1.35}.overview-percent:first-child{margin-top:0}.overview-percent:last-child{margin-bottom:0}.overview-raw{font-size:12px;overflow-wrap:anywhere}#overview-table td.utm-note-cell{position:static;left:auto;width:auto;min-width:0;z-index:auto;background:#fafafa;white-space:normal}.utm-note-title{display:block;font-size:12px;color:#707070;margin-bottom:6px}.utm-note-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 8px}.utm-note-textarea{width:100%;max-width:100%;min-width:0;resize:vertical;min-height:90px}.utm-note-status{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.utm-note-status:empty{display:none}.utm-note-edit .toolbar{margin-bottom:0}.utm-note-cell .btn{white-space:normal}.utm-note-read:has(.utm-note-text[hidden]){display:flex;align-items:center}
/* #25: one editable matrix and independent saved-settings copy. */
.container{width:min(1440px,100%)}.overview-table{min-width:1280px}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){width:auto}.overview-table td{position:static!important}.overview-table tr[data-overview-utm] th{background:#fafafa}.overview-table .matrix-variant{display:grid;grid-template-columns:44px 58px 48px 1fr 20px;align-items:center;gap:6px;margin-top:7px}.matrix-variant .form-control{height:30px;min-height:30px;min-width:0;padding:3px 5px;font-size:13px}.matrix-labels{display:grid;grid-template-columns:44px 58px 48px 1fr;gap:6px;color:#78818e;font-size:11px}.matrix-default{display:flex;gap:4px;align-items:center;font-size:12px;font-weight:400;white-space:nowrap}.matrix-default input{margin:0}.matrix-percent{font-size:12px;white-space:nowrap}.matrix-percent.positive{font-weight:600}.matrix-remove{padding:0;border:0;background:none;color:#7a8594;font-size:19px;cursor:pointer;line-height:24px}.matrix-remove:hover{color:#a94442}.matrix-variants+.btn{font-size:12px;border:0;background:none;padding:6px 0 0;color:#337ab7}.matrix-footer td{background:#f8f9fb}.matrix-footer .toolbar{margin:0;gap:7px}.matrix-status{font-size:12px;font-weight:400;margin:7px 0}.matrix-dirty{color:#9c6413}.matrix-environment{font-size:12px;font-weight:400}.copy-grid{display:grid;grid-template-columns:320px minmax(0,1fr);gap:32px}.copy-grid>label{font-weight:600}.copy-grid select{margin-top:8px}.copy-hint{display:block;margin-top:10px;font-size:12px;font-weight:400}.copy-grid .target-options{margin-top:8px}.copy-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;border-top:1px solid #e1e5eb;margin-top:20px;padding-top:16px}.copy-bottom p{margin:0;font-size:13px}.copy-bottom .btn{flex-shrink:0}#copy-reason{margin:12px 0 0}.utm-note-read{display:flex;align-items:center;justify-content:space-between;gap:14px}.utm-note-text{margin:0}.utm-note-cell .btn{flex-shrink:0}.overview-wrapper{overscroll-behavior-x:contain}.overview-table caption{font-size:12px}@media(max-width:600px){.overview-table{min-width:1240px}.copy-grid{grid-template-columns:1fr;gap:20px}.copy-bottom{align-items:stretch;flex-direction:column}.copy-bottom .btn{align-self:flex-start}.overview-table th:first-child{width:90px}.matrix-footer .toolbar{flex-wrap:nowrap}.matrix-footer .btn{font-size:12px}.utm-note-read{align-items:flex-start}.utm-note-cell .btn{max-width:150px}.overview-table .form-control{font-size:13px}}
#notice{position:static;box-shadow:none}
+96 -147
View File
@@ -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;}});
})();
+5 -1
View File
@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog,LinkBuilder,Notes};
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog,LinkBuilder,Notes,Copy};
header('X-Content-Type-Options: nosniff');header('X-Frame-Options: DENY');header('Referrer-Policy: same-origin');header('Cache-Control: no-store');
$path=parse_url($_SERVER['REQUEST_URI']??'/',PHP_URL_PATH);
if(!str_starts_with((string)$path,'/api/')) {require dirname(__DIR__).'/views/dashboard.php';exit;}
@@ -59,6 +59,10 @@ try {
$catalog=Catalog::read($store,$read['config']);$warnings=[];if($catalog===null)foreach(['degr','tp','cons'] as $key)if(isset($read['config']->utm->$key))$warnings[]="{$key}: существование группы на основном сайте не проверено.";
reply(['config'=>$read['config'],'version'=>$read['version'],'warnings'=>$warnings,'catalog'=>$catalog,'sets'=>$sets->list($target,$read['config'])]);
}
if($path==='/api/copy'){
if($method!=='POST')throw new Problem('Метод копирования не поддерживается.',405);
reply(['job'=>(new Copy($store))->create($data)],202);
}
if($path==='/api/apply'&&$method==='POST'){$data['targets']=array_map(static fn($t)=>(array)$t,(array)($data['targets']??[]));$data['changes']=array_map(static fn($c)=>(array)$c,(array)($data['changes']??[]));reply(['job'=>$jobs->create('apply',$data)],202);}
if($path==='/api/jobs'&&$method==='GET')reply(isset($_GET['id'])?['job'=>$jobs->get((string)$_GET['id'])]:['jobs'=>$jobs->all()]);
if($path==='/api/jobs/retry'&&$method==='POST')reply(['job'=>$jobs->retry((string)($data['id']??''),(array)($data['targets']??[]))],202);
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Копирует только сохранённые host и canonical UTM; клиент не передаёт значения config. */
final class Copy
{
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
private const ALIASES = ['reg'=>['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;
}
}
+4 -3
View File
@@ -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();
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Jobs,Copy,Problem,IndpicSetStorage};
function copyCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
$root=sys_get_temp_dir().'/preland-copy-'.bin2hex(random_bytes(6));mkdir($root,0700,true);
$store=new Store($root.'/state');$paths=[];
foreach(['source','a','b','c'] as $id){$paths[$id]=$root.'/'.$id;mkdir($paths[$id],0700);}
putenv('ADMIN_LOCAL_ROOTS='.implode(',',$paths));
foreach($paths as $id=>$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);}
+4 -4
View File
@@ -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);
+38
View File
@@ -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);});
+2 -3
View File
@@ -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;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.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;i<expected.length;i++){const weight=await cell.locator('.overview-value').nth(i).evaluate(n=>Number(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;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.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<img src=x onerror=alert(1)>\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';
+3 -76
View File
@@ -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<ids.length;i++){const r=await api('apply',{targets:[targets[i]],changes:[{path:['host'],value:originals[i].config.host}]});const job=await poll(r.job.id);assert.equal(job.results[0].status,'success','fixture host restored');}};
try{
await page.locator('#source-target').selectOption(ids[0]);
await page.getByLabel('Основной сайт',{exact:true}).waitFor();
await page.locator('#targets input[value="'+ids[1]+'"]').check();
await page.waitForFunction(()=>!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');
+2 -2
View File
@@ -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');
+3 -11
View File
@@ -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)});
+10
View File
@@ -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)});
+3 -47
View File
@@ -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)});
+8 -85
View File
@@ -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<originals.length;i++)try{await open(['a','b'][i]);await setWeights(originals[i].config.utm.indpic.weights);await page.getByLabel('indpic default',{exact:true}).selectOption(String(originals[i].config.utm.indpic.default));const restored=await submit();assert.equal(restored.job.results[0].status,'success',restored.job.results[0].message);assert.deepEqual((await read(['a','b'][i])).config,originals[i].config,'UI restored full original config');}catch(error){cleanupErrors.push(error);}
fs.writeFileSync(output+'/admin-utm-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
try{await browser.close();}catch(error){cleanupErrors.push(error);}
if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'UTM scenario failed; separate UI restore errors',{cause:primaryError});
if(primaryError)throw primaryError;if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'UI restore failed');
}
console.log('PASS real UI bulk ZIP3 / rejected default without weight / weights3=0+default3 to a+b / reread / preserve other fields / public origins / UI restore');
})().catch(error=>{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)});
+3 -3
View File
@@ -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{
+4 -6
View File
@@ -7,12 +7,10 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.
<body><header class="navbar"><div class="container nav-inner"><strong>Прелендинги</strong><span>UTM и наборы изображений</span><button id="logout" class="btn btn-default" hidden>Выйти</button></div></header>
<main class="container"><div id="notice" role="status" aria-live="polite" hidden></div>
<section id="login-panel" class="panel login-panel"><h1>Вход в админку</h1><form id="login-form"><label>Логин<input class="form-control" name="username" autocomplete="username" required></label><label>Пароль<input class="form-control" name="password" type="password" autocomplete="current-password" required></label><label class="checkbox remember-choice"><input name="remember" type="checkbox"> Запомнить меня на 90 дней</label><button class="btn btn-primary" type="submit">Войти</button></form></section>
<div id="workspace" hidden><h1>Настройка прелендингов</h1><nav class="tabs" aria-label="Разделы"><a href="#overview">Сводка</a><a href="#link-builder">Конструктор ссылки</a><a href="#settings">Настройки</a><a href="#indpic">indpic</a><a href="#connections">Подключения</a><a href="#history">Журнал и резерв</a></nav>
<section id="overview" class="panel"><h2>Сводка текущих конфигураций</h2><p class="text-muted">Сохранённые runtime-конфиги каждого домена на момент чтения. Несохранённые изменения редактора сюда не попадают.</p><button id="overview-refresh" type="button" class="btn btn-default">Обновить сводку</button><div class="table-responsive overview-wrapper"><table id="overview-table" class="table table-bordered overview-table"><caption class="text-muted">Проценты — доли весов; default отмечен у варианта. Таблицу можно прокручивать по горизонтали.</caption><thead></thead><tbody></tbody></table></div></section>
<section id="link-builder" class="panel"><h2>Конструктор ссылки</h2><p class="text-muted">Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.</p><label class="builder-domain">Домен ссылки<select id="builder-target" class="form-control"><option value="">Выберите домен</option></select></label><p id="builder-state" class="text-muted" role="status" aria-live="polite">Выберите домен.</p><div id="builder-fields" class="form-grid"></div><div id="builder-warnings" aria-live="polite"></div><button id="builder-save" type="button" class="btn btn-primary" disabled>Сформировать и сохранить</button><div id="builder-result" hidden><label>Готовая ссылка<input id="builder-url" class="form-control" type="url" readonly spellcheck="false"></label><div class="toolbar"><button id="builder-copy" type="button" class="btn btn-default">Скопировать</button><a id="builder-open" class="btn btn-default" target="_blank" rel="noopener noreferrer">Открыть ссылку</a></div><p id="builder-saved-at" class="text-muted"></p></div></section>
<section id="settings" class="panel"><h2>Настройки домена</h2><div class="toolbar"><label class="grow">Открытый домен<select id="source-target" class="form-control"><option value="">Выберите домен</option></select></label><button id="reload" class="btn btn-default" type="button">Перечитать</button></div><p id="read-state" class="text-muted">Выберите домен для чтения текущего config.json.</p><div id="warnings"></div><div id="config-fields"></div></section>
<section class="panel" id="apply-panel"><h2>Применить изменения</h2><p>Отмечены только введённые или явно выбранные поля. Остальные значения каждого домена сохраняются.</p><div id="targets" class="target-options"></div><div class="toolbar"><button id="select-all-fields" class="btn btn-default">Выбрать все поля формы</button><button id="clear-fields" class="btn btn-default">Снять выбор полей</button></div><p id="change-summary" aria-live="polite">Поля не выбраны.</p><p id="target-summary" class="text-muted"></p><button id="apply" class="btn btn-primary" disabled>Применить к выбранным</button></section>
<section id="indpic" class="panel"><h2>indpic= UTM</h2><p class="text-muted">Таблица открытого домена. ZIP и проверка SFTP используют выбранные выше цели. Загрузка не меняет default и веса.</p><div class="table-responsive"><table class="table table-bordered table-hover indpic-sets-table"><thead><tr><th>Номер / название</th><th class="indpic-default-column">Default</th><th class="indpic-check-column">Проверка</th><th>ZIP</th><th class="indpic-actions-column">Действия</th></tr></thead><tbody id="sets-body"></tbody></table></div><h3>Добавить Set</h3><form id="set-add" class="indpic-add-form"><label>№<input name="number" class="form-control indpic-number-input" type="number" min="1" max="9" required></label><label>Название Set<input name="name" class="form-control" maxlength="255"></label><label class="checkbox"><input name="sftp" type="checkbox"> Загружен по SFTP</label><label class="zip-field">ZIP<input name="file" type="file" accept=".zip,application/zip" class="form-control" required></label><button class="btn btn-default">Загрузить и добавить</button></form></section>
<div id="workspace" hidden><h1>Настройка прелендингов</h1><nav class="tabs" aria-label="Разделы"><a href="#overview">Настройки</a><a href="#copy-panel">Копирование</a><a href="#link-builder">Конструктор ссылки</a><a href="#indpic">indpic</a><a href="#connections">Подключения</a><a href="#history">Журнал и резерв</a></nav>
<section id="overview" class="panel"><h2>Настройки доменов</h2><p class="text-muted">Редактируйте веса и default прямо в ячейках. Проценты рассчитываются из весов. Сохранение — отдельно для каждого домена.</p><button id="overview-refresh" type="button" class="btn btn-default">Обновить настройки</button><div class="table-responsive overview-wrapper"><table id="overview-table" class="table table-bordered overview-table"><caption class="text-muted">Таблицу можно прокручивать по горизонтали.</caption><thead></thead><tbody></tbody></table></div></section>
<section id="copy-panel" class="panel"><h2>Копировать настройки</h2><div class="copy-grid"><label>Откуда<select id="copy-source" class="form-control"><option value="">Выберите домен</option></select><span class="text-muted copy-hint">Берём настройки, уже сохранённые на домене.</span></label><div><strong>Куда</strong><div id="copy-targets" class="target-options"></div><div class="toolbar"><button id="copy-all" type="button" class="btn btn-default btn-xs">Выбрать все</button><button id="copy-none" type="button" class="btn btn-default btn-xs">Снять выбор</button></div></div></div><div class="copy-bottom"><p class="text-muted">Основной сайт и все UTM: варианты, веса и default.<br>Заметки и файлы наборов изображений не копируются. Отсутствующая группа источника сохраняет группу получателя.</p><button id="copy-submit" class="btn btn-primary" type="button" disabled>Копировать в 0 доменов</button></div><div id="copy-results" aria-live="polite"></div><p id="copy-reason" class="text-muted" role="status" aria-live="polite"></p></section><section id="link-builder" class="panel"><h2>Конструктор ссылки</h2><p class="text-muted">Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.</p><label class="builder-domain">Домен ссылки<select id="builder-target" class="form-control"><option value="">Выберите домен</option></select></label><p id="builder-state" class="text-muted" role="status" aria-live="polite">Выберите домен.</p><div id="builder-fields" class="form-grid"></div><div id="builder-warnings" aria-live="polite"></div><button id="builder-save" type="button" class="btn btn-primary" disabled>Сформировать и сохранить</button><div id="builder-result" hidden><label>Готовая ссылка<input id="builder-url" class="form-control" type="url" readonly spellcheck="false"></label><div class="toolbar"><button id="builder-copy" type="button" class="btn btn-default">Скопировать</button><a id="builder-open" class="btn btn-default" target="_blank" rel="noopener noreferrer">Открыть ссылку</a></div><p id="builder-saved-at" class="text-muted"></p></div></section>
<section id="indpic" class="panel"><h2>indpic= UTM</h2><p class="text-muted">Выбор домена и целей ниже относится только к наборам и резервным копиям. Загрузка не меняет default и веса.</p><div class="toolbar"><label class="grow">Открытый домен<select id="source-target" class="form-control"><option value="">Выберите домен</option></select></label><button id="reload" class="btn btn-default" type="button">Перечитать</button></div><p id="read-state" class="text-muted">Выберите домен.</p><p class="text-muted">Цели загрузки ZIP и проверки</p><div id="targets" class="target-options"></div><div class="table-responsive"><table class="table table-bordered table-hover indpic-sets-table"><thead><tr><th>Номер / название</th><th class="indpic-default-column">Default</th><th class="indpic-check-column">Проверка</th><th>ZIP</th><th class="indpic-actions-column">Действия</th></tr></thead><tbody id="sets-body"></tbody></table></div><h3>Добавить Set</h3><form id="set-add" class="indpic-add-form"><label>№<input name="number" class="form-control indpic-number-input" type="number" min="1" max="9" required></label><label>Название Set<input name="name" class="form-control" maxlength="255"></label><label class="checkbox"><input name="sftp" type="checkbox"> Загружен по SFTP</label><label class="zip-field">ZIP<input name="file" type="file" accept=".zip,application/zip" class="form-control" required></label><button class="btn btn-default">Загрузить и добавить</button></form></section>
<section id="connections" class="panel"><h2>Домены и подключения</h2><p class="text-muted">Удаление подключения оставляет сайт и файлы на хостинге. Секрет можно заменить; прочитать сохранённый секрет нельзя.</p><div id="connection-list"></div><h3 id="connection-title">Добавить подключение</h3><form id="connection-form"><div class="form-grid"><label>ID<input name="id" class="form-control" required pattern="[A-Za-z0-9_-]+"></label><label>Название<input name="name" class="form-control" required></label><label>Домен<input name="domain" class="form-control" required></label><label>Окружение<select name="environment" class="form-control"><option value="stage">stage</option><option value="production">production</option></select></label><label>Тип<select name="type" class="form-control"><option value="sftp">SFTP / SSH</option><option value="local">Локальная папка</option></select></label><label>Хост SSH<input name="host" class="form-control"></label><label>Порт<input name="port" type="number" min="1" max="65535" value="22" class="form-control"></label><label>Пользователь SSH<input name="user" class="form-control"></label><label>Корневая папка<input name="root" class="form-control" required></label><label>Проверенный ключ хоста<input name="host_key" class="form-control" autocomplete="off"></label><label>Новый секрет / пароль<input name="secret" type="password" class="form-control" autocomplete="new-password"></label><label class="checkbox"><input name="enabled" type="checkbox"> Включено для записи</label></div><div class="toolbar"><button class="btn btn-primary">Сохранить подключение</button><button class="btn btn-default" id="connection-new" type="button">Очистить форму</button></div></form></section>
<section id="history" class="panel"><h2>Журнал и резервные копии</h2><button id="refresh-history" class="btn btn-default">Обновить журнал и резерв</button><h3>Резерв открытого домена</h3><div id="backups"></div><h3>Задания</h3><p class="text-muted">Успешные цели не повторяются. После конфликта перечитайте домен и проверьте изменения; неизвестный результат требует проверки фактического состояния.</p><div id="jobs" aria-live="polite"></div></section>
</div></main>