130 lines
3.7 KiB
JavaScript
130 lines
3.7 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { startBackupForVm } from './routes/backup.js';
|
|
|
|
const schedulesPath = path.resolve(process.cwd(), 'schedules.json');
|
|
const checkIntervalMs = 30 * 1000;
|
|
let schedules = [];
|
|
let timer = null;
|
|
|
|
export async function startScheduler() {
|
|
await loadSchedules();
|
|
if (timer) return;
|
|
timer = setInterval(runDueSchedules, checkIntervalMs);
|
|
timer.unref?.();
|
|
runDueSchedules();
|
|
}
|
|
|
|
export function listSchedules() {
|
|
return schedules
|
|
.map((schedule) => ({ ...schedule }))
|
|
.sort((a, b) => a.vmName.localeCompare(b.vmName));
|
|
}
|
|
|
|
export async function replaceSchedules(nextSchedules) {
|
|
schedules = normalizeSchedules(nextSchedules);
|
|
await saveSchedules();
|
|
return listSchedules();
|
|
}
|
|
|
|
async function runDueSchedules() {
|
|
const now = new Date();
|
|
let changed = false;
|
|
|
|
for (const schedule of schedules) {
|
|
if (!schedule.enabled) continue;
|
|
if (!schedule.nextRunAt) {
|
|
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours, schedule.timeOfDay);
|
|
changed = true;
|
|
continue;
|
|
}
|
|
if (new Date(schedule.nextRunAt) > now) continue;
|
|
|
|
try {
|
|
await startBackupForVm(schedule.vmName);
|
|
schedule.lastRunAt = now.toISOString();
|
|
schedule.lastError = '';
|
|
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours, schedule.timeOfDay);
|
|
} catch (error) {
|
|
schedule.lastError = String(error.message || error);
|
|
schedule.nextRunAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
|
|
}
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
await saveSchedules();
|
|
}
|
|
}
|
|
|
|
async function loadSchedules() {
|
|
try {
|
|
const content = await readFile(schedulesPath, 'utf8');
|
|
schedules = normalizeSchedules(JSON.parse(content));
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
schedules = [];
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function saveSchedules() {
|
|
const tempPath = `${schedulesPath}.tmp`;
|
|
await writeFile(tempPath, `${JSON.stringify(schedules, null, 2)}\n`, { mode: 0o600 });
|
|
await rename(tempPath, schedulesPath);
|
|
}
|
|
|
|
function normalizeSchedules(values) {
|
|
const seen = new Set();
|
|
return (Array.isArray(values) ? values : [])
|
|
.map((value) => {
|
|
const vmName = String(value.vmName || '').trim();
|
|
const intervalHours = Math.max(1, Number(value.intervalHours) || 24);
|
|
if (!vmName || seen.has(vmName)) return null;
|
|
seen.add(vmName);
|
|
return {
|
|
id: value.id || `schedule_${crypto.randomBytes(8).toString('hex')}`,
|
|
vmName,
|
|
enabled: Boolean(value.enabled),
|
|
intervalHours,
|
|
timeOfDay: normalizeTimeOfDay(value.timeOfDay),
|
|
nextRunAt: value.nextRunAt || nextRunFrom(new Date(), intervalHours, normalizeTimeOfDay(value.timeOfDay)),
|
|
lastRunAt: value.lastRunAt || null,
|
|
lastError: value.lastError || '',
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
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';
|
|
}
|