diff --git a/db/patch-maintenance-cron.sql b/db/patch-maintenance-cron.sql index 55e45ddf5..bc51b881b 100644 --- a/db/patch-maintenance-cron.sql +++ b/db/patch-maintenance-cron.sql @@ -1,6 +1,8 @@ -- You should not modify if this have pushed to Github, unless it does serious wrong with the db. BEGIN TRANSACTION; +DROP TABLE maintenance_timeslot; + -- 999 characters. https://stackoverflow.com/questions/46134830/maximum-length-for-cron-job ALTER TABLE maintenance ADD cron TEXT; ALTER TABLE maintenance ADD timezone VARCHAR(255); diff --git a/package-lock.json b/package-lock.json index 27205042e..0b8bcbf69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "chartjs-adapter-dayjs": "~1.0.0", "concurrently": "^7.1.0", "core-js": "~3.26.1", + "cronstrue": "~2.24.0", "cross-env": "~7.0.3", "cypress": "^10.1.0", "delay": "^5.0.0", @@ -7252,6 +7253,15 @@ "node": ">=6.0" } }, + "node_modules/cronstrue": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.24.0.tgz", + "integrity": "sha512-A1of24mAGz+OWrdGsxT9BOnDqn2ba182hie8Jx0UcEC2t+ZKtfAJxaFntKUgR7sIisU297fgHBSlNhMIfvAkSA==", + "dev": true, + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", diff --git a/package.json b/package.json index e92129258..90e506436 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "chartjs-adapter-dayjs": "~1.0.0", "concurrently": "^7.1.0", "core-js": "~3.26.1", + "cronstrue": "~2.24.0", "cross-env": "~7.0.3", "cypress": "^10.1.0", "delay": "^5.0.0", diff --git a/server/model/maintenance.js b/server/model/maintenance.js index 8f3f322c2..c92f8189d 100644 --- a/server/model/maintenance.js +++ b/server/model/maintenance.js @@ -9,9 +9,6 @@ const apicache = require("../modules/apicache"); class Maintenance extends BeanModel { - static statusList = {}; - static jobList = {}; - /** * Return an object that ready to parse to JSON for public * Only show necessary data to public @@ -47,16 +44,41 @@ class Maintenance extends BeanModel { timeslotList: [], cron: this.cron, duration: this.duration, + durationMinutes: parseInt(this.duration / 60), timezone: await this.getTimezone(), timezoneOffset: await this.getTimezoneOffset(), status: await this.getStatus(), }; - if (this.strategy === "single") { + 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)) { @@ -93,7 +115,7 @@ class Maintenance extends BeanModel { /** * Get a list of days in month that maintenance is active for - * @returns {number[]} Array of active days in month + * @returns {number[]|string[]} Array of active days in month */ getDayOfMonthList() { return JSON.parse(this.days_of_month).sort(function (a, b) { @@ -130,7 +152,6 @@ class Maintenance extends BeanModel { bean.strategy = obj.strategy; bean.interval_day = obj.intervalDay; bean.timezone = obj.timezone; - bean.duration = obj.duration; bean.active = obj.active; if (obj.dateRange[0]) { @@ -141,13 +162,18 @@ class Maintenance extends BeanModel { } } - bean.start_time = parseTimeFromTimeObject(obj.timeRange[0]); - bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]); + if (bean.strategy === "cron") { + bean.duration = obj.durationMinutes * 60; + bean.cron = obj.cron; + } - bean.weekdays = JSON.stringify(obj.weekdays); - bean.days_of_month = JSON.stringify(obj.daysOfMonth); - - await bean.generateCron(); + 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(); + } return bean; } @@ -155,8 +181,8 @@ class Maintenance extends BeanModel { /** * Run the cron */ - async run() { - if (Maintenance.jobList[this.id]) { + async run(throwError = false) { + if (this.beanMeta.job) { log.debug("maintenance", "Maintenance is already running, stop it first. id: " + this.id); this.stop(); } @@ -165,28 +191,106 @@ class Maintenance extends BeanModel { // 1.21.2 migration if (!this.cron) { - //this.generateCron(); - //this.timezone = "UTC"; - // this.duration = + await this.generateCron(); + if (!this.timezone) { + this.timezone = "UTC"; + } if (this.cron) { - //await R.store(this); + await R.store(this); } } - if (this.strategy === "single") { - Maintenance.jobList[this.id] = new Cron(this.start_date, { timezone: await this.getTimezone() }, () => { + 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); + + // Check if duration is still in the window. If not, use the duration from the current time to the end of the window + let duration; + + if (customDuration > 0) { + duration = customDuration; + } else if (this.end_date) { + let d = dayjs(this.end_date).diff(dayjs(), "second"); + if (d < this.duration) { + duration = d * 1000; + } + } else { + duration = this.duration * 1000; + } + + 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 + 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"); + } + } + + getRunningTimeslot() { + let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").format("YYYY-MM-DD HH:mm:ss"))); + 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; + } } stop() { - if (Maintenance.jobList[this.id]) { - Maintenance.jobList[this.id].stop(); - delete Maintenance.jobList[this.id]; + if (this.beanMeta.job) { + this.beanMeta.job.stop(); + delete this.beanMeta.job; } } @@ -228,21 +332,25 @@ class Maintenance extends BeanModel { return "under-maintenance"; } - if (!Maintenance.statusList[this.id]) { - Maintenance.statusList[this.id] = "unknown"; + if (!this.beanMeta.status) { + return "unknown"; } - return Maintenance.statusList[this.id]; - } - - setStatus(status) { - Maintenance.statusList[this.id] = status; + 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 === "recurring-interval") { + if (this.strategy === "cron") { + // Do nothing for cron + } else if (!this.strategy.startsWith("recurring-")) { + this.cron = ""; + } else if (this.strategy === "recurring-interval") { let array = this.start_time.split(":"); let hour = parseInt(array[0]); let minute = parseInt(array[1]); @@ -250,6 +358,37 @@ class Maintenance extends BeanModel { 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(); } } diff --git a/server/model/maintenance_timeslot.js b/server/model/maintenance_timeslot.js deleted file mode 100644 index b86c4c1be..000000000 --- a/server/model/maintenance_timeslot.js +++ /dev/null @@ -1,156 +0,0 @@ -const { BeanModel } = require("redbean-node/dist/bean-model"); -const { R } = require("redbean-node"); -const dayjs = require("dayjs"); -const { log, utcToLocal, SQL_DATETIME_FORMAT_WITHOUT_SECOND, localToUTC } = require("../../src/util"); -const { UptimeKumaServer } = require("../uptime-kuma-server"); - -class MaintenanceTimeslot extends BeanModel { - - /** - * Return an object that ready to parse to JSON for public - * Only show necessary data to public - * @returns {Object} - */ - async toPublicJSON() { - const serverTimezoneOffset = UptimeKumaServer.getInstance().getTimezoneOffset(); - - const obj = { - id: this.id, - startDate: this.start_date, - endDate: this.end_date, - startDateServerTimezone: utcToLocal(this.start_date, SQL_DATETIME_FORMAT_WITHOUT_SECOND), - endDateServerTimezone: utcToLocal(this.end_date, SQL_DATETIME_FORMAT_WITHOUT_SECOND), - serverTimezoneOffset, - }; - - return obj; - } - - /** - * Return an object that ready to parse to JSON - * @returns {Object} - */ - async toJSON() { - return await this.toPublicJSON(); - } - - /** - * @param {Maintenance} maintenance - * @param {dayjs} minDate (For recurring type only) Generate a next timeslot from this date. - * @param {boolean} removeExist Remove existing timeslot before create - * @returns {Promise} - */ - static async generateTimeslot(maintenance, minDate = null, removeExist = false) { - log.info("maintenance", "Generate Timeslot for maintenance id: " + maintenance.id); - - if (removeExist) { - await R.exec("DELETE FROM maintenance_timeslot WHERE maintenance_id = ? ", [ - maintenance.id - ]); - } - - if (maintenance.strategy === "manual") { - log.debug("maintenance", "No need to generate timeslot for manual type"); - - } else if (maintenance.strategy === "single") { - let bean = R.dispense("maintenance_timeslot"); - bean.maintenance_id = maintenance.id; - bean.start_date = maintenance.start_date; - bean.end_date = maintenance.end_date; - bean.generated_next = true; - - if (!await this.isDuplicateTimeslot(bean)) { - await R.store(bean); - return bean; - } else { - log.debug("maintenance", "Duplicate timeslot, skip"); - return null; - } - - } else if (maintenance.strategy === "recurring-interval") { - // Prevent dead loop, in case interval_day is not set - if (!maintenance.interval_day || maintenance.interval_day <= 0) { - maintenance.interval_day = 1; - } - - return await this.handleRecurringType(maintenance, minDate, (startDateTime) => { - return startDateTime.add(maintenance.interval_day, "day"); - }, () => { - return true; - }); - - } else if (maintenance.strategy === "recurring-weekday") { - let dayOfWeekList = maintenance.getDayOfWeekList(); - log.debug("timeslot", dayOfWeekList); - - if (dayOfWeekList.length <= 0) { - log.debug("timeslot", "No weekdays selected?"); - return null; - } - - const isValid = (startDateTime) => { - log.debug("timeslot", "nextDateTime: " + startDateTime); - - let day = startDateTime.local().day(); - log.debug("timeslot", "nextDateTime.day(): " + day); - - return dayOfWeekList.includes(day); - }; - - return await this.handleRecurringType(maintenance, minDate, (startDateTime) => { - while (true) { - startDateTime = startDateTime.add(1, "day"); - - if (isValid(startDateTime)) { - return startDateTime; - } - } - }, isValid); - - } else if (maintenance.strategy === "recurring-day-of-month") { - let dayOfMonthList = maintenance.getDayOfMonthList(); - if (dayOfMonthList.length <= 0) { - log.debug("timeslot", "No day selected?"); - return null; - } - - const isValid = (startDateTime) => { - let day = parseInt(startDateTime.local().format("D")); - - log.debug("timeslot", "day: " + day); - - // Check 1-31 - if (dayOfMonthList.includes(day)) { - return startDateTime; - } - - // Check "lastDay1","lastDay2"... - let daysInMonth = startDateTime.daysInMonth(); - let lastDayList = []; - - // Small first, e.g. 28 > 29 > 30 > 31 - for (let i = 4; i >= 1; i--) { - if (dayOfMonthList.includes("lastDay" + i)) { - lastDayList.push(daysInMonth - i + 1); - } - } - log.debug("timeslot", lastDayList); - return lastDayList.includes(day); - }; - - return await this.handleRecurringType(maintenance, minDate, (startDateTime) => { - while (true) { - startDateTime = startDateTime.add(1, "day"); - if (isValid(startDateTime)) { - return startDateTime; - } - } - }, isValid); - } else { - throw new Error("Unknown maintenance strategy"); - } - } - -} - -module.exports = MaintenanceTimeslot; diff --git a/server/socket-handlers/maintenance-socket-handler.js b/server/socket-handlers/maintenance-socket-handler.js index 2b377f2ff..160a62603 100644 --- a/server/socket-handlers/maintenance-socket-handler.js +++ b/server/socket-handlers/maintenance-socket-handler.js @@ -23,7 +23,7 @@ module.exports.maintenanceSocketHandler = (socket) => { let maintenanceID = await R.store(bean); server.maintenanceList[maintenanceID] = bean; - bean.run(); + await bean.run(true); await server.sendMaintenanceList(socket); @@ -54,7 +54,7 @@ module.exports.maintenanceSocketHandler = (socket) => { await Maintenance.jsonToBean(bean, maintenance); await R.store(bean); - await bean.run(); + await bean.run(true); await server.sendMaintenanceList(socket); callback({ @@ -274,7 +274,6 @@ module.exports.maintenanceSocketHandler = (socket) => { } maintenance.active = false; - maintenance.setStatus("inactive"); await R.store(maintenance); maintenance.stop(); diff --git a/src/components/MaintenanceTime.vue b/src/components/MaintenanceTime.vue index 6da21fe01..9a7980315 100644 --- a/src/components/MaintenanceTime.vue +++ b/src/components/MaintenanceTime.vue @@ -3,16 +3,23 @@
{{ $t("Manual") }}
-
- {{ maintenance.timeslotList[0].startDate }} - - - {{ maintenance.timeslotList[0].endDate }} - (UTC{{ maintenance.timezoneOffset }}) +
+
+ {{ startDateTime }} + - + {{ endDateTime }} +
+
+ UTC{{ maintenance.timezoneOffset }} {{ maintenance.timezone }} +
@@ -31,6 +46,7 @@ export default { background-color: rgba(255, 255, 255, 0.5); border-radius: 20px; padding: 0 10px; + margin-right: 5px; .to { margin: 0 6px; diff --git a/src/lang/en.json b/src/lang/en.json index cf7185a89..d09e4882c 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -433,7 +433,7 @@ "dnsCacheDescription": "It may be not working in some IPv6 environments, disable it if you encounter any issues.", "Single Maintenance Window": "Single Maintenance Window", "Maintenance Time Window of a Day": "Maintenance Time Window of a Day", - "Effective Date Range": "Effective Date Range", + "Effective Date Range": "Effective Date Range (Optional)", "Schedule Maintenance": "Schedule Maintenance", "Date and Time": "Date and Time", "DateTime Range": "DateTime Range", diff --git a/src/pages/EditMaintenance.vue b/src/pages/EditMaintenance.vue index be2abe8cb..1f59305c0 100644 --- a/src/pages/EditMaintenance.vue +++ b/src/pages/EditMaintenance.vue @@ -95,7 +95,6 @@ - @@ -103,6 +102,25 @@ + +