diff --git a/db/patch-maintenance-cron.sql b/db/patch-maintenance-cron.sql new file mode 100644 index 000000000..bc51b881b --- /dev/null +++ b/db/patch-maintenance-cron.sql @@ -0,0 +1,11 @@ +-- 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); +ALTER TABLE maintenance ADD duration INTEGER; + +COMMIT; diff --git a/package-lock.json b/package-lock.json index d198105ec..0b8bcbf69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "command-exists": "~1.2.9", "compare-versions": "~3.6.0", "compression": "~1.7.4", + "croner": "^6.0.3", "dayjs": "~1.11.5", "dotenv": "~16.0.3", "express": "~4.17.3", @@ -87,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", @@ -7243,6 +7245,23 @@ "yup": "0.32.9" } }, + "node_modules/croner": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/croner/-/croner-6.0.3.tgz", + "integrity": "sha512-Go+s9AaI+MeZUDJ6Kp7OYXCbM3svJ0qZ3IpkGoPetZLnP5wpX8MBTEiJOTYDFokP0Ph85GFZEUTBL9fo1e4DtQ==", + "engines": { + "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 a447b6a59..90e506436 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "command-exists": "~1.2.9", "compare-versions": "~3.6.0", "compression": "~1.7.4", + "croner": "^6.0.3", "dayjs": "~1.11.5", "dotenv": "~16.0.3", "express": "~4.17.3", @@ -146,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/database.js b/server/database.js index e52ae8bfc..b678714e2 100644 --- a/server/database.js +++ b/server/database.js @@ -74,6 +74,7 @@ class Database { "patch-add-description-monitor.sql": true, "patch-api-key-table.sql": true, "patch-monitor-tls.sql": true, + "patch-maintenance-cron.sql": true, }; /** diff --git a/server/model/maintenance.js b/server/model/maintenance.js index 45db63d13..189a513aa 100644 --- a/server/model/maintenance.js +++ b/server/model/maintenance.js @@ -1,8 +1,10 @@ const { BeanModel } = require("redbean-node/dist/bean-model"); -const { parseTimeObject, parseTimeFromTimeObject, utcToLocal, localToUTC, log } = require("../../src/util"); -const { timeObjectToUTC, timeObjectToLocal } = require("../util-server"); +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 { @@ -15,16 +17,16 @@ class Maintenance extends BeanModel { let dateRange = []; if (this.start_date) { - dateRange.push(utcToLocal(this.start_date)); + dateRange.push(this.start_date); if (this.end_date) { - dateRange.push(utcToLocal(this.end_date)); + dateRange.push(this.end_date); } } let timeRange = []; - let startTime = timeObjectToLocal(parseTimeObject(this.start_time)); + let startTime = parseTimeObject(this.start_time); timeRange.push(startTime); - let endTime = timeObjectToLocal(parseTimeObject(this.end_time)); + let endTime = parseTimeObject(this.end_time); timeRange.push(endTime); let obj = { @@ -39,12 +41,43 @@ class Maintenance extends BeanModel { 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(), + timezoneOffset: await this.getTimezoneOffset(), + status: await this.getStatus(), }; - const timeslotList = await this.getTimeslotList(); + 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(); - for (let timeslot of timeslotList) { - obj.timeslotList.push(await timeslot.toPublicJSON()); + 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)) { @@ -55,54 +88,9 @@ class Maintenance extends BeanModel { obj.daysOfMonth = []; } - // Maintenance Status - if (!obj.active) { - obj.status = "inactive"; - } else if (obj.strategy === "manual") { - obj.status = "under-maintenance"; - } else if (obj.timeslotList.length > 0) { - let currentTimestamp = dayjs().unix(); - - for (let timeslot of obj.timeslotList) { - if (dayjs.utc(timeslot.startDate).unix() <= currentTimestamp && dayjs.utc(timeslot.endDate).unix() >= currentTimestamp) { - log.debug("timeslot", "Timeslot ID: " + timeslot.id); - log.debug("timeslot", "currentTimestamp:" + currentTimestamp); - log.debug("timeslot", "timeslot.start_date:" + dayjs.utc(timeslot.startDate).unix()); - log.debug("timeslot", "timeslot.end_date:" + dayjs.utc(timeslot.endDate).unix()); - - obj.status = "under-maintenance"; - break; - } - } - - if (!obj.status) { - obj.status = "scheduled"; - } - } else if (obj.timeslotList.length === 0) { - obj.status = "ended"; - } else { - obj.status = "unknown"; - } - return obj; } - /** - * Only get future or current timeslots only - * @returns {Promise<[]>} - */ - async getTimeslotList() { - return R.convertToBeans("maintenance_timeslot", await R.getAll(` - SELECT maintenance_timeslot.* - FROM maintenance_timeslot, maintenance - WHERE maintenance_timeslot.maintenance_id = maintenance.id - AND maintenance.id = ? - AND ${Maintenance.getActiveAndFutureMaintenanceSQLCondition()} - `, [ - this.id - ])); - } - /** * Return an object that ready to parse to JSON * @param {string} timezone If not specified, the timeRange will be in UTC @@ -126,7 +114,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) { @@ -135,26 +123,10 @@ class Maintenance extends BeanModel { } /** - * Get the start date and time for maintenance - * @returns {dayjs.Dayjs} Start date and time - */ - getStartDateTime() { - let startOfTheDay = dayjs.utc(this.start_date).format("HH:mm"); - log.debug("timeslot", "startOfTheDay: " + startOfTheDay); - - // Start Time - let startTimeSecond = dayjs.utc(this.start_time, "HH:mm").diff(dayjs.utc(startOfTheDay, "HH:mm"), "second"); - log.debug("timeslot", "startTime: " + startTimeSecond); - - // Bake StartDate + StartTime = Start DateTime - return dayjs.utc(this.start_date).add(startTimeSecond, "second"); - } - - /** - * Get the duraction of maintenance in seconds + * Get the duration of maintenance in seconds * @returns {number} Duration of maintenance */ - getDuration() { + 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) { @@ -169,71 +141,255 @@ class Maintenance extends BeanModel { * @param {Object} obj Data to fill bean with * @returns {Bean} Filled bean */ - static jsonToBean(bean, obj) { + static async jsonToBean(bean, obj) { if (obj.id) { bean.id = obj.id; } - // Apply timezone offset to timeRange, as it cannot apply automatically. - if (obj.timeRange[0]) { - timeObjectToUTC(obj.timeRange[0]); - if (obj.timeRange[1]) { - timeObjectToUTC(obj.timeRange[1]); - } - } - bean.title = obj.title; bean.description = obj.description; bean.strategy = obj.strategy; bean.interval_day = obj.intervalDay; + bean.timezone = obj.timezone; bean.active = obj.active; if (obj.dateRange[0]) { - bean.start_date = localToUTC(obj.dateRange[0]); + bean.start_date = obj.dateRange[0]; if (obj.dateRange[1]) { - bean.end_date = localToUTC(obj.dateRange[1]); + bean.end_date = obj.dateRange[1]; } } - 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); + 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; } /** - * SQL conditions for active maintenance - * @returns {string} + * Run the cron */ - static getActiveMaintenanceSQLCondition() { - return ` - ( - (maintenance_timeslot.start_date <= DATETIME('now') - AND maintenance_timeslot.end_date >= DATETIME('now') - AND maintenance.active = 1) - OR - (maintenance.strategy = 'manual' AND active = 1) - ) - `; + 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); + + // 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 (this.beanMeta.job) { + this.beanMeta.job.stop(); + delete this.beanMeta.job; + } + } + + async isUnderMaintenance() { + return (await this.getStatus()) === "under-maintenance"; + } + + async getTimezone() { + if (!this.timezone) { + return await UptimeKumaServer.getInstance().getTimezone(); + } + return this.timezone; + } + + async getTimezoneOffset() { + return dayjs.tz(dayjs(), await this.getTimezone()).format("Z"); + } + + 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; } /** - * SQL conditions for active and future maintenance - * @returns {string} + * Generate Cron for recurring maintenance + * @returns {Promise} */ - static getActiveAndFutureMaintenanceSQLCondition() { - return ` - ( - ((maintenance_timeslot.end_date >= DATETIME('now') - AND maintenance.active = 1) - OR - (maintenance.strategy = 'manual' AND active = 1)) - ) - `; + 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") { + let array = this.start_time.split(":"); + let hour = parseInt(array[0]); + let minute = parseInt(array[1]); + this.cron = minute + " " + hour + " */" + this.interval_day + " * *"; + 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 dad719c74..000000000 --- a/server/model/maintenance_timeslot.js +++ /dev/null @@ -1,223 +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"); - } - } - - static async isDuplicateTimeslot(timeslot) { - let bean = await R.findOne("maintenance_timeslot", "maintenance_id = ? AND start_date = ? AND end_date = ?", [ - timeslot.maintenance_id, - timeslot.start_date, - timeslot.end_date - ]); - return bean !== null; - } - - /** - * Generate a next timeslot for all recurring types - * @param maintenance - * @param minDate - * @param {function} nextDayCallback The logic how to get the next possible day - * @param {function} isValidCallback Check the day whether is matched the current strategy - * @returns {Promise} - */ - static async handleRecurringType(maintenance, minDate, nextDayCallback, isValidCallback) { - let bean = R.dispense("maintenance_timeslot"); - - let duration = maintenance.getDuration(); - let startDateTime = maintenance.getStartDateTime(); - let endDateTime; - - // Keep generating from the first possible date, until it is ok - while (true) { - //log.debug("timeslot", "startDateTime: " + startDateTime.format()); - - // Handling out of effective date range - if (startDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) { - log.debug("timeslot", "Out of effective date range"); - return null; - } - - endDateTime = startDateTime.add(duration, "second"); - - // If endDateTime is out of effective date range, use the end datetime from effective date range - if (endDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) { - endDateTime = dayjs.utc(maintenance.end_date); - } - - // If minDate is set, the endDateTime must be bigger than it. - // And the endDateTime must be bigger current time - // Is valid under current recurring strategy - if ( - (!minDate || endDateTime.diff(minDate) > 0) && - endDateTime.diff(dayjs()) > 0 && - isValidCallback(startDateTime) - ) { - break; - } - startDateTime = nextDayCallback(startDateTime); - } - - bean.maintenance_id = maintenance.id; - bean.start_date = localToUTC(startDateTime); - bean.end_date = localToUTC(endDateTime); - bean.generated_next = false; - - if (!await this.isDuplicateTimeslot(bean)) { - await R.store(bean); - return bean; - } else { - log.debug("maintenance", "Duplicate timeslot, skip"); - return null; - } - } -} - -module.exports = MaintenanceTimeslot; diff --git a/server/model/monitor.js b/server/model/monitor.js index 44460819e..b4a0ba2a3 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -16,7 +16,6 @@ const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent"); const { DockerHost } = require("../docker"); -const Maintenance = require("./maintenance"); const { UptimeCacheList } = require("../uptime-cache-list"); const Gamedig = require("gamedig"); @@ -1303,18 +1302,19 @@ class Monitor extends BeanModel { * @returns {Promise} */ static async isUnderMaintenance(monitorID) { - let activeCondition = Maintenance.getActiveMaintenanceSQLCondition(); - const maintenance = await R.getRow(` - SELECT COUNT(*) AS count - FROM monitor_maintenance mm - JOIN maintenance - ON mm.maintenance_id = maintenance.id - AND mm.monitor_id = ? - LEFT JOIN maintenance_timeslot - ON maintenance_timeslot.maintenance_id = maintenance.id - WHERE ${activeCondition} - LIMIT 1`, [ monitorID ]); - return maintenance.count !== 0; + const maintenanceIDList = await R.getCol(` + SELECT maintenance_id FROM monitor_maintenance + WHERE monitor_id = ? + `, [ monitorID ]); + + for (const maintenanceID of maintenanceIDList) { + const maintenance = await UptimeKumaServer.getInstance().getMaintenance(maintenanceID); + if (maintenance && await maintenance.isUnderMaintenance()) { + return true; + } + } + + return false; } /** Make sure monitor interval is between bounds */ diff --git a/server/model/status_page.js b/server/model/status_page.js index 84af99e88..65b77367e 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -3,7 +3,6 @@ const { R } = require("redbean-node"); const cheerio = require("cheerio"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const jsesc = require("jsesc"); -const Maintenance = require("./maintenance"); const googleAnalytics = require("../google-analytics"); class StatusPage extends BeanModel { @@ -290,21 +289,17 @@ class StatusPage extends BeanModel { try { const publicMaintenanceList = []; - let activeCondition = Maintenance.getActiveMaintenanceSQLCondition(); - let maintenanceBeanList = R.convertToBeans("maintenance", await R.getAll(` - SELECT DISTINCT maintenance.* - FROM maintenance - JOIN maintenance_status_page - ON maintenance_status_page.maintenance_id = maintenance.id - AND maintenance_status_page.status_page_id = ? - LEFT JOIN maintenance_timeslot - ON maintenance_timeslot.maintenance_id = maintenance.id - WHERE ${activeCondition} - ORDER BY maintenance.end_date - `, [ statusPageId ])); + let maintenanceIDList = await R.getCol(` + SELECT DISTINCT maintenance_id + FROM maintenance_status_page + WHERE status_page_id = ? + `, [ statusPageId ]); - for (const bean of maintenanceBeanList) { - publicMaintenanceList.push(await bean.toPublicJSON()); + for (const maintenanceID of maintenanceIDList) { + let maintenance = UptimeKumaServer.getInstance().getMaintenance(maintenanceID); + if (maintenance && await maintenance.isUnderMaintenance()) { + publicMaintenanceList.push(await maintenance.toPublicJSON()); + } } return publicMaintenanceList; diff --git a/server/socket-handlers/maintenance-socket-handler.js b/server/socket-handlers/maintenance-socket-handler.js index 929150cdd..160a62603 100644 --- a/server/socket-handlers/maintenance-socket-handler.js +++ b/server/socket-handlers/maintenance-socket-handler.js @@ -5,7 +5,6 @@ const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const Maintenance = require("../model/maintenance"); const server = UptimeKumaServer.getInstance(); -const MaintenanceTimeslot = require("../model/maintenance_timeslot"); /** * Handlers for Maintenance @@ -19,10 +18,12 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", maintenance); - let bean = Maintenance.jsonToBean(R.dispense("maintenance"), maintenance); + let bean = await Maintenance.jsonToBean(R.dispense("maintenance"), maintenance); bean.user_id = socket.userID; let maintenanceID = await R.store(bean); - await MaintenanceTimeslot.generateTimeslot(bean); + + server.maintenanceList[maintenanceID] = bean; + await bean.run(true); await server.sendMaintenanceList(socket); @@ -45,17 +46,15 @@ module.exports.maintenanceSocketHandler = (socket) => { try { checkLogin(socket); - let bean = await R.findOne("maintenance", " id = ? ", [ maintenance.id ]); + let bean = server.getMaintenance(maintenance.id); if (bean.user_id !== socket.userID) { throw new Error("Permission denied."); } - Maintenance.jsonToBean(bean, maintenance); - + await Maintenance.jsonToBean(bean, maintenance); await R.store(bean); - await MaintenanceTimeslot.generateTimeslot(bean, null, true); - + await bean.run(true); await server.sendMaintenanceList(socket); callback({ @@ -236,6 +235,7 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", `Delete Maintenance: ${maintenanceID} User ID: ${socket.userID}`); if (maintenanceID in server.maintenanceList) { + server.maintenanceList[maintenanceID].stop(); delete server.maintenanceList[maintenanceID]; } @@ -267,9 +267,15 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", `Pause Maintenance: ${maintenanceID} User ID: ${socket.userID}`); - await R.exec("UPDATE maintenance SET active = 0 WHERE id = ? ", [ - maintenanceID, - ]); + let maintenance = server.getMaintenance(maintenanceID); + + if (!maintenance) { + throw new Error("Maintenance not found"); + } + + maintenance.active = false; + await R.store(maintenance); + maintenance.stop(); apicache.clear(); @@ -294,9 +300,15 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", `Resume Maintenance: ${maintenanceID} User ID: ${socket.userID}`); - await R.exec("UPDATE maintenance SET active = 1 WHERE id = ? ", [ - maintenanceID, - ]); + let maintenance = server.getMaintenance(maintenanceID); + + if (!maintenance) { + throw new Error("Maintenance not found"); + } + + maintenance.active = true; + await R.store(maintenance); + await maintenance.run(); apicache.clear(); diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index d28f00a92..914e12e48 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -47,8 +47,6 @@ class UptimeKumaServer { */ indexHTML = ""; - generateMaintenanceTimeslotsInterval = undefined; - /** * Plugins Manager * @type {PluginsManager} @@ -112,8 +110,7 @@ class UptimeKumaServer { log.debug("DEBUG", "Timezone: " + process.env.TZ); log.debug("DEBUG", "Current Time: " + dayjs.tz().format()); - await this.generateMaintenanceTimeslots(); - this.generateMaintenanceTimeslotsInterval = setInterval(this.generateMaintenanceTimeslots, 60 * 1000); + await this.loadMaintenanceList(); } /** @@ -175,16 +172,33 @@ class UptimeKumaServer { */ async getMaintenanceJSONList(userID) { let result = {}; + for (let maintenanceID in this.maintenanceList) { + result[maintenanceID] = await this.maintenanceList[maintenanceID].toJSON(); + } + return result; + } + + /** + * Load maintenance list and run + * @param userID + * @returns {Promise} + */ + async loadMaintenanceList(userID) { + let maintenanceList = await R.findAll("maintenance", " ORDER BY end_date DESC, title", [ - let maintenanceList = await R.find("maintenance", " user_id = ? ORDER BY end_date DESC, title", [ - userID, ]); for (let maintenance of maintenanceList) { - result[maintenance.id] = await maintenance.toJSON(); + this.maintenanceList[maintenance.id] = maintenance; + maintenance.run(this); } + } - return result; + getMaintenance(maintenanceID) { + if (this.maintenanceList[maintenanceID]) { + return this.maintenanceList[maintenanceID]; + } + return null; } /** @@ -240,7 +254,7 @@ class UptimeKumaServer { * Attempt to get the current server timezone * If this fails, fall back to environment variables and then make a * guess. - * @returns {string} + * @returns {Promise} */ async getTimezone() { let timezone = await Settings.get("serverTimezone"); @@ -271,28 +285,9 @@ class UptimeKumaServer { dayjs.tz.setDefault(timezone); } - /** Load the timeslots for maintenance */ - async generateMaintenanceTimeslots() { - log.debug("maintenance", "Routine: Generating Maintenance Timeslots"); - - // Prevent #2776 - // Remove duplicate maintenance_timeslot with same start_date, end_date and maintenance_id - await R.exec("DELETE FROM maintenance_timeslot WHERE id NOT IN (SELECT MIN(id) FROM maintenance_timeslot GROUP BY start_date, end_date, maintenance_id)"); - - let list = await R.find("maintenance_timeslot", " generated_next = 0 AND start_date <= DATETIME('now') "); - - for (let maintenanceTimeslot of list) { - let maintenance = await maintenanceTimeslot.maintenance; - await MaintenanceTimeslot.generateTimeslot(maintenance, maintenanceTimeslot.end_date, false); - maintenanceTimeslot.generated_next = true; - await R.store(maintenanceTimeslot); - } - - } - /** Stop the server */ async stop() { - clearTimeout(this.generateMaintenanceTimeslotsInterval); + } loadPlugins() { @@ -341,5 +336,4 @@ module.exports = { }; // Must be at the end -const MaintenanceTimeslot = require("./model/maintenance_timeslot"); const { MonitorType } = require("./monitor-types/monitor-type"); diff --git a/src/assets/app.scss b/src/assets/app.scss index f550406fd..d233e8d98 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -556,6 +556,31 @@ h5.settings-subheading::after { border-bottom: 1px solid $dark-border-color; } + +$shadow-box-padding: 20px; + +.shadow-box-with-fixed-bottom-bar { + padding-top: $shadow-box-padding; + padding-bottom: 0; + padding-right: $shadow-box-padding; + padding-left: $shadow-box-padding; +} + +.fixed-bottom-bar { + position: sticky; + bottom: 0; + margin-left: -$shadow-box-padding; + margin-right: -$shadow-box-padding; + z-index: 100; + background-color: rgba(white, 0.2); + backdrop-filter: blur(2px); + border-radius: 0 0 10px 10px; + + .dark & { + background-color: rgba($dark-header-bg, 0.9); + } +} + // Localization @import "localization.scss"; diff --git a/src/components/MaintenanceTime.vue b/src/components/MaintenanceTime.vue index 07d657400..ba8855534 100644 --- a/src/components/MaintenanceTime.vue +++ b/src/components/MaintenanceTime.vue @@ -3,16 +3,23 @@
{{ $t("Manual") }}
-
- {{ maintenance.timeslotList[0].startDateServerTimezone }} - - - {{ maintenance.timeslotList[0].endDateServerTimezone }} - (UTC{{ maintenance.timeslotList[0].serverTimezoneOffset }}) +
+
+ {{ 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 e7656c474..4a2fe8813 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -394,6 +394,12 @@ "backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.", "Optional": "Optional", "or": "or", + "sameAsServerTimezone": "Same as Server Timezone", + "startDateTime": "Start Date/Time", + "endDateTime": "End Date/Time", + "cronExpression": "Cron Expression", + "cronSchedule": "Schedule: ", + "invalidCronExpression": "Invalid Cron Expression: {0}", "recurringInterval": "Interval", "Recurring": "Recurring", "strategyManual": "Active/Inactive Manually", @@ -429,7 +435,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 00e649381..eb9ef7fd7 100644 --- a/src/pages/EditMaintenance.vue +++ b/src/pages/EditMaintenance.vue @@ -3,7 +3,7 @@

{{ pageName }}

-
+
@@ -85,35 +85,39 @@

{{ $t("Date and Time") }}

-
⚠️ {{ $t("warningTimezone") }}: {{ $root.info.serverTimezone }} ({{ $root.info.serverTimezoneOffset }})
-
+ + @@ -180,7 +184,6 @@
- + + - -
- -
+ +
+ +
@@ -226,11 +246,12 @@ diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index b202be305..defa458e6 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -923,7 +923,11 @@ export default { * @returns {string} Sanitized HTML */ maintenanceHTML(description) { - return DOMPurify.sanitize(marked(description)); + if (description) { + return DOMPurify.sanitize(marked(description)); + } else { + return ""; + } }, }