331 lines
13 KiB
JavaScript
331 lines
13 KiB
JavaScript
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
|
|
import { normalizeRunPlanSchedulePayload } from '../forms/schemas.js';
|
|
import { camelRunPlanSchedule, camelRunPlanScheduleRun } from './mappers.js';
|
|
import { loadVisibleRunPlan } from './runPlanModel.js';
|
|
|
|
const MAX_PREVIEW_OCCURRENCES = 12;
|
|
|
|
export function listRunPlanSchedules(userId, isAdmin = false) {
|
|
const scope = isAdmin ? '1=1' : visibleClause('rs');
|
|
return db.prepare(`
|
|
SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name
|
|
FROM runplan_schedules rs
|
|
JOIN runplans r ON r.id = rs.runplan_id
|
|
LEFT JOIN scripts s ON s.id = r.script_id
|
|
LEFT JOIN groups g ON g.id = rs.group_id
|
|
WHERE ${scope}
|
|
ORDER BY COALESCE(rs.next_run_at, rs.updated_at) ASC, rs.name ASC
|
|
`).all(...(isAdmin ? [] : [userId, userId])).map(withPreview).map(camelRunPlanSchedule);
|
|
}
|
|
|
|
export function getRunPlanSchedule(scheduleId, userId, isAdmin = false) {
|
|
const scope = isAdmin ? '1=1' : visibleClause('rs');
|
|
const row = db.prepare(`
|
|
SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name
|
|
FROM runplan_schedules rs
|
|
JOIN runplans r ON r.id = rs.runplan_id
|
|
LEFT JOIN scripts s ON s.id = r.script_id
|
|
LEFT JOIN groups g ON g.id = rs.group_id
|
|
WHERE rs.id = ? AND ${scope}
|
|
`).get(scheduleId, ...(isAdmin ? [] : [userId, userId]));
|
|
return row ? camelRunPlanSchedule(withPreview(row)) : null;
|
|
}
|
|
|
|
export function createRunPlanSchedule(body, userId) {
|
|
const payload = normalizeRunPlanSchedulePayload(body);
|
|
if (!loadVisibleRunPlan(payload.runplanId, userId)) throw new Error('RunPlan not found or not visible');
|
|
const scheduleId = id('sched');
|
|
const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : '';
|
|
db.prepare(`
|
|
INSERT INTO runplan_schedules (
|
|
id, name, description, runplan_id, enabled, schedule_type, start_at, end_at, time_of_day,
|
|
interval_value, interval_unit, days_of_week, day_of_month, month_of_year, next_run_at,
|
|
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
scheduleId,
|
|
payload.name,
|
|
payload.description,
|
|
payload.runplanId,
|
|
payload.enabled ? 1 : 0,
|
|
payload.scheduleType,
|
|
payload.startAt,
|
|
payload.endAt || null,
|
|
payload.timeOfDay || null,
|
|
payload.intervalValue,
|
|
payload.intervalUnit,
|
|
json(payload.daysOfWeek, []),
|
|
payload.dayOfMonth,
|
|
payload.monthOfYear,
|
|
nextRunAt,
|
|
payload.visibility,
|
|
userId,
|
|
payload.groupId,
|
|
userId,
|
|
now(),
|
|
now()
|
|
);
|
|
return getRunPlanSchedule(scheduleId, userId, false);
|
|
}
|
|
|
|
export function updateRunPlanSchedule(scheduleId, body, userId, isAdmin = false) {
|
|
const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin);
|
|
if (!existing) return null;
|
|
const payload = normalizeRunPlanSchedulePayload({
|
|
...camelRunPlanSchedule(withPreview(existing)),
|
|
...body,
|
|
runplanId: body.runplanId || body.runplan_id || existing.runplan_id
|
|
});
|
|
if (!loadVisibleRunPlan(payload.runplanId, userId) && !isAdmin) throw new Error('RunPlan not found or not visible');
|
|
const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : '';
|
|
db.prepare(`
|
|
UPDATE runplan_schedules
|
|
SET name = ?, description = ?, runplan_id = ?, enabled = ?, schedule_type = ?, start_at = ?,
|
|
end_at = ?, time_of_day = ?, interval_value = ?, interval_unit = ?, days_of_week = ?,
|
|
day_of_month = ?, month_of_year = ?, next_run_at = ?, visibility = ?, group_id = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
payload.name,
|
|
payload.description,
|
|
payload.runplanId,
|
|
payload.enabled ? 1 : 0,
|
|
payload.scheduleType,
|
|
payload.startAt,
|
|
payload.endAt || null,
|
|
payload.timeOfDay || null,
|
|
payload.intervalValue,
|
|
payload.intervalUnit,
|
|
json(payload.daysOfWeek, []),
|
|
payload.dayOfMonth,
|
|
payload.monthOfYear,
|
|
nextRunAt,
|
|
payload.visibility,
|
|
payload.groupId,
|
|
now(),
|
|
scheduleId
|
|
);
|
|
return getRunPlanSchedule(scheduleId, userId, isAdmin);
|
|
}
|
|
|
|
export function deleteRunPlanSchedule(scheduleId, userId, isAdmin = false) {
|
|
const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin);
|
|
if (!existing) return false;
|
|
db.prepare('DELETE FROM runplan_schedules WHERE id = ?').run(scheduleId);
|
|
return true;
|
|
}
|
|
|
|
export function setRunPlanScheduleEnabled(scheduleId, enabled, userId, isAdmin = false) {
|
|
const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin);
|
|
if (!existing) return null;
|
|
const nextRunAt = enabled ? computeNextRun(rowToSchedule(existing), new Date(Date.now() - 1000)) : '';
|
|
db.prepare('UPDATE runplan_schedules SET enabled = ?, next_run_at = ?, updated_at = ? WHERE id = ?')
|
|
.run(enabled ? 1 : 0, nextRunAt, now(), scheduleId);
|
|
return getRunPlanSchedule(scheduleId, userId, isAdmin);
|
|
}
|
|
|
|
export function listDueRunPlanSchedules(nowIso = now()) {
|
|
return db.prepare(`
|
|
SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name
|
|
FROM runplan_schedules rs
|
|
JOIN runplans r ON r.id = rs.runplan_id
|
|
LEFT JOIN scripts s ON s.id = r.script_id
|
|
WHERE rs.enabled = 1
|
|
AND rs.next_run_at IS NOT NULL
|
|
AND rs.next_run_at != ''
|
|
AND rs.next_run_at <= ?
|
|
ORDER BY rs.next_run_at ASC
|
|
LIMIT 50
|
|
`).all(nowIso).map((row) => camelRunPlanSchedule(withPreview(row)));
|
|
}
|
|
|
|
export function recordRunPlanScheduleDispatch(schedule, { jobId = '', plannedFor = '', status = 'started', message = '' } = {}) {
|
|
const raw = getRawSchedule(schedule.id);
|
|
if (!raw) return null;
|
|
const effectivePlannedFor = plannedFor || raw.next_run_at || now();
|
|
const nextRunAt = computeNextRun(rowToSchedule(raw), new Date(Date.now() + 1000));
|
|
const nextEnabled = raw.schedule_type === 'once' && !nextRunAt ? 0 : raw.enabled;
|
|
db.prepare(`
|
|
INSERT INTO runplan_schedule_runs (id, schedule_id, runplan_id, job_id, planned_for, status, message, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(id('srn'), raw.id, raw.runplan_id, jobId || null, effectivePlannedFor, status, message, now());
|
|
db.prepare(`
|
|
UPDATE runplan_schedules
|
|
SET last_run_at = ?, last_job_id = ?, next_run_at = ?, enabled = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(now(), jobId || null, nextRunAt, nextEnabled, now(), raw.id);
|
|
return getRawSchedule(raw.id);
|
|
}
|
|
|
|
export function listRunPlanScheduleRuns(userId, isAdmin = false, limit = 100) {
|
|
const scope = isAdmin ? '1=1' : visibleClause('rs');
|
|
return db.prepare(`
|
|
SELECT sr.*, r.name AS runplan_name, j.status AS job_status
|
|
FROM runplan_schedule_runs sr
|
|
JOIN runplan_schedules rs ON rs.id = sr.schedule_id
|
|
LEFT JOIN runplans r ON r.id = sr.runplan_id
|
|
LEFT JOIN jobs j ON j.id = sr.job_id
|
|
WHERE ${scope}
|
|
ORDER BY sr.created_at DESC
|
|
LIMIT ?
|
|
`).all(...(isAdmin ? [limit] : [userId, userId, limit])).map(camelRunPlanScheduleRun);
|
|
}
|
|
|
|
export function previewRunPlanSchedule(body) {
|
|
const payload = normalizeRunPlanSchedulePayload(body);
|
|
return previewScheduleOccurrences(payload);
|
|
}
|
|
|
|
export function previewScheduleOccurrences(schedule, count = MAX_PREVIEW_OCCURRENCES, fromDate = new Date(Date.now() - 1000)) {
|
|
const occurrences = [];
|
|
let cursor = new Date(fromDate);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const next = computeNextRun(schedule, cursor);
|
|
if (!next) break;
|
|
occurrences.push(next);
|
|
cursor = new Date(Date.parse(next) + 1000);
|
|
}
|
|
return occurrences;
|
|
}
|
|
|
|
export function computeNextRun(schedule, fromDate = new Date()) {
|
|
const start = validDate(schedule.startAt || schedule.start_at);
|
|
if (!start) return '';
|
|
const end = validDate(schedule.endAt || schedule.end_at);
|
|
const from = validDate(fromDate) || new Date();
|
|
const type = schedule.scheduleType || schedule.schedule_type || 'once';
|
|
const after = new Date(Math.max(from.getTime(), start.getTime() - 1000));
|
|
let next = null;
|
|
|
|
if (type === 'once') next = start > after ? start : null;
|
|
else if (type === 'interval') next = nextIntervalRun(schedule, start, after);
|
|
else if (type === 'daily') next = nextDailyRun(schedule, start, after);
|
|
else if (type === 'weekly') next = nextWeeklyRun(schedule, start, after);
|
|
else if (type === 'monthly') next = nextMonthlyRun(schedule, start, after);
|
|
else if (type === 'yearly') next = nextYearlyRun(schedule, start, after);
|
|
|
|
if (!next) return '';
|
|
if (end && next > end) return '';
|
|
return next.toISOString();
|
|
}
|
|
|
|
function nextIntervalRun(schedule, start, after) {
|
|
const value = Math.max(1, Number(schedule.intervalValue ?? schedule.interval_value) || 1);
|
|
const unit = schedule.intervalUnit || schedule.interval_unit || 'hours';
|
|
let candidate = new Date(start);
|
|
for (let i = 0; i < 10000 && candidate <= after; i += 1) candidate = addInterval(candidate, value, unit);
|
|
return candidate > after ? candidate : null;
|
|
}
|
|
|
|
function nextDailyRun(schedule, start, after) {
|
|
return nextByDayScan(schedule, start, after, () => true);
|
|
}
|
|
|
|
function nextWeeklyRun(schedule, start, after) {
|
|
const days = parseScheduleDays(schedule);
|
|
const daySet = new Set(days.length ? days : [start.getDay()]);
|
|
return nextByDayScan(schedule, start, after, (date) => daySet.has(date.getDay()), 370);
|
|
}
|
|
|
|
function nextMonthlyRun(schedule, start, after) {
|
|
const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate()));
|
|
let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime())));
|
|
for (let i = 0; i < 240; i += 1) {
|
|
const candidate = withTime(clampMonthDate(cursor.getFullYear(), cursor.getMonth(), day), schedule, start);
|
|
if (candidate >= start && candidate > after) return candidate;
|
|
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function nextYearlyRun(schedule, start, after) {
|
|
const month = Math.min(12, Math.max(1, Number(schedule.monthOfYear ?? schedule.month_of_year) || start.getMonth() + 1)) - 1;
|
|
const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate()));
|
|
const firstYear = Math.max(start.getFullYear(), after.getFullYear());
|
|
for (let year = firstYear; year < firstYear + 25; year += 1) {
|
|
const candidate = withTime(clampMonthDate(year, month, day), schedule, start);
|
|
if (candidate >= start && candidate > after) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function nextByDayScan(schedule, start, after, matches, maxDays = 730) {
|
|
let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime())));
|
|
for (let i = 0; i < maxDays; i += 1) {
|
|
if (matches(cursor)) {
|
|
const candidate = withTime(cursor, schedule, start);
|
|
if (candidate >= start && candidate > after) return candidate;
|
|
}
|
|
cursor = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseScheduleDays(schedule) {
|
|
const raw = schedule.daysOfWeek ?? schedule.days_of_week ?? [];
|
|
const days = Array.isArray(raw) ? raw : parseJson(raw, []);
|
|
return [...new Set(days.map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b);
|
|
}
|
|
|
|
function addInterval(date, value, unit) {
|
|
const next = new Date(date);
|
|
if (unit === 'minutes') next.setMinutes(next.getMinutes() + value);
|
|
else if (unit === 'hours') next.setHours(next.getHours() + value);
|
|
else if (unit === 'days') next.setDate(next.getDate() + value);
|
|
else if (unit === 'months') next.setMonth(next.getMonth() + value);
|
|
else if (unit === 'years') next.setFullYear(next.getFullYear() + value);
|
|
return next;
|
|
}
|
|
|
|
function withTime(date, schedule, fallback) {
|
|
const time = schedule.timeOfDay || schedule.time_of_day || `${String(fallback.getHours()).padStart(2, '0')}:${String(fallback.getMinutes()).padStart(2, '0')}`;
|
|
const [hours, minutes] = String(time).split(':').map(Number);
|
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
|
|
}
|
|
|
|
function clampMonthDate(year, month, day) {
|
|
const lastDay = new Date(year, month + 1, 0).getDate();
|
|
return new Date(year, month, Math.min(day, lastDay));
|
|
}
|
|
|
|
function startOfDay(date) {
|
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
}
|
|
|
|
function validDate(value) {
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
function withPreview(row) {
|
|
return {
|
|
...row,
|
|
preview_json: json(previewScheduleOccurrences(rowToSchedule(row)), [])
|
|
};
|
|
}
|
|
|
|
function rowToSchedule(row) {
|
|
return {
|
|
id: row.id,
|
|
scheduleType: row.schedule_type,
|
|
startAt: row.start_at,
|
|
endAt: row.end_at || '',
|
|
timeOfDay: row.time_of_day || '',
|
|
intervalValue: row.interval_value || 1,
|
|
intervalUnit: row.interval_unit || 'hours',
|
|
daysOfWeek: parseJson(row.days_of_week, []),
|
|
dayOfMonth: row.day_of_month || null,
|
|
monthOfYear: row.month_of_year || null
|
|
};
|
|
}
|
|
|
|
function getRawVisibleSchedule(scheduleId, userId, isAdmin = false) {
|
|
const scope = isAdmin ? '1=1' : visibleClause();
|
|
return db.prepare(`SELECT * FROM runplan_schedules WHERE id = ? AND ${scope}`)
|
|
.get(scheduleId, ...(isAdmin ? [] : [userId, userId]));
|
|
}
|
|
|
|
function getRawSchedule(scheduleId) {
|
|
return db.prepare('SELECT * FROM runplan_schedules WHERE id = ?').get(scheduleId);
|
|
}
|