diff options
Diffstat (limited to 'server/model/maintenance.js')
-rw-r--r-- | server/model/maintenance.js | 457 |
1 files changed, 457 insertions, 0 deletions
diff --git a/server/model/maintenance.js b/server/model/maintenance.js new file mode 100644 index 0000000..7111a18 --- /dev/null +++ b/server/model/maintenance.js @@ -0,0 +1,457 @@ +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>} 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>} 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<Bean>} 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<void>} + */ + 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<boolean>} The maintenance is active? + */ + async isUnderMaintenance() { + return (await this.getStatus()) === "under-maintenance"; + } + + /** + * Get the timezone of the maintenance + * @returns {Promise<string>} 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<string>} offset + */ + async getTimezoneOffset() { + return dayjs.tz(dayjs(), await this.getTimezone()).format("Z"); + } + + /** + * Get the current status of the maintenance + * @returns {Promise<string>} 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<void>} + */ + 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; |