mirror of
				https://github.com/louislam/uptime-kuma.git
				synced 2025-10-31 19:39:20 +08:00 
			
		
		
		
	Merge pull request #3003 from louislam/maintenance-fix
Maintenance Core Bug Fix & Improvements
This commit is contained in:
		
							
								
								
									
										11
									
								
								db/patch-maintenance-cron.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								db/patch-maintenance-cron.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										19
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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, | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -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<void>} | ||||
|      */ | ||||
|     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(); | ||||
|         } | ||||
|  | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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<MaintenanceTimeslot>} | ||||
|      */ | ||||
|     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<null|MaintenanceTimeslot>} | ||||
|      */ | ||||
|     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; | ||||
| @@ -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<boolean>} | ||||
|      */ | ||||
|     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 */ | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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(); | ||||
|  | ||||
|   | ||||
| @@ -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<void>} | ||||
|      */ | ||||
|     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<string>} | ||||
|      */ | ||||
|     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"); | ||||
|   | ||||
| @@ -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"; | ||||
|   | ||||
| @@ -3,16 +3,23 @@ | ||||
|         <div v-if="maintenance.strategy === 'manual'" class="timeslot"> | ||||
|             {{ $t("Manual") }} | ||||
|         </div> | ||||
|         <div v-else-if="maintenance.timeslotList.length > 0" class="timeslot"> | ||||
|             {{ maintenance.timeslotList[0].startDateServerTimezone }} | ||||
|             <span class="to">-</span> | ||||
|             {{ maintenance.timeslotList[0].endDateServerTimezone }} | ||||
|             (UTC{{ maintenance.timeslotList[0].serverTimezoneOffset }}) | ||||
|         <div v-else-if="maintenance.timeslotList.length > 0"> | ||||
|             <div class="timeslot"> | ||||
|                 {{ startDateTime }} | ||||
|                 <span class="to">-</span> | ||||
|                 {{ endDateTime }} | ||||
|             </div> | ||||
|             <div class="timeslot"> | ||||
|                 UTC{{ maintenance.timezoneOffset }} <span v-if="maintenance.timezone !== 'UTC'">{{ maintenance.timezone }}</span> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import dayjs from "dayjs"; | ||||
| import { SQL_DATETIME_FORMAT_WITHOUT_SECOND } from "../util.ts"; | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         maintenance: { | ||||
| @@ -20,6 +27,14 @@ export default { | ||||
|             required: true | ||||
|         }, | ||||
|     }, | ||||
|     computed: { | ||||
|         startDateTime() { | ||||
|             return dayjs(this.maintenance.timeslotList[0].startDate).tz(this.maintenance.timezone).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND); | ||||
|         }, | ||||
|         endDateTime() { | ||||
|             return dayjs(this.maintenance.timeslotList[0].endDate).tz(this.maintenance.timezone).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND); | ||||
|         } | ||||
|     }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|         <div> | ||||
|             <h1 class="mb-3">{{ pageName }}</h1> | ||||
|             <form @submit.prevent="submit"> | ||||
|                 <div class="shadow-box"> | ||||
|                 <div class="shadow-box shadow-box-with-fixed-bottom-bar"> | ||||
|                     <div class="row"> | ||||
|                         <div class="col-xl-10"> | ||||
|                             <!-- Title --> | ||||
| @@ -85,35 +85,39 @@ | ||||
|  | ||||
|                             <h2 class="mt-5">{{ $t("Date and Time") }}</h2> | ||||
|  | ||||
|                             <div>⚠️ {{ $t("warningTimezone") }}: <mark>{{ $root.info.serverTimezone }} ({{ $root.info.serverTimezoneOffset }})</mark></div> | ||||
|  | ||||
|                             <!-- Strategy --> | ||||
|                             <div class="my-3"> | ||||
|                                 <label for="strategy" class="form-label">{{ $t("Strategy") }}</label> | ||||
|                                 <select id="strategy" v-model="maintenance.strategy" class="form-select"> | ||||
|                                     <option value="manual">{{ $t("strategyManual") }}</option> | ||||
|                                     <option value="single">{{ $t("Single Maintenance Window") }}</option> | ||||
|                                     <option value="cron">{{ $t("cronExpression") }}</option> | ||||
|                                     <option value="recurring-interval">{{ $t("Recurring") }} - {{ $t("recurringInterval") }}</option> | ||||
|                                     <option value="recurring-weekday">{{ $t("Recurring") }} - {{ $t("dayOfWeek") }}</option> | ||||
|                                     <option value="recurring-day-of-month">{{ $t("Recurring") }} - {{ $t("dayOfMonth") }}</option> | ||||
|                                     <option v-if="false" value="recurring-day-of-year">{{ $t("Recurring") }} - Day of Year</option> | ||||
|                                 </select> | ||||
|                             </div> | ||||
|  | ||||
|                             <!-- Single Maintenance Window --> | ||||
|                             <template v-if="maintenance.strategy === 'single'"> | ||||
|                                 <!-- DateTime Range --> | ||||
|                             </template> | ||||
|  | ||||
|                             <template v-if="maintenance.strategy === 'cron'"> | ||||
|                                 <!-- Cron --> | ||||
|                                 <div class="my-3"> | ||||
|                                     <label class="form-label">{{ $t("DateTime Range") }}</label> | ||||
|                                     <Datepicker | ||||
|                                         v-model="maintenance.dateRange" | ||||
|                                         :dark="$root.isDark" | ||||
|                                         range | ||||
|                                         :monthChangeOnScroll="false" | ||||
|                                         :minDate="minDate" | ||||
|                                         format="yyyy-MM-dd HH:mm" | ||||
|                                         modelType="yyyy-MM-dd HH:mm:ss" | ||||
|                                     /> | ||||
|                                     <label for="cron" class="form-label"> | ||||
|                                         {{ $t("cronExpression") }} | ||||
|                                     </label> | ||||
|                                     <p>{{ $t("cronSchedule") }}{{ cronDescription }}</p> | ||||
|                                     <input id="cron" v-model="maintenance.cron" type="text" class="form-control" required> | ||||
|                                 </div> | ||||
|  | ||||
|                                 <div class="my-3"> | ||||
|                                     <!-- Duration --> | ||||
|                                     <label for="duration" class="form-label"> | ||||
|                                         {{ $t("Duration (Minutes)") }} | ||||
|                                     </label> | ||||
|                                     <input id="duration" v-model="maintenance.durationMinutes" type="number" class="form-control" required min="1" step="1"> | ||||
|                                 </div> | ||||
|                             </template> | ||||
|  | ||||
| @@ -180,7 +184,6 @@ | ||||
|                                 </div> | ||||
|                             </template> | ||||
|  | ||||
|                             <!-- For any recurring types --> | ||||
|                             <template v-if="maintenance.strategy === 'recurring-interval' || maintenance.strategy === 'recurring-weekday' || maintenance.strategy === 'recurring-day-of-month'"> | ||||
|                                 <!-- Maintenance Time Window of a Day --> | ||||
|                                 <div class="my-3"> | ||||
| @@ -192,33 +195,50 @@ | ||||
|                                         disableTimeRangeValidation range | ||||
|                                     /> | ||||
|                                 </div> | ||||
|                             </template> | ||||
|  | ||||
|                             <template v-if="maintenance.strategy === 'recurring-interval' || maintenance.strategy === 'recurring-weekday' || maintenance.strategy === 'recurring-day-of-month' || maintenance.strategy === 'cron' || maintenance.strategy === 'single'"> | ||||
|                                 <!-- Timezone --> | ||||
|                                 <div class="mb-4"> | ||||
|                                     <label for="timezone" class="form-label"> | ||||
|                                         {{ $t("Timezone") }} | ||||
|                                     </label> | ||||
|                                     <select id="timezone" v-model="maintenance.timezone" class="form-select"> | ||||
|                                         <option :value="null">{{ $t("sameAsServerTimezone") }}</option> | ||||
|                                         <option value="UTC">UTC</option> | ||||
|                                         <option | ||||
|                                             v-for="(timezone, index) in timezoneList" | ||||
|                                             :key="index" | ||||
|                                             :value="timezone.value" | ||||
|                                         > | ||||
|                                             {{ timezone.name }} | ||||
|                                         </option> | ||||
|                                     </select> | ||||
|                                 </div> | ||||
|  | ||||
|                                 <!-- Date Range --> | ||||
|                                 <div class="my-3"> | ||||
|                                     <label class="form-label">{{ $t("Effective Date Range") }}</label> | ||||
|                                     <Datepicker | ||||
|                                         v-model="maintenance.dateRange" | ||||
|                                         :dark="$root.isDark" | ||||
|                                         range datePicker | ||||
|                                         :monthChangeOnScroll="false" | ||||
|                                         :minDate="minDate" | ||||
|                                         format="yyyy-MM-dd HH:mm:ss" | ||||
|                                         modelType="yyyy-MM-dd HH:mm:ss" | ||||
|                                         required | ||||
|                                     /> | ||||
|  | ||||
|                                     <div class="row"> | ||||
|                                         <div class="col"> | ||||
|                                             <div class="mb-2">{{ $t("startDateTime") }}</div> | ||||
|                                             <input v-model="maintenance.dateRange[0]" type="datetime-local" class="form-control"> | ||||
|                                         </div> | ||||
|  | ||||
|                                         <div class="col"> | ||||
|                                             <div class="mb-2">{{ $t("endDateTime") }}</div> | ||||
|                                             <input v-model="maintenance.dateRange[1]" type="datetime-local" class="form-control"> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </template> | ||||
|  | ||||
|                             <div class="mt-4 mb-1"> | ||||
|                                 <button | ||||
|                                     id="monitor-submit-btn" class="btn btn-primary" type="submit" | ||||
|                                     :disabled="processing" | ||||
|                                 > | ||||
|                                     {{ $t("Save") }} | ||||
|                                 </button> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="fixed-bottom-bar p-3"> | ||||
|                         <button id="monitor-submit-btn" class="btn btn-primary" type="submit" :disabled="processing">{{ $t("Save") }}</button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </form> | ||||
|         </div> | ||||
| @@ -226,11 +246,12 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
|  | ||||
| import { useToast } from "vue-toastification"; | ||||
| import VueMultiselect from "vue-multiselect"; | ||||
| import dayjs from "dayjs"; | ||||
| import Datepicker from "@vuepic/vue-datepicker"; | ||||
| import { timezoneList } from "../util-frontend"; | ||||
| import cronstrue from "cronstrue/i18n"; | ||||
|  | ||||
| const toast = useToast(); | ||||
|  | ||||
| @@ -242,6 +263,7 @@ export default { | ||||
|  | ||||
|     data() { | ||||
|         return { | ||||
|             timezoneList: timezoneList(), | ||||
|             processing: false, | ||||
|             maintenance: {}, | ||||
|             affectedMonitors: [], | ||||
| @@ -256,18 +278,6 @@ export default { | ||||
|                     langKey: "lastDay1", | ||||
|                     value: "lastDay1", | ||||
|                 }, | ||||
|                 { | ||||
|                     langKey: "lastDay2", | ||||
|                     value: "lastDay2", | ||||
|                 }, | ||||
|                 { | ||||
|                     langKey: "lastDay3", | ||||
|                     value: "lastDay3", | ||||
|                 }, | ||||
|                 { | ||||
|                     langKey: "lastDay4", | ||||
|                     value: "lastDay4", | ||||
|                 } | ||||
|             ], | ||||
|             weekdays: [ | ||||
|                 { | ||||
| @@ -311,6 +321,34 @@ export default { | ||||
|  | ||||
|     computed: { | ||||
|  | ||||
|         cronDescription() { | ||||
|             if (! this.maintenance.cron) { | ||||
|                 return ""; | ||||
|             } | ||||
|  | ||||
|             let locale = ""; | ||||
|  | ||||
|             if (this.$root.language) { | ||||
|                 locale = this.$root.language.replace("-", "_"); | ||||
|             } | ||||
|  | ||||
|             // Special handling | ||||
|             // If locale is also not working in your language, you can map it here | ||||
|             // https://github.com/bradymholt/cRonstrue/tree/master/src/i18n/locales | ||||
|             if (locale === "zh_HK") { | ||||
|                 locale = "zh_TW"; | ||||
|             } | ||||
|  | ||||
|             try { | ||||
|                 return cronstrue.toString(this.maintenance.cron, { | ||||
|                     locale, | ||||
|                 }); | ||||
|             } catch (e) { | ||||
|                 return this.$t("invalidCronExpression", e.message); | ||||
|             } | ||||
|  | ||||
|         }, | ||||
|  | ||||
|         selectedStatusPagesOptions() { | ||||
|             return Object.values(this.$root.statusPageList).map(statusPage => { | ||||
|                 return { | ||||
| @@ -370,6 +408,8 @@ export default { | ||||
|                     description: "", | ||||
|                     strategy: "single", | ||||
|                     active: 1, | ||||
|                     cron: "30 3 * * *", | ||||
|                     durationMinutes: 60, | ||||
|                     intervalDay: 1, | ||||
|                     dateRange: [ this.minDate ], | ||||
|                     timeRange: [{ | ||||
| @@ -381,6 +421,7 @@ export default { | ||||
|                     }], | ||||
|                     weekdays: [], | ||||
|                     daysOfMonth: [], | ||||
|                     timezone: null, | ||||
|                 }; | ||||
|             } else if (this.isEdit) { | ||||
|                 this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => { | ||||
| @@ -501,10 +542,6 @@ export default { | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .shadow-box { | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| textarea { | ||||
|     min-height: 150px; | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|         <div> | ||||
|             <h1 class="mb-3">{{ pageName }}</h1> | ||||
|             <form @submit.prevent="submit"> | ||||
|                 <div class="shadow-box"> | ||||
|                 <div class="shadow-box shadow-box-with-fixed-bottom-bar"> | ||||
|                     <div class="row"> | ||||
|                         <div class="col-md-6"> | ||||
|                             <h2 class="mb-2">{{ $t("General") }}</h2> | ||||
| @@ -1102,31 +1102,7 @@ message HealthCheckResponse { | ||||
| <style lang="scss" scoped> | ||||
|     @import "../assets/vars.scss"; | ||||
|  | ||||
|     $padding: 20px; | ||||
|  | ||||
|     .shadow-box { | ||||
|         padding-top: $padding; | ||||
|         padding-bottom: 0; | ||||
|         padding-right: $padding; | ||||
|         padding-left: $padding; | ||||
|     } | ||||
|  | ||||
|     textarea { | ||||
|         min-height: 200px; | ||||
|     } | ||||
|  | ||||
|     .fixed-bottom-bar { | ||||
|         position: sticky; | ||||
|         bottom: 0; | ||||
|         margin-left: -$padding; | ||||
|         margin-right: -$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); | ||||
|         } | ||||
|     } | ||||
| </style> | ||||
|   | ||||
| @@ -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 ""; | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|     } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user