summaryrefslogtreecommitdiffstats
path: root/server/model/maintenance.js
diff options
context:
space:
mode:
Diffstat (limited to 'server/model/maintenance.js')
-rw-r--r--server/model/maintenance.js457
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;