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'; }