Add friendly backup schedule controls
This commit is contained in:
@@ -1 +0,0 @@
|
||||
91dbea90a30cd5f9d06646d1a397cad3318afabe0db7480c34f28faa45a14b0c backupper-2026.06.14.8-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014 backupper-2026.06.14.9-x86_64-1.txz
|
||||
Vendored
+5
-2
@@ -2,13 +2,16 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.14.8">
|
||||
<!ENTITY version "2026.06.14.9">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||
<!ENTITY packageSHA256 "91dbea90a30cd5f9d06646d1a397cad3318afabe0db7480c34f28faa45a14b0c">
|
||||
<!ENTITY packageSHA256 "8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||
<CHANGES>
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.14.8">
|
||||
<!ENTITY version "2026.06.14.9">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||
<CHANGES>
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ Tag="backup restic snapshots restore"
|
||||
<?php
|
||||
$pluginRoot = '/plugins/backupper';
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606148">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606149">
|
||||
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||
<header class="bu-header">
|
||||
<div class="bu-brand">
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=202606148" alt="Backupper logo">
|
||||
<div><h1>Backupper <span class="bu-version">2026.06.14.8</span></h1><p>Encrypted Restic backups for Unraid</p></div>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=202606149" alt="Backupper logo">
|
||||
<div><h1>Backupper <span class="bu-version">2026.06.14.9</span></h1><p>Encrypted Restic backups for Unraid</p></div>
|
||||
</div>
|
||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
|
||||
</header>
|
||||
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
|
||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
</div>
|
||||
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.8.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.js"></script>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 `<fieldset class="wide bu-fieldset bu-schedule"><legend>Backup schedule</legend><div class="bu-schedule-grid"><label>Days<select name="scheduleMode">${modes.map(([value,label])=>`<option value="${value}" ${value===schedule.mode?'selected':''}>${label}</option>`).join('')}</select></label><label>Time<input name="scheduleTime" type="time" required value="${esc(schedule.time)}"></label></div><div class="bu-weekdays" ${schedule.mode==='selected'?'':'hidden'}><span>Weekdays</span>${weekDays.map(day=>`<label><input type="checkbox" name="scheduleDays" value="${day.value}" ${schedule.days.includes(day.value)?'checked':''}><span>${day.short}</span></label>`).join('')}</div>${schedule.mode==='custom'?`<label class="bu-custom-cron">Existing schedule<input name="customCron" readonly value="${esc(schedule.cron)}"></label>`:''}</fieldset>`;
|
||||
}
|
||||
|
||||
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 => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(j.type)}</td><td>${esc(repoName(j.repositoryId))}</td><td>${esc(j.schedule?.cron || 'manual')}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${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'))}</td></tr>`).join('');
|
||||
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(j.type)}</td><td>${esc(repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${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'))}</td></tr>`).join('');
|
||||
return `<div class="bu-toolbar"><div><h2>Backup Jobs</h2><div class="bu-muted">One isolated Restic repository per job.</div></div>${button('New job','new-job','primary')}</div>
|
||||
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Type</th><th>Repository</th><th>Schedule</th><th>Enabled</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">No backup jobs configured.</div>'}</section>`;
|
||||
}
|
||||
@@ -254,8 +289,8 @@
|
||||
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>Compression<select name="compression">${['auto','off','max'].map(v=>`<option ${v===(job.compression||'auto')?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Compression<select name="compression">${['auto','off','max'].map(v=>`<option ${v===(job.compression||'auto')?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
${scheduleFields(job)}
|
||||
<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>
|
||||
<fieldset class="wide bu-fieldset"><legend>Backup retention</legend><p class="bu-muted">Snapshots matching any enabled rule are kept. Set daily, weekly and monthly to 0 for a strict age limit.</p><div class="bu-retention-grid"><label>Keep all backups for days<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Additional daily backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Additional weekly backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Additional monthly backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user