fix profile-scoped backups and shared profile rules

This commit is contained in:
Mateusz Gruszczyński
2026-06-07 23:12:00 +02:00
parent 51e00a4e37
commit 8990f2b404
10 changed files with 264 additions and 86 deletions
+1 -1
View File
@@ -1 +1 @@
export const backupToolsSource = " function fillBackupSettings(settings={}, prefix='app'){\n const cap=prefix==='profile'?'Profile':'App';\n const enabled=$(prefix==='profile'?'profileBackupAutoEnabled':'backupAutoEnabled');\n const interval=$(prefix==='profile'?'profileBackupAutoInterval':'backupAutoInterval');\n const retention=$(prefix==='profile'?'profileBackupRetentionDays':'backupRetentionDays');\n if(enabled) enabled.checked=!!settings.enabled;\n if(interval) interval.value=settings.interval_hours||24;\n if(retention) retention.value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) · ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">${esc(type)} backup · Created: ${esc(preview.created_at||'-')} · ${preview.automatic?'automatic':'manual'} · sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\" data-type=\"${esc(b.backup_type||'profile')}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n function switchBackupPane(pane){\n document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));\n document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n fillBackupSettings(j.profile_auto||{}, 'profile');\n fillBackupSettings(j.app_auto||j.auto||{}, 'app');\n if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);\n if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '<div class=\"empty-mini\">Application backups are admin-only.</div>';\n if(!j.can_app_backup) document.querySelector('[data-backup-pane=\"app\"]')?.classList.add('disabled');\n }\n";
export const backupToolsSource = " function fillBackupSettings(settings={}, prefix='app'){\n const cap=prefix==='profile'?'Profile':'App';\n const enabled=$(prefix==='profile'?'profileBackupAutoEnabled':'backupAutoEnabled');\n const interval=$(prefix==='profile'?'profileBackupAutoInterval':'backupAutoInterval');\n const retention=$(prefix==='profile'?'profileBackupRetentionDays':'backupRetentionDays');\n if(enabled) enabled.checked=!!settings.enabled;\n if(interval) interval.value=settings.interval_hours||24;\n if(retention) retention.value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '<div class=\"backup-preview-empty\">No saved rows in this table.</div>';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`<details class=\"backup-preview-table-details\"><summary><span><b>${esc(t.name)}</b><small>${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)</small></span></summary>${backupPreviewDetails(t)}</details>`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\n return `<div class=\"surface-section backup-preview-card\"><div class=\"section-title\"><i class=\"fa-solid fa-eye\"></i> Backup preview</div><div class=\"small text-muted mb-2\">${esc(type)} backup \u00b7 Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 Owner: ${esc(preview.owner_name||'-')} \u00b7 sensitive values hidden</div>${rows || '<div class=\"empty-mini\">Backup has no previewable settings.</div>'}</div>`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Owner','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),esc(b.owner_name||'-'),b.automatic?'Auto':'Manual',`<div class=\"table-action-group backup-actions\"><button class=\"btn btn-xs btn-outline-info backup-preview-btn\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-eye\"></i> Preview</button><a class=\"btn btn-xs btn-outline-secondary\" href=\"/api/backup/${esc(b.id)}/download\"><i class=\"fa-solid fa-download\"></i> Download</a><button class=\"btn btn-xs btn-outline-warning backup-restore\" data-id=\"${esc(b.id)}\" data-type=\"${esc(b.backup_type||'profile')}\"><i class=\"fa-solid fa-rotate-left\"></i> Restore</button><button class=\"btn btn-xs btn-outline-danger backup-delete\" data-id=\"${esc(b.id)}\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button></div>`]),'backup-table');\n }\n function switchBackupPane(pane){\n document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));\n document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n fillBackupSettings(j.profile_auto||{}, 'profile');\n fillBackupSettings(j.app_auto||j.auto||{}, 'app');\n if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);\n if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '<div class=\"empty-mini\">Application backups are admin-only.</div>';\n if(!j.can_app_backup) document.querySelector('[data-backup-pane=\"app\"]')?.classList.add('disabled');\n }\n";
+1 -1
View File
@@ -1 +1 @@
export const labelToolsSource = " async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`<div class=\"label-manager-row\"><span class=\"chip\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</span><button class=\"btn btn-xs btn-outline-danger delete-label\" data-id=\"${esc(l.id)}\" title=\"Delete label\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div>`).join(''):'<div class=\"empty-state\"><i class=\"fa-solid fa-tags\"></i><b>No labels.</b><span>Add first label above.</span></div>'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class=\"chip label-selected\" data-label=\"${esc(l)}\" title=\"Remove\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)} <i class=\"fa-solid fa-xmark ms-1\"></i></button>`).join('') || '<span class=\"text-muted small\">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class=\"chip label-chip ${modalLabels.has(l.name)?'active':''}\" data-label=\"${esc(l.name)}\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</button>`).join('') || '<span class=\"text-muted small\">No saved labels.</span>'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n";
export const labelToolsSource = " async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`<div class=\"label-manager-row\"><span class=\"chip\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</span><small class=\"text-muted\">Owner: ${esc(l.owner_name||'-')}</small><button class=\"btn btn-xs btn-outline-danger delete-label\" data-id=\"${esc(l.id)}\" title=\"Delete label\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div>`).join(''):'<div class=\"empty-state\"><i class=\"fa-solid fa-tags\"></i><b>No labels.</b><span>Add first label above.</span></div>'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class=\"chip label-selected\" data-label=\"${esc(l)}\" title=\"Remove\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)} <i class=\"fa-solid fa-xmark ms-1\"></i></button>`).join('') || '<span class=\"text-muted small\">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class=\"chip label-chip ${modalLabels.has(l.name)?'active':''}\" data-label=\"${esc(l.name)}\" title=\"Owner: ${esc(l.owner_name||'-')}\"><i class=\"fa-solid fa-tag\"></i> ${esc(l.name)}</button>`).join('') || '<span class=\"text-muted small\">No saved labels.</span>'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n";
+1 -1
View File
@@ -1 +1 @@
export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.owner_name||'-'),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";