diff --git a/dist/backupper-2026.06.14.8-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.8-x86_64-1.txz.sha256 deleted file mode 100644 index c6632df..0000000 --- a/dist/backupper-2026.06.14.8-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -91dbea90a30cd5f9d06646d1a397cad3318afabe0db7480c34f28faa45a14b0c backupper-2026.06.14.8-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.8-x86_64-1.txz b/dist/backupper-2026.06.14.9-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.8-x86_64-1.txz rename to dist/backupper-2026.06.14.9-x86_64-1.txz index 85b69ce..43245dd 100644 Binary files a/dist/backupper-2026.06.14.8-x86_64-1.txz and b/dist/backupper-2026.06.14.9-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.9-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9-x86_64-1.txz.sha256 new file mode 100644 index 0000000..45af24d --- /dev/null +++ b/dist/backupper-2026.06.14.9-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014 backupper-2026.06.14.9-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index 1c1656b..c31ee27 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.14.9 +- Replace the job cron input with daily, weekday, selected-day, and time controls. + ### 2026.06.14.8 - Show Michael Roll as the plugin author and add a dedicated Backupper logo. diff --git a/plugin/backupper.plg b/plugin/backupper.plg index db0406c..ad9324d 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.14.9 +- Replace the job cron input with daily, weekday, selected-day, and time controls. + ### 2026.06.14.8 - Show Michael Roll as the plugin author and add a dedicated Backupper logo. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 7759ca7..c90f646 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,12 +9,12 @@ Tag="backup restic snapshots restore" - +
- -

Backupper 2026.06.14.8

Encrypted Restic backups for Unraid

+ +

Backupper 2026.06.14.9

Encrypted Restic backups for Unraid

