const { BeanModel } = require("redbean-node/dist/bean-model"); const { parseTimeObject, parseTimeFromTimeObject, log } = require("../../src/util"); const { R } = require("redbean-node"); const dayjs = require("dayjs"); const Cron = require("croner"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const apicache = require("../modules/apicache"); class Maintenance extends BeanModel { /** * Return an object that ready to parse to JSON for public * Only show necessary data to public * @returns {Promise} Object ready to parse */ async toPublicJSON() { let dateRange = []; if (this.start_date) { dateRange.push(this.start_date); } else { dateRange.push(null); } if (this.end_date) { dateRange.push(this.end_date); } let timeRange = []; let startTime = parseTimeObject(this.start_time); timeRange.push(startTime); let endTime = parseTimeObject(this.end_time); timeRange.push(endTime); let obj = { id: this.id, title: this.title, description: this.description, strategy: this.strategy, intervalDay: this.interval_day, active: !!this.active, dateRange: dateRange, timeRange: timeRange, weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [], daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [], timeslotList: [], cron: this.cron, duration: this.duration, durationMinutes: parseInt(this.duration / 60), timezone: await this.getTimezone(), // Only valid timezone timezoneOption: this.timezone, // Mainly for dropdown menu, because there is a option "SAME_AS_SERVER" timezoneOffset: await this.getTimezoneOffset(), status: await this.getStatus(), }; if (this.strategy === "manual") { // Do nothing, no timeslots } else if (this.strategy === "single") { obj.timeslotList.push({ startDate: this.start_date, endDate: this.end_date, }); } else { // Should be cron or recurring here if (this.beanMeta.job) { let runningTimeslot = this.getRunningTimeslot(); if (runningTimeslot) { obj.timeslotList.push(runningTimeslot); } let nextRunDate = this.beanMeta.job.nextRun(); if (nextRunDate) { let startDateDayjs = dayjs(nextRunDate); let startDate = startDateDayjs.toISOString(); let endDate = startDateDayjs.add(this.duration, "second").toISOString(); obj.timeslotList.push({ startDate, endDate, }); } } } if (!Array.isArray(obj.weekdays)) { obj.weekdays = []; } if (!Array.isArray(obj.daysOfMonth)) { obj.daysOfMonth = []; } return obj; } /** * Return an object that ready to parse to JSON * @param {string} timezone If not specified, the timeRange will be in UTC * @returns {Promise} Object ready to parse */ async toJSON(timezone = null) { return this.toPublicJSON(timezone); } /** * Get a list of weekdays that the maintenance is active for * Monday=1, Tuesday=2 etc. * @returns {number[]} Array of active weekdays */ getDayOfWeekList() { log.debug("timeslot", "List: " + this.weekdays); return JSON.parse(this.weekdays).sort(function (a, b) { return a - b; }); } /** * Get a list of days in month that maintenance is active for * @returns {number[]|string[]} Array of active days in month */ getDayOfMonthList() { return JSON.parse(this.days_of_month).sort(function (a, b) { return a - b; }); } /** * Get the duration of maintenance in seconds * @returns {number} Duration of maintenance */ calcDuration() { let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second"); // Add 24hours if it is across day if (duration < 0) { duration += 24 * 3600; } return duration; } /** * Convert data from socket to bean * @param {Bean} bean Bean to fill in * @param {object} obj Data to fill bean with * @returns {Promise} Filled bean */ static async jsonToBean(bean, obj) { if (obj.id) { bean.id = obj.id; } bean.title = obj.title; bean.description = obj.description; bean.strategy = obj.strategy; bean.interval_day = obj.intervalDay; bean.timezone = obj.timezoneOption; bean.active = obj.active; if (obj.dateRange[0]) { bean.start_date = obj.dateRange[0]; } else { bean.start_date = null; } if (obj.dateRange[1]) { bean.end_date = obj.dateRange[1]; } else { bean.end_date = null; } if (bean.strategy === "cron") { bean.duration = obj.durationMinutes * 60; bean.cron = obj.cron; this.validateCron(bean.cron); } if (bean.strategy.startsWith("recurring-")) { bean.start_time = parseTimeFromTimeObject(obj.timeRange[0]); bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]); bean.weekdays = JSON.stringify(obj.weekdays); bean.days_of_month = JSON.stringify(obj.daysOfMonth); await bean.generateCron(); this.validateCron(bean.cron); } return bean; } /** * Throw error if cron is invalid * @param {string|Date} cron Pattern or date * @returns {void} */ static validateCron(cron) { let job = new Cron(cron, () => {}); job.stop(); } /** * Run the cron * @param {boolean} throwError Should an error be thrown on failure * @returns {Promise} */ async run(throwError = false) { if (this.beanMeta.job) { log.debug("maintenance", "Maintenance is already running, stop it first. id: " + this.id); this.stop(); } log.debug("maintenance", "Run maintenance id: " + this.id); // 1.21.2 migration if (!this.cron) { await this.generateCron(); if (!this.timezone) { this.timezone = "UTC"; } if (this.cron) { await R.store(this); } } if (this.strategy === "manual") { // Do nothing, because it is controlled by the user } else if (this.strategy === "single") { this.beanMeta.job = new Cron(this.start_date, { timezone: await this.getTimezone() }, () => { log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now"); UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); apicache.clear(); }); } else if (this.cron != null) { // Here should be cron or recurring try { this.beanMeta.status = "scheduled"; let startEvent = (customDuration = 0) => { log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now"); this.beanMeta.status = "under-maintenance"; clearTimeout(this.beanMeta.durationTimeout); let duration = this.inferDuration(customDuration); UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); this.beanMeta.durationTimeout = setTimeout(() => { // End of maintenance for this timeslot this.beanMeta.status = "scheduled"; UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); }, duration); }; // Create Cron if (this.strategy === "recurring-interval") { // For recurring-interval, Croner needs to have interval and startAt const startDate = dayjs(this.startDate); const [ hour, minute ] = this.startTime.split(":"); const startDateTime = startDate.hour(hour).minute(minute); this.beanMeta.job = new Cron(this.cron, { timezone: await this.getTimezone(), interval: this.interval_day * 24 * 60 * 60, startAt: startDateTime.toISOString(), }, startEvent); } else { this.beanMeta.job = new Cron(this.cron, { timezone: await this.getTimezone(), }, startEvent); } // Continue if the maintenance is still in the window let runningTimeslot = this.getRunningTimeslot(); let current = dayjs(); if (runningTimeslot) { let duration = dayjs(runningTimeslot.endDate).diff(current, "second") * 1000; log.debug("maintenance", "Maintenance id: " + this.id + " Remaining duration: " + duration + "ms"); startEvent(duration); } } catch (e) { log.error("maintenance", "Error in maintenance id: " + this.id); log.error("maintenance", "Cron: " + this.cron); log.error("maintenance", e); if (throwError) { throw e; } } } else { log.error("maintenance", "Maintenance id: " + this.id + " has no cron"); } } /** * Get timeslots where maintenance is running * @returns {object|null} Maintenance time slot */ getRunningTimeslot() { let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").toDate())); let end = start.add(this.duration, "second"); let current = dayjs(); if (current.isAfter(start) && current.isBefore(end)) { return { startDate: start.toISOString(), endDate: end.toISOString(), }; } else { return null; } } /** * Calculate the maintenance duration * @param {number} customDuration - The custom duration in milliseconds. * @returns {number} The inferred duration in milliseconds. */ inferDuration(customDuration) { // Check if duration is still in the window. If not, use the duration from the current time to the end of the window if (customDuration > 0) { return customDuration; } else if (this.end_date) { let d = dayjs(this.end_date).diff(dayjs(), "second"); if (d < this.duration) { return d * 1000; } } return this.duration * 1000; } /** * Stop the maintenance * @returns {void} */ stop() { if (this.beanMeta.job) { this.beanMeta.job.stop(); delete this.beanMeta.job; } } /** * Is this maintenance currently active * @returns {Promise} The maintenance is active? */ async isUnderMaintenance() { return (await this.getStatus()) === "under-maintenance"; } /** * Get the timezone of the maintenance * @returns {Promise} timezone */ async getTimezone() { if (!this.timezone || this.timezone === "SAME_AS_SERVER") { return await UptimeKumaServer.getInstance().getTimezone(); } return this.timezone; } /** * Get offset for timezone * @returns {Promise} offset */ async getTimezoneOffset() { return dayjs.tz(dayjs(), await this.getTimezone()).format("Z"); } /** * Get the current status of the maintenance * @returns {Promise} Current status */ async getStatus() { if (!this.active) { return "inactive"; } if (this.strategy === "manual") { return "under-maintenance"; } // Check if the maintenance is started if (this.start_date && dayjs().isBefore(dayjs.tz(this.start_date, await this.getTimezone()))) { return "scheduled"; } // Check if the maintenance is ended if (this.end_date && dayjs().isAfter(dayjs.tz(this.end_date, await this.getTimezone()))) { return "ended"; } if (this.strategy === "single") { return "under-maintenance"; } if (!this.beanMeta.status) { return "unknown"; } return this.beanMeta.status; } /** * Generate Cron for recurring maintenance * @returns {Promise} */ async generateCron() { log.info("maintenance", "Generate cron for maintenance id: " + this.id); if (this.strategy === "cron") { // Do nothing for cron } else if (!this.strategy.startsWith("recurring-")) { this.cron = ""; } else if (this.strategy === "recurring-interval") { // For intervals, the pattern is calculated in the run function as the interval-option is set this.cron = "* * * * *"; this.duration = this.calcDuration(); log.debug("maintenance", "Cron: " + this.cron); log.debug("maintenance", "Duration: " + this.duration); } else if (this.strategy === "recurring-weekday") { let list = this.getDayOfWeekList(); let array = this.start_time.split(":"); let hour = parseInt(array[0]); let minute = parseInt(array[1]); this.cron = minute + " " + hour + " * * " + list.join(","); this.duration = this.calcDuration(); } else if (this.strategy === "recurring-day-of-month") { let list = this.getDayOfMonthList(); let array = this.start_time.split(":"); let hour = parseInt(array[0]); let minute = parseInt(array[1]); let dayList = []; for (let day of list) { if (typeof day === "string" && day.startsWith("lastDay")) { if (day === "lastDay1") { dayList.push("L"); } // Unfortunately, lastDay2-4 is not supported by cron } else { dayList.push(day); } } // Remove duplicate dayList = [ ...new Set(dayList) ]; this.cron = minute + " " + hour + " " + dayList.join(",") + " * *"; this.duration = this.calcDuration(); } } } module.exports = Maintenance;