diff --git a/db/patch-maintenance-table2.sql b/db/patch-maintenance-table2.sql new file mode 100644 index 000000000..96b2ebde0 --- /dev/null +++ b/db/patch-maintenance-table2.sql @@ -0,0 +1,83 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +-- Just for someone who tested maintenance before (patch-maintenance-table.sql) +DROP TABLE IF EXISTS maintenance_status_page; +DROP TABLE IF EXISTS monitor_maintenance; +DROP TABLE IF EXISTS maintenance; +DROP TABLE IF EXISTS maintenance_timeslot; + +-- maintenance +CREATE TABLE [maintenance] ( + [id] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + [title] VARCHAR(150) NOT NULL, + [description] TEXT NOT NULL, + [user_id] INTEGER REFERENCES [user]([id]) ON DELETE SET NULL ON UPDATE CASCADE, + [active] BOOLEAN NOT NULL DEFAULT 1, + [strategy] VARCHAR(50) NOT NULL DEFAULT 'single', + [start_date] DATETIME, + [end_date] DATETIME, + [start_time] TIME, + [end_time] TIME, + [weekdays] VARCHAR2(250) DEFAULT '[]', + [days_of_month] TEXT DEFAULT '[]', + [interval_day] INTEGER +); + +CREATE INDEX [manual_active] ON [maintenance] ( + [strategy], + [active] +); + +CREATE INDEX [active] ON [maintenance] ([active]); + +CREATE INDEX [maintenance_user_id] ON [maintenance] ([user_id]); + +-- maintenance_status_page +CREATE TABLE maintenance_status_page ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + status_page_id INTEGER NOT NULL, + maintenance_id INTEGER NOT NULL, + CONSTRAINT FK_maintenance FOREIGN KEY (maintenance_id) REFERENCES maintenance (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT FK_status_page FOREIGN KEY (status_page_id) REFERENCES status_page (id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX [status_page_id_index] + ON [maintenance_status_page]([status_page_id]); + +CREATE INDEX [maintenance_id_index] + ON [maintenance_status_page]([maintenance_id]); + +-- maintenance_timeslot +CREATE TABLE [maintenance_timeslot] ( + [id] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + [maintenance_id] INTEGER NOT NULL CONSTRAINT [FK_maintenance] REFERENCES [maintenance]([id]) ON DELETE CASCADE ON UPDATE CASCADE, + [start_date] DATETIME NOT NULL, + [end_date] DATETIME, + [generated_next] BOOLEAN DEFAULT 0 +); + +CREATE INDEX [maintenance_id] ON [maintenance_timeslot] ([maintenance_id] DESC); + +CREATE INDEX [active_timeslot_index] ON [maintenance_timeslot] ( + [maintenance_id] DESC, + [start_date] DESC, + [end_date] DESC +); + +CREATE INDEX [generated_next_index] ON [maintenance_timeslot] ([generated_next]); + +-- monitor_maintenance +CREATE TABLE monitor_maintenance ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + maintenance_id INTEGER NOT NULL, + CONSTRAINT FK_maintenance FOREIGN KEY (maintenance_id) REFERENCES maintenance (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT FK_monitor FOREIGN KEY (monitor_id) REFERENCES monitor (id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX [maintenance_id_index2] ON [monitor_maintenance]([maintenance_id]); + +CREATE INDEX [monitor_id_index] ON [monitor_maintenance]([monitor_id]); + +COMMIT; diff --git a/package-lock.json b/package-lock.json index 776783526..3347c1bda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "@vitejs/plugin-legacy": "~2.1.0", "@vitejs/plugin-vue": "~3.1.0", "@vue/compiler-sfc": "~3.2.36", + "@vuepic/vue-datepicker": "~3.4.8", "aedes": "^0.46.3", "babel-plugin-rewire": "~1.2.0", "bootstrap": "5.1.3", @@ -3925,6 +3926,21 @@ "integrity": "sha512-dTyhTIRmGXBjxJE+skC8tTWCGLCVc4wQgRRLt8+O9p5ewBAjoBwtCAkLPrtToSr1xltoe3st21Pv953aOZ7alg==", "dev": true }, + "node_modules/@vuepic/vue-datepicker": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-3.4.8.tgz", + "integrity": "sha512-nbuMA7IgjtT99LqcjSTSUcl7omTZSB+7vYSWQ9gQm31Frm/1wn54fT1Q0HaRD9nHXX982AACbqeND4K80SKONw==", + "dev": true, + "dependencies": { + "date-fns": "^2.29.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "vue": ">=3.2.0" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -19613,6 +19629,15 @@ "integrity": "sha512-dTyhTIRmGXBjxJE+skC8tTWCGLCVc4wQgRRLt8+O9p5ewBAjoBwtCAkLPrtToSr1xltoe3st21Pv953aOZ7alg==", "dev": true }, + "@vuepic/vue-datepicker": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-3.4.8.tgz", + "integrity": "sha512-nbuMA7IgjtT99LqcjSTSUcl7omTZSB+7vYSWQ9gQm31Frm/1wn54fT1Q0HaRD9nHXX982AACbqeND4K80SKONw==", + "dev": true, + "requires": { + "date-fns": "^2.29.2" + } + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", diff --git a/package.json b/package.json index ee7496d92..8c774d71c 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "@vitejs/plugin-legacy": "~2.1.0", "@vitejs/plugin-vue": "~3.1.0", "@vue/compiler-sfc": "~3.2.36", + "@vuepic/vue-datepicker": "~3.4.8", "aedes": "^0.46.3", "babel-plugin-rewire": "~1.2.0", "bootstrap": "5.1.3", diff --git a/server/client.js b/server/client.js index a0c52e1e4..ef96c7f44 100644 --- a/server/client.js +++ b/server/client.js @@ -4,7 +4,8 @@ const { TimeLogger } = require("../src/util"); const { R } = require("redbean-node"); const { UptimeKumaServer } = require("./uptime-kuma-server"); -const io = UptimeKumaServer.getInstance().io; +const server = UptimeKumaServer.getInstance(); +const io = server.io; const { setting } = require("./util-server"); const checkVersion = require("./check-version"); @@ -121,7 +122,9 @@ async function sendInfo(socket) { socket.emit("info", { version: checkVersion.version, latestVersion: checkVersion.latestVersion, - primaryBaseURL: await setting("primaryBaseURL") + primaryBaseURL: await setting("primaryBaseURL"), + serverTimezone: await server.getTimezone(), + serverTimezoneOffset: server.getTimezoneOffset(), }); } diff --git a/server/database.js b/server/database.js index b1a23a475..16f12445b 100644 --- a/server/database.js +++ b/server/database.js @@ -64,6 +64,7 @@ class Database { "patch-add-other-auth.sql": { parents: [ "patch-monitor-basic-auth.sql" ] }, "patch-add-radius-monitor.sql": true, "patch-monitor-add-resend-interval.sql": true, + "patch-maintenance-table2.sql": true, }; /** diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js index a0b40d08e..fa02cae8a 100644 --- a/server/model/heartbeat.js +++ b/server/model/heartbeat.js @@ -1,8 +1,4 @@ const dayjs = require("dayjs"); -const utc = require("dayjs/plugin/utc"); -let timezone = require("dayjs/plugin/timezone"); -dayjs.extend(utc); -dayjs.extend(timezone); const { BeanModel } = require("redbean-node/dist/bean-model"); /** @@ -10,6 +6,7 @@ const { BeanModel } = require("redbean-node/dist/bean-model"); * 0 = DOWN * 1 = UP * 2 = PENDING + * 3 = MAINTENANCE */ class Heartbeat extends BeanModel { diff --git a/server/model/maintenance.js b/server/model/maintenance.js new file mode 100644 index 000000000..35030801e --- /dev/null +++ b/server/model/maintenance.js @@ -0,0 +1,215 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { parseTimeObject, parseTimeFromTimeObject, utcToLocal, localToUTC, log } = require("../../src/util"); +const { timeObjectToUTC, timeObjectToLocal } = require("../util-server"); +const { R } = require("redbean-node"); +const dayjs = require("dayjs"); + +class Maintenance extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {Object} + */ + async toPublicJSON() { + + let dateRange = []; + if (this.start_date) { + dateRange.push(utcToLocal(this.start_date)); + if (this.end_date) { + dateRange.push(utcToLocal(this.end_date)); + } + } + + let timeRange = []; + let startTime = timeObjectToLocal(parseTimeObject(this.start_time)); + timeRange.push(startTime); + let endTime = timeObjectToLocal(parseTimeObject(this.end_time)); + timeRange.push(endTime); + + let obj = { + id: this.id, + title: this.title, + description: this.description, + strategy: this.strategy, + intervalDay: this.interval_day, + active: !!this.active, + dateRange: dateRange, + timeRange: timeRange, + weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [], + daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [], + timeslotList: [], + }; + + const timeslotList = await this.getTimeslotList(); + + for (let timeslot of timeslotList) { + obj.timeslotList.push(await timeslot.toPublicJSON()); + } + + if (!Array.isArray(obj.weekdays)) { + obj.weekdays = []; + } + + if (!Array.isArray(obj.daysOfMonth)) { + 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 + * @returns {Object} + */ + async toJSON(timezone = null) { + return this.toPublicJSON(timezone); + } + + getDayOfWeekList() { + log.debug("timeslot", "List: " + this.weekdays); + return JSON.parse(this.weekdays).sort(function (a, b) { + return a - b; + }); + } + + getDayOfMonthList() { + return JSON.parse(this.days_of_month).sort(function (a, b) { + return a - b; + }); + } + + 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"); + } + + getDuration() { + let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second"); + // Add 24hours if it is across day + if (duration < 0) { + duration += 24 * 3600; + } + return duration; + } + + static 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.active = obj.active; + + if (obj.dateRange[0]) { + bean.start_date = localToUTC(obj.dateRange[0]); + + if (obj.dateRange[1]) { + bean.end_date = localToUTC(obj.dateRange[1]); + } + } + + 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); + + return bean; + } + + /** + * SQL conditions for active maintenance + * @returns {string} + */ + 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) + + `; + } + + /** + * SQL conditions for active and future maintenance + * @returns {string} + */ + static getActiveAndFutureMaintenanceSQLCondition() { + return ` + ((maintenance_timeslot.end_date >= DATETIME('now') + AND maintenance.active = 1) + OR + (maintenance.strategy = 'manual' AND active = 1)) + `; + } +} + +module.exports = Maintenance; diff --git a/server/model/maintenance_timeslot.js b/server/model/maintenance_timeslot.js new file mode 100644 index 000000000..2babe6bca --- /dev/null +++ b/server/model/maintenance_timeslot.js @@ -0,0 +1,189 @@ +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 { + + 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; + } + + 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) { + 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; + return await R.store(bean); + + } 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"); + } + } + + /** + * 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; + return await R.store(bean); + } +} + +module.exports = MaintenanceTimeslot; diff --git a/server/model/monitor.js b/server/model/monitor.js index c0a3cce65..e8342b09c 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -1,12 +1,8 @@ const https = require("https"); const dayjs = require("dayjs"); -const utc = require("dayjs/plugin/utc"); -let timezone = require("dayjs/plugin/timezone"); -dayjs.extend(utc); -dayjs.extend(timezone); const axios = require("axios"); const { Prometheus } = require("../prometheus"); -const { log, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util"); +const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger } = require("../../src/util"); const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mqttAsync, setSetting, httpNtlm, radius } = require("../util-server"); const { R } = require("redbean-node"); const { BeanModel } = require("redbean-node/dist/bean-model"); @@ -18,12 +14,14 @@ 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"); /** * status: * 0 = DOWN * 1 = UP * 2 = PENDING + * 3 = MAINTENANCE */ class Monitor extends BeanModel { @@ -37,6 +35,7 @@ class Monitor extends BeanModel { id: this.id, name: this.name, sendUrl: this.sendUrl, + maintenance: await Monitor.isUnderMaintenance(this.id), }; if (this.sendUrl) { @@ -96,6 +95,7 @@ class Monitor extends BeanModel { proxyId: this.proxy_id, notificationIDList, tags: tags, + maintenance: await Monitor.isUnderMaintenance(this.id), mqttUsername: this.mqttUsername, mqttPassword: this.mqttPassword, mqttTopic: this.mqttTopic, @@ -230,7 +230,10 @@ class Monitor extends BeanModel { } try { - if (this.type === "http" || this.type === "keyword") { + if (await Monitor.isUnderMaintenance(this.id)) { + bean.msg = "Monitor under maintenance"; + bean.status = MAINTENANCE; + } else if (this.type === "http" || this.type === "keyword") { // Do not do any queries/high loading things before the "bean.ping" let startTime = dayjs().valueOf(); @@ -606,8 +609,12 @@ class Monitor extends BeanModel { if (isImportant) { bean.important = true; - log.debug("monitor", `[${this.name}] sendNotification`); - await Monitor.sendNotification(isFirstBeat, this, bean); + if (Monitor.isImportantForNotification(isFirstBeat, previousBeat?.status, bean.status)) { + log.debug("monitor", `[${this.name}] sendNotification`); + await Monitor.sendNotification(isFirstBeat, this, bean); + } else { + log.debug("monitor", `[${this.name}] will not sendNotification because it is (or was) under maintenance`); + } // Reset down count bean.downCount = 0; @@ -616,6 +623,8 @@ class Monitor extends BeanModel { log.debug("monitor", `[${this.name}] apicache clear`); apicache.clear(); + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + } else { bean.important = false; @@ -639,6 +648,8 @@ class Monitor extends BeanModel { beatInterval = this.retryInterval; } log.warn("monitor", `Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`); + } else if (bean.status === MAINTENANCE) { + log.warn("monitor", `Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`); } else { log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`); } @@ -849,7 +860,7 @@ class Monitor extends BeanModel { -- SUM all uptime duration, also trim off the beat out of time window SUM( CASE - WHEN (status = 1) + WHEN (status = 1 OR status = 3) THEN CASE WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration @@ -920,11 +931,49 @@ class Monitor extends BeanModel { // DOWN -> PENDING = this case not exists // DOWN -> DOWN = not important // * DOWN -> UP = important - let isImportant = isFirstBeat || + // MAINTENANCE -> MAINTENANCE = not important + // * MAINTENANCE -> UP = important + // * MAINTENANCE -> DOWN = important + // * DOWN -> MAINTENANCE = important + // * UP -> MAINTENANCE = important + return isFirstBeat || + (previousBeatStatus === DOWN && currentBeatStatus === MAINTENANCE) || + (previousBeatStatus === UP && currentBeatStatus === MAINTENANCE) || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === UP) || + (previousBeatStatus === UP && currentBeatStatus === DOWN) || + (previousBeatStatus === DOWN && currentBeatStatus === UP) || + (previousBeatStatus === PENDING && currentBeatStatus === DOWN); + } + + /** + * Is this beat important for notifications? + * @param {boolean} isFirstBeat Is this the first beat of this monitor? + * @param {const} previousBeatStatus Status of the previous beat + * @param {const} currentBeatStatus Status of the current beat + * @returns {boolean} True if is an important beat else false + */ + static isImportantForNotification(isFirstBeat, previousBeatStatus, currentBeatStatus) { + // * ? -> ANY STATUS = important [isFirstBeat] + // UP -> PENDING = not important + // * UP -> DOWN = important + // UP -> UP = not important + // PENDING -> PENDING = not important + // * PENDING -> DOWN = important + // PENDING -> UP = not important + // DOWN -> PENDING = this case not exists + // DOWN -> DOWN = not important + // * DOWN -> UP = important + // MAINTENANCE -> MAINTENANCE = not important + // MAINTENANCE -> UP = not important + // * MAINTENANCE -> DOWN = important + // DOWN -> MAINTENANCE = not important + // UP -> MAINTENANCE = not important + return isFirstBeat || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) || (previousBeatStatus === UP && currentBeatStatus === DOWN) || (previousBeatStatus === DOWN && currentBeatStatus === UP) || (previousBeatStatus === PENDING && currentBeatStatus === DOWN); - return isImportant; } /** @@ -1061,6 +1110,26 @@ class Monitor extends BeanModel { monitorID ]); } + + /** + * Check if monitor is under maintenance + * @param {number} monitorID ID of monitor to check + * @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; + } } module.exports = Monitor; diff --git a/server/model/status_page.js b/server/model/status_page.js index 68c7f8b04..4e7b38cf8 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -3,6 +3,7 @@ const { R } = require("redbean-node"); const cheerio = require("cheerio"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const jsesc = require("jsesc"); +const Maintenance = require("./maintenance"); class StatusPage extends BeanModel { @@ -90,6 +91,8 @@ class StatusPage extends BeanModel { incident = incident.toPublicJSON(); } + let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id); + // Public Group List const publicGroupList = []; const showTags = !!statusPage.show_tags; @@ -107,7 +110,8 @@ class StatusPage extends BeanModel { return { config: await statusPage.toPublicJSON(), incident, - publicGroupList + publicGroupList, + maintenanceList, }; } @@ -266,6 +270,36 @@ class StatusPage extends BeanModel { } } + /** + * Get list of maintenances + * @param {number} statusPageId ID of status page to get maintenance for + * @returns {Object} Object representing maintenances sanitized for public + */ + static async getMaintenanceList(statusPageId) { + try { + const publicMaintenanceList = []; + + let activeCondition = Maintenance.getActiveMaintenanceSQLCondition(); + let maintenanceBeanList = R.convertToBeans("maintenance", await R.getAll(` + SELECT maintenance.* + FROM maintenance, maintenance_status_page msp, maintenance_timeslot + WHERE msp.maintenance_id = maintenance.id + AND maintenance_timeslot.maintenance_id = maintenance.id + AND msp.status_page_id = ? + AND ${activeCondition} + ORDER BY maintenance.end_date + `, [ statusPageId ])); + + for (const bean of maintenanceBeanList) { + publicMaintenanceList.push(await bean.toPublicJSON()); + } + + return publicMaintenanceList; + + } catch (error) { + return []; + } + } } module.exports = StatusPage; diff --git a/server/routers/api-router.js b/server/routers/api-router.js index d71f903a0..bbecbced3 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -4,7 +4,7 @@ const { R } = require("redbean-node"); const apicache = require("../modules/apicache"); const Monitor = require("../model/monitor"); const dayjs = require("dayjs"); -const { UP, DOWN, flipStatus, log } = require("../../src/util"); +const { UP, MAINTENANCE, DOWN, flipStatus, log } = require("../../src/util"); const StatusPage = require("../model/status_page"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { makeBadge } = require("badge-maker"); @@ -67,6 +67,11 @@ router.get("/api/push/:pushToken", async (request, response) => { duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second"); } + if (await Monitor.isUnderMaintenance(monitor.id)) { + msg = "Monitor under maintenance"; + status = MAINTENANCE; + } + log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`); log.debug("router", "PreviousStatus: " + previousStatus); log.debug("router", "Current Status: " + status); @@ -87,7 +92,7 @@ router.get("/api/push/:pushToken", async (request, response) => { ok: true, }); - if (bean.important) { + if (Monitor.isImportantForNotification(isFirstBeat, previousStatus, status)) { await Monitor.sendNotification(isFirstBeat, monitor, bean); } diff --git a/server/server.js b/server/server.js index 2efad753c..888294b16 100644 --- a/server/server.js +++ b/server/server.js @@ -5,6 +5,12 @@ */ console.log("Welcome to Uptime Kuma"); +// As the log function need to use dayjs, it should be very top +const dayjs = require("dayjs"); +dayjs.extend(require("dayjs/plugin/utc")); +dayjs.extend(require("dayjs/plugin/timezone")); +dayjs.extend(require("dayjs/plugin/customParseFormat")); + // Check Node.js Version const nodeVersion = parseInt(process.versions.node.split(".")[0]); const requiredVersion = 14; @@ -33,6 +39,7 @@ log.info("server", "Importing Node libraries"); const fs = require("fs"); log.info("server", "Importing 3rd-party libraries"); + log.debug("server", "Importing express"); const express = require("express"); const expressStaticGzip = require("express-static-gzip"); @@ -127,6 +134,7 @@ const StatusPage = require("./model/status_page"); const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler"); const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler"); const { dockerSocketHandler } = require("./socket-handlers/docker-socket-handler"); +const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-socket-handler"); const { Settings } = require("./settings"); app.use(express.json()); @@ -155,6 +163,7 @@ let needSetup = false; (async () => { Database.init(args); await initDatabase(testMode); + await server.initAfterDatabaseReady(); server.entryPage = await Settings.get("entryPage"); await StatusPage.loadDomainMappingList(); @@ -1057,10 +1066,15 @@ let needSetup = false; socket.on("getSettings", async (callback) => { try { checkLogin(socket); + const data = await getSettings("general"); + + if (!data.serverTimezone) { + data.serverTimezone = await server.getTimezone(); + } callback({ ok: true, - data: await getSettings("general"), + data: data, }); } catch (e) { @@ -1088,12 +1102,18 @@ let needSetup = false; await setSettings("general", data); server.entryPage = data.entryPage; + // Also need to apply timezone globally + if (data.serverTimezone) { + await server.setTimezone(data.serverTimezone); + } + callback({ ok: true, msg: "Saved" }); sendInfo(socket); + server.sendMaintenanceList(socket); } catch (e) { callback({ @@ -1452,6 +1472,7 @@ let needSetup = false; databaseSocketHandler(socket); proxySocketHandler(socket); dockerSocketHandler(socket); + maintenanceSocketHandler(socket); log.debug("server", "added all socket handlers"); @@ -1554,6 +1575,7 @@ async function afterLogin(socket, user) { socket.join(user.id); let monitorList = await server.sendMonitorList(socket); + server.sendMaintenanceList(socket); sendNotificationList(socket); sendProxyList(socket); sendDockerHostList(socket); @@ -1699,6 +1721,8 @@ async function shutdownFunction(signal) { log.info("server", "Shutdown requested"); log.info("server", "Called signal: " + signal); + await server.stop(); + log.info("server", "Stopping all monitors"); for (let id in server.monitorList) { let monitor = server.monitorList[id]; diff --git a/server/socket-handlers/maintenance-socket-handler.js b/server/socket-handlers/maintenance-socket-handler.js new file mode 100644 index 000000000..5294050ca --- /dev/null +++ b/server/socket-handlers/maintenance-socket-handler.js @@ -0,0 +1,311 @@ +const { checkLogin } = require("../util-server"); +const { log } = require("../../src/util"); +const { R } = require("redbean-node"); +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 + * @param {Socket} socket Socket.io instance + */ +module.exports.maintenanceSocketHandler = (socket) => { + // Add a new maintenance + socket.on("addMaintenance", async (maintenance, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", maintenance); + + let bean = Maintenance.jsonToBean(R.dispense("maintenance"), maintenance); + bean.user_id = socket.userID; + let maintenanceID = await R.store(bean); + await MaintenanceTimeslot.generateTimeslot(bean); + + await server.sendMaintenanceList(socket); + + callback({ + ok: true, + msg: "Added Successfully.", + maintenanceID, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Edit a maintenance + socket.on("editMaintenance", async (maintenance, callback) => { + try { + checkLogin(socket); + + let bean = await R.findOne("maintenance", " id = ? ", [ maintenance.id ]); + + if (bean.user_id !== socket.userID) { + throw new Error("Permission denied."); + } + + Maintenance.jsonToBean(bean, maintenance); + + await R.store(bean); + await MaintenanceTimeslot.generateTimeslot(bean, null, true); + + await server.sendMaintenanceList(socket); + + callback({ + ok: true, + msg: "Saved.", + maintenanceID: bean.id, + }); + + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Add a new monitor_maintenance + socket.on("addMonitorMaintenance", async (maintenanceID, monitors, callback) => { + try { + checkLogin(socket); + + await R.exec("DELETE FROM monitor_maintenance WHERE maintenance_id = ?", [ + maintenanceID + ]); + + for await (const monitor of monitors) { + let bean = R.dispense("monitor_maintenance"); + + bean.import({ + monitor_id: monitor.id, + maintenance_id: maintenanceID + }); + await R.store(bean); + } + + apicache.clear(); + + callback({ + ok: true, + msg: "Added Successfully.", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Add a new monitor_maintenance + socket.on("addMaintenanceStatusPage", async (maintenanceID, statusPages, callback) => { + try { + checkLogin(socket); + + await R.exec("DELETE FROM maintenance_status_page WHERE maintenance_id = ?", [ + maintenanceID + ]); + + for await (const statusPage of statusPages) { + let bean = R.dispense("maintenance_status_page"); + + bean.import({ + status_page_id: statusPage.id, + maintenance_id: maintenanceID + }); + await R.store(bean); + } + + apicache.clear(); + + callback({ + ok: true, + msg: "Added Successfully.", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Get Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + let bean = await R.findOne("maintenance", " id = ? AND user_id = ? ", [ + maintenanceID, + socket.userID, + ]); + + callback({ + ok: true, + maintenance: await bean.toJSON(), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMaintenanceList", async (callback) => { + try { + checkLogin(socket); + await server.sendMaintenanceList(socket); + callback({ + ok: true, + }); + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMonitorMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Get Monitors for Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + let monitors = await R.getAll("SELECT monitor.id, monitor.name FROM monitor_maintenance mm JOIN monitor ON mm.monitor_id = monitor.id WHERE mm.maintenance_id = ? ", [ + maintenanceID, + ]); + + callback({ + ok: true, + monitors, + }); + + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMaintenanceStatusPage", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Get Status Pages for Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + let statusPages = await R.getAll("SELECT status_page.id, status_page.title FROM maintenance_status_page msp JOIN status_page ON msp.status_page_id = status_page.id WHERE msp.maintenance_id = ? ", [ + maintenanceID, + ]); + + callback({ + ok: true, + statusPages, + }); + + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Delete Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + if (maintenanceID in server.maintenanceList) { + delete server.maintenanceList[maintenanceID]; + } + + await R.exec("DELETE FROM maintenance WHERE id = ? AND user_id = ? ", [ + maintenanceID, + socket.userID, + ]); + + callback({ + ok: true, + msg: "Deleted Successfully.", + }); + + await server.sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("pauseMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Pause Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + await R.exec("UPDATE maintenance SET active = 0 WHERE id = ? ", [ + maintenanceID, + ]); + + callback({ + ok: true, + msg: "Paused Successfully.", + }); + + await server.sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("resumeMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Resume Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + await R.exec("UPDATE maintenance SET active = 1 WHERE id = ? ", [ + maintenanceID, + ]); + + callback({ + ok: true, + msg: "Resume Successfully", + }); + + await server.sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 6e77e1fd2..078cc31d9 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -9,6 +9,8 @@ const Database = require("./database"); const util = require("util"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { Settings } = require("./settings"); +const dayjs = require("dayjs"); +// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()` /** * `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue. @@ -26,6 +28,13 @@ class UptimeKumaServer { * @type {{}} */ monitorList = {}; + + /** + * Main maintenance list + * @type {{}} + */ + maintenanceList = {}; + entryPage = "dashboard"; app = undefined; httpServer = undefined; @@ -37,6 +46,8 @@ class UptimeKumaServer { */ indexHTML = ""; + generateMaintenanceTimeslotsInterval = undefined; + static getInstance(args) { if (UptimeKumaServer.instance == null) { UptimeKumaServer.instance = new UptimeKumaServer(args); @@ -77,6 +88,16 @@ class UptimeKumaServer { this.io = new Server(this.httpServer); } + async initAfterDatabaseReady() { + process.env.TZ = await this.getTimezone(); + dayjs.tz.setDefault(process.env.TZ); + 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); + } + async sendMonitorList(socket) { let list = await this.getMonitorJSONList(socket.userID); this.io.to(socket.userID).emit("monitorList", list); @@ -104,6 +125,40 @@ class UptimeKumaServer { return result; } + /** + * Send maintenance list to client + * @param {Socket} socket Socket.io instance to send to + * @returns {Object} + */ + async sendMaintenanceList(socket) { + return await this.sendMaintenanceListByUserID(socket.userID); + } + + async sendMaintenanceListByUserID(userID) { + let list = await this.getMaintenanceJSONList(userID); + this.io.to(userID).emit("maintenanceList", list); + return list; + } + + /** + * Get a list of maintenances for the given user. + * @param {string} userID - The ID of the user to get maintenances for. + * @returns {Promise} A promise that resolves to an object with maintenance IDs as keys and maintenances objects as values. + */ + async getMaintenanceJSONList(userID) { + let result = {}; + + 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(); + } + + return result; + } + /** * Write error to log file * @param {any} error The error to write @@ -147,8 +202,49 @@ class UptimeKumaServer { return clientIP.replace(/^.*:/, ""); } } + + async getTimezone() { + let timezone = await Settings.get("serverTimezone"); + if (timezone) { + return timezone; + } else if (process.env.TZ) { + return process.env.TZ; + } else { + return dayjs.tz.guess(); + } + } + + getTimezoneOffset() { + return dayjs().format("Z"); + } + + async setTimezone(timezone) { + await Settings.set("serverTimezone", timezone, "general"); + process.env.TZ = timezone; + dayjs.tz.setDefault(timezone); + } + + async generateMaintenanceTimeslots() { + + 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); + } + + } + + async stop() { + clearTimeout(this.generateMaintenanceTimeslotsInterval); + } } module.exports = { UptimeKumaServer }; + +// Must be at the end +const MaintenanceTimeslot = require("./model/maintenance_timeslot"); diff --git a/server/util-server.js b/server/util-server.js index b975a43f3..39adcccd4 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -21,6 +21,7 @@ const { rfc2865: { file, attributes }, }, } = require("node-radius-utils"); +const dayjs = require("dayjs"); // From ping-lite exports.WIN = /^win/.test(process.platform); @@ -658,3 +659,64 @@ module.exports.send403 = (res, msg = "") => { "msg": msg, }); }; + +function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) { + let offsetString; + + if (timezone) { + offsetString = dayjs().tz(timezone).format("Z"); + } else { + offsetString = dayjs().format("Z"); + } + + let hours = parseInt(offsetString.substring(1, 3)); + let minutes = parseInt(offsetString.substring(4, 6)); + + if ( + (timeObjectToUTC && offsetString.startsWith("+")) || + (!timeObjectToUTC && offsetString.startsWith("-")) + ) { + hours *= -1; + minutes *= -1; + } + + obj.hours += hours; + obj.minutes += minutes; + + // Handle out of bound + if (obj.minutes < 0) { + obj.minutes += 60; + obj.hours--; + } else if (obj.minutes > 60) { + obj.minutes -= 60; + obj.hours++; + } + + if (obj.hours < 0) { + obj.hours += 24; + } else if (obj.hours > 24) { + obj.hours -= 24; + } + + return obj; +} + +/** + * + * @param {object} obj + * @param {string} timezone + * @returns {object} + */ +module.exports.timeObjectToUTC = (obj, timezone = undefined) => { + return timeObjectConvertTimezone(obj, timezone, true); +}; + +/** + * + * @param {object} obj + * @param {string} timezone + * @returns {object} + */ +module.exports.timeObjectToLocal = (obj, timezone = undefined) => { + return timeObjectConvertTimezone(obj, timezone, false); +}; diff --git a/src/assets/app.scss b/src/assets/app.scss index bf8e70048..7da76fff0 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -22,6 +22,19 @@ textarea.form-control { width: 10px; } +.bg-maintenance { + color: white !important; + background-color: $maintenance !important; +} + +.bg-dark { + color: white; +} + +.text-maintenance { + color: $maintenance !important; +} + .list-group { border-radius: 0.75rem; @@ -107,6 +120,19 @@ optgroup { } } +.btn-normal { + $bg-color: #F5F5F5; + + background-color: $bg-color; + border-color: $bg-color; + + &:hover { + $hover-color: darken($bg-color, 3%); + background-color: $hover-color; + border-color: $hover-color; + } +} + .btn-warning { color: white; @@ -256,6 +282,20 @@ optgroup { color: white; } + .btn-normal { + $bg-color: $dark-header-bg; + + color: $dark-font-color; + background-color: $bg-color; + border-color: $bg-color; + + &:hover { + $hover-color: darken($bg-color, 3%); + background-color: $hover-color; + border-color: $hover-color; + } + } + .btn-warning { color: $dark-font-color2; @@ -323,6 +363,7 @@ optgroup { &.bg-info, &.bg-warning, &.bg-danger, + &.bg-maintenance, &.bg-light { color: $dark-font-color2; } diff --git a/src/assets/vars.scss b/src/assets/vars.scss index 91ab917e5..e48a6efb6 100644 --- a/src/assets/vars.scss +++ b/src/assets/vars.scss @@ -1,6 +1,7 @@ $primary: #5cdd8b; $danger: #dc3545; $warning: #f8a306; +$maintenance: #1747f5; $link-color: #111; $border-radius: 50rem; diff --git a/src/assets/vue-datepicker.scss b/src/assets/vue-datepicker.scss new file mode 100644 index 000000000..dedbc0801 --- /dev/null +++ b/src/assets/vue-datepicker.scss @@ -0,0 +1,39 @@ +@import "@vuepic/vue-datepicker/dist/main.css"; +@import "vars.scss"; + +// Must use #{ } +// Remark: https://stackoverflow.com/questions/50202991/unable-to-set-scss-variable-to-css-variable +.dp__theme_dark { + --dp-background-color: #{$dark-bg2}; + --dp-text-color: #{$dark-font-color}; + --dp-hover-color: #484848; + --dp-hover-text-color: #ffffff; + --dp-hover-icon-color: #959595; + --dp-primary-color: #{#5cdd8b}; + --dp-primary-text-color: #ffffff; + --dp-secondary-color: #494949; + --dp-border-color: #{$dark-border-color}; + --dp-menu-border-color: #2d2d2d; + --dp-border-color-hover: #{$dark-border-color}; + --dp-disabled-color: #212121; + --dp-scroll-bar-background: #212121; + --dp-scroll-bar-color: #484848; + --dp-success-color: #{$primary}; + --dp-success-color-disabled: #428f59; + --dp-icon-color: #959595; + --dp-danger-color: #e53935; + --dp-highlight-color: rgba(0, 92, 178, 0.2); +} + +.dp__input { + border-radius: $border-radius; +} + +// Fix: Full width of text input when using "inline textInput inlineWithInput" mode +.dp__main > div[aria-label="Datepicker input"] { + width: 100%; +} + +.dp__main > div[aria-label="Datepicker menu"]:nth-child(2) { + margin-top: 20px; +} diff --git a/src/components/Datetime.vue b/src/components/Datetime.vue index b24ab0b3c..84bae5031 100644 --- a/src/components/Datetime.vue +++ b/src/components/Datetime.vue @@ -4,12 +4,6 @@ + + diff --git a/src/components/PingChart.vue b/src/components/PingChart.vue index e472d898a..2733e68c9 100644 --- a/src/components/PingChart.vue +++ b/src/components/PingChart.vue @@ -16,18 +16,14 @@ - + + diff --git a/src/pages/MaintenanceDetails.vue b/src/pages/MaintenanceDetails.vue new file mode 100644 index 000000000..04c216915 --- /dev/null +++ b/src/pages/MaintenanceDetails.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/pages/ManageMaintenance.vue b/src/pages/ManageMaintenance.vue new file mode 100644 index 000000000..9ded04595 --- /dev/null +++ b/src/pages/ManageMaintenance.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 45d5e0c24..6cecf6682 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -218,12 +218,29 @@ {{ $t("Degraded Service") }} +
+ + {{ $t("maintenanceStatus-under-maintenance") }} +
+
+ + + {{ $t("Description") }}: @@ -295,8 +312,9 @@ import "vue-prism-editor/dist/prismeditor.min.css"; // import the styles somewhe import { useToast } from "vue-toastification"; import Confirm from "../components/Confirm.vue"; import PublicGroupList from "../components/PublicGroupList.vue"; +import MaintenanceTime from "../components/MaintenanceTime.vue"; import { getResBaseURL } from "../util-frontend"; -import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_PARTIAL_DOWN, UP } from "../util.ts"; +import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE } from "../util.ts"; const toast = useToast(); @@ -316,6 +334,7 @@ export default { ImageCropUpload, Confirm, PrismEditor, + MaintenanceTime, }, // Leave Page for vue route change @@ -356,6 +375,7 @@ export default { loadedData: false, baseURL: "", clickedEditButton: false, + maintenanceList: [], }; }, computed: { @@ -409,6 +429,10 @@ export default { return "bg-" + this.incident.style; }, + maintenanceClass() { + return "bg-maintenance"; + }, + overallStatus() { if (Object.keys(this.$root.publicLastHeartbeatList).length === 0) { @@ -421,7 +445,9 @@ export default { for (let id in this.$root.publicLastHeartbeatList) { let beat = this.$root.publicLastHeartbeatList[id]; - if (beat.status === UP) { + if (beat.status === MAINTENANCE) { + return STATUS_PAGE_MAINTENANCE; + } else if (beat.status === UP) { hasUp = true; } else { status = STATUS_PAGE_PARTIAL_DOWN; @@ -447,6 +473,10 @@ export default { return this.overallStatus === STATUS_PAGE_ALL_DOWN; }, + isMaintenance() { + return this.overallStatus === STATUS_PAGE_MAINTENANCE; + }, + }, watch: { @@ -551,6 +581,7 @@ export default { } this.incident = res.data.incident; + this.maintenanceList = res.data.maintenanceList; this.$root.publicGroupList = res.data.publicGroupList; }).catch( function (error) { if (error.response.status === 404) { @@ -946,6 +977,24 @@ footer { } } +.maintenance-bg-info { + color: $maintenance; +} + +.maintenance-icon { + font-size: 35px; + vertical-align: middle; +} + +.dark .shadow-box { + background-color: #0d1117; +} + +.status-maintenance { + color: $maintenance; + margin-right: 5px; +} + .mobile { h1 { font-size: 22px; @@ -1007,4 +1056,10 @@ footer { } } +.bg-maintenance { + .alert-heading { + font-weight: bold; + } +} + diff --git a/src/router.js b/src/router.js index 7d29a1882..380488264 100644 --- a/src/router.js +++ b/src/router.js @@ -6,6 +6,7 @@ import Dashboard from "./pages/Dashboard.vue"; import DashboardHome from "./pages/DashboardHome.vue"; import Details from "./pages/Details.vue"; import EditMonitor from "./pages/EditMonitor.vue"; +import EditMaintenance from "./pages/EditMaintenance.vue"; import List from "./pages/List.vue"; const Settings = () => import("./pages/Settings.vue"); import Setup from "./pages/Setup.vue"; @@ -14,6 +15,9 @@ import Entry from "./pages/Entry.vue"; import ManageStatusPage from "./pages/ManageStatusPage.vue"; import AddStatusPage from "./pages/AddStatusPage.vue"; import NotFound from "./pages/NotFound.vue"; +import DockerHosts from "./components/settings/Docker.vue"; +import MaintenanceDetails from "./pages/MaintenanceDetails.vue"; +import ManageMaintenance from "./pages/ManageMaintenance.vue"; // Settings - Sub Pages import Appearance from "./components/settings/Appearance.vue"; @@ -25,7 +29,6 @@ const Security = () => import("./components/settings/Security.vue"); import Proxies from "./components/settings/Proxies.vue"; import Backup from "./components/settings/Backup.vue"; import About from "./components/settings/About.vue"; -import DockerHosts from "./components/settings/Docker.vue"; const routes = [ { @@ -126,6 +129,22 @@ const routes = [ path: "/add-status-page", component: AddStatusPage, }, + { + path: "/maintenance", + component: ManageMaintenance, + }, + { + path: "/maintenance/:id", + component: MaintenanceDetails, + }, + { + path: "/add-maintenance", + component: EditMaintenance, + }, + { + path: "/maintenance/edit/:id", + component: EditMaintenance, + }, ], }, ], diff --git a/src/util-frontend.js b/src/util-frontend.js index 36dac49f9..3323f3279 100644 --- a/src/util-frontend.js +++ b/src/util-frontend.js @@ -1,12 +1,7 @@ import dayjs from "dayjs"; -import timezone from "dayjs/plugin/timezone"; -import utc from "dayjs/plugin/utc"; import timezones from "timezones-list"; import { localeDirection, currentLocale } from "./i18n"; -dayjs.extend(utc); -dayjs.extend(timezone); - /** * Returns the offset from UTC in hours for the current locale. * @returns {number} The offset from UTC in hours. diff --git a/src/util.js b/src/util.js index d766feb76..9cdecc17a 100644 --- a/src/util.js +++ b/src/util.js @@ -7,17 +7,21 @@ // Backend uses the compiled file util.js // Frontend uses util.ts Object.defineProperty(exports, "__esModule", { value: true }); -exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0; -const _dayjs = require("dayjs"); -const dayjs = _dayjs; +exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0; +const dayjs = require("dayjs"); exports.isDev = process.env.NODE_ENV === "development"; exports.appName = "Uptime Kuma"; exports.DOWN = 0; exports.UP = 1; exports.PENDING = 2; +exports.MAINTENANCE = 3; exports.STATUS_PAGE_ALL_DOWN = 0; exports.STATUS_PAGE_ALL_UP = 1; exports.STATUS_PAGE_PARTIAL_DOWN = 2; +exports.STATUS_PAGE_MAINTENANCE = 3; +exports.SQL_DATE_FORMAT = "YYYY-MM-DD"; +exports.SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss"; +exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm"; /** Flip the status of s */ function flipStatus(s) { if (s === exports.UP) { @@ -100,7 +104,7 @@ class Logger { } module = module.toUpperCase(); level = level.toUpperCase(); - const now = new Date().toISOString(); + const now = dayjs.tz(new Date()).format(); const formattedMessage = (typeof msg === "string") ? `${now} [${module}] ${level}: ${msg}` : msg; if (level === "INFO") { console.info(formattedMessage); @@ -303,3 +307,71 @@ function getMonitorRelativeURL(id) { return "/dashboard/" + id; } exports.getMonitorRelativeURL = getMonitorRelativeURL; +function getMaintenanceRelativeURL(id) { + return "/maintenance/" + id; +} +exports.getMaintenanceRelativeURL = getMaintenanceRelativeURL; +/** + * Parse to Time Object that used in VueDatePicker + * @param {string} time E.g. 12:00 + * @returns object + */ +function parseTimeObject(time) { + if (!time) { + return { + hours: 0, + minutes: 0, + }; + } + let array = time.split(":"); + if (array.length < 2) { + throw new Error("parseVueDatePickerTimeFormat: Invalid Time"); + } + let obj = { + hours: parseInt(array[0]), + minutes: parseInt(array[1]), + seconds: 0, + }; + if (array.length >= 3) { + obj.seconds = parseInt(array[2]); + } + return obj; +} +exports.parseTimeObject = parseTimeObject; +/** + * @returns string e.g. 12:00 + */ +function parseTimeFromTimeObject(obj) { + if (!obj) { + return obj; + } + let result = ""; + result += obj.hours.toString().padStart(2, "0") + ":" + obj.minutes.toString().padStart(2, "0"); + if (obj.seconds) { + result += ":" + obj.seconds.toString().padStart(2, "0"); + } + return result; +} +exports.parseTimeFromTimeObject = parseTimeFromTimeObject; +function isoToUTCDateTime(input) { + return dayjs(input).utc().format(exports.SQL_DATETIME_FORMAT); +} +exports.isoToUTCDateTime = isoToUTCDateTime; +/** + * @param input + */ +function utcToISODateTime(input) { + return dayjs.utc(input).toISOString(); +} +exports.utcToISODateTime = utcToISODateTime; +/** + * For SQL_DATETIME_FORMAT + */ +function utcToLocal(input, format = exports.SQL_DATETIME_FORMAT) { + return dayjs.utc(input).local().format(format); +} +exports.utcToLocal = utcToLocal; +function localToUTC(input, format = exports.SQL_DATETIME_FORMAT) { + return dayjs(input).utc().format(format); +} +exports.localToUTC = localToUTC; diff --git a/src/util.ts b/src/util.ts index b69f31ace..fd2b466ba 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,18 +6,25 @@ // Backend uses the compiled file util.js // Frontend uses util.ts -import * as _dayjs from "dayjs"; -const dayjs = _dayjs; +import * as dayjs from "dayjs"; +import * as timezone from "dayjs/plugin/timezone"; +import * as utc from "dayjs/plugin/utc"; export const isDev = process.env.NODE_ENV === "development"; export const appName = "Uptime Kuma"; export const DOWN = 0; export const UP = 1; export const PENDING = 2; +export const MAINTENANCE = 3; export const STATUS_PAGE_ALL_DOWN = 0; export const STATUS_PAGE_ALL_UP = 1; export const STATUS_PAGE_PARTIAL_DOWN = 2; +export const STATUS_PAGE_MAINTENANCE = 3; + +export const SQL_DATE_FORMAT = "YYYY-MM-DD"; +export const SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss"; +export const SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm"; /** Flip the status of s */ export function flipStatus(s: number) { @@ -112,7 +119,7 @@ class Logger { module = module.toUpperCase(); level = level.toUpperCase(); - const now = new Date().toISOString(); + const now = dayjs.tz(new Date()).format(); const formattedMessage = (typeof msg === "string") ? `${now} [${module}] ${level}: ${msg}` : msg; if (level === "INFO") { @@ -336,3 +343,79 @@ export function genSecret(length = 64) { export function getMonitorRelativeURL(id: string) { return "/dashboard/" + id; } + +export function getMaintenanceRelativeURL(id: string) { + return "/maintenance/" + id; +} + +/** + * Parse to Time Object that used in VueDatePicker + * @param {string} time E.g. 12:00 + * @returns object + */ +export function parseTimeObject(time: string) { + if (!time) { + return { + hours: 0, + minutes: 0, + }; + } + + let array = time.split(":"); + + if (array.length < 2) { + throw new Error("parseVueDatePickerTimeFormat: Invalid Time"); + } + + let obj = { + hours: parseInt(array[0]), + minutes: parseInt(array[1]), + seconds: 0, + } + if (array.length >= 3) { + obj.seconds = parseInt(array[2]); + } + return obj; +} + +/** + * @returns string e.g. 12:00 + */ +export function parseTimeFromTimeObject(obj : any) { + if (!obj) { + return obj; + } + + let result = ""; + + result += obj.hours.toString().padStart(2, "0") + ":" + obj.minutes.toString().padStart(2, "0") + + if (obj.seconds) { + result += ":" + obj.seconds.toString().padStart(2, "0") + } + + return result; +} + + +export function isoToUTCDateTime(input : string) { + return dayjs(input).utc().format(SQL_DATETIME_FORMAT); +} + +/** + * @param input + */ +export function utcToISODateTime(input : string) { + return dayjs.utc(input).toISOString(); +} + +/** + * For SQL_DATETIME_FORMAT + */ +export function utcToLocal(input : string, format = SQL_DATETIME_FORMAT) { + return dayjs.utc(input).local().format(format); +} + +export function localToUTC(input : string, format = SQL_DATETIME_FORMAT) { + return dayjs(input).utc().format(format); +} diff --git a/test/backend.spec.js b/test/backend.spec.js index 5b9fa92c6..644a0fd08 100644 --- a/test/backend.spec.js +++ b/test/backend.spec.js @@ -6,6 +6,9 @@ const { UptimeKumaServer } = require("../server/uptime-kuma-server"); const Database = require("../server/database"); const {Settings} = require("../server/settings"); const fs = require("fs"); +const dayjs = require("dayjs"); +dayjs.extend(require("dayjs/plugin/utc")); +dayjs.extend(require("dayjs/plugin/timezone")); jest.mock("axios");