Add type-aware backup job forms

This commit is contained in:
Mikei386
2026-06-14 12:46:11 +02:00
parent 92564a1620
commit 800cb60f83
13 changed files with 153 additions and 17 deletions
+4
View File
@@ -78,6 +78,9 @@
.bu-browser-card input[type="checkbox"], .bu-table input[type="checkbox"] { accent-color: var(--bu-accent); height: 20px; width: 20px; }
.bu-count { align-items: center; border: 1px solid var(--bu-border); border-radius: 999px; display: inline-flex; font-size: 13px; font-weight: 700; padding: 7px 11px; }
.bu-inline-error { background: rgba(180,35,47,.08); border: 1px solid var(--bu-bad); border-radius: 5px; color: var(--bu-bad); font-weight: 600; margin: 10px 0; padding: 10px 12px; }
.bu-workload-options { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); margin-top: 15px; }
.bu-manual-source { display: grid; gap: 7px; margin-top: 14px; }
.bu-info-card { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 6px; padding: 16px; }
#backupper-app .bu-button {
appearance: none;
@@ -181,6 +184,7 @@
@media (max-width: 700px) {
#backupper-app { padding-left: 8px; padding-right: 8px; }
.bu-form { grid-template-columns: 1fr; }
.bu-workload-options { grid-template-columns: 1fr; }
.bu-chart-grid-layout { grid-template-columns: 1fr; }
.bu-header { align-items: flex-start; flex-direction: column; gap: 12px; }
.bu-table { display: block; overflow-x: auto; }
+54 -10
View File
@@ -7,7 +7,7 @@
const toast = document.getElementById('bu-toast');
const tooltip = document.getElementById('bu-tooltip');
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path}));
const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '' };
const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '' };
const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
@@ -243,23 +243,57 @@
function jobForm(job = {}) {
state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path);
const source = (job.sources || []).filter(s=>s.workloadId).map(s=>`@${s.workloadId}`).join('\n');
state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId);
state.workloads = []; state.workloadError = '';
state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = '';
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
content.innerHTML = `<section class="bu-card"><h2>${job.id ? 'Edit' : 'New'} backup job</h2><form id="bu-job-form" class="bu-form">
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Type<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option ${v===job.type?'selected':''}>${v}</option>`).join('')}</select></label>
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Schedule (cron)<input name="cron" value="${esc(job.schedule?.cron || '0 2 * * *')}"></label>
<label>Consistency<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Controlled stop</option></select></label>
<label>Compression<select name="compression">${['auto','off','max'].map(v=>`<option ${v===(job.compression||'auto')?'selected':''}>${v}</option>`).join('')}</select></label>
<div class="wide"><h3>Backup folders</h3><div class="bu-muted">Select folders exactly like files and folders in the snapshot browser.</div><div id="bu-source-browser"></div></div>
<label class="wide">Additional manual paths or workload IDs<textarea name="sources">${esc(source)}</textarea></label>
<div id="bu-job-specific" class="wide"></div>
<label class="wide">Exclude patterns, one per line<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
<label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label>
<label><span>Enabled</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
<div class="wide bu-actions">${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
enhanceTooltips(content);
renderSourceBrowser();
renderJobSpecific(job.type || 'share', job);
}
function renderJobSpecific(type, job = {}) {
const host = document.getElementById('bu-job-specific');
if (!host) return;
if (type === 'share' || type === 'appdata') {
host.innerHTML = `<h3>${type==='appdata'?'Appdata folders':'Share folders'}</h3><div class="bu-muted">Select one or more folders. Existing file exclude patterns remain available below.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Additional manual paths<textarea name="sources"></textarea></label>`;
state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user';
state.sourceDirectories = []; state.sourceBrowserError = '';
loadSourceDirectories(state.sourceBrowserPath);
} else if (type === 'docker' || type === 'vm') {
host.innerHTML = `<h3>${type==='docker'?'Docker containers':'Virtual machines'}</h3><div class="bu-muted">Select the workloads to back up. Metadata and discovered bind mounts or virtual disks are included automatically.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Loading...</div></div><div class="bu-workload-options"><label>Consistency<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Controlled stop</option></select></label><label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
loadWorkloads(type);
} else {
state.backupSelections = ['/boot'];
host.innerHTML = `<div class="bu-info-card"><h3>Unraid USB flash backup</h3><p>The complete <strong>/boot</strong> configuration drive is backed up. No additional source selection is required.</p></div>`;
}
enhanceTooltips(host);
}
function renderWorkloadBrowser(type) {
const host = document.getElementById('bu-workload-browser');
if (!host) return;
const selected = new Set(state.workloadSelections);
const rows = state.workloads.map(item=>`<tr><td><input type="checkbox" class="bu-workload-select" value="${esc(item.id)}" ${selected.has(item.id)?'checked':''}></td><td><strong>${esc(item.name)}</strong><br><span class="bu-muted">${esc(item.id)}</span></td><td>${esc(item.status||'unknown')}</td></tr>`).join('');
const error = state.workloadError ? `<div class="bu-inline-error">${esc(state.workloadError)}</div>` : '';
host.innerHTML = `<div class="bu-toolbar"><h3>${type==='docker'?'Available containers':'Available virtual machines'}</h3><span class="bu-count">Selected: ${state.workloadSelections.length}</span></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Select</th><th>Name</th><th>Status</th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">No workloads found.</td></tr>'}</tbody></table></div>`;
}
async function loadWorkloads(type) {
try {
state.workloads = await api(`/v1/workloads/${type}`); state.workloadError = '';
} catch (error) {
state.workloads = []; state.workloadError = `Workloads could not be loaded: ${error.message}`;
}
renderWorkloadBrowser(type);
}
function renderSourceBrowser() {
@@ -382,9 +416,11 @@
async function submit(kind) {
if (kind === 'submit-job') {
const f=new FormData(document.getElementById('bu-job-form')); const existing=state.config.jobs.find(j=>j.id===f.get('id'));
const manual=lines(f.get('sources')).map(v=>v.startsWith('@')?{workloadId:v.slice(1)}:{path:v});
const selected=state.backupSelections.map(path=>({path}));
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type:f.get('type'),enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources:[...selected,...manual].filter((item,index,all)=>all.findIndex(other=>(other.path||`@${other.workloadId}`)===(item.path||`@${item.workloadId}`))===index),schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs'))};
const type=f.get('type'); let sources=[];
if(type==='docker'||type==='vm') sources=state.workloadSelections.map(workloadId=>({workloadId}));
else if(type==='flash') sources=[];
else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index);
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
}
if (kind === 'submit-repo') {
@@ -407,6 +443,10 @@
document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); });
content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); });
content.addEventListener('change', e => {
if (e.target.name === 'type' && e.target.closest('#bu-job-form')) {
state.backupSelections=[]; state.workloadSelections=[];
renderJobSpecific(e.target.value);
}
if (e.target.classList.contains('bu-source-select')) {
state.backupSelections = e.target.checked ? [...new Set([...state.backupSelections,e.target.value])] : state.backupSelections.filter(path=>path!==e.target.value);
renderSourceBrowser();
@@ -415,6 +455,10 @@
state.restoreIncludes = e.target.checked ? [...new Set([...state.restoreIncludes,e.target.value])] : state.restoreIncludes.filter(path=>path!==e.target.value);
render();
}
if (e.target.classList.contains('bu-workload-select')) {
state.workloadSelections = e.target.checked ? [...new Set([...state.workloadSelections,e.target.value])] : state.workloadSelections.filter(id=>id!==e.target.value);
renderWorkloadBrowser(document.querySelector('#bu-job-form [name="type"]')?.value||'docker');
}
});
content.addEventListener('submit', e => {
const form = e.target.closest('form');