feat(admin): add runtime overview and independent saved UTM link builder
deploy-preland / deploy zoomstage.online (push) Successful in 57s
deploy-preland / deploy zoomstage.online (push) Successful in 57s
This commit is contained in:
1 parent
48c71f3300
commit
2cfa2e2d2e
10 files changed
+421
-8
No files matched your search
@@ -8,6 +8,27 @@
|
|||||||
Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории,
|
Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории,
|
||||||
копии и фактический статус — в базе знаний, основной реестр — preland#22.
|
копии и фактический статус — в базе знаний, основной реестр — preland#22.
|
||||||
|
|
||||||
|
## Сводка и конструктор ссылки
|
||||||
|
|
||||||
|
Первый экран читает сохранённые конфиги всех подключений: основной сайт, реквизиты,
|
||||||
|
дополнительные поля, default и все веса UTM. У каждой колонки своё время чтения,
|
||||||
|
версия, обновление и ошибка; при отказе прежний снимок явно помечается. Обновление
|
||||||
|
сводки не сбрасывает черновик редактора и не меняет его версию для CAS.
|
||||||
|
|
||||||
|
Конструктор работает отдельно: значения или «Рандом» в восьми списках, затем
|
||||||
|
«Сформировать и сохранить». Последний выбор и точный URL сохраняются на сервере
|
||||||
|
отдельно по ID домена, восстанавливаются после перезагрузки. Изменение списка скрывает
|
||||||
|
старую ссылку до явного сохранения. Значащая цифра стоит на пятой позиции метки,
|
||||||
|
`0` означает выбор по весам. Конструктор не меняет config, default, веса или наборы,
|
||||||
|
не создаёт задания; сохранённый выбор доступен и при недоступном хостинге.
|
||||||
|
|
||||||
|
`GET /api/link-builder?target=ID` читает выбор, `POST /api/link-builder` сохраняет
|
||||||
|
`{target,selection}` с ровно восемью каноническими UTM-ключами. Действуют общий вход
|
||||||
|
и CSRF. Состояние находится в приватном `state/link-builder-ID.json` с режимом0600.
|
||||||
|
При переименовании домена меняется origin ссылки, выбор и сохранённые метки остаются.
|
||||||
|
Неизвестная в текущем runtime/catalog группа сопровождается предупреждением;
|
||||||
|
конструктор не исправляет конфигурацию автоматически.
|
||||||
|
|
||||||
## Состав и доступы
|
## Состав и доступы
|
||||||
|
|
||||||
`compose.yaml` запускает web и отдельный worker из одного неизменяемого образа. Нужны
|
`compose.yaml` запускает web и отдельный worker из одного неизменяемого образа. Нужны
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@
|
|||||||
"license": "proprietary",
|
"license": "proprietary",
|
||||||
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "phpseclib/phpseclib": "^3.0"},
|
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "phpseclib/phpseclib": "^3.0"},
|
||||||
"config": {"sort-packages": true},
|
"config": {"sort-packages": true},
|
||||||
"scripts": {"test": ["php tests/backend-review.php", "php tests/backend-http.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"], "test:worker": "php tests/backend-worker.php"}
|
||||||
}
|
}
|
||||||
@@ -2,3 +2,6 @@
|
|||||||
* api/modules/cpanel/views/settings/index.php at 591a67cc8302 (preland#22). */
|
* api/modules/cpanel/views/settings/index.php at 591a67cc8302 (preland#22). */
|
||||||
*{box-sizing:border-box}body{margin:0;color:#333;background:#f7f7f7;font:14px/1.42857143 "Helvetica Neue",Helvetica,Arial,sans-serif}.container{width:min(1170px,100%);padding:0 15px;margin:0 auto}.navbar{background:#222;color:#ddd;min-height:52px}.nav-inner{display:flex;gap:24px;align-items:center;min-height:52px}.nav-inner strong{font-size:18px}.nav-inner button{margin-left:auto}h1{font-size:30px;font-weight:500;margin:24px 0 20px}h2{font-size:24px;font-weight:500;margin:0 0 18px}h3{font-size:18px;font-weight:500}p{margin:0 0 12px}.panel{padding:20px;background:white;border:1px solid #ddd;border-radius:4px;margin:20px 0}.login-panel{max-width:420px;margin:60px auto}.login-panel label{display:block;margin:15px 0}.form-control{display:block;width:100%;min-height:34px;padding:6px 12px;font:inherit;line-height:1.42857143;color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:inset 0 1px 1px #0001}input[type=checkbox]{margin:0 5px 0 0;accent-color:#337ab7}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid #337ab7;outline-offset:2px}button,input,select{font:inherit}.btn{display:inline-block;padding:6px 12px;border:1px solid transparent;border-radius:4px;font-weight:400;text-align:center;cursor:pointer;background:white;color:#333}.btn-default{border-color:#ccc}.btn-default:hover{background:#e6e6e6}.btn-primary{background:#337ab7;border-color:#2e6da4;color:white}.btn-danger{background:#d9534f;color:white}.btn-xs{padding:1px 5px;font-size:12px}.btn:disabled{opacity:.5;cursor:not-allowed}.toolbar,.target-options,.utm-inline-options,.utm-inline-option{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.toolbar{margin:12px 0}.grow{flex:1;min-width:200px}.target-options label{padding:8px 10px;border:1px solid #ddd;border-radius:4px}.text-muted{color:#707070}.alert{border:1px solid #ddd;border-radius:4px;padding:15px;margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.alert-danger{background:#f2dede;color:#a94442;border-color:#ebccd1}.alert-success{background:#dff0d8;color:#3c763d}.alert-warning{background:#fcf8e3;color:#8a6d3b}.alert-info{background:#d9edf7;color:#31708f}.tabs{display:flex;flex-wrap:wrap;gap:24px;border-bottom:1px solid #ddd;padding-bottom:12px}a,.utm-default-action,.utm-group-save{color:#337ab7}.utm-default-action,.utm-group-save{border:0;background:none;cursor:pointer;padding:0;text-decoration:underline}.utm-default-value{font-weight:bold;color:#3c763d}.table-responsive{overflow-x:auto;max-width:100%}.table{width:100%;border-collapse:collapse;margin-bottom:12px}.table th{text-align:left;background:#f5f5f5}.table td,.table th{padding:8px;vertical-align:middle;border:1px solid #ddd}.table-hover tbody tr:hover{background:#f5f5f5}.indpic-sets-table{table-layout:fixed;min-width:900px}.indpic-sets-table th:first-child{width:170px}.indpic-default-column{width:105px}.indpic-check-column{width:110px}.indpic-actions-column{width:100px}.indpic-file-control{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.indpic-file-control input{max-width:230px;min-width:0}.indpic-add-form{display:flex;align-items:end;flex-wrap:wrap;gap:10px;margin-top:14px}.indpic-number-input{width:96px}.indpic-add-form .checkbox{margin:0 4px 7px}.indpic-add-form input[name=name]{width:170px}.indpic-add-form input[type=file]{max-width:300px}.utm-group-number{font-size:18px;font-weight:600}.utm-group-name{margin:6px 0}.utm-inline-options{gap:24px;margin-bottom:12px}.utm-inline-option{gap:6px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.field{margin:12px 0}.field-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}.field-head label{font-weight:600}.nested{border-left:3px solid #eee;padding-left:15px;min-width:0}.field-block{margin:18px 0;padding-top:12px;border-top:1px solid #eee}.weight-table{max-width:560px}.weight-table input,.weight-table select{width:100%;min-width:75px}.mixed{font-size:12px;color:#8a6d3b;margin-left:8px}.job{margin:16px 0;border-top:1px solid #ddd;padding-top:12px}.job .table{min-width:480px}.status-success{color:#3c763d}.status-error,.status-conflict{color:#a94442}.status-unknown{color:#8a6d3b}.connection{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.connection span{flex:1;min-width:160px}.break{overflow-wrap:anywhere}dialog{border:1px solid #999;border-radius:6px;box-shadow:0 5px 15px #0008;padding:0;width:960px;max-width:calc(100% - 40px)}dialog::backdrop{background:#0007}.modal-header{position:relative;text-align:center;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header h2{font-size:18px;margin:0 34px}.close{position:absolute;right:15px;top:10px;background:none;border:0;font-size:25px;color:#777;cursor:pointer}.modal-body{padding:15px;max-height:calc(100vh - 180px);overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}[hidden]{display:none!important}@media(max-wLine truncated
|
*{box-sizing:border-box}body{margin:0;color:#333;background:#f7f7f7;font:14px/1.42857143 "Helvetica Neue",Helvetica,Arial,sans-serif}.container{width:min(1170px,100%);padding:0 15px;margin:0 auto}.navbar{background:#222;color:#ddd;min-height:52px}.nav-inner{display:flex;gap:24px;align-items:center;min-height:52px}.nav-inner strong{font-size:18px}.nav-inner button{margin-left:auto}h1{font-size:30px;font-weight:500;margin:24px 0 20px}h2{font-size:24px;font-weight:500;margin:0 0 18px}h3{font-size:18px;font-weight:500}p{margin:0 0 12px}.panel{padding:20px;background:white;border:1px solid #ddd;border-radius:4px;margin:20px 0}.login-panel{max-width:420px;margin:60px auto}.login-panel label{display:block;margin:15px 0}.form-control{display:block;width:100%;min-height:34px;padding:6px 12px;font:inherit;line-height:1.42857143;color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:inset 0 1px 1px #0001}input[type=checkbox]{margin:0 5px 0 0;accent-color:#337ab7}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid #337ab7;outline-offset:2px}button,input,select{font:inherit}.btn{display:inline-block;padding:6px 12px;border:1px solid transparent;border-radius:4px;font-weight:400;text-align:center;cursor:pointer;background:white;color:#333}.btn-default{border-color:#ccc}.btn-default:hover{background:#e6e6e6}.btn-primary{background:#337ab7;border-color:#2e6da4;color:white}.btn-danger{background:#d9534f;color:white}.btn-xs{padding:1px 5px;font-size:12px}.btn:disabled{opacity:.5;cursor:not-allowed}.toolbar,.target-options,.utm-inline-options,.utm-inline-option{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.toolbar{margin:12px 0}.grow{flex:1;min-width:200px}.target-options label{padding:8px 10px;border:1px solid #ddd;border-radius:4px}.text-muted{color:#707070}.alert{border:1px solid #ddd;border-radius:4px;padding:15px;margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.alert-danger{background:#f2dede;color:#a94442;border-color:#ebccd1}.alert-success{background:#dff0d8;color:#3c763d}.alert-warning{background:#fcf8e3;color:#8a6d3b}.alert-info{background:#d9edf7;color:#31708f}.tabs{display:flex;flex-wrap:wrap;gap:24px;border-bottom:1px solid #ddd;padding-bottom:12px}a,.utm-default-action,.utm-group-save{color:#337ab7}.utm-default-action,.utm-group-save{border:0;background:none;cursor:pointer;padding:0;text-decoration:underline}.utm-default-value{font-weight:bold;color:#3c763d}.table-responsive{overflow-x:auto;max-width:100%}.table{width:100%;border-collapse:collapse;margin-bottom:12px}.table th{text-align:left;background:#f5f5f5}.table td,.table th{padding:8px;vertical-align:middle;border:1px solid #ddd}.table-hover tbody tr:hover{background:#f5f5f5}.indpic-sets-table{table-layout:fixed;min-width:900px}.indpic-sets-table th:first-child{width:170px}.indpic-default-column{width:105px}.indpic-check-column{width:110px}.indpic-actions-column{width:100px}.indpic-file-control{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.indpic-file-control input{max-width:230px;min-width:0}.indpic-add-form{display:flex;align-items:end;flex-wrap:wrap;gap:10px;margin-top:14px}.indpic-number-input{width:96px}.indpic-add-form .checkbox{margin:0 4px 7px}.indpic-add-form input[name=name]{width:170px}.indpic-add-form input[type=file]{max-width:300px}.utm-group-number{font-size:18px;font-weight:600}.utm-group-name{margin:6px 0}.utm-inline-options{gap:24px;margin-bottom:12px}.utm-inline-option{gap:6px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.field{margin:12px 0}.field-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}.field-head label{font-weight:600}.nested{border-left:3px solid #eee;padding-left:15px;min-width:0}.field-block{margin:18px 0;padding-top:12px;border-top:1px solid #eee}.weight-table{max-width:560px}.weight-table input,.weight-table select{width:100%;min-width:75px}.mixed{font-size:12px;color:#8a6d3b;margin-left:8px}.job{margin:16px 0;border-top:1px solid #ddd;padding-top:12px}.job .table{min-width:480px}.status-success{color:#3c763d}.status-error,.status-conflict{color:#a94442}.status-unknown{color:#8a6d3b}.connection{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.connection span{flex:1;min-width:160px}.break{overflow-wrap:anywhere}dialog{border:1px solid #999;border-radius:6px;box-shadow:0 5px 15px #0008;padding:0;width:960px;max-width:calc(100% - 40px)}dialog::backdrop{background:#0007}.modal-header{position:relative;text-align:center;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header h2{font-size:18px;margin:0 34px}.close{position:absolute;right:15px;top:10px;background:none;border:0;font-size:25px;color:#777;cursor:pointer}.modal-body{padding:15px;max-height:calc(100vh - 180px);overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}[hidden]{display:none!important}@media(max-wLine truncated
|
||||||
#notice{position:sticky;top:8px;z-index:100;box-shadow:0 2px 6px #0002}
|
#notice{position:sticky;top:8px;z-index:100;box-shadow:0 2px 6px #0002}
|
||||||
|
/* Overview and link builder retain the existing CPanel / Bootstrap surface. */
|
||||||
|
.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}
|
||||||
@@ -9,6 +9,128 @@
|
|||||||
const enums={theme:{'1':'dark','2':'light'},reg:{'1':'before','2':'after'},banch:{'1':'b','2':'a'},focus:{'1':'face','2':'center'}};
|
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 descriptions={theme:{'1':'тёмная','2':'светлая'},reg:{'1':'ввод имейл → подтверждение PIN → оплата','2':'ввод имейл → оплата → подтверждение PIN'},banch:{'1':'нет pop-up','2':'есть pop-up'},focus:{'1':'лицо, ближайшее к центру','2':'геометрический центр'}};
|
||||||
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
|
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
|
||||||
|
// Read-only runtime snapshots and independent constructor drafts never enter state.reads/fields.
|
||||||
|
const overview={entries:new Map(),pending:new Map(),serial:0};
|
||||||
|
const builder={target:'',entries:new Map(),serial:0};
|
||||||
|
function groupFor(config,marker){
|
||||||
|
const keys=[marker,...(marker==='reg'?['registration']:marker==='banch'?['chob','bchoice']:[])].filter(key=>Object.hasOwn(config?.utm||{},key));
|
||||||
|
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,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:'Веса отсутствуют или имеют неверный тип.'}));
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
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;}
|
||||||
|
if(path.length===1&&path[0]==='requisites'&&Array.isArray(value)&&value.every(v=>typeof v==='string')){values.set(key,{label:'Реквизиты',text:value.length?value.join('\n'):'Пустой массив'});return;}
|
||||||
|
if(value!==null&&typeof value==='object'){const entries=Object.entries(value);if(!entries.length){values.set(key,{label:key,text:Array.isArray(value)?'Пустой массив':'Пустой объект'});return;}for(const [child,next]of entries)visit([...path,child],next);return;}
|
||||||
|
const text=value===null?'null':typeof value==='boolean'?(value?'Да':'Нет')+' (boolean)':typeof value==='number'?String(value)+' (число)':String(value);
|
||||||
|
values.set(key,{label:key==='host'?'Основной сайт':key,text});
|
||||||
|
};
|
||||||
|
for(const [key,value]of Object.entries(config||{}))if(key!=='utm')visit([key],value);
|
||||||
|
const utm=config?.utm;
|
||||||
|
if(utm&&typeof utm==='object'&&!Array.isArray(utm)){for(const [key,value]of Object.entries(utm)){
|
||||||
|
const marker=key==='registration'?'reg':['chob','bchoice'].includes(key)?'banch':key;
|
||||||
|
if(!markers.includes(marker))visit(['utm',key],value);
|
||||||
|
else if(groupFor(config,marker).conflict.length)visit(['utm',key],value);
|
||||||
|
else if(value&&typeof value==='object'&&!Array.isArray(value))for(const [extra,next]of Object.entries(value))if(!['default','weights'].includes(extra))visit(['utm',key,extra],next);
|
||||||
|
}}else if(utm!==undefined)visit(['utm'],utm);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);}
|
||||||
|
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;}}
|
||||||
|
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='';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
function selectionsEqual(a,b){return !!a&&!!b&&markers.every(marker=>String(a[marker])===String(b[marker]));}
|
||||||
|
function constructorValid(selection){return markers.every(marker=>selection[marker]==='rnd'||!!digitFor(marker,selection[marker])&&String(digitFor(marker,selection[marker]))===selection[marker]);}
|
||||||
|
function builderWarnings(entry){
|
||||||
|
const result=[],snapshot=overview.entries.get(builder.target),config=snapshot?.data?.config;
|
||||||
|
if(!config){result.push('Текущий runtime-конфиг не прочитан; фактическое применение выбранных значений не проверено.');return result;}
|
||||||
|
if(snapshot.status!=='success')result.push('Проверка выбора использует прежний снимок runtime-конфига; обновите сводку.');
|
||||||
|
const catalog=snapshot.data.catalog,generatedAt=Date.parse(catalog?.generated_at||''),now=Date.now();
|
||||||
|
const host=value=>{try{return new URL(String(value).includes('://')?String(value):'https://'+value).hostname.toLowerCase().replace(/[.]$/,'');}catch(_){return '';}};
|
||||||
|
const catalogFresh=snapshot.status==='success'&&Number.isFinite(generatedAt)&&generatedAt>=now-180000&&generatedAt<=now+30000&&host(catalog?.host)&&host(catalog.host)===host(config.host);
|
||||||
|
if(!catalogFresh)result.push('degr/tp/cons: актуальный каталог основного сайта недоступен; существование групп не проверено.');
|
||||||
|
for(const marker of markers){const choice=entry.draft[marker],group=groupFor(config,marker);if(group.conflict.length){result.push(marker+': конфликт ключей '+group.conflict.join(', ')+'. Публичный resolver может выбрать другое значение.');continue;}
|
||||||
|
if(choice!=='rnd'&&!digitFor(marker,choice)){result.push(marker+': выберите допустимое значение. Сохранённый выбор не заменён автоматически.');continue;}
|
||||||
|
const weights=group.value?.weights||{},variants=Object.keys(weights).map(v=>digitFor(marker,v));
|
||||||
|
if(['indpic','degr','tp'].includes(marker)&&choice!=='rnd'&&!variants.includes(choice))result.push(marker+'='+choice+': варианта нет в текущих весах; при открытии ссылки resolver использует default (или штатный fallback). Конфигурация не меняется.');
|
||||||
|
if(catalogFresh&&['degr','tp','cons'].includes(marker)&&Array.isArray(catalog.groups?.[marker])&&choice!=='rnd'&&digitFor(marker,choice)&&!catalog.groups[marker].some(item=>String(item.number)===choice))result.push(marker+'='+choice+': группы нет в актуальном каталоге основного сайта. '+(marker==='cons'?'Прелендинг передаст выбранное значение; основной сайт может не принять эту группу.':'Основной сайт может не принять эту группу; наличие варианта в весах не подтверждает её существование.'));
|
||||||
|
if(!group.key)result.push(marker+': группа отсутствует в runtime; при открытии применяется штатный fallback.');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function renderBuilder(){
|
||||||
|
const entry=builder.entries.get(builder.target);$('builder-fields').replaceChildren();$('builder-result').hidden=true;$('builder-warnings').replaceChildren();$('builder-save').disabled=true;
|
||||||
|
if(!builder.target){$('builder-state').textContent='Выберите домен.';return;}
|
||||||
|
if(!entry?.ready){$('builder-state').textContent=entry?.error?'Ошибка чтения выбора: '+entry.error:'Чтение последнего выбора…';if(entry?.error)$('builder-fields').append(button('Повторить чтение выбора',()=>openBuilder(builder.target,true)));return;}
|
||||||
|
for(const marker of markers){const values=[...(enums[marker]?Object.keys(enums[marker]):Array.from({length:9},(_,i)=>String(i+1))),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)})));
|
||||||
|
if(entry.draft[marker]&&!values.includes(entry.draft[marker]))select.append(el('option',{value:entry.draft[marker],text:'Недопустимый сохранённый выбор: '+entry.draft[marker]}));select.value=entry.draft[marker]||'';select.disabled=entry.saving;
|
||||||
|
select.addEventListener('change',()=>{entry.draft[marker]=select.value;entry.error='';updateBuilderState();});$('builder-fields').append(labelControl(marker,select));
|
||||||
|
}
|
||||||
|
updateBuilderState();
|
||||||
|
}
|
||||||
|
function updateBuilderState(){
|
||||||
|
const entry=builder.entries.get(builder.target);if(!entry?.ready)return;const dirty=!selectionsEqual(entry.draft,entry.saved),valid=constructorValid(entry.draft);
|
||||||
|
$('builder-state').textContent=entry.saving?'Формирование и сохранение…':entry.error?'Ошибка: '+entry.error:dirty?'Есть несохранённый выбор. Нажмите «Сформировать и сохранить».':'Показан последний сохранённый выбор этого домена.';
|
||||||
|
$('builder-save').disabled=!!entry.saving||!valid;for(const select of $('builder-fields').querySelectorAll('select'))select.disabled=!!entry.saving;
|
||||||
|
$('builder-warnings').replaceChildren(...builderWarnings(entry).map(text=>el('p',{class:'alert alert-warning',text})));
|
||||||
|
const show=!!entry.url&&!dirty;let safeUrl=null;if(show){try{const parsed=new URL(entry.url),target=state.targets.find(t=>t.id===builder.target);if(parsed.protocol==='https:'&&parsed.host===target?.domain)safeUrl=parsed.href;}catch(_){}}
|
||||||
|
$('builder-result').hidden=!safeUrl;if(safeUrl){$('builder-url').value=safeUrl;$('builder-open').href=safeUrl;$('builder-saved-at').textContent=entry.savedAt?'Выбор сохранён '+formatDate(entry.savedAt):'';}else if(show)$('builder-state').textContent='Сервер вернул недопустимую ссылку. Сформируйте её повторно.';
|
||||||
|
}
|
||||||
|
async function openBuilder(id,force=false){
|
||||||
|
builder.target=id;$('builder-target').value=id;if(!id){renderBuilder();return;}const domain=state.targets.find(t=>t.id===id)?.domain;if(builder.entries.get(id)?.ready&&builder.entries.get(id).domain===domain&&!force){renderBuilder();return;}
|
||||||
|
const request=++builder.serial;builder.entries.set(id,{ready:false,request,domain});renderBuilder();
|
||||||
|
try{const stored=await api('link-builder?target='+encodeURIComponent(id));if(!stored.selection&&!overview.entries.get(id)?.data)await(overview.pending.get(id)||refreshOverviewTarget(id));if(builder.entries.get(id)?.request!==request||!state.csrf)return;
|
||||||
|
const saved=stored.selection?{...stored.selection}:null;const draft=saved?{...saved}:{};if(!saved){const config=overview.entries.get(id)?.data?.config;for(const marker of markers){const group=groupFor(config,marker);draft[marker]=group.conflict.length?'':digitFor(marker,group.value?.default)||'';}}
|
||||||
|
builder.entries.set(id,{ready:true,request,domain,draft,saved,url:stored.url,savedAt:stored.saved_at,error:''});
|
||||||
|
}catch(error){if(builder.entries.get(id)?.request===request&&state.csrf)builder.entries.set(id,{ready:false,request,domain,error:error.message});}finally{if(builder.target===id)renderBuilder();}
|
||||||
|
}
|
||||||
|
async function saveBuilder(){
|
||||||
|
const id=builder.target,entry=builder.entries.get(id);if(!entry?.ready||!constructorValid(entry.draft)||entry.saving)return;const request=entry.request;entry.saving=true;entry.error='';updateBuilderState();
|
||||||
|
try{const result=await api('link-builder',{target:id,selection:{...entry.draft}});if(builder.entries.get(id)?.request!==request||!state.csrf)return;entry.saved={...result.selection};entry.draft={...result.selection};entry.url=result.url;entry.savedAt=result.saved_at;}catch(error){entry.error=error.message;}finally{entry.saving=false;if(builder.target===id)updateBuilderState();}
|
||||||
|
}
|
||||||
|
$('overview-refresh').addEventListener('click',()=>run(refreshOverview));
|
||||||
|
$('builder-target').addEventListener('change',event=>run(()=>openBuilder(event.target.value)));
|
||||||
|
$('builder-save').addEventListener('click',()=>run(saveBuilder));
|
||||||
|
$('builder-copy').addEventListener('click',()=>run(async()=>{const input=$('builder-url');if($('builder-result').hidden)return;try{if(!navigator.clipboard)throw new Error();await navigator.clipboard.writeText(input.value);notice('Ссылка скопирована.','success');}catch(_){input.focus();input.select();notice('Ссылка выделена. Скопируйте её через меню браузера или Ctrl+C.','info');}}));
|
||||||
|
|
||||||
const statuses={queued:'Ожидание',running:'Выполняется',success:'Сохранено и проверено',error:'Ошибка',conflict:'Конфликт версии — перечитайте домен',unknown:'Неизвестный результат — проверьте состояние'};
|
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;}
|
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;}}));return control;};
|
||||||
@@ -21,7 +143,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;
|
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);}}
|
async function run(action){try{await action();}catch(error){notice(error.message);}}
|
||||||
function showLogin(){for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';state.fields=[];state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
|
function 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 selected(){return [...$('targets').querySelectorAll('input:checked')].map(n=>n.value);}
|
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 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 valueAt(object,path){return path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);}
|
||||||
@@ -108,7 +230,7 @@
|
|||||||
async function fetchRead(id){const data=await api('config?target='+encodeURIComponent(id));state.reads.set(id,data);return data;}
|
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();}}
|
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();}));}}
|
function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:target.id===state.source&&target.enabled,disabled:!target.enabled});const status=el('span',{class:'text-muted'});const label=el('label',{},input,targetName(target.id),status);box.append(label);input.addEventListener('change',()=>run(async()=>{if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;updateSummary();try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}updateSummary();}));}}
|
||||||
async function loadState(){const data=await api('state');state.csrf=data.csrf;state.targets=data.targets||[];$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;const source=$('source-target');source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));source.value=state.source;renderTargetOptions();renderConnections();await loadJobs();}
|
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;}
|
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=chosenChanges(),ids=safeTargets()){
|
||||||
if(!changes.length)throw new Error('Выберите поля для применения.');
|
if(!changes.length)throw new Error('Выберите поля для применения.');
|
||||||
@@ -127,7 +249,7 @@
|
|||||||
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));}
|
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();
|
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 showJob(job,dialog=false){const id=typeof job==='string'?job:job.id;if(typeof job==='object'){state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();}if(dialog)modal('Проверка / публикация indpic','Задание '+id+' принято. Ожидание проверки…');await pollJob(id,dialog);}
|
||||||
async function pollJob(id,dialog){pollTimers.delete(id);const r=await api('jobs?id='+encodeURIComponent(id));const job=r.job;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();if(dialog&&$('indpic-result-modal').open){modal('Результат проверки indpic',(job.results||[]).map(v=>targetName(v.target)+'\n'+(statuses[v.status]||v.status)+'\n'+(v.message||'')).join('\n\n')||'Ожидание…');}if(job.status!=='completed'){clearTimeout(pollTimers.get(id));pollTimers.set(id,setTimeout(()=>run(()=>pollJob(id,dialog)),1400));}else{clearTimeout(pollTimers.get(id));pollTimers.delete(id);const configChanged=['apply','restore'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success')state.reads.delete(result.target);if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}updateSummary();}}
|
async function 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 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:'Резервных копий пока нет.'}));}
|
async function loadBackups(){const box=$('backups');box.replaceChildren();if(!state.source)return;const targetId=state.source;const r=await api('backups?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;for(const backup of r.backups||[]){box.append(el('div',{class:'toolbar'},el('span',{class:'break',text:(backup.created_at?formatDate(backup.created_at):'')+' · '+backup.id}),button('Восстановить',async()=>{if(!confirm('Восстановить резерв '+backup.id+' на '+targetName(state.source)+'?'))return;const read=readCurrent();if(!read)throw new Error('Перед восстановлением перечитайте текущий домен.');const result=await api('restore',{target:state.source,id:backup.id,version:read.version});await showJob(result.job);})));}if(!(r.backups||[]).length)box.append(el('p',{class:'text-muted',text:'Резервных копий пока нет.'}));}
|
||||||
function renderConnections(){const list=$('connection-list');list.replaceChildren();for(const t of state.targets){list.append(el('div',{class:'connection'},el('span',{class:'break',text:t.name+' · '+targetName(t.id)+' · '+t.type+' · '+(t.enabled?'включено':'отключено')+(t.verified?' · соединение проверено':' · требуется проверка')+(t.has_secret?' · секрет сохранён':'')}),button('Изменить',()=>{const form=$('connection-form');form.reset();for(const key of ['id','name','domain','environment','type','host','port','user','root','host_key'])form.elements[key].value=t[key]??'';form.elements.enabled.checked=!!t.enabled;form.elements.id.readOnly=true;$('connection-title').textContent='Изменить подключение';form.elements.name.focus();}),button('Проверить соединение',async()=>{const result=await api('targets/test',{id:t.id});modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');}),button('Удалить',async()=>{if(confirm('Удалить подключение '+t.domain+'? Сайт и файлы останутся.')){await api('targets',{id:t.id},'DELETE');if(state.source===t.id){state.source='';state.fields=[];$('config-fields').replaceChildren();}await loadState();}},'danger')));}}
|
function renderConnections(){const list=$('connection-list');list.replaceChildren();for(const t of state.targets){list.append(el('div',{class:'connection'},el('span',{class:'break',text:t.name+' · '+targetName(t.id)+' · '+t.type+' · '+(t.enabled?'включено':'отключено')+(t.verified?' · соединение проверено':' · требуется проверка')+(t.has_secret?' · секрет сохранён':'')}),button('Изменить',()=>{const form=$('connection-form');form.reset();for(const key of ['id','name','domain','environment','type','host','port','user','root','host_key'])form.elements[key].value=t[key]??'';form.elements.enabled.checked=!!t.enabled;form.elements.id.readOnly=true;$('connection-title').textContent='Изменить подключение';form.elements.name.focus();}),button('Проверить соединение',async()=>{const result=await api('targets/test',{id:t.id});modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');}),button('Удалить',async()=>{if(confirm('Удалить подключение '+t.domain+'? Сайт и файлы останутся.')){await api('targets',{id:t.id},'DELETE');if(state.source===t.id){state.source='';state.fields=[];$('config-fields').replaceChildren();}await loadState();}},'danger')));}}
|
||||||
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;try{const data=await api('login',{username:form.username.value,password:form.password.value});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
|
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;try{const data=await api('login',{username:form.username.value,password:form.password.value});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
|
||||||
|
|||||||
+17
-2
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require dirname(__DIR__).'/bootstrap.php';
|
require dirname(__DIR__).'/bootstrap.php';
|
||||||
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog};
|
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog,LinkBuilder};
|
||||||
header('X-Content-Type-Options: nosniff');header('X-Frame-Options: DENY');header('Referrer-Policy: same-origin');header('Cache-Control: no-store');
|
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);
|
$path=parse_url($_SERVER['REQUEST_URI']??'/',PHP_URL_PATH);
|
||||||
if(!str_starts_with((string)$path,'/api/')) {require dirname(__DIR__).'/views/dashboard.php';exit;}
|
if(!str_starts_with((string)$path,'/api/')) {require dirname(__DIR__).'/views/dashboard.php';exit;}
|
||||||
@@ -22,8 +22,23 @@ try {
|
|||||||
if($path==='/api/login'&&$method==='POST')reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown'));
|
if($path==='/api/login'&&$method==='POST')reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown'));
|
||||||
$user=$auth->requireUser();
|
$user=$auth->requireUser();
|
||||||
if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??'');
|
if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??'');
|
||||||
if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
|
|
||||||
if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);}
|
if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);}
|
||||||
|
// Авторизация, idle timestamp и CSRF уже проверены; SSH/файловые операции
|
||||||
|
// не должны удерживать PHP session lock и блокировать соседние запросы.
|
||||||
|
session_write_close();
|
||||||
|
if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
|
||||||
|
if($path==='/api/link-builder'){
|
||||||
|
$builder=new LinkBuilder($store);
|
||||||
|
if($method==='GET'){
|
||||||
|
if(array_diff(array_keys($_GET),['target'])||!is_string($_GET['target']??null))throw new Problem('Укажите ID подключения конструктора ссылки.');
|
||||||
|
reply($builder->get($_GET['target']));
|
||||||
|
}
|
||||||
|
if($method==='POST'){
|
||||||
|
if(count($data)!==2||array_diff(array_keys($data),['target','selection'])||!is_string($data['target']??null)||!(($data['selection']??null) instanceof stdClass))throw new Problem('Передайте ID подключения и объект выбора конструктора ссылки.');
|
||||||
|
reply($builder->save($data['target'],get_object_vars($data['selection'])));
|
||||||
|
}
|
||||||
|
throw new Problem('Метод конструктора ссылки не поддерживается.',405);
|
||||||
|
}
|
||||||
if($path==='/api/targets'&&$method==='POST')reply(['target'=>$store->saveTarget((array)($data['target']??[]),isset($data['secret'])?(string)$data['secret']:null)]);
|
if($path==='/api/targets'&&$method==='POST')reply(['target'=>$store->saveTarget((array)($data['target']??[]),isset($data['secret'])?(string)$data['secret']:null)]);
|
||||||
if($path==='/api/targets'&&$method==='DELETE'){$store->deleteTarget((string)($data['id']??''));reply([]);}
|
if($path==='/api/targets'&&$method==='DELETE'){$store->deleteTarget((string)($data['id']??''));reply([]);}
|
||||||
if($path==='/api/targets/test'&&$method==='POST'){$id=(string)($data['id']??'');$t=$store->target($id);(new Transport($store,$t))->test();$store->verifyTarget($id,$t);reply(['message'=>'Пути, блокировка, чтение и права записи проверены.']);}
|
if($path==='/api/targets/test'&&$method==='POST'){$id=(string)($data['id']??'');$t=$store->target($id);(new Transport($store,$t))->test();$store->verifyTarget($id,$t);reply(['message'=>'Пути, блокировка, чтение и права записи проверены.']);}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PrelandAdmin;
|
||||||
|
|
||||||
|
/** Независимый выбор меток: читает реестр целей, пишет только приватное состояние ссылки. */
|
||||||
|
final class LinkBuilder
|
||||||
|
{
|
||||||
|
private const KEYS = ['indpic', 'theme', 'degr', 'reg', 'banch', 'tp', 'cons', 'focus'];
|
||||||
|
private const ENUMS = ['theme', 'reg', 'banch', 'focus'];
|
||||||
|
|
||||||
|
public function __construct(private readonly Store $store) {}
|
||||||
|
|
||||||
|
public function get(string $targetId): array
|
||||||
|
{
|
||||||
|
$this->assertId($targetId);
|
||||||
|
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId): array {
|
||||||
|
$origin = $this->origin($this->store->target($targetId));
|
||||||
|
$name = $this->filename($targetId);
|
||||||
|
$saved = $this->store->read($name, null);
|
||||||
|
if ($saved === null) return ['selection'=>null, 'url'=>null, 'saved_at'=>null];
|
||||||
|
if (!is_array($saved) || !is_array($saved['selection'] ?? null)
|
||||||
|
|| !is_string($saved['url'] ?? null) || !is_string($saved['saved_at'] ?? null)) {
|
||||||
|
throw new Problem('Повреждено сохранённое состояние конструктора ссылки.', 503);
|
||||||
|
}
|
||||||
|
$selection = $this->selection($saved['selection']);
|
||||||
|
$query = parse_url($saved['url'], PHP_URL_QUERY);
|
||||||
|
if (!is_string($query)) throw new Problem('Повреждена сохранённая ссылка.', 503);
|
||||||
|
parse_str($query, $parameters);
|
||||||
|
if (array_keys($parameters) !== self::KEYS) throw new Problem('Повреждена сохранённая ссылка.', 503);
|
||||||
|
foreach ($selection as $key=>$value) {
|
||||||
|
$digit = $value === 'rnd' ? '0' : $value;
|
||||||
|
if (!is_string($parameters[$key]) || !preg_match('/^[a-f0-9]{4}' . $digit . '[a-f0-9]{12}$/D', $parameters[$key])) {
|
||||||
|
throw new Problem('Повреждена сохранённая ссылка.', 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Переименование цели меняет только origin, сохранённый шум не генерируется заново.
|
||||||
|
$url = $origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
if ($saved['url'] !== $url) {
|
||||||
|
$saved['url'] = $url;
|
||||||
|
$this->store->write($name, $saved);
|
||||||
|
}
|
||||||
|
return ['selection'=>$selection, 'url'=>$url, 'saved_at'=>$saved['saved_at']];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(string $targetId, array $selection): array
|
||||||
|
{
|
||||||
|
$this->assertId($targetId);
|
||||||
|
$selection = $this->selection($selection);
|
||||||
|
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId, $selection): array {
|
||||||
|
$origin = $this->origin($this->store->target($targetId));
|
||||||
|
$parameters = [];
|
||||||
|
foreach ($selection as $key=>$value) {
|
||||||
|
$parameters[$key] = bin2hex(random_bytes(2)) . ($value === 'rnd' ? '0' : $value) . bin2hex(random_bytes(6));
|
||||||
|
}
|
||||||
|
$saved = [
|
||||||
|
'selection'=>$selection,
|
||||||
|
'url'=>$origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986),
|
||||||
|
'saved_at'=>gmdate(DATE_ATOM),
|
||||||
|
];
|
||||||
|
$this->store->write($this->filename($targetId), $saved);
|
||||||
|
return $saved;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selection(array $input): array
|
||||||
|
{
|
||||||
|
if (count($input) !== count(self::KEYS) || array_diff(array_keys($input), self::KEYS)) {
|
||||||
|
throw new Problem('Передайте ровно восемь параметров конструктора ссылки.');
|
||||||
|
}
|
||||||
|
$selection = [];
|
||||||
|
foreach (self::KEYS as $key) {
|
||||||
|
$value = $input[$key] ?? null;
|
||||||
|
if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))
|
||||||
|
|| (in_array($key, self::ENUMS, true) && !in_array($value, ['1', '2', 'rnd'], true))) {
|
||||||
|
throw new Problem("Недопустимое значение параметра {$key} конструктора ссылки.");
|
||||||
|
}
|
||||||
|
$selection[$key] = $value;
|
||||||
|
}
|
||||||
|
return $selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function origin(array $target): string
|
||||||
|
{
|
||||||
|
$domain = $target['domain'] ?? null;
|
||||||
|
if (!is_string($domain) || strlen($domain)>253 || !preg_match('/^[a-z0-9.-]+$/iD', $domain)
|
||||||
|
|| str_starts_with($domain, '.') || str_ends_with($domain, '.')
|
||||||
|
|| filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
|
||||||
|
throw new Problem('В подключении указан небезопасный домен для HTTPS-ссылки.');
|
||||||
|
}
|
||||||
|
return 'https://' . strtolower($domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertId(string $targetId): void
|
||||||
|
{
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $targetId)) throw new Problem('Некорректный ID подключения.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filename(string $targetId): string
|
||||||
|
{
|
||||||
|
return 'link-builder-' . $targetId . '.json';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__.'/backend.php';
|
require __DIR__.'/backend.php';
|
||||||
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture-admin-password',PASSWORD_DEFAULT);$env['ADMIN_STATE_DIR']=$root.'/state';$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
|
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture-admin-password',PASSWORD_DEFAULT);$env['ADMIN_STATE_DIR']=$root.'/state';$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
|
||||||
|
$env['PHP_CLI_SERVER_WORKERS']='4';
|
||||||
$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);if(!is_resource($server))throw new RuntimeException('server failed');
|
$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);if(!is_resource($server))throw new RuntimeException('server failed');
|
||||||
$cookie='';
|
$cookie='';
|
||||||
function request(string $path,string $method='GET',?array $body=null,?string $csrf=null): array {
|
function request(string $path,string $method='GET',?array $body=null,?string $csrf=null): array {
|
||||||
@@ -15,6 +16,7 @@ function request(string $path,string $method='GET',?array $body=null,?string $cs
|
|||||||
try{
|
try{
|
||||||
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(50000);}while(microtime(true)<$deadline);
|
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(50000);}while(microtime(true)<$deadline);
|
||||||
[$status,$body]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
|
[$status,$body]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
|
||||||
|
[$status,$body]=request('/api/link-builder?target=test');check($status===401,'HTTP link builder requires authentication');
|
||||||
[$status,$body,$headers]=request('/api/login','POST',['username'=>'test-admin','password'=>'fixture-admin-password']);check($status===200&&isset($body['csrf']),'HTTP login creates authenticated CSRF session');$csrf=$body['csrf'];
|
[$status,$body,$headers]=request('/api/login','POST',['username'=>'test-admin','password'=>'fixture-admin-password']);check($status===200&&isset($body['csrf']),'HTTP login creates authenticated CSRF session');$csrf=$body['csrf'];
|
||||||
check((bool)array_filter($headers,static fn($h)=>str_contains(strtolower($h),'httponly')&&str_contains(strtolower($h),'samesite=strict')),'session cookie HttpOnly and SameSite Strict');
|
check((bool)array_filter($headers,static fn($h)=>str_contains(strtolower($h),'httponly')&&str_contains(strtolower($h),'samesite=strict')),'session cookie HttpOnly and SameSite Strict');
|
||||||
[$status,$body]=request('/api/state');check($status===200&&isset($body['targets'][0])&&!array_key_exists('secret',$body['targets'][0]),'HTTP state redacts transport credentials');
|
[$status,$body]=request('/api/state');check($status===200&&isset($body['targets'][0])&&!array_key_exists('secret',$body['targets'][0]),'HTTP state redacts transport credentials');
|
||||||
@@ -23,6 +25,33 @@ try{
|
|||||||
[$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id'];
|
[$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id'];
|
||||||
(new PrelandAdmin\Jobs($store))->run($id);
|
(new PrelandAdmin\Jobs($store))->run($id);
|
||||||
[$status,$body]=request('/api/jobs?id='.$id);check($body['job']['results'][0]['status']==='success'&&$body['job']['changes'][0]['path']===['host'],'HTTP persisted result and changed paths visible');
|
[$status,$body]=request('/api/jobs?id='.$id);check($body['job']['results'][0]['status']==='success'&&$body['job']['changes'][0]['path']===['host'],'HTTP persisted result and changed paths visible');
|
||||||
|
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body['selection']===null&&$body['url']===null&&$body['saved_at']===null,'HTTP new link builder state is explicitly empty');
|
||||||
|
$linkSelection=array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus'],'rnd');$runtimeHash=hash_file('sha256',$root.'/site/config.json');$jobCount=count(glob($store->dir.'/jobs/*.json'));
|
||||||
|
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection]);check($status===403,'HTTP link generation requires CSRF');
|
||||||
|
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&$body['selection']===$linkSelection&&str_starts_with($body['url'],'https://test.invalid/?'),'HTTP explicit link generation returns saved independent choice');$savedLink=$body;
|
||||||
|
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body===$savedLink,'HTTP reload returns exact stored URL without fresh noise');
|
||||||
|
$linkSelection['focus']='2';[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&array_diff_assoc($body['selection'],$savedLink['selection'])===['focus'=>'2'],'HTTP changing one link selector preserves other choices');
|
||||||
|
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection+['evil'=>'1']],$csrf);check($status===422,'HTTP unexpected selection key rejected');
|
||||||
|
[$status,$body]=request('/api/link-builder','POST',['target'=>['test'],'selection'=>$linkSelection],$csrf);check($status===422,'HTTP non-string target rejected with safe validation status');
|
||||||
|
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>[]],$csrf);check($status===422,'HTTP selection must be complete object');
|
||||||
|
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection,'url'=>'javascript:alert(1)'],$csrf);check($status===422,'HTTP caller cannot inject URL or extra top-level fields');
|
||||||
|
[$status,$body]=request('/api/targets','POST',['target'=>['id'=>'test','domain'=>'renamed.invalid']],$csrf);check($status===200,'HTTP target domain rename accepted');
|
||||||
|
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&str_starts_with($body['url'],'https://renamed.invalid/?')&&$body['selection']===$linkSelection,'HTTP saved selection survives domain rename with current URL base');
|
||||||
|
// Настоящий медленный SSH read: отдельный TCP fixture принимает соединение без SSH banner.
|
||||||
|
$slowPort=random_int(30000,39000);$accepted=$root.'/slow-accepted';$ready=$root.'/slow-ready';
|
||||||
|
$tcpCode='$server=stream_socket_server("tcp://127.0.0.1:".$argv[1]);if(!$server)exit(2);file_put_contents($argv[2],"ready");$client=stream_socket_accept($server,10);if($client){file_put_contents($argv[3],"accepted");sleep(3);fclose($client);}fclose($server);';
|
||||||
|
$tcp=proc_open([PHP_BINARY,'-r',$tcpCode,(string)$slowPort,$ready,$accepted],[0=>['pipe','r'],1=>$log,2=>$log],$tcpPipes);fclose($tcpPipes[0]);
|
||||||
|
$slowRead=null;
|
||||||
|
try{
|
||||||
|
$until=microtime(true)+5;while(!is_file($ready)&µtime(true)<$until)usleep(10000);if(!is_file($ready))throw new RuntimeException('Slow TCP fixture unavailable');
|
||||||
|
$targets=$store->read('targets.json');$targets['slow']=['id'=>'slow','domain'=>'slow.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>$slowPort,'user'=>'fixture','host_key'=>'SHA256:'.str_repeat('A',43),'secret'=>'fixture','root'=>'/not-mounted','enabled'=>true];$store->write('targets.json',$targets);
|
||||||
|
$readCode='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>10]]);@file_get_contents($argv[1],false,$ctx);';
|
||||||
|
$slowRead=proc_open([PHP_BINARY,'-r',$readCode,'http://127.0.0.1:'.$port.'/api/config?target=slow',$cookie],[0=>['pipe','r'],1=>$log,2=>$log],$readPipes);fclose($readPipes[0]);
|
||||||
|
$until=microtime(true)+5;while(!is_file($accepted)&µtime(true)<$until)usleep(10000);if(!is_file($accepted))throw new RuntimeException('Slow authenticated config read did not reach transport');
|
||||||
|
$started=microtime(true);[$status,$body]=request('/api/link-builder?target=test');$elapsed=microtime(true)-$started;
|
||||||
|
check($status===200&&$elapsed<1.5,'slow remote config read does not block link builder in same authenticated session');
|
||||||
|
}finally{if(is_resource($slowRead))proc_close($slowRead);proc_close($tcp);}
|
||||||
|
check(hash_file('sha256',$root.'/site/config.json')===$runtimeHash&&count(glob($store->dir.'/jobs/*.json'))===$jobCount,'HTTP link generation does not change runtime or enqueue jobs');
|
||||||
[$status,$body]=request('/api/logout','POST',null,$csrf);check($status===200,'HTTP logout succeeds');
|
[$status,$body]=request('/api/logout','POST',null,$csrf);check($status===200,'HTTP logout succeeds');
|
||||||
[$status,$body]=request('/api/state');check($status===401,'HTTP logout invalidates session');
|
[$status,$body]=request('/api/state');check($status===401,'HTTP logout invalidates session');
|
||||||
echo "HTTP integration complete\n";
|
echo "HTTP integration complete\n";
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require dirname(__DIR__) . '/bootstrap.php';
|
||||||
|
|
||||||
|
use PrelandAdmin\{LinkBuilder, Problem, Store};
|
||||||
|
|
||||||
|
function linkCheck(bool $condition, string $message): void
|
||||||
|
{
|
||||||
|
if (!$condition) throw new RuntimeException($message);
|
||||||
|
echo "PASS {$message}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkReject(callable $callback, string $message): void
|
||||||
|
{
|
||||||
|
try { $callback(); } catch (Problem) { linkCheck(true, $message); return; }
|
||||||
|
throw new RuntimeException('Приняты неверные данные: ' . $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$testRoot = sys_get_temp_dir() . '/link-builder-' . bin2hex(random_bytes(8));
|
||||||
|
$linkStore = new Store($testRoot);
|
||||||
|
// Недоступные runtime/SSH: конструктор обязан работать без их чтения.
|
||||||
|
$linkStore->write('targets.json', [
|
||||||
|
'first' => ['id'=>'first','domain'=>'first.example','type'=>'sftp','host'=>'unreachable.invalid','root'=>'/not-mounted','secret'=>'fixture-only-secret'],
|
||||||
|
'second' => ['id'=>'second','domain'=>'second.example','type'=>'local','root'=>'/not-mounted'],
|
||||||
|
]);
|
||||||
|
$runtimeFile = $testRoot . '/runtime-fixture.json';
|
||||||
|
file_put_contents($runtimeFile, '{"host":"unchanged.example","utm":{"indpic":{"default":"1"}}}');
|
||||||
|
$runtimeBefore = hash_file('sha256', $runtimeFile);
|
||||||
|
$builder = new LinkBuilder($linkStore);
|
||||||
|
$empty = ['selection'=>null, 'url'=>null, 'saved_at'=>null];
|
||||||
|
linkCheck($builder->get('first') === $empty, 'first opening has no implicit saved choice or generated URL');
|
||||||
|
$selection = ['indpic'=>'9','theme'=>'2','degr'=>'3','reg'=>'1','banch'=>'rnd','tp'=>'4','cons'=>'5','focus'=>'rnd'];
|
||||||
|
$saved = $builder->save('first', $selection);
|
||||||
|
linkCheck($saved['selection'] === $selection && is_string($saved['saved_at']), 'explicit save returns complete independent selection and timestamp');
|
||||||
|
linkCheck(str_starts_with($saved['url'], 'https://first.example/?'), 'URL uses saved target HTTPS origin');
|
||||||
|
parse_str(parse_url($saved['url'], PHP_URL_QUERY), $parameters);
|
||||||
|
linkCheck(array_keys($parameters) === array_keys($selection), 'URL contains all eight canonical UTM keys');
|
||||||
|
foreach ($selection as $key=>$value) {
|
||||||
|
$digit = $value === 'rnd' ? '0' : $value;
|
||||||
|
linkCheck((bool)preg_match('/^[a-zA-Z0-9]{4}' . $digit . '[a-zA-Z0-9]+$/D', $parameters[$key]), "{$key} has four ASCII prefix characters, selected digit and noise suffix");
|
||||||
|
}
|
||||||
|
linkCheck((new LinkBuilder(new Store($testRoot)))->get('first') === $saved, 'reload preserves exact saved URL and selection without regenerating noise');
|
||||||
|
$regenerated = $builder->save('first', $selection);
|
||||||
|
linkCheck($regenerated['selection']===$saved['selection']&&$regenerated['url']!==$saved['url'],'only explicit generation refreshes random noise for unchanged selection');
|
||||||
|
$saved = $regenerated;
|
||||||
|
linkCheck($builder->get('second') === $empty, 'other target does not inherit saved selection');
|
||||||
|
$modified = $saved['selection']; $modified['theme'] = '1';
|
||||||
|
$updated = $builder->save('first', $modified);
|
||||||
|
linkCheck($updated['selection']['theme']==='1' && array_diff_assoc($updated['selection'], $selection)===['theme'=>'1'], 'changing one selector preserves all other saved choices');
|
||||||
|
$builder->save('second', array_fill_keys(array_keys($selection), 'rnd'));
|
||||||
|
linkCheck($builder->get('first') === $updated, 'saving another target does not overwrite first target state');
|
||||||
|
$targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed.example';$linkStore->write('targets.json',$targets);
|
||||||
|
$renamed = $builder->get('first');
|
||||||
|
linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin');
|
||||||
|
linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise');
|
||||||
|
foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'9']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) {
|
||||||
|
linkReject(fn()=>$builder->save('first',$invalid),'strict selection rejects missing, unknown, wrong-type or injected value');
|
||||||
|
}
|
||||||
|
linkReject(fn()=>$builder->get('../targets'),'target traversal rejected');
|
||||||
|
linkReject(fn()=>$builder->get('missing'),'unknown target rejected');
|
||||||
|
foreach (['evil.example/path','user@evil.example','https://evil.example','evil.example:443','bad..example','-bad.example',"evil.example\nX: bad"] as $domain) {
|
||||||
|
$targets['first']['domain']=$domain;$linkStore->write('targets.json',$targets);
|
||||||
|
linkReject(fn()=>$builder->get('first'),'unsafe registry domain rejected instead of composing executable URL');
|
||||||
|
}
|
||||||
|
linkCheck(hash_file('sha256',$runtimeFile)===$runtimeBefore && glob($testRoot.'/jobs/*.json')===[], 'link builder leaves runtime bytes unchanged and creates no jobs');
|
||||||
|
linkCheck((fileperms($testRoot.'/link-builder-first.json') & 0777)===0600,'saved selection and URL remain in private 0600 state');
|
||||||
|
echo "Link builder contract complete\n";
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* Approved overview + independent link builder, preland#22/issue3767.
|
||||||
|
* Automated Docker QA; production CUA verification is owned by the lead.
|
||||||
|
* No mocked routes; constructor must never write config or create jobs. */
|
||||||
|
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=[],mutations=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
|
||||||
|
page.on('request',r=>{if(r.method()!=='GET'&&/\/api\/(apply|restore|sets\/|jobs\/retry)/.test(r.url()))mutations.push(r.url());});
|
||||||
|
try{
|
||||||
|
await page.goto(process.env.ADMIN_URL||'http://admin');
|
||||||
|
assert.match(await page.locator('script[src*="admin.js"]').getAttribute('src'),/^\/assets\/admin[.]js[?]v=[a-f0-9]{12}$/);
|
||||||
|
assert.match(await page.locator('link[href*="admin.css"]').getAttribute('href'),/^\/assets\/admin[.]css[?]v=[a-f0-9]{12}$/);
|
||||||
|
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'});
|
||||||
|
assert.equal(await page.locator('#workspace > section').first().getAttribute('id'),'overview','saved runtime overview is first section');
|
||||||
|
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),8);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();await page.locator('#overview-table tr[data-overview-extra=requisites]').waitFor();
|
||||||
|
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;};
|
||||||
|
const before=await Promise.all(['a','b'].map(read));
|
||||||
|
await page.locator('#builder-target').selectOption('a');
|
||||||
|
await page.locator('#builder-fields select[data-marker=theme]').waitFor();
|
||||||
|
const generate=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/link-builder')&&r.request().method()==='POST');await page.getByRole('button',{name:'Сформировать и сохранить',exact:true}).click();const result=await(await response).json();assert.ok(result.ok,result.error);await page.waitForFunction(url=>document.querySelector('#builder-result').hidden===false&&document.querySelector('#builder-url').value===url,result.url);return result.url;};
|
||||||
|
const expectedTheme='rnd';await page.locator('#builder-fields select[data-marker=theme]').selectOption(expectedTheme);
|
||||||
|
const first=await generate();assert.equal(new URL(first).searchParams.get('theme')[4],'0');
|
||||||
|
const selectionA=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(selectionA.selection.theme,'rnd');assert.equal(selectionA.url,first);
|
||||||
|
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.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 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);
|
||||||
|
await page.locator('#builder-target').selectOption('b');await page.locator('#builder-fields select[data-marker=theme]').waitFor();const persistedB=await page.request.get('/api/link-builder?target=b').then(r=>r.json());if(persistedB.selection)assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),persistedB.selection.theme);
|
||||||
|
const after=await Promise.all(['a','b'].map(read));for(let i=0;i<2;i++){assert.equal(after[i].version,before[i].version);assert.deepEqual(after[i].config,before[i].config);}
|
||||||
|
assert.deepEqual(mutations,[],'no config/job/sets mutations from overview or constructor');assert.deepEqual(errors,[]);
|
||||||
|
await page.setViewportSize({width:390,height:844});await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
|
||||||
|
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/overview-builder-evidence.json',JSON.stringify({configVersions:after.map(r=>r.version),selection:saved.selection,url:saved.url,mutations,pageErrors:errors},null,2));
|
||||||
|
console.log('PASS real overview / independent constructor / saved selection URL reload / no config writes / editor isolation / desktop mobile');
|
||||||
|
}finally{await browser.close();}
|
||||||
|
})().catch(error=>{console.error(error);process.exit(1);});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
$adminCssVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.css'), 0, 12);
|
||||||
|
$adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.js'), 0, 12);
|
||||||
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Прелендинги — UTM и наборы изображений</title><link rel="stylesheet" href="/assets/admin.css"><script src="/assets/admin.js" defer></script></head>
|
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Прелендинги — UTM и наборы изображений</title><link rel="stylesheet" href="/assets/admin.css?v=<?= $adminCssVersion ?>"><script src="/assets/admin.js?v=<?= $adminJsVersion ?>" defer></script></head>
|
||||||
<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>
|
<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>
|
<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><button class="btn btn-primary" type="submit">Войти</button></form></section>
|
<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><button class="btn btn-primary" type="submit">Войти</button></form></section>
|
||||||
<div id="workspace" hidden><h1>Настройка прелендингов</h1><nav class="tabs" aria-label="Разделы"><a href="#settings">Настройки</a><a href="#indpic">indpic</a><a href="#connections">Подключения</a><a href="#history">Журнал и резерв</a></nav>
|
<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 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 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>
|
<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>
|
||||||
|
|||||||
Reference in new issue
Block a user