feat(admin): compact UTM percentages and shared notes
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
This commit is contained in:
1 parent
4fc4fd8162
commit
f2097d4800
10 files changed
+240
-16
No files matched your search
@@ -32,6 +32,25 @@
|
||||
Неизвестная в текущем runtime/catalog группа сопровождается предупреждением;
|
||||
конструктор не исправляет конфигурацию автоматически.
|
||||
|
||||
## Компактная сводка и заметки — preland#23
|
||||
|
||||
Ячейка UTM содержит только номер и долю заданного веса: `1 (100%)`,
|
||||
`2 (0%) - default`. Положительные доли выделены жирным. Проценты округляются
|
||||
до двух знаков без лишних нулей; при всех нулевых весах у всех вариантов `0%`.
|
||||
Таблица не меняет default/веса и не подставляет другому варианту `100%`.
|
||||
|
||||
Под каждой из восьми UTM расположена общая для доменов заметка. Редактор —
|
||||
растягиваемая по высоте textarea на всю ширину таблицы; сохранение явное.
|
||||
Черновик сохраняется при обновлении колонок или ошибке запроса. При конфликте
|
||||
сохранения нужно явно загрузить серверный текст; чужой текст молча не заменяется.
|
||||
|
||||
`GET /api/utm-notes` читает восемь заметок, `POST /api/utm-notes` принимает
|
||||
`{marker,text,version}`. Авторизация/CSRF общие; версия отдельная у каждой заметки,
|
||||
запись защищена CAS. Plain text до10000 Unicode-символов, пустой текст очищает
|
||||
заметку. Файл `state/utm-notes.json` приватный0600, хранится независимо от
|
||||
config.json, подключений, конструктора ссылок и заданий. Заметки не доставляются
|
||||
на хостинги и сохраняются после замены контейнера.
|
||||
|
||||
## Состав и доступы
|
||||
|
||||
`compose.yaml` запускает web и отдельный worker из одного неизменяемого образа. Нужны
|
||||
|
||||
+1
-1
@@ -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"], "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"], "test:worker": "php tests/backend-worker.php"}
|
||||
}
|
||||
@@ -6,3 +6,5 @@
|
||||
.overview-wrapper{margin-top:15px}.overview-table{min-width:760px;table-layout:fixed}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){min-width:190px}.overview-table th,.overview-table td{vertical-align:top;overflow-wrap:anywhere}.overview-table th:first-child,.overview-table td:first-child{position:sticky;left:0;background:#f5f5f5;z-index:1}.overview-table caption{text-align:left;padding:8px 0}.overview-domain{font-size:15px;margin-bottom:6px}.overview-status{font-size:12px;font-weight:400;margin:8px 0}.overview-weights{list-style:none;padding:0;margin:7px 0 0}.overview-weights li{margin:3px 0}.overview-warning{color:#8a6d3b;margin-top:8px;font-size:12px}.overview-error{color:#a94442}.overview-table .toolbar{gap:6px;margin-bottom:0}.builder-domain{display:block;max-width:450px;margin:15px 0}.builder-field{display:block}.builder-field select{margin-top:5px}#builder-fields{margin:15px 0}#builder-result{margin-top:18px}#builder-url{font-family:monospace}#builder-state{margin-top:10px}#builder-warnings .alert{padding:10px}#builder-save{margin-top:5px}@media(max-width:600px){.overview-table{min-width:720px}.overview-table th:first-child{width:90px}.overview-table .btn{padding:4px 8px}.builder-domain{max-width:100%}}
|
||||
.overview-plain{white-space:pre-wrap}
|
||||
.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}
|
||||
@@ -21,17 +21,75 @@
|
||||
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,digit=digitFor(marker,value.default);
|
||||
cell.append(el('strong',{text:'Default: '+(digit?optionLabel(marker,digit):Object.hasOwn(value,'default')?'некорректное значение '+String(value.default):'не задан')}));
|
||||
const weights=value.weights;
|
||||
if(weights&&typeof weights==='object'&&!Array.isArray(weights)){
|
||||
const list=el('ul',{class:'overview-weights'}),seen=new Set();let collision=false;
|
||||
for(const [variant,weight]of Object.entries(weights)){const normalized=digitFor(marker,variant);if(normalized&&seen.has(normalized))collision=true;seen.add(normalized);list.append(el('li',{text:(normalized?optionLabel(marker,normalized):variant+' (неизвестный вариант)')+' — вес '+String(weight)}));}
|
||||
if(!Object.keys(weights).length)list.append(el('li',{class:'text-muted',text:'Вариантов нет'}));cell.append(list);
|
||||
if(collision)cell.append(el('p',{class:'overview-warning',text:'Конфликт semantic и numeric вариантов; исходные записи показаны без объединения.'}));
|
||||
}else cell.append(el('p',{class:'overview-warning',text:'Веса отсутствуют или имеют неверный тип.'}));
|
||||
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 text=variant.digit+' ('+percent+'%)'+(variant.digit===defaultDigit?' - default':'');cell.append(el(percent>0?'strong':'span',{class:'overview-percent',text}));}
|
||||
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=state.targets.length+1;return entry;}
|
||||
const entry={marker,epoch:0,note:null,draft:'',baseVersion:'',editing:false,saving:false,loading:false,error:'',conflict:false};
|
||||
const makeButton=(text,action)=>el('button',{type:'button',class:'btn btn-default btn-xs',text,onclick:()=>run(action)});
|
||||
entry.row=el('tr',{'data-utm-note':marker});entry.cell=el('td',{class:'utm-note-cell'});entry.row.append(entry.cell);
|
||||
entry.title=el('span',{class:'utm-note-title',text:'Заметка '+marker});entry.text=el('div',{class:'utm-note-text'});
|
||||
entry.edit=makeButton('Добавить заметку',()=>{if(!entry.note)return;entry.epoch++;entry.draft=entry.note.text;entry.baseVersion=entry.note.version;entry.editing=true;entry.error='';entry.conflict=false;renderNote(entry);entry.textarea.focus();});
|
||||
entry.edit.setAttribute('aria-label','Добавить заметку '+marker);
|
||||
entry.readView=el('div',{class:'utm-note-read'},entry.text,entry.edit);
|
||||
entry.textarea=el('textarea',{class:'form-control utm-note-textarea',rows:3,'aria-label':'Заметка '+marker});
|
||||
entry.textarea.addEventListener('input',()=>{entry.draft=entry.textarea.value;if(!entry.conflict)entry.error='';renderNote(entry);});
|
||||
entry.save=makeButton('Сохранить',()=>saveNote(marker));entry.save.setAttribute('aria-label','Сохранить заметку '+marker);
|
||||
entry.cancel=makeButton('Отмена',()=>{const wasConflict=entry.conflict;entry.epoch++;entry.editing=false;entry.draft=entry.note?.text||'';entry.error='';entry.conflict=false;renderNote(entry);if(wasConflict)run(loadNotes);});entry.cancel.setAttribute('aria-label','Отменить заметку '+marker);
|
||||
entry.reload=makeButton('Загрузить сохранённую заметку',()=>loadCurrentNote(marker));entry.reload.setAttribute('aria-label','Загрузить сохранённую заметку '+marker);
|
||||
entry.editView=el('div',{class:'utm-note-edit'},entry.textarea,el('div',{class:'toolbar'},entry.save,entry.cancel,entry.reload));
|
||||
entry.retry=makeButton('Повторить загрузку заметок',loadNotes);entry.retry.setAttribute('aria-label','Повторить загрузку заметок '+marker);
|
||||
entry.status=el('p',{class:'utm-note-status text-muted',role:'status','aria-live':'polite'});
|
||||
entry.cell.append(entry.title,entry.readView,entry.editView,entry.status,entry.retry);utmNotes.entries.set(marker,entry);renderNote(entry);return entry;
|
||||
}
|
||||
function renderNote(entry){
|
||||
entry.cell.colSpan=state.targets.length+1;entry.text.textContent=entry.note?.text||'';entry.text.hidden=!entry.note?.text;
|
||||
entry.readView.hidden=entry.editing;entry.editView.hidden=!entry.editing;
|
||||
entry.edit.textContent=entry.note?.text?'Редактировать':'Добавить заметку';entry.edit.setAttribute('aria-label',(entry.note?.text?'Редактировать':'Добавить')+' заметку '+entry.marker);entry.edit.disabled=!entry.note||entry.loading;
|
||||
if(entry.textarea.value!==entry.draft)entry.textarea.value=entry.draft;
|
||||
entry.textarea.disabled=entry.saving||entry.loading;entry.save.disabled=entry.saving||entry.loading||entry.conflict||!entry.baseVersion;entry.cancel.disabled=entry.saving||entry.loading;
|
||||
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?'': 'Заметка ещё не загружена.';
|
||||
}
|
||||
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(){
|
||||
const request=++utmNotes.serial,epochs=new Map(markers.map(marker=>[marker,noteEntry(marker).epoch]));utmNotes.loading=true;utmNotes.error='';for(const entry of utmNotes.entries.values())renderNote(entry);
|
||||
try{const result=await api('utm-notes');if(request!==utmNotes.serial||!state.csrf)return;for(const marker of markers){const entry=noteEntry(marker),note=result.notes?.[marker];if(entry.epoch!==epochs.get(marker)||entry.saving||entry.loading)continue;if(!validNote(note)){entry.error='Сервер не вернул корректную заметку '+marker+'.';continue;}if(!entry.editing){entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.error='';}else if(entry.baseVersion!==note.version){entry.error='Заметка уже изменена. Загрузите сохранённую заметку явно; черновик сохранён.';entry.conflict=true;}}}
|
||||
catch(error){if(request===utmNotes.serial&&state.csrf)utmNotes.error='Ошибка загрузки заметок: '+error.message;}
|
||||
finally{if(request===utmNotes.serial){utmNotes.loading=false;for(const entry of utmNotes.entries.values())renderNote(entry);}}
|
||||
}
|
||||
async function loadCurrentNote(marker){
|
||||
const entry=noteEntry(marker);entry.epoch++;entry.loading=true;renderNote(entry);
|
||||
try{const result=await api('utm-notes');if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;const note=result.notes?.[marker];if(!validNote(note))throw new Error('Сервер не вернул корректную сохранённую заметку.');entry.epoch++;entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.conflict=false;entry.error='';}
|
||||
catch(error){entry.error='Не удалось загрузить сохранённую заметку: '+error.message;}
|
||||
finally{entry.loading=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
|
||||
}
|
||||
async function saveNote(marker){
|
||||
const entry=noteEntry(marker);if(!entry.editing||entry.saving||entry.loading||entry.conflict||!entry.baseVersion)return;entry.epoch++;entry.saving=true;entry.error='';renderNote(entry);
|
||||
try{const result=await api('utm-notes',{marker,text:entry.draft,version:entry.baseVersion});if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;if(!validNote(result.note))throw new Error('Сервер не подтвердил сохранённую заметку.');entry.epoch++;entry.note=result.note;entry.draft=result.note.text;entry.baseVersion=result.note.version;entry.editing=false;entry.conflict=false;}
|
||||
catch(error){entry.error=error.message;entry.conflict=error.status===409;}
|
||||
finally{entry.saving=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
|
||||
}
|
||||
|
||||
function overviewExtra(config){
|
||||
const values=new Map(),secretKeys=new Set(['secret','password','private_key','privatekey','credential','credentials','api_key','apikey','access_token','accesstoken','refresh_token','refreshtoken','authorization']);
|
||||
const visit=(path,value)=>{const key=path.join('.');if(path.some(part=>secretKeys.has(String(part).toLowerCase().replace(/-/g,'_')))){values.set(key,{label:key,text:'Скрытое поле'});return;}
|
||||
@@ -59,19 +117,21 @@
|
||||
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 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:key==='requisites'?1:2;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',{scope:'row',text:marker}));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(row);}
|
||||
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{scope:'row',text:marker}));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(row);body.append(noteEntry(marker).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();
|
||||
}
|
||||
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;
|
||||
}
|
||||
async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;try{await Promise.allSettled(state.targets.map(target=>refreshOverviewTarget(target.id)));}finally{control.disabled=false;}}
|
||||
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);}
|
||||
if(builder.target&&!ids.has(builder.target))builder.target='';
|
||||
@@ -143,7 +203,7 @@
|
||||
if(!response.ok||!body.ok){if(response.status===401)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body;
|
||||
}
|
||||
async function run(action){try{await action();}catch(error){notice(error.message);}}
|
||||
function showLogin(){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(){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 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);}
|
||||
|
||||
+10
-1
@@ -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};
|
||||
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog,LinkBuilder,Notes};
|
||||
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;}
|
||||
@@ -30,6 +30,15 @@ try {
|
||||
// не должны удерживать PHP session lock и блокировать соседние запросы.
|
||||
session_write_close();
|
||||
if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
|
||||
if($path==='/api/utm-notes'){
|
||||
$notes=new Notes($store);
|
||||
if($method==='GET'){
|
||||
if($_GET)throw new Problem('Заметки UTM общие для всех доменов и не принимают параметры.');
|
||||
reply(['notes'=>$notes->all()]);
|
||||
}
|
||||
if($method==='POST')reply(['note'=>$notes->save($data)]);
|
||||
throw new Problem('Метод заметок UTM не поддерживается.',405);
|
||||
}
|
||||
if($path==='/api/link-builder'){
|
||||
$builder=new LinkBuilder($store);
|
||||
if($method==='GET'){
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
|
||||
final class Notes
|
||||
{
|
||||
private const MARKERS=['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
public function __construct(private readonly Store $store) {}
|
||||
|
||||
public function all(): array {
|
||||
return $this->store->locked('utm-notes',fn()=>$this->load());
|
||||
}
|
||||
|
||||
public function save(array $input): array {
|
||||
if(count($input)!==3||array_diff(array_keys($input),['marker','text','version'])||!is_string($input['marker']??null)||!in_array($input['marker'],self::MARKERS,true)||!is_string($input['text']??null)||!is_string($input['version']??null))throw new Problem('Передайте маркер UTM, текст и версию заметки.');
|
||||
if(!self::validText($input['text']))throw new Problem('Заметка должна содержать корректный UTF-8, не более 10000 символов, без управляющих символов кроме перевода строки и табуляции.');
|
||||
return $this->store->locked('utm-notes',function() use($input){
|
||||
$notes=$this->load();$marker=$input['marker'];
|
||||
if(!hash_equals($notes[$marker]['version'],$input['version']))throw new Problem('Заметка уже изменена. Загрузите актуальную версию перед сохранением.',409);
|
||||
$note=['text'=>$input['text'],'version'=>bin2hex(random_bytes(32)),'saved_at'=>gmdate(DATE_ATOM)];
|
||||
$notes[$marker]=$note;$this->store->write('utm-notes.json',$notes);return $note;
|
||||
});
|
||||
}
|
||||
|
||||
private function load(): array {
|
||||
$path=$this->store->path('utm-notes.json');clearstatcache(true,$path);
|
||||
if(is_link($path)||file_exists($path)&&(!is_file($path)||filesize($path)>1048576))throw new Problem('Повреждено приватное состояние заметок.',503);
|
||||
if(!file_exists($path)){
|
||||
$notes=[];foreach(self::MARKERS as $marker)$notes[$marker]=['text'=>'','version'=>hash('sha256','utm-notes:'.$marker.':empty'),'saved_at'=>null];return $notes;
|
||||
}
|
||||
$notes=$this->store->read('utm-notes.json',null);
|
||||
if(!is_array($notes)||count($notes)!==8||array_diff(self::MARKERS,array_keys($notes)))throw new Problem('Повреждено приватное состояние заметок.',503);
|
||||
$ordered=[];
|
||||
foreach(self::MARKERS as $marker){
|
||||
$note=$notes[$marker];
|
||||
if(!is_array($note)||count($note)!==3||array_diff(array_keys($note),['text','version','saved_at'])||!is_string($note['text']??null)||!self::validText($note['text'])||!is_string($note['version']??null)||!preg_match('/^[a-f0-9]{64}$/D',$note['version'])||!array_key_exists('saved_at',$note)||($note['saved_at']!==null&&(!is_string($note['saved_at'])||!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$note['saved_at']))))throw new Problem('Повреждено приватное состояние заметок.',503);
|
||||
if($note['saved_at']===null&&($note['text']!==''||$note['version']!==hash('sha256','utm-notes:'.$marker.':empty')))throw new Problem('Повреждено приватное состояние заметок.',503);
|
||||
$ordered[$marker]=$note;
|
||||
}
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
private static function validText(string $text): bool {
|
||||
return strlen($text)<=40000&&preg_match('//u',$text)===1&&preg_match('/[\x{0000}-\x{0008}\x{000B}\x{000C}\x{000E}-\x{001F}\x{007F}-\x{009F}]/u',$text)===0&&preg_match_all('/./us',$text)<=10000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* preland#23: real compact overview and CAS notes. Runtime configs/jobs stay unchanged. */
|
||||
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 context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('dialog',d=>d.accept());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()});});
|
||||
const output=process.env.UI_OUTPUT||'/tmp';let initialNote=null,csrf='',primaryError=null;
|
||||
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 row=()=>page.locator('tr[data-utm-note=reg]'),textarea=()=>row().getByLabel('Заметка reg',{exact:true});
|
||||
const enterEdit=async()=>{await row().getByRole('button',{name:/(?:Добавить|Редактировать) заметку reg/}).click();await textarea().waitFor();};
|
||||
const withDelayedNotesGet=async operation=>{
|
||||
let release,ready;const gate=new Promise(resolve=>release=resolve),captured=new Promise(resolve=>ready=resolve);let held=false;
|
||||
const handler=async route=>{if(route.request().method()==='GET'&&!held){held=true;const response=await route.fetch();ready();await gate;await route.fulfill({response});}else await route.continue();};
|
||||
await page.route('**/api/utm-notes',handler);
|
||||
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([captured,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET notes was not captured')),5000))]);await operation();release();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
|
||||
finally{release();await page.unroute('**/api/utm-notes',handler);}
|
||||
};
|
||||
const postStartThenOldGet=async()=>{
|
||||
let releasePost,releaseGet,postReady,getReady;const postGate=new Promise(resolve=>releasePost=resolve),getGate=new Promise(resolve=>releaseGet=resolve),postSeen=new Promise(resolve=>postReady=resolve),getSeen=new Promise(resolve=>getReady=resolve);let heldPost=false,heldGet=false;
|
||||
const handler=async route=>{if(route.request().method()==='POST'&&!heldPost){heldPost=true;postReady();await postGate;await route.continue();}else if(route.request().method()==='GET'&&!heldGet){heldGet=true;const response=await route.fetch();getReady();await getGate;await route.fulfill({response});}else await route.continue();};
|
||||
await page.route('**/api/utm-notes',handler);
|
||||
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);const posted=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await Promise.race([postSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('POST notes was not captured')),5000))]);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([getSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET during POST was not captured')),5000))]);releasePost();const response=await(await posted).json();assert.ok(response.ok,response.error);await textarea().waitFor({state:'hidden'});releaseGet();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
|
||||
finally{releasePost();releaseGet();await page.unroute('**/api/utm-notes',handler);}
|
||||
};
|
||||
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});
|
||||
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-percent').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');}}}
|
||||
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+1);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';
|
||||
await withDelayedNotesGet(async()=>{await enterEdit();await textarea().fill(newer);await save();});
|
||||
await row().getByText(newer,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,newer,'late GET cannot roll back successful Save');
|
||||
const postFirst=newer+'\nPOST начат раньше GET';await enterEdit();await textarea().fill(postFirst);await postStartThenOldGet();await row().getByText(postFirst,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,postFirst,'GET started during Save cannot roll back committed result');
|
||||
await enterEdit();const conflictDraft=postFirst+'\nМой конфликтный черновик';await textarea().fill(conflictDraft);const current=(await request('utm-notes')).notes.reg;const external='Внешняя заметка '+Date.now();await request('utm-notes',{marker:'reg',text:external,version:current.version});
|
||||
const conflictResponse=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();assert.equal((await conflictResponse).status(),409);assert.equal(await textarea().inputValue(),conflictDraft);await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,external);await withDelayedNotesGet(async()=>{const latest=(await request('utm-notes')).notes.reg;const finalExternal=external+' — новая версия';await request('utm-notes',{marker:'reg',text:finalExternal,version:latest.version});await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).click();await page.waitForFunction(text=>document.querySelector('textarea[aria-label="Заметка reg"]').value===text,finalExternal);});assert.equal(await textarea().inputValue(),external+' — новая версия','late GET cannot roll back explicit server reload');
|
||||
const networkDraft=external+'\nВосстановление связи';await textarea().fill(networkDraft);await context.setOffline(true);await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await row().locator('[role=status]').getByText(/Связь|соединени|ошибк/i).waitFor();assert.equal(await textarea().inputValue(),networkDraft);await context.setOffline(false);await save();assert.equal((await request('utm-notes')).notes.reg.text,networkDraft);
|
||||
await page.screenshot({path:output+'/notes-desktop.png',fullPage:true});await page.setViewportSize({width:390,height:844});await enterEdit();await textarea().fill('Мобильный черновик\nНесколько строк');await page.screenshot({path:output+'/notes-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.equal(await textarea().evaluate(n=>getComputedStyle(n).resize),'vertical');await row().getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
|
||||
for(const before of snapshots){const after=await page.request.get('/api/config?target='+before.id).then(r=>r.json());if(before.ok){assert.ok(after.ok,after.error);assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);}}assert.deepEqual((await request('jobs')).jobs.map(j=>j.id).sort(),jobsBefore);assert.deepEqual(errors,[]);fs.writeFileSync(output+'/notes-evidence.json',JSON.stringify({configVersions:snapshots.filter(r=>r.ok).map(r=>({id:r.id,version:r.version})),jobsUnchanged:true,pageErrors:errors,console:consoleMessages},null,2));console.log('PASS actual compact percentages / notes input-save-reload / cursor survives refresh / CAS conflict explicit reload / offline recovery / desktop mobile / runtime jobs unchanged');
|
||||
}catch(error){primaryError=error;}finally{const cleanup=[];try{await context.setOffline(false);if(initialNote){if(!(await textarea().isVisible()))await enterEdit();await textarea().fill(initialNote.text);const latest=(await request('utm-notes')).notes.reg;await request('utm-notes',{marker:'reg',text:initialNote.text,version:latest.version});}}catch(error){cleanup.push(error);}try{await browser.close();}catch(error){cleanup.push(error);}if(primaryError&&cleanup.length)throw new AggregateError([primaryError,...cleanup],'notes scenario failed; separate cleanup errors',{cause:primaryError});if(primaryError)throw primaryError;if(cleanup.length)throw new AggregateError(cleanup,'notes cleanup failed');}
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -35,7 +35,7 @@ const fs = require('node:fs');
|
||||
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('textarea').count(),0,'JSON textarea absent');
|
||||
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});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
use PrelandAdmin\{Notes,Problem,Store};
|
||||
function notesCheck(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo "PASS {$message}\n";}
|
||||
function notesReject(callable $callback,int $status,string $message): void {try{$callback();}catch(Problem $e){notesCheck($e->status===$status,$message);return;}throw new RuntimeException('Accepted invalid operation: '.$message);}
|
||||
notesCheck(class_exists(Notes::class),'Notes service is available');
|
||||
$root=sys_get_temp_dir().'/utm-notes-'.bin2hex(random_bytes(8));$store=new Store($root);$notes=new Notes($store);$markers=['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
$runtime=$root.'/runtime.json';file_put_contents($runtime,'{"host":"untouched.example","utm":{}}');$runtimeHash=hash_file('sha256',$runtime);
|
||||
$all=$notes->all();notesCheck(array_keys($all)===$markers&&count(array_filter($all,static fn($n)=>$n['text']===''&&$n['saved_at']===null&&is_string($n['version'])))===8,'GET contract provides all eight empty notes with stable versions');
|
||||
notesCheck(!file_exists($root.'/utm-notes.json')&&$notes->all()===$all,'initial read does not create state or regenerate versions');
|
||||
$text="Общее описание для всех доменов\n<script>alert('literal')</script>\tпример\r\n😀";
|
||||
$saved=$notes->save(['marker'=>'theme','text'=>$text,'version'=>$all['theme']['version']]);notesCheck($saved['text']===$text&&$saved['version']!==$all['theme']['version']&&is_string($saved['saved_at']),'save stores exact multiline Unicode plaintext with new revision and timestamp');
|
||||
notesCheck((new Notes(new Store($root)))->all()['theme']===$saved,'fresh service reload returns exact saved note and version');
|
||||
notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'stale','version'=>$all['theme']['version']]),409,'stale per-note version conflicts instead of overwriting');
|
||||
$indpic=$notes->save(['marker'=>'indpic','text'=>'Independent note','version'=>$all['indpic']['version']]);notesCheck($notes->all()['theme']===$saved&&$indpic['text']==='Independent note','another marker can save its original revision without losing sibling note');
|
||||
$cleared=$notes->save(['marker'=>'theme','text'=>'','version'=>$saved['version']]);notesCheck($cleared['text']===''&&$cleared['version']!==$all['theme']['version']&&$cleared['version']!==$saved['version'],'explicit clear keeps a new revision and prevents ABA');
|
||||
notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'old initial','version'=>$all['theme']['version']]),409,'initial version remains stale after save then clear');
|
||||
$large=str_repeat('😀',10000);$max=$notes->save(['marker'=>'cons','text'=>$large,'version'=>$all['cons']['version']]);notesCheck($max['text']===$large,'limit counts 10000 Unicode characters rather than UTF8 bytes');
|
||||
foreach([['marker'=>'unknown','text'=>'x','version'=>'x'],['marker'=>'registration','text'=>'x','version'=>'x'],['marker'=>'theme','text'=>1,'version'=>'x'],['marker'=>'theme','text'=>'x','version'=>null],['marker'=>'theme','text'=>'x'],['marker'=>'theme','text'=>'x','version'=>'x','domain'=>'evil.example'],['marker'=>['theme'],'text'=>'x','version'=>'x'],['marker'=>'theme','text'=>str_repeat('a',10001),'version'=>'x'],['marker'=>'theme','text'=>"invalid\xFF",'version'=>'x'],['marker'=>'theme','text'=>"NUL\0",'version'=>'x'],['marker'=>'theme','text'=>"DEL\x7f",'version'=>'x'],['marker'=>'theme','text'=>"C1\u{0085}",'version'=>'x']] as $invalid)notesReject(fn()=>$notes->save($invalid),422,'invalid marker, fields, types, length, UTF8 or controls rejected');
|
||||
$pids=[];$snapshot=$notes->all();foreach(['degr','tp'] as $marker){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>$marker,'text'=>'parallel-'.$marker,'version'=>$snapshot[$marker]['version']]);exit(0);}catch(Throwable){exit(2);}}$pids[]=$pid;}foreach($pids as $pid){pcntl_waitpid($pid,$status);notesCheck(pcntl_wexitstatus($status)===0,'parallel unrelated note save succeeds');}notesCheck($notes->all()['degr']['text']==='parallel-degr'&&$notes->all()['tp']['text']==='parallel-tp','global file lock preserves concurrent writes to different notes');
|
||||
$snapshot=$notes->all();$pids=[];foreach(['first','second'] as $text){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>'reg','text'=>$text,'version'=>$snapshot['reg']['version']]);exit(0);}catch(Problem $e){exit($e->status===409?9:2);}}$pids[]=$pid;}$codes=[];foreach($pids as $pid){pcntl_waitpid($pid,$status);$codes[]=pcntl_wexitstatus($status);}sort($codes);notesCheck($codes===[0,9],'concurrent same-note saves produce one success and one conflict');
|
||||
notesCheck((fileperms($root.'/utm-notes.json')&0777)===0600,'notes state is private 0600');$good=file_get_contents($root.'/utm-notes.json');
|
||||
foreach(['{broken','null','[]','{"unexpected":{}}',str_replace('Independent note','bad\\u0000text',$good)] as $bad){file_put_contents($root.'/utm-notes.json',$bad);notesReject(fn()=>$notes->all(),503,'corrupt private note state fails closed');}file_put_contents($root.'/utm-notes.json',$good);
|
||||
file_put_contents($root.'/utm-notes.json','null');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not reset','version'=>$indpic['version']]),503,'write never resets corrupt note state');notesCheck(file_get_contents($root.'/utm-notes.json')==='null','failed corrupt-state write leaves evidence intact');
|
||||
unlink($root.'/utm-notes.json');symlink($runtime,$root.'/utm-notes.json');notesReject(fn()=>$notes->all(),503,'note symlink read fails closed');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not follow','version'=>$indpic['version']]),503,'note symlink write fails closed');unlink($root.'/utm-notes.json');file_put_contents($root.'/utm-notes.json',$good);chmod($root.'/utm-notes.json',0600);
|
||||
notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'notes never modify runtime, targets or jobs');
|
||||
// Настоящий HTTP-контракт, отдельный PHP server, обычный auth/CSRF.
|
||||
$env=getenv();$env['ADMIN_STATE_DIR']=$root;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='notes-fixture';$env['ADMIN_PASSWORD_HASH']=password_hash('notes-fixture-password',PASSWORD_DEFAULT);unset($env['PHP_CLI_SERVER_WORKERS']);$port=random_int(19000,29000);$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);fclose($pipes[0]);$cookie='';
|
||||
function notesRequest(string $method='GET',?array $data=null,?string $csrf=null,string $path='/api/utm-notes'): 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'=>$method,'header'=>implode("\r\n",$headers),'content'=>$data===null?'':json_encode((object)$data,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$h=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$h[0]??'',$m);foreach($h as $line)if(str_starts_with(strtolower($line),'set-cookie: '))$cookie=explode(';',substr($line,12))[0];return [(int)($m[1]??0),json_decode($raw,true,64,JSON_THROW_ON_ERROR)];}
|
||||
try{$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);break;}usleep(20000);}while(microtime(true)<$until);
|
||||
[$status]=notesRequest();notesCheck($status===401,'HTTP notes require authentication');[$status,$login]=notesRequest('POST',['username'=>'notes-fixture','password'=>'notes-fixture-password'],null,'/api/login');notesCheck($status===200,'HTTP fixture login succeeds');$csrf=$login['csrf'];
|
||||
[$status,$body]=notesRequest();notesCheck($status===200&&array_keys($body['notes'])===$markers,'HTTP GET returns eight global note objects without target');$version=$body['notes']['focus']['version'];
|
||||
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'csrf','version'=>$version]);notesCheck($status===403,'HTTP notes mutation requires CSRF');
|
||||
[$status,$body]=notesRequest('POST',['marker'=>'focus','text'=>'HTTP global note','version'=>$version],$csrf);notesCheck($status===200&&$body['note']['text']==='HTTP global note','HTTP POST saves exact note response');$httpSaved=$body['note'];
|
||||
[$status,$body]=notesRequest();notesCheck($body['notes']['focus']===$httpSaved,'HTTP reload restores note across requests');
|
||||
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'stale HTTP','version'=>$version],$csrf);notesCheck($status===409,'HTTP stale editor gets 409 conflict');
|
||||
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'x','version'=>$httpSaved['version'],'target'=>'any'],$csrf);notesCheck($status===422,'HTTP note refuses domain scope and unknown top-level fields');
|
||||
[$status]=notesRequest('POST',['marker'=>'focus','text'=>false,'version'=>$httpSaved['version']],$csrf);notesCheck($status===422,'HTTP strict note value types return 422');
|
||||
file_put_contents($root.'/utm-notes.json','{bad');[$status]=notesRequest();notesCheck($status===503,'HTTP corrupt file returns safe 503');file_put_contents($root.'/utm-notes.json',$good);
|
||||
notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'HTTP notes leave runtime, jobs and target registry unchanged');
|
||||
[$status]=notesRequest('POST',[],$csrf,'/api/logout');notesCheck($status===200,'HTTP note fixture logs out');
|
||||
}finally{proc_terminate($server);proc_close($server);fclose($log);}
|
||||
echo "UTM notes contract complete\n";
|
||||
@@ -8,7 +8,7 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.
|
||||
<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="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>
|
||||
|
||||
Reference in new issue
Block a user