Connecting...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index 6b3a61c..73a22fb 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -87,6 +87,15 @@ .bu-fieldset { border: 1px solid var(--bu-border); border-radius: 6px; padding: 14px; } .bu-fieldset legend { color: var(--bu-text); font-size: 17px; font-weight: 700; padding: 0 7px; } .bu-retention-grid { display: grid; gap: 15px; grid-template-columns: repeat(4, minmax(150px, 1fr)); } +.bu-schedule-grid { display: grid; gap: 15px; grid-template-columns: minmax(240px, 2fr) minmax(150px, 1fr); } +.bu-weekdays { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } +.bu-weekdays[hidden] { display: none; } +.bu-weekdays > span { font-weight: 700; margin-right: 4px; } +#backupper-app .bu-weekdays label { display: block; } +#backupper-app .bu-weekdays label span { background: var(--bu-panel-alt); border: 1px solid var(--bu-border-strong); border-radius: 5px; cursor: pointer; display: block; min-width: 46px; padding: 9px 11px; text-align: center; } +#backupper-app .bu-weekdays input { opacity: 0; pointer-events: none; position: absolute; } +#backupper-app .bu-weekdays input:checked + span { background: var(--bu-accent) !important; border-color: var(--bu-accent) !important; color: #fff !important; } +.bu-custom-cron { margin-top: 14px; } #backupper-app .bu-button { appearance: none; @@ -192,6 +201,7 @@ .bu-form { grid-template-columns: 1fr; } .bu-workload-options { grid-template-columns: 1fr; } .bu-retention-grid { grid-template-columns: 1fr; } + .bu-schedule-grid { 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; } diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index 669f149..c722267 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -13,7 +13,9 @@ name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', type: 'Art der zu sichernden Daten. Beispiel: appdata für Containerdaten, vm für virtuelle Maschinen oder flash für den Unraid-USB-Stick.', repositoryId: 'Speicherziel für diesen Vorgang. Jeder Backup-Job verwendet ein eigenes Restic-Repository.', - cron: 'Zeitplan im Cron-Format: Minute Stunde Tag Monat Wochentag. Beispiel: 0 2 * * * startet täglich um 02:00 Uhr; 30 3 * * 0 jeden Sonntag um 03:30 Uhr.', + scheduleMode: 'Legt fest, an welchen Tagen das Backup startet. Beispiel: täglich, Montag bis Freitag oder ausgewählte Wochentage.', + scheduleTime: 'Uhrzeit für den automatischen Start. Beispiel: 02:30 startet das Backup nachts um halb drei.', + scheduleDays: 'Wähle mindestens einen Wochentag. Beispiel: Montag, Mittwoch und Sonntag jeweils zur eingestellten Uhrzeit.', consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.', compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.', sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', @@ -119,6 +121,39 @@ while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; } return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`; }; + const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}]; + function parseSchedule(cron = '0 2 * * *') { + const match = String(cron).trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(.+)$/); + if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return {mode:'custom',time:'02:00',days:[],cron}; + const time = `${String(match[2]).padStart(2,'0')}:${String(match[1]).padStart(2,'0')}`; + if (match[3] === '*') return {mode:'daily',time,days:[],cron}; + if (match[3] === '1-5') return {mode:'weekdays',time,days:[],cron}; + const days = match[3].split(','); + if (days.length && days.every(day=>weekDays.some(item=>item.value===day))) return {mode:'selected',time,days:[...new Set(days)],cron}; + return {mode:'custom',time,days:[],cron}; + } + function scheduleCron(form) { + const mode = form.get('scheduleMode'); + if (mode === 'custom') return form.get('customCron'); + const [hour, minute] = String(form.get('scheduleTime') || '02:00').split(':'); + const dayField = mode === 'daily' ? '*' : mode === 'weekdays' ? '1-5' : form.getAll('scheduleDays').join(','); + if (mode === 'selected' && !dayField) throw new Error('Select at least one weekday for the backup schedule'); + return `${Number(minute)} ${Number(hour)} * * ${dayField}`; + } + function scheduleLabel(cron) { + const schedule = parseSchedule(cron); + if (schedule.mode === 'custom') return cron || 'Manual'; + if (schedule.mode === 'daily') return `Täglich um ${schedule.time} Uhr`; + if (schedule.mode === 'weekdays') return `Mo–Fr um ${schedule.time} Uhr`; + const labels = weekDays.filter(day=>schedule.days.includes(day.value)).map(day=>day.short).join(', '); + return `${labels} um ${schedule.time} Uhr`; + } + function scheduleFields(job = {}) { + const schedule = parseSchedule(job.schedule?.cron || '0 2 * * *'); + const modes = [['daily','Täglich'],['weekdays','Montag bis Freitag'],['selected','Ausgewählte Wochentage']]; + if (schedule.mode === 'custom') modes.push(['custom','Bestehenden Cron-Zeitplan beibehalten']); + return `
Backup schedule
Weekdays${weekDays.map(day=>``).join('')}
${schedule.mode==='custom'?``:''}
`; + } function lineChart(runs, field, formatter, emptyText) { const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})); @@ -157,7 +192,7 @@ }; container.querySelectorAll('.bu-card .bu-muted').forEach(element => setTooltip(element.closest('.bu-card') || element, dashboardHelp[element.textContent.trim()])); const headerHelp = { - Job: 'Name und interne ID des Backup-Jobs.', Type: 'Art der gesicherten Daten.', Repository: 'Verwendetes Restic-Speicherziel.', Schedule: 'Automatischer Cron-Zeitplan.', Enabled: 'Zeigt, ob der Job automatisch laufen darf.', Actions: 'Verfügbare Aktionen für diesen Eintrag.', Name: 'Anzeigename und interne ID.', Location: 'Pfad oder Adresse des Restic-Ziels.', Mount: 'Zeigt, ob Backupper das Netzwerkziel selbst mountet.', Snapshot: 'Eindeutiger Sicherungsstand.', Time: 'Erstellungszeitpunkt des Snapshots.', Host: 'Hostname des Systems, das den Snapshot erstellt hat.', Paths: 'Im Snapshot enthaltene Quellpfade.', Task: 'Art und Ziel der Aufgabe.', Status: 'Aktueller oder endgültiger Laufstatus.', Created: 'Zeitpunkt, an dem die Aufgabe eingereiht wurde.', Message: 'Ergebnis oder Fehlerbeschreibung.', Size: 'Gespeicherte Dateigröße im Snapshot.' + Job: 'Name und interne ID des Backup-Jobs.', Type: 'Art der gesicherten Daten.', Repository: 'Verwendetes Restic-Speicherziel.', Schedule: 'Wochentage und Uhrzeit des automatischen Backups.', Enabled: 'Zeigt, ob der Job automatisch laufen darf.', Actions: 'Verfügbare Aktionen für diesen Eintrag.', Name: 'Anzeigename und interne ID.', Location: 'Pfad oder Adresse des Restic-Ziels.', Mount: 'Zeigt, ob Backupper das Netzwerkziel selbst mountet.', Snapshot: 'Eindeutiger Sicherungsstand.', Time: 'Erstellungszeitpunkt des Snapshots.', Host: 'Hostname des Systems, das den Snapshot erstellt hat.', Paths: 'Im Snapshot enthaltene Quellpfade.', Task: 'Art und Ziel der Aufgabe.', Status: 'Aktueller oder endgültiger Laufstatus.', Created: 'Zeitpunkt, an dem die Aufgabe eingereiht wurde.', Message: 'Ergebnis oder Fehlerbeschreibung.', Size: 'Gespeicherte Dateigröße im Snapshot.' }; container.querySelectorAll('.bu-table th').forEach(th => setTooltip(th, headerHelp[th.textContent.trim()] || 'Informationen zu dieser Tabellenspalte.')); const snapshotSelect = container.querySelector('#bu-snapshot-repo'); @@ -240,7 +275,7 @@ } function renderJobs() { - const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(j.type)}${esc(repoName(j.repositoryId))}${esc(j.schedule?.cron || 'manual')}${j.enabled ? status('success') : status('cancelled')}${actionGroup(button('Run',`run-job:${j.id}`,'primary')+button('Edit',`edit-job:${j.id}`)+button('Duplicate',`duplicate-job:${j.id}`)+button('Delete',`delete-job:${j.id}`,'danger'))}`).join(''); + const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(j.type)}${esc(repoName(j.repositoryId))}${esc(scheduleLabel(j.schedule?.cron))}${j.enabled ? status('success') : status('cancelled')}${actionGroup(button('Run',`run-job:${j.id}`,'primary')+button('Edit',`edit-job:${j.id}`)+button('Duplicate',`duplicate-job:${j.id}`)+button('Delete',`delete-job:${j.id}`,'danger'))}`).join(''); return `

Backup Jobs

One isolated Restic repository per job.
${button('New job','new-job','primary')}
${rows ? `${rows}
JobTypeRepositoryScheduleEnabledActions
` : '
No backup jobs configured.
'}
`; } @@ -254,8 +289,8 @@ content.innerHTML = `

${job.id ? 'Edit' : 'New'} backup job

- - + + ${scheduleFields(job)}
Backup retention

Snapshots matching any enabled rule are kept. Set daily, weekly and monthly to 0 for a strict age limit.

@@ -427,7 +462,7 @@ 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 retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))}; if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Retention must keep at least one age or calendar policy'); - 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,notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; + 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:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,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') { @@ -450,6 +485,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 === 'scheduleMode') { + const weekdays=e.target.closest('.bu-schedule')?.querySelector('.bu-weekdays'); + if (weekdays) weekdays.hidden=e.target.value!=='selected'; + } if (e.target.name === 'type' && e.target.closest('#bu-job-form')) { state.backupSelections=[]; state.workloadSelections=[]; renderJobSpecific(e.target.value);