Retention Editor added in UI
Time set in Schedular added
This commit is contained in:
@@ -3,5 +3,8 @@ AWS_SECRET_ACCESS_KEY="your_s3_secret"
|
|||||||
RESTIC_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/bucket-name"
|
RESTIC_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/bucket-name"
|
||||||
RESTIC_PASSWORD="restic_encryption_password"
|
RESTIC_PASSWORD="restic_encryption_password"
|
||||||
ZFS_POOL_NAME="incus-pool"
|
ZFS_POOL_NAME="incus-pool"
|
||||||
|
RESTIC_KEEP_DAILY=7
|
||||||
|
RESTIC_KEEP_WEEKLY=0
|
||||||
|
RESTIC_KEEP_MONTHLY=0
|
||||||
PORT=3000
|
PORT=3000
|
||||||
API_TOKEN=""
|
API_TOKEN=""
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export const config = {
|
|||||||
RESTIC_REPOSITORY: process.env.RESTIC_REPOSITORY || '',
|
RESTIC_REPOSITORY: process.env.RESTIC_REPOSITORY || '',
|
||||||
RESTIC_PASSWORD: process.env.RESTIC_PASSWORD || '',
|
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 = [
|
export const editableEnv = [
|
||||||
@@ -32,6 +37,9 @@ export const editableEnv = [
|
|||||||
{ key: 'RESTIC_REPOSITORY', label: 'Restic repository', required: true, secret: false },
|
{ key: 'RESTIC_REPOSITORY', label: 'Restic repository', required: true, secret: false },
|
||||||
{ key: 'RESTIC_PASSWORD', label: 'Restic password', required: true, secret: true },
|
{ key: 'RESTIC_PASSWORD', label: 'Restic password', required: true, secret: true },
|
||||||
{ key: 'ZFS_POOL_NAME', label: 'ZFS pool name', required: true, secret: false },
|
{ 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: 'PORT', label: 'API port', required: false, secret: false },
|
||||||
{ key: 'API_TOKEN', label: 'API token', required: false, secret: true },
|
{ 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.AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || '';
|
||||||
config.resticEnv.RESTIC_REPOSITORY = process.env.RESTIC_REPOSITORY || '';
|
config.resticEnv.RESTIC_REPOSITORY = process.env.RESTIC_REPOSITORY || '';
|
||||||
config.resticEnv.RESTIC_PASSWORD = process.env.RESTIC_PASSWORD || '';
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export async function runBackupJob(job) {
|
|||||||
|
|
||||||
setJobStep(job, 'Applying Restic retention policy');
|
setJobStep(job, 'Applying Restic retention policy');
|
||||||
setJobProgress(job, { percent: 96 });
|
setJobProgress(job, { percent: 96 });
|
||||||
await runRestic(['forget', '--tag', job.vmName, '--keep-daily', '7', '--prune'], {
|
await runRestic(retentionArgs(job.vmName), {
|
||||||
log: (line) => appendJobLog(job, line),
|
log: (line) => appendJobLog(job, line),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,6 +115,25 @@ function formatBytes(value) {
|
|||||||
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
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) {
|
async function cleanupBackup(job, zvol, snapshotName) {
|
||||||
setJobStep(job, 'Running cleanup');
|
setJobStep(job, 'Running cleanup');
|
||||||
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
|
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async function runDueSchedules() {
|
|||||||
for (const schedule of schedules) {
|
for (const schedule of schedules) {
|
||||||
if (!schedule.enabled) continue;
|
if (!schedule.enabled) continue;
|
||||||
if (!schedule.nextRunAt) {
|
if (!schedule.nextRunAt) {
|
||||||
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours);
|
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours, schedule.timeOfDay);
|
||||||
changed = true;
|
changed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,10 @@ async function runDueSchedules() {
|
|||||||
await startBackupForVm(schedule.vmName);
|
await startBackupForVm(schedule.vmName);
|
||||||
schedule.lastRunAt = now.toISOString();
|
schedule.lastRunAt = now.toISOString();
|
||||||
schedule.lastError = '';
|
schedule.lastError = '';
|
||||||
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours);
|
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours, schedule.timeOfDay);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
schedule.lastError = String(error.message || 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;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,8 @@ function normalizeSchedules(values) {
|
|||||||
vmName,
|
vmName,
|
||||||
enabled: Boolean(value.enabled),
|
enabled: Boolean(value.enabled),
|
||||||
intervalHours,
|
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,
|
lastRunAt: value.lastRunAt || null,
|
||||||
lastError: value.lastError || '',
|
lastError: value.lastError || '',
|
||||||
};
|
};
|
||||||
@@ -98,6 +99,31 @@ function normalizeSchedules(values) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextRunFrom(date, intervalHours) {
|
function nextRunFrom(date, intervalHours, timeOfDay) {
|
||||||
return new Date(date.getTime() + intervalHours * 60 * 60 * 1000).toISOString();
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function Scheduler({ onChanged, schedules, vms }) {
|
|||||||
nextRunAt: row.nextRunAt,
|
nextRunAt: row.nextRunAt,
|
||||||
lastRunAt: row.lastRunAt,
|
lastRunAt: row.lastRunAt,
|
||||||
lastError: row.lastError,
|
lastError: row.lastError,
|
||||||
|
timeOfDay: row.timeOfDay || '02:00',
|
||||||
}));
|
}));
|
||||||
const result = await api.put('/schedules', { schedules: payload });
|
const result = await api.put('/schedules', { schedules: payload });
|
||||||
setDrafts(mergeSchedules(vms, result.data));
|
setDrafts(mergeSchedules(vms, result.data));
|
||||||
@@ -73,6 +74,7 @@ export function Scheduler({ onChanged, schedules, vms }) {
|
|||||||
<th className="px-4 py-3 font-medium">Enabled</th>
|
<th className="px-4 py-3 font-medium">Enabled</th>
|
||||||
<th className="px-4 py-3 font-medium">VM</th>
|
<th className="px-4 py-3 font-medium">VM</th>
|
||||||
<th className="px-4 py-3 font-medium">Interval</th>
|
<th className="px-4 py-3 font-medium">Interval</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Time</th>
|
||||||
<th className="px-4 py-3 font-medium">Next Run</th>
|
<th className="px-4 py-3 font-medium">Next Run</th>
|
||||||
<th className="px-4 py-3 font-medium">Last Run</th>
|
<th className="px-4 py-3 font-medium">Last Run</th>
|
||||||
<th className="px-4 py-3 font-medium">Last Error</th>
|
<th className="px-4 py-3 font-medium">Last Error</th>
|
||||||
@@ -102,6 +104,14 @@ export function Scheduler({ onChanged, schedules, vms }) {
|
|||||||
<option value={168}>Weekly</option>
|
<option value={168}>Weekly</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
className="h-9 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-zinc-100 outline-none"
|
||||||
|
onChange={(event) => updateRow(row.vmName, { timeOfDay: event.target.value, nextRunAt: null })}
|
||||||
|
type="time"
|
||||||
|
value={row.timeOfDay || '02:00'}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-zinc-300">{formatTime(row.nextRunAt)}</td>
|
<td className="px-4 py-3 text-zinc-300">{formatTime(row.nextRunAt)}</td>
|
||||||
<td className="px-4 py-3 text-zinc-300">{formatTime(row.lastRunAt)}</td>
|
<td className="px-4 py-3 text-zinc-300">{formatTime(row.lastRunAt)}</td>
|
||||||
<td className="px-4 py-3 text-red-300">{row.lastError || '-'}</td>
|
<td className="px-4 py-3 text-red-300">{row.lastError || '-'}</td>
|
||||||
@@ -109,7 +119,7 @@ export function Scheduler({ onChanged, schedules, vms }) {
|
|||||||
))}
|
))}
|
||||||
{!rows.length ? (
|
{!rows.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-8 text-center text-zinc-500" colSpan="6">
|
<td className="px-4 py-8 text-center text-zinc-500" colSpan="7">
|
||||||
No VMs loaded.
|
No VMs loaded.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -139,6 +149,7 @@ function mergeSchedules(vms, schedules) {
|
|||||||
vmName: vm.name,
|
vmName: vm.name,
|
||||||
enabled: Boolean(byVm.get(vm.name)?.enabled),
|
enabled: Boolean(byVm.get(vm.name)?.enabled),
|
||||||
intervalHours: byVm.get(vm.name)?.intervalHours || 24,
|
intervalHours: byVm.get(vm.name)?.intervalHours || 24,
|
||||||
|
timeOfDay: byVm.get(vm.name)?.timeOfDay || '02:00',
|
||||||
nextRunAt: byVm.get(vm.name)?.nextRunAt || null,
|
nextRunAt: byVm.get(vm.name)?.nextRunAt || null,
|
||||||
lastRunAt: byVm.get(vm.name)?.lastRunAt || null,
|
lastRunAt: byVm.get(vm.name)?.lastRunAt || null,
|
||||||
lastError: byVm.get(vm.name)?.lastError || '',
|
lastError: byVm.get(vm.name)?.lastError || '',
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export function Settings({ onChanged }) {
|
|||||||
<input
|
<input
|
||||||
className="h-10 min-w-0 flex-1 bg-transparent px-3 text-zinc-100 outline-none"
|
className="h-10 min-w-0 flex-1 bg-transparent px-3 text-zinc-100 outline-none"
|
||||||
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
onChange={(event) => 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] || ''}
|
value={values[field.key] || ''}
|
||||||
/>
|
/>
|
||||||
{field.secret ? (
|
{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 }) {
|
function Notice({ text, tone }) {
|
||||||
const classes = {
|
const classes = {
|
||||||
bad: 'border-red-900 bg-red-950/50 text-red-200',
|
bad: 'border-red-900 bg-red-950/50 text-red-200',
|
||||||
|
|||||||
Reference in New Issue
Block a user