Add friendly backup schedule controls

This commit is contained in:
Mikei386
2026-06-14 13:39:19 +02:00
parent 589b37bd96
commit b7fe6ac86f
8 changed files with 69 additions and 14 deletions
-1
View File
@@ -1 +0,0 @@
91dbea90a30cd5f9d06646d1a397cad3318afabe0db7480c34f28faa45a14b0c backupper-2026.06.14.8-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
8a28603e85c551b4581cbb6568d380600f5aca517d76aa512d2319dac5444014 backupper-2026.06.14.9-x86_64-1.txz
+5 -2
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "backupper"> <!ENTITY name "backupper">
<!ENTITY author "Michael Roll"> <!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 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 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"> <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> <CHANGES>
### 2026.06.14.9
- Replace the job cron input with daily, weekday, selected-day, and time controls.
### 2026.06.14.8 ### 2026.06.14.8
- Show Michael Roll as the plugin author and add a dedicated Backupper logo. - Show Michael Roll as the plugin author and add a dedicated Backupper logo.
+4 -1
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "backupper"> <!ENTITY name "backupper">
<!ENTITY author "Michael Roll"> <!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 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 packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!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"> <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> <CHANGES>
### 2026.06.14.9
- Replace the job cron input with daily, weekday, selected-day, and time controls.
### 2026.06.14.8 ### 2026.06.14.8
- Show Michael Roll as the plugin author and add a dedicated Backupper logo. - Show Michael Roll as the plugin author and add a dedicated Backupper logo.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="backup restic snapshots restore"
<?php <?php
$pluginRoot = '/plugins/backupper'; $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"> <div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header"> <header class="bu-header">
<div class="bu-brand"> <div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=202606148" alt="Backupper logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=202606149" alt="Backupper logo">
<div><h1>Backupper <span class="bu-version">2026.06.14.8</span></h1><p>Encrypted Restic backups for Unraid</p></div> <div><h1>Backupper <span class="bu-version">2026.06.14.9</span></h1><p>Encrypted Restic backups for Unraid</p></div>
</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> <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> </header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <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>
+10
View File
@@ -87,6 +87,15 @@
.bu-fieldset { border: 1px solid var(--bu-border); border-radius: 6px; padding: 14px; } .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-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-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 { #backupper-app .bu-button {
appearance: none; appearance: none;
@@ -192,6 +201,7 @@
.bu-form { grid-template-columns: 1fr; } .bu-form { grid-template-columns: 1fr; }
.bu-workload-options { grid-template-columns: 1fr; } .bu-workload-options { grid-template-columns: 1fr; }
.bu-retention-grid { 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-chart-grid-layout { grid-template-columns: 1fr; }
.bu-header { align-items: flex-start; flex-direction: column; gap: 12px; } .bu-header { align-items: flex-start; flex-direction: column; gap: 12px; }
.bu-table { display: block; overflow-x: auto; } .bu-table { display: block; overflow-x: auto; }
+45 -6
View File
@@ -13,7 +13,9 @@
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', 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.', 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.', 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.', 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.', 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.', 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++; } while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[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 `MoFr 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) { function lineChart(runs, field, formatter, emptyText) {
const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})); 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()])); container.querySelectorAll('.bu-card .bu-muted').forEach(element => setTooltip(element.closest('.bu-card') || element, dashboardHelp[element.textContent.trim()]));
const headerHelp = { 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.')); container.querySelectorAll('.bu-table th').forEach(th => setTooltip(th, headerHelp[th.textContent.trim()] || 'Informationen zu dieser Tabellenspalte.'));
const snapshotSelect = container.querySelector('#bu-snapshot-repo'); const snapshotSelect = container.querySelector('#bu-snapshot-repo');
@@ -240,7 +275,7 @@
} }
function renderJobs() { 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> 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>`; <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"> 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> <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>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>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>
<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> <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 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> <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); 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'))}; 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'); 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(); state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
} }
if (kind === 'submit-repo') { 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(); }); 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('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); });
content.addEventListener('change', e => { 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')) { if (e.target.name === 'type' && e.target.closest('#bu-job-form')) {
state.backupSelections=[]; state.workloadSelections=[]; state.backupSelections=[]; state.workloadSelections=[];
renderJobSpecific(e.target.value); renderJobSpecific(e.target.value);