diff --git a/backend/.env.example b/backend/.env.example index 35b4574..de49d5e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,5 +3,8 @@ AWS_SECRET_ACCESS_KEY="your_s3_secret" RESTIC_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/bucket-name" RESTIC_PASSWORD="restic_encryption_password" ZFS_POOL_NAME="incus-pool" +RESTIC_KEEP_DAILY=7 +RESTIC_KEEP_WEEKLY=0 +RESTIC_KEEP_MONTHLY=0 PORT=3000 API_TOKEN="" diff --git a/backend/src/config.js b/backend/src/config.js index 6565951..ccddd49 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -24,6 +24,11 @@ export const config = { RESTIC_REPOSITORY: process.env.RESTIC_REPOSITORY || '', RESTIC_PASSWORD: process.env.RESTIC_PASSWORD || '', }, + retention: { + keepDaily: Number(process.env.RESTIC_KEEP_DAILY || 7), + keepWeekly: Number(process.env.RESTIC_KEEP_WEEKLY || 0), + keepMonthly: Number(process.env.RESTIC_KEEP_MONTHLY || 0), + }, }; export const editableEnv = [ @@ -32,6 +37,9 @@ export const editableEnv = [ { key: 'RESTIC_REPOSITORY', label: 'Restic repository', required: true, secret: false }, { key: 'RESTIC_PASSWORD', label: 'Restic password', required: true, secret: true }, { key: 'ZFS_POOL_NAME', label: 'ZFS pool name', required: true, secret: false }, + { key: 'RESTIC_KEEP_DAILY', label: 'Keep daily snapshots', required: false, secret: false }, + { key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false }, + { key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false }, { key: 'PORT', label: 'API port', required: false, secret: false }, { key: 'API_TOKEN', label: 'API token', required: false, secret: true }, ]; @@ -103,4 +111,7 @@ function applyRuntimeEnv(values) { config.resticEnv.AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || ''; config.resticEnv.RESTIC_REPOSITORY = process.env.RESTIC_REPOSITORY || ''; config.resticEnv.RESTIC_PASSWORD = process.env.RESTIC_PASSWORD || ''; + config.retention.keepDaily = Number(process.env.RESTIC_KEEP_DAILY || 7); + config.retention.keepWeekly = Number(process.env.RESTIC_KEEP_WEEKLY || 0); + config.retention.keepMonthly = Number(process.env.RESTIC_KEEP_MONTHLY || 0); } diff --git a/backend/src/routes/backup.js b/backend/src/routes/backup.js index c665346..611afb1 100644 --- a/backend/src/routes/backup.js +++ b/backend/src/routes/backup.js @@ -80,7 +80,7 @@ export async function runBackupJob(job) { setJobStep(job, 'Applying Restic retention policy'); setJobProgress(job, { percent: 96 }); - await runRestic(['forget', '--tag', job.vmName, '--keep-daily', '7', '--prune'], { + await runRestic(retentionArgs(job.vmName), { log: (line) => appendJobLog(job, line), }); @@ -115,6 +115,25 @@ function formatBytes(value) { return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } +function retentionArgs(vmName) { + const args = ['forget', '--tag', vmName, '--prune']; + const keepDaily = positiveInteger(config.retention.keepDaily); + const keepWeekly = positiveInteger(config.retention.keepWeekly); + const keepMonthly = positiveInteger(config.retention.keepMonthly); + + if (keepDaily) args.push('--keep-daily', String(keepDaily)); + if (keepWeekly) args.push('--keep-weekly', String(keepWeekly)); + if (keepMonthly) args.push('--keep-monthly', String(keepMonthly)); + if (!keepDaily && !keepWeekly && !keepMonthly) args.push('--keep-daily', '7'); + + return args; +} + +function positiveInteger(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0; +} + async function cleanupBackup(job, zvol, snapshotName) { setJobStep(job, 'Running cleanup'); await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { diff --git a/backend/src/scheduler.js b/backend/src/scheduler.js index a488a1d..736c02d 100644 --- a/backend/src/scheduler.js +++ b/backend/src/scheduler.js @@ -35,7 +35,7 @@ async function runDueSchedules() { for (const schedule of schedules) { if (!schedule.enabled) continue; if (!schedule.nextRunAt) { - schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours); + schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours, schedule.timeOfDay); changed = true; continue; } @@ -45,10 +45,10 @@ async function runDueSchedules() { await startBackupForVm(schedule.vmName); schedule.lastRunAt = now.toISOString(); schedule.lastError = ''; - schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours); + schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours, schedule.timeOfDay); } catch (error) { schedule.lastError = String(error.message || error); - schedule.nextRunAt = nextRunFrom(new Date(now.getTime() + 5 * 60 * 1000), schedule.intervalHours); + schedule.nextRunAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); } changed = true; } @@ -90,7 +90,8 @@ function normalizeSchedules(values) { vmName, enabled: Boolean(value.enabled), intervalHours, - nextRunAt: value.nextRunAt || nextRunFrom(new Date(), intervalHours), + timeOfDay: normalizeTimeOfDay(value.timeOfDay), + nextRunAt: value.nextRunAt || nextRunFrom(new Date(), intervalHours, normalizeTimeOfDay(value.timeOfDay)), lastRunAt: value.lastRunAt || null, lastError: value.lastError || '', }; @@ -98,6 +99,31 @@ function normalizeSchedules(values) { .filter(Boolean); } -function nextRunFrom(date, intervalHours) { - return new Date(date.getTime() + intervalHours * 60 * 60 * 1000).toISOString(); +function nextRunFrom(date, intervalHours, timeOfDay) { + const intervalMs = intervalHours * 60 * 60 * 1000; + const candidate = withTimeOfDay(date, timeOfDay); + + while (candidate <= date) { + candidate.setTime(candidate.getTime() + intervalMs); + } + + return candidate.toISOString(); +} + +function withTimeOfDay(date, timeOfDay) { + const [hours, minutes] = normalizeTimeOfDay(timeOfDay).split(':').map(Number); + const next = new Date(date); + next.setHours(hours, minutes, 0, 0); + return next; +} + +function normalizeTimeOfDay(value) { + const text = String(value || '02:00').trim(); + if (/^\d{2}:\d{2}$/.test(text)) { + const [hours, minutes] = text.split(':').map(Number); + if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { + return text; + } + } + return '02:00'; } diff --git a/frontend/src/components/Scheduler.jsx b/frontend/src/components/Scheduler.jsx index 752cfdf..ef80288 100644 --- a/frontend/src/components/Scheduler.jsx +++ b/frontend/src/components/Scheduler.jsx @@ -33,6 +33,7 @@ export function Scheduler({ onChanged, schedules, vms }) { nextRunAt: row.nextRunAt, lastRunAt: row.lastRunAt, lastError: row.lastError, + timeOfDay: row.timeOfDay || '02:00', })); const result = await api.put('/schedules', { schedules: payload }); setDrafts(mergeSchedules(vms, result.data)); @@ -73,6 +74,7 @@ export function Scheduler({ onChanged, schedules, vms }) { Enabled VM Interval + Time Next Run Last Run Last Error @@ -102,6 +104,14 @@ export function Scheduler({ onChanged, schedules, vms }) { + + updateRow(row.vmName, { timeOfDay: event.target.value, nextRunAt: null })} + type="time" + value={row.timeOfDay || '02:00'} + /> + {formatTime(row.nextRunAt)} {formatTime(row.lastRunAt)} {row.lastError || '-'} @@ -109,7 +119,7 @@ export function Scheduler({ onChanged, schedules, vms }) { ))} {!rows.length ? ( - + No VMs loaded. @@ -139,6 +149,7 @@ function mergeSchedules(vms, schedules) { vmName: vm.name, enabled: Boolean(byVm.get(vm.name)?.enabled), intervalHours: byVm.get(vm.name)?.intervalHours || 24, + timeOfDay: byVm.get(vm.name)?.timeOfDay || '02:00', nextRunAt: byVm.get(vm.name)?.nextRunAt || null, lastRunAt: byVm.get(vm.name)?.lastRunAt || null, lastError: byVm.get(vm.name)?.lastError || '', diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index e2bbc93..a915de4 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -81,7 +81,8 @@ export function Settings({ onChanged }) { setValues((current) => ({ ...current, [field.key]: event.target.value }))} - type={field.secret && !secretVisible ? 'password' : 'text'} + min={field.key.startsWith('RESTIC_KEEP_') ? '0' : undefined} + type={inputType(field, secretVisible)} value={values[field.key] || ''} /> {field.secret ? ( @@ -113,6 +114,12 @@ export function Settings({ onChanged }) { ); } +function inputType(field, secretVisible) { + if (field.secret && !secretVisible) return 'password'; + if (field.key.startsWith('RESTIC_KEEP_')) return 'number'; + return 'text'; +} + function Notice({ text, tone }) { const classes = { bad: 'border-red-900 bg-red-950/50 text-red-200',