feat(admin): compact UTM percentages and shared notes
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
This commit is contained in:
1 parent
4fc4fd8162
commit
f2097d4800
10 files changed
+240
-16
No files matched your search
@@ -6,3 +6,5 @@
|
||||
.overview-wrapper{margin-top:15px}.overview-table{min-width:760px;table-layout:fixed}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){min-width:190px}.overview-table th,.overview-table td{vertical-align:top;overflow-wrap:anywhere}.overview-table th:first-child,.overview-table td:first-child{position:sticky;left:0;background:#f5f5f5;z-index:1}.overview-table caption{text-align:left;padding:8px 0}.overview-domain{font-size:15px;margin-bottom:6px}.overview-status{font-size:12px;font-weight:400;margin:8px 0}.overview-weights{list-style:none;padding:0;margin:7px 0 0}.overview-weights li{margin:3px 0}.overview-warning{color:#8a6d3b;margin-top:8px;font-size:12px}.overview-error{color:#a94442}.overview-table .toolbar{gap:6px;margin-bottom:0}.builder-domain{display:block;max-width:450px;margin:15px 0}.builder-field{display:block}.builder-field select{margin-top:5px}#builder-fields{margin:15px 0}#builder-result{margin-top:18px}#builder-url{font-family:monospace}#builder-state{margin-top:10px}#builder-warnings .alert{padding:10px}#builder-save{margin-top:5px}@media(max-width:600px){.overview-table{min-width:720px}.overview-table th:first-child{width:90px}.overview-table .btn{padding:4px 8px}.builder-domain{max-width:100%}}
|
||||
.overview-plain{white-space:pre-wrap}
|
||||
.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0}
|
||||
/* #23: compact numeric percentages; note rows span the whole matrix without sticky cells. */
|
||||
.overview-percent{display:block;margin:6px 0;line-height:1.35}.overview-percent:first-child{margin-top:0}.overview-percent:last-child{margin-bottom:0}.overview-raw{font-size:12px;overflow-wrap:anywhere}#overview-table td.utm-note-cell{position:static;left:auto;width:auto;min-width:0;z-index:auto;background:#fafafa;white-space:normal}.utm-note-title{display:block;font-size:12px;color:#707070;margin-bottom:6px}.utm-note-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 8px}.utm-note-textarea{width:100%;max-width:100%;min-width:0;resize:vertical;min-height:90px}.utm-note-status{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.utm-note-status:empty{display:none}.utm-note-edit .toolbar{margin-bottom:0}.utm-note-cell .btn{white-space:normal}.utm-note-read:has(.utm-note-text[hidden]){display:flex;align-items:center}
|
||||
@@ -21,17 +21,75 @@
|
||||
const cell=el('td'),group=groupFor(config,marker);
|
||||
if(group.conflict.length){cell.append(el('p',{class:'overview-error',text:'Конфликт ключей: '+group.conflict.join(', ')}));return cell;}
|
||||
if(!group.value||typeof group.value!=='object'||Array.isArray(group.value)){cell.append(el('span',{class:'text-muted',text:group.key?'Некорректная группа':'Группа отсутствует'}));return cell;}
|
||||
const value=group.value,digit=digitFor(marker,value.default);
|
||||
cell.append(el('strong',{text:'Default: '+(digit?optionLabel(marker,digit):Object.hasOwn(value,'default')?'некорректное значение '+String(value.default):'не задан')}));
|
||||
const weights=value.weights;
|
||||
if(weights&&typeof weights==='object'&&!Array.isArray(weights)){
|
||||
const list=el('ul',{class:'overview-weights'}),seen=new Set();let collision=false;
|
||||
for(const [variant,weight]of Object.entries(weights)){const normalized=digitFor(marker,variant);if(normalized&&seen.has(normalized))collision=true;seen.add(normalized);list.append(el('li',{text:(normalized?optionLabel(marker,normalized):variant+' (неизвестный вариант)')+' — вес '+String(weight)}));}
|
||||
if(!Object.keys(weights).length)list.append(el('li',{class:'text-muted',text:'Вариантов нет'}));cell.append(list);
|
||||
if(collision)cell.append(el('p',{class:'overview-warning',text:'Конфликт semantic и numeric вариантов; исходные записи показаны без объединения.'}));
|
||||
}else cell.append(el('p',{class:'overview-warning',text:'Веса отсутствуют или имеют неверный тип.'}));
|
||||
const value=group.value,defaultDigit=digitFor(marker,value.default),weights=value.weights;
|
||||
if(!defaultDigit)cell.append(el('p',{class:'overview-error',text:'Некорректный default: '+String(value.default??'не задан')}));
|
||||
if(!weights||typeof weights!=='object'||Array.isArray(weights)){cell.append(el('p',{class:'overview-error',text:'Веса отсутствуют или имеют неверный тип.'}));return cell;}
|
||||
const variants=[],seen=new Set();let invalid=false;
|
||||
for(const [raw,weight]of Object.entries(weights)){
|
||||
const digit=digitFor(marker,raw);
|
||||
if(!digit||typeof weight!=='number'||!Number.isFinite(weight)||weight<0){invalid=true;cell.append(el('p',{class:'overview-error',text:'Недопустимый вариант/вес: '+raw+' = '+JSON.stringify(weight)}));continue;}
|
||||
if(seen.has(digit)){invalid=true;cell.append(el('p',{class:'overview-error',text:'Конфликт semantic и numeric вариантов: '+raw+' / '+digit}));}
|
||||
seen.add(digit);variants.push({digit,weight});
|
||||
}
|
||||
if(invalid){for(const [raw,weight]of Object.entries(weights))cell.append(el('div',{class:'overview-raw',text:raw+' = '+JSON.stringify(weight)}));return cell;}
|
||||
if(defaultDigit&&!seen.has(defaultDigit))variants.push({digit:defaultDigit,weight:0});
|
||||
variants.sort((a,b)=>Number(a.digit)-Number(b.digit));
|
||||
const maximum=Math.max(0,...variants.map(v=>v.weight)),sum=maximum>0?variants.reduce((total,v)=>total+v.weight/maximum,0):0;
|
||||
for(const variant of variants){const percent=sum>0?Math.round((variant.weight/maximum)/sum*10000)/100:0;const text=variant.digit+' ('+percent+'%)'+(variant.digit===defaultDigit?' - default':'');cell.append(el(percent>0?'strong':'span',{class:'overview-percent',text}));}
|
||||
if(!variants.length)cell.append(el('span',{class:'text-muted',text:'Вариантов нет'}));
|
||||
return cell;
|
||||
}
|
||||
const utmNotes={entries:new Map(),loading:false,error:'',serial:0};
|
||||
function noteEntry(marker){
|
||||
if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=state.targets.length+1;return entry;}
|
||||
const entry={marker,epoch:0,note:null,draft:'',baseVersion:'',editing:false,saving:false,loading:false,error:'',conflict:false};
|
||||
const makeButton=(text,action)=>el('button',{type:'button',class:'btn btn-default btn-xs',text,onclick:()=>run(action)});
|
||||
entry.row=el('tr',{'data-utm-note':marker});entry.cell=el('td',{class:'utm-note-cell'});entry.row.append(entry.cell);
|
||||
entry.title=el('span',{class:'utm-note-title',text:'Заметка '+marker});entry.text=el('div',{class:'utm-note-text'});
|
||||
entry.edit=makeButton('Добавить заметку',()=>{if(!entry.note)return;entry.epoch++;entry.draft=entry.note.text;entry.baseVersion=entry.note.version;entry.editing=true;entry.error='';entry.conflict=false;renderNote(entry);entry.textarea.focus();});
|
||||
entry.edit.setAttribute('aria-label','Добавить заметку '+marker);
|
||||
entry.readView=el('div',{class:'utm-note-read'},entry.text,entry.edit);
|
||||
entry.textarea=el('textarea',{class:'form-control utm-note-textarea',rows:3,'aria-label':'Заметка '+marker});
|
||||
entry.textarea.addEventListener('input',()=>{entry.draft=entry.textarea.value;if(!entry.conflict)entry.error='';renderNote(entry);});
|
||||
entry.save=makeButton('Сохранить',()=>saveNote(marker));entry.save.setAttribute('aria-label','Сохранить заметку '+marker);
|
||||
entry.cancel=makeButton('Отмена',()=>{const wasConflict=entry.conflict;entry.epoch++;entry.editing=false;entry.draft=entry.note?.text||'';entry.error='';entry.conflict=false;renderNote(entry);if(wasConflict)run(loadNotes);});entry.cancel.setAttribute('aria-label','Отменить заметку '+marker);
|
||||
entry.reload=makeButton('Загрузить сохранённую заметку',()=>loadCurrentNote(marker));entry.reload.setAttribute('aria-label','Загрузить сохранённую заметку '+marker);
|
||||
entry.editView=el('div',{class:'utm-note-edit'},entry.textarea,el('div',{class:'toolbar'},entry.save,entry.cancel,entry.reload));
|
||||
entry.retry=makeButton('Повторить загрузку заметок',loadNotes);entry.retry.setAttribute('aria-label','Повторить загрузку заметок '+marker);
|
||||
entry.status=el('p',{class:'utm-note-status text-muted',role:'status','aria-live':'polite'});
|
||||
entry.cell.append(entry.title,entry.readView,entry.editView,entry.status,entry.retry);utmNotes.entries.set(marker,entry);renderNote(entry);return entry;
|
||||
}
|
||||
function renderNote(entry){
|
||||
entry.cell.colSpan=state.targets.length+1;entry.text.textContent=entry.note?.text||'';entry.text.hidden=!entry.note?.text;
|
||||
entry.readView.hidden=entry.editing;entry.editView.hidden=!entry.editing;
|
||||
entry.edit.textContent=entry.note?.text?'Редактировать':'Добавить заметку';entry.edit.setAttribute('aria-label',(entry.note?.text?'Редактировать':'Добавить')+' заметку '+entry.marker);entry.edit.disabled=!entry.note||entry.loading;
|
||||
if(entry.textarea.value!==entry.draft)entry.textarea.value=entry.draft;
|
||||
entry.textarea.disabled=entry.saving||entry.loading;entry.save.disabled=entry.saving||entry.loading||entry.conflict||!entry.baseVersion;entry.cancel.disabled=entry.saving||entry.loading;
|
||||
entry.reload.hidden=!entry.conflict;entry.reload.disabled=entry.saving||entry.loading;
|
||||
entry.retry.hidden=!utmNotes.error;entry.retry.disabled=utmNotes.loading;
|
||||
const error=entry.error||utmNotes.error;entry.status.className='utm-note-status '+(error?'overview-error':'text-muted');
|
||||
entry.status.textContent=entry.saving?'Сохранение заметки…':entry.loading?'Загрузка сохранённой заметки…':error?error:utmNotes.loading?'Загрузка заметок…':entry.note?.saved_at?'Сохранено '+formatDate(entry.note.saved_at):entry.note?'': 'Заметка ещё не загружена.';
|
||||
}
|
||||
function validNote(note){return note&&typeof note.text==='string'&&typeof note.version==='string'&&(note.saved_at===null||typeof note.saved_at==='string');}
|
||||
async function loadNotes(){
|
||||
const request=++utmNotes.serial,epochs=new Map(markers.map(marker=>[marker,noteEntry(marker).epoch]));utmNotes.loading=true;utmNotes.error='';for(const entry of utmNotes.entries.values())renderNote(entry);
|
||||
try{const result=await api('utm-notes');if(request!==utmNotes.serial||!state.csrf)return;for(const marker of markers){const entry=noteEntry(marker),note=result.notes?.[marker];if(entry.epoch!==epochs.get(marker)||entry.saving||entry.loading)continue;if(!validNote(note)){entry.error='Сервер не вернул корректную заметку '+marker+'.';continue;}if(!entry.editing){entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.error='';}else if(entry.baseVersion!==note.version){entry.error='Заметка уже изменена. Загрузите сохранённую заметку явно; черновик сохранён.';entry.conflict=true;}}}
|
||||
catch(error){if(request===utmNotes.serial&&state.csrf)utmNotes.error='Ошибка загрузки заметок: '+error.message;}
|
||||
finally{if(request===utmNotes.serial){utmNotes.loading=false;for(const entry of utmNotes.entries.values())renderNote(entry);}}
|
||||
}
|
||||
async function loadCurrentNote(marker){
|
||||
const entry=noteEntry(marker);entry.epoch++;entry.loading=true;renderNote(entry);
|
||||
try{const result=await api('utm-notes');if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;const note=result.notes?.[marker];if(!validNote(note))throw new Error('Сервер не вернул корректную сохранённую заметку.');entry.epoch++;entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.conflict=false;entry.error='';}
|
||||
catch(error){entry.error='Не удалось загрузить сохранённую заметку: '+error.message;}
|
||||
finally{entry.loading=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
|
||||
}
|
||||
async function saveNote(marker){
|
||||
const entry=noteEntry(marker);if(!entry.editing||entry.saving||entry.loading||entry.conflict||!entry.baseVersion)return;entry.epoch++;entry.saving=true;entry.error='';renderNote(entry);
|
||||
try{const result=await api('utm-notes',{marker,text:entry.draft,version:entry.baseVersion});if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;if(!validNote(result.note))throw new Error('Сервер не подтвердил сохранённую заметку.');entry.epoch++;entry.note=result.note;entry.draft=result.note.text;entry.baseVersion=result.note.version;entry.editing=false;entry.conflict=false;}
|
||||
catch(error){entry.error=error.message;entry.conflict=error.status===409;}
|
||||
finally{entry.saving=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
|
||||
}
|
||||
|
||||
function overviewExtra(config){
|
||||
const values=new Map(),secretKeys=new Set(['secret','password','private_key','privatekey','credential','credentials','api_key','apikey','access_token','accesstoken','refresh_token','refreshtoken','authorization']);
|
||||
const visit=(path,value)=>{const key=path.join('.');if(path.some(part=>secretKeys.has(String(part).toLowerCase().replace(/-/g,'_')))){values.set(key,{label:key,text:'Скрытое поле'});return;}
|
||||
@@ -59,19 +117,21 @@
|
||||
const refresh=button('Обновить',()=>refreshOverviewTarget(target.id));refresh.setAttribute('aria-label','Обновить сводку '+target.domain);refresh.disabled=entry?.status==='loading';
|
||||
th.append(el('div',{class:'toolbar'},refresh,button('Редактировать',async()=>{await openTarget(target.id);if(state.source===target.id){$('source-target').value=target.id;$('settings').scrollIntoView({behavior:'smooth',block:'start'});}})));head.append(th);
|
||||
}
|
||||
const active=document.activeElement,noteFocus=active?.matches('.utm-note-textarea')?{node:active,start:active.selectionStart,end:active.selectionEnd,direction:active.selectionDirection,scroll:active.scrollTop}:null;
|
||||
table.querySelector('thead').replaceChildren(head);const body=table.querySelector('tbody');body.replaceChildren();
|
||||
const extraByTarget=new Map(),extraKeys=new Map();
|
||||
for(const target of state.targets){const extra=overviewExtra(overview.entries.get(target.id)?.data?.config);extraByTarget.set(target.id,extra);for(const [key,value]of extra)extraKeys.set(key,value.label);}
|
||||
const orderedKeys=[...extraKeys.keys()].sort((a,b)=>{const rank=key=>key==='host'?0:key==='requisites'?1:2;return rank(a)-rank(b)||a.localeCompare(b,'ru');});
|
||||
for(const key of orderedKeys){const row=el('tr',{'data-overview-extra':key},el('th',{scope:'row',text:extraKeys.get(key)}));for(const target of state.targets){const entry=overview.entries.get(target.id),value=extraByTarget.get(target.id)?.get(key);row.append(el('td',{class:'overview-plain',text:value?value.text:entry?.data?'Поле отсутствует':'Нет прочитанного конфига'}));}body.append(row);}
|
||||
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{scope:'row',text:marker}));for(const target of state.targets){const entry=overview.entries.get(target.id);row.append(entry?.data?overviewCell(entry.data.config,marker):el('td',{class:'text-muted',text:entry?.status==='loading'?'Загрузка…':'Нет прочитанного конфига'}));}body.append(row);}
|
||||
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{scope:'row',text:marker}));for(const target of state.targets){const entry=overview.entries.get(target.id);row.append(entry?.data?overviewCell(entry.data.config,marker):el('td',{class:'text-muted',text:entry?.status==='loading'?'Загрузка…':'Нет прочитанного конфига'}));}body.append(row);body.append(noteEntry(marker).row);}
|
||||
if(noteFocus?.node.isConnected&&!noteFocus.node.disabled){noteFocus.node.focus({preventScroll:true});noteFocus.node.setSelectionRange(noteFocus.start,noteFocus.end,noteFocus.direction);noteFocus.node.scrollTop=noteFocus.scroll;}
|
||||
if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState();
|
||||
}
|
||||
function refreshOverviewTarget(id){
|
||||
const request=++overview.serial,previous=overview.entries.get(id);overview.entries.set(id,{...previous,status:'loading',request});renderOverview();
|
||||
const pending=(async()=>{try{const data=await api('config?target='+encodeURIComponent(id));if(overview.entries.get(id)?.request!==request||!state.csrf)return;overview.entries.set(id,{status:'success',request,data,readAt:new Date().toISOString()});}catch(error){if(overview.entries.get(id)?.request!==request||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});}finally{if(overview.entries.get(id)?.request===request){overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;
|
||||
}
|
||||
async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;try{await Promise.allSettled(state.targets.map(target=>refreshOverviewTarget(target.id)));}finally{control.disabled=false;}}
|
||||
async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;run(loadNotes);try{await Promise.allSettled(state.targets.map(target=>refreshOverviewTarget(target.id)));}finally{control.disabled=false;}}
|
||||
function syncOverview(){
|
||||
const ids=new Set(state.targets.map(target=>target.id));for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
|
||||
if(builder.target&&!ids.has(builder.target))builder.target='';
|
||||
@@ -143,7 +203,7 @@
|
||||
if(!response.ok||!body.ok){if(response.status===401)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body;
|
||||
}
|
||||
async function run(action){try{await action();}catch(error){notice(error.message);}}
|
||||
function showLogin(){overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';state.fields=[];state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
|
||||
function showLogin(){utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';state.fields=[];state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
|
||||
function selected(){return [...$('targets').querySelectorAll('input:checked')].map(n=>n.value);}
|
||||
function targetName(id){const t=state.targets.find(t=>t.id===id);return t? t.domain+' ('+t.environment+')':id;}
|
||||
function valueAt(object,path){return path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);}
|
||||
|
||||
Reference in new issue
Block a user