From 0d3414c6d6089f7b41f6bb4b1729f01ab2cb5e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karel=20Kr=C3=BDda?= <karel.kryda@gmail.com> Date: Sun, 23 Jan 2022 15:22:00 +0100 Subject: [PATCH] A complete maintenance planning system has been created --- db/patch-maintenance-table.sql | 25 +++ package-lock.json | 54 ++++--- server/database.js | 1 + server/model/heartbeat.js | 1 + server/model/maintenance.js | 38 +++++ server/model/monitor.js | 64 +++++++- server/routers/api-router.js | 38 ++++- server/server.js | 217 +++++++++++++++++++++++++ src/assets/app.scss | 1 + src/assets/vars.scss | 1 + src/components/HeartbeatBar.vue | 6 +- src/components/MonitorList.vue | 91 ++++++++++- src/components/PingChart.vue | 10 +- src/components/PublicGroupList.vue | 4 + src/components/Status.vue | 8 + src/components/Uptime.vue | 8 + src/icon.js | 2 + src/languages/en.js | 2 + src/languages/zh-TW.js | 1 - src/layouts/Layout.vue | 26 ++- src/mixins/datetime.js | 17 ++ src/mixins/socket.js | 36 ++++- src/pages/Dashboard.vue | 1 + src/pages/DashboardHome.vue | 22 ++- src/pages/Details.vue | 4 + src/pages/EditMaintenance.vue | 247 +++++++++++++++++++++++++++++ src/pages/EditMonitor.vue | 2 +- src/pages/MaintenanceDetails.vue | 141 ++++++++++++++++ src/pages/StatusPage.vue | 64 +++++++- src/router.js | 22 ++- src/util.js | 10 +- src/util.ts | 8 +- 32 files changed, 1121 insertions(+), 51 deletions(-) create mode 100644 db/patch-maintenance-table.sql create mode 100644 server/model/maintenance.js create mode 100644 src/pages/EditMaintenance.vue create mode 100644 src/pages/MaintenanceDetails.vue diff --git a/db/patch-maintenance-table.sql b/db/patch-maintenance-table.sql new file mode 100644 index 00000000..ee4a7f88 --- /dev/null +++ b/db/patch-maintenance-table.sql @@ -0,0 +1,25 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +CREATE TABLE maintenance +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + title VARCHAR(150), + description TEXT, + user_id INTEGER REFERENCES user ON UPDATE CASCADE ON DELETE SET NULL, + start_date DATETIME, + end_date DATETIME +); + +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_user_id on maintenance (user_id); + +COMMIT; diff --git a/package-lock.json b/package-lock.json index fc21a63f..7ab00b75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14914,7 +14914,8 @@ "@fortawesome/vue-fontawesome": { "version": "3.0.0-5", "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.0-5.tgz", - "integrity": "sha512-aNmBT4bOecrFsZTog1l6AJDQHPP3ocXV+WQ3Ogy8WZCqstB/ahfhH4CPu5i4N9Hw0MBKXqE+LX+NbUxcj8cVTw==" + "integrity": "sha512-aNmBT4bOecrFsZTog1l6AJDQHPP3ocXV+WQ3Ogy8WZCqstB/ahfhH4CPu5i4N9Hw0MBKXqE+LX+NbUxcj8cVTw==", + "requires": {} }, "@gar/promisify": { "version": "1.1.2", @@ -16117,7 +16118,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-1.9.4.tgz", "integrity": "sha512-0CZqaCoChriPTTtGkERy1LGPcYjGFpi2uYRhBPIkqJqUGV5JnJFhQAgh6oH9j5XZHfrRaisX8W0xSpO4T7S78A==", - "dev": true + "dev": true, + "requires": {} }, "@vue/compiler-core": { "version": "3.2.22", @@ -16277,7 +16279,8 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true + "dev": true, + "requires": {} }, "acorn-walk": { "version": "7.2.0", @@ -16766,7 +16769,8 @@ "bootstrap": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz", - "integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q==" + "integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q==", + "requires": {} }, "brace-expansion": { "version": "1.1.11", @@ -16958,7 +16962,8 @@ "chartjs-adapter-dayjs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs/-/chartjs-adapter-dayjs-1.0.0.tgz", - "integrity": "sha512-EnbVqTJGFKLpg1TROLdCEufrzbmIa2oeLGx8O2Wdjw2EoMudoOo9+YFu+6CM0Z0hQ/v3yq/e/Y6efQMu22n8Jg==" + "integrity": "sha512-EnbVqTJGFKLpg1TROLdCEufrzbmIa2oeLGx8O2Wdjw2EoMudoOo9+YFu+6CM0Z0hQ/v3yq/e/Y6efQMu22n8Jg==", + "requires": {} }, "check-password-strength": { "version": "2.0.3", @@ -17548,7 +17553,8 @@ "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", - "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==" + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "requires": {} } } }, @@ -17571,7 +17577,8 @@ "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", - "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==" + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "requires": {} } } }, @@ -20015,7 +20022,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true + "dev": true, + "requires": {} }, "jest-puppeteer": { "version": "6.0.0", @@ -21774,12 +21782,14 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-scss": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.2.tgz", - "integrity": "sha512-xfdkU128CkKKKVAwkyt0M8OdnelJ3MRcIRAPPQkRpoPeuzWY3RIeg7piRCpZ79MK7Q16diLXMMAD9dN5mauPlQ==" + "integrity": "sha512-xfdkU128CkKKKVAwkyt0M8OdnelJ3MRcIRAPPQkRpoPeuzWY3RIeg7piRCpZ79MK7Q16diLXMMAD9dN5mauPlQ==", + "requires": {} }, "postcss-selector-parser": { "version": "6.0.8", @@ -21979,7 +21989,8 @@ "version": "7.4.6", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", - "dev": true + "dev": true, + "requires": {} } } }, @@ -23080,7 +23091,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-6.0.0.tgz", "integrity": "sha512-ZorSSdyMcxWpROYUvLEMm0vSZud2uB7tX1hzBZwvVY9SV/uly4AvvJPPhCcymZL3fcQhEQG5AELmrxWqtmzacw==", - "dev": true + "dev": true, + "requires": {} }, "stylelint-config-standard": { "version": "24.0.0", @@ -23653,17 +23665,20 @@ "vue-confirm-dialog": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/vue-confirm-dialog/-/vue-confirm-dialog-1.0.2.tgz", - "integrity": "sha512-gTo1bMDWOLd/6ihmWv8VlPxhc9QaKoE5YqlsKydUOfrrQ3Q3taljF6yI+1TMtAtJLrvZ8DYrePhgBhY1VCJzbQ==" + "integrity": "sha512-gTo1bMDWOLd/6ihmWv8VlPxhc9QaKoE5YqlsKydUOfrrQ3Q3taljF6yI+1TMtAtJLrvZ8DYrePhgBhY1VCJzbQ==", + "requires": {} }, "vue-contenteditable": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/vue-contenteditable/-/vue-contenteditable-3.0.4.tgz", - "integrity": "sha512-CmtqT4zHQwLoJEyNVaXUjjUFPUVYlXXBHfSbRCHCUjODMqrn6L293LM1nc1ELx8epitZZvecTfIqOLlSzB3d+w==" + "integrity": "sha512-CmtqT4zHQwLoJEyNVaXUjjUFPUVYlXXBHfSbRCHCUjODMqrn6L293LM1nc1ELx8epitZZvecTfIqOLlSzB3d+w==", + "requires": {} }, "vue-demi": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.10.1.tgz", - "integrity": "sha512-L6Oi+BvmMv6YXvqv5rJNCFHEKSVu7llpWWJczqmAQYOdmPPw5PNYoz1KKS//Fxhi+4QP64dsPjtmvnYGo1jemA==" + "integrity": "sha512-L6Oi+BvmMv6YXvqv5rJNCFHEKSVu7llpWWJczqmAQYOdmPPw5PNYoz1KKS//Fxhi+4QP64dsPjtmvnYGo1jemA==", + "requires": {} }, "vue-eslint-parser": { "version": "7.11.0", @@ -23735,7 +23750,8 @@ "vue-demi": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.11.4.tgz", - "integrity": "sha512-/3xFwzSykLW2HiiLie43a+FFgNOcokbBJ+fzvFXd0r2T8MYohqvphUyDQ8lbAwzQ3Dlcrb1c9ykifGkhSIAk6A==" + "integrity": "sha512-/3xFwzSykLW2HiiLie43a+FFgNOcokbBJ+fzvFXd0r2T8MYohqvphUyDQ8lbAwzQ3Dlcrb1c9ykifGkhSIAk6A==", + "requires": {} } } }, @@ -23750,7 +23766,8 @@ "vue-toastification": { "version": "2.0.0-rc.5", "resolved": "https://registry.npmjs.org/vue-toastification/-/vue-toastification-2.0.0-rc.5.tgz", - "integrity": "sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==" + "integrity": "sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==", + "requires": {} }, "vuedraggable": { "version": "4.1.0", @@ -23929,7 +23946,8 @@ "version": "7.5.5", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", - "dev": true + "dev": true, + "requires": {} }, "xml-name-validator": { "version": "3.0.0", diff --git a/server/database.js b/server/database.js index afcace70..6645e537 100644 --- a/server/database.js +++ b/server/database.js @@ -53,6 +53,7 @@ class Database { "patch-2fa-invalidate-used-token.sql": true, "patch-notification_sent_history.sql": true, "patch-monitor-basic-auth.sql": true, + "patch-maintenance-table.sql": true, } /** diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js index e0a77c06..617ac598 100644 --- a/server/model/heartbeat.js +++ b/server/model/heartbeat.js @@ -10,6 +10,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 00000000..4958a203 --- /dev/null +++ b/server/model/maintenance.js @@ -0,0 +1,38 @@ +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"); + +class Maintenance extends BeanModel { + + /** + * Return a object that ready to parse to JSON for public + * Only show necessary data to public + */ + async toPublicJSON() { + return { + id: this.id, + title: this.title, + description: this.description, + start_date: this.start_date, + end_date: this.end_date + }; + } + + /** + * Return a object that ready to parse to JSON + */ + async toJSON() { + return { + id: this.id, + title: this.title, + description: this.description, + start_date: this.start_date, + end_date: this.end_date + }; + } +} + +module.exports = Maintenance; diff --git a/server/model/monitor.js b/server/model/monitor.js index c4441d63..cd62ec6b 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -6,7 +6,7 @@ dayjs.extend(utc); dayjs.extend(timezone); const axios = require("axios"); const { Prometheus } = require("../prometheus"); -const { debug, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util"); +const { debug, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger} = require("../../src/util"); const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, errorLog } = require("../util-server"); const { R } = require("redbean-node"); const { BeanModel } = require("redbean-node/dist/bean-model"); @@ -20,6 +20,7 @@ const apicache = require("../modules/apicache"); * 0 = DOWN * 1 = UP * 2 = PENDING + * 3 = MAINTENANCE */ class Monitor extends BeanModel { @@ -28,9 +29,12 @@ class Monitor extends BeanModel { * Only show necessary data to public */ async toPublicJSON() { + const maintenance = await R.getAll("SELECT mm.*, maintenance.start_date, maintenance.end_date FROM monitor_maintenance mm JOIN maintenance ON mm.maintenance_id = maintenance.id WHERE mm.monitor_id = ? AND datetime(maintenance.start_date) <= datetime('now', 'localtime') AND datetime(maintenance.end_date) >= datetime('now', 'localtime')", [this.id]); + return { id: this.id, name: this.name, + maintenance: (maintenance.length !== 0), }; } @@ -50,6 +54,7 @@ class Monitor extends BeanModel { } const tags = await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ?", [this.id]); + const maintenance = await R.getAll("SELECT mm.*, maintenance.start_date, maintenance.end_date FROM monitor_maintenance mm JOIN maintenance ON mm.maintenance_id = maintenance.id WHERE mm.monitor_id = ? AND datetime(maintenance.start_date) <= datetime('now', 'localtime') AND datetime(maintenance.end_date) >= datetime('now', 'localtime')", [this.id]); return { id: this.id, @@ -79,6 +84,7 @@ class Monitor extends BeanModel { pushToken: this.pushToken, notificationIDList, tags: tags, + maintenance: (maintenance.length !== 0), }; } @@ -136,6 +142,8 @@ class Monitor extends BeanModel { bean.time = R.isoDateTime(dayjs.utc()); bean.status = DOWN; + const maintenance = await R.getAll("SELECT mm.*, maintenance.start_date, maintenance.end_date FROM monitor_maintenance mm JOIN maintenance ON mm.maintenance_id = maintenance.id WHERE mm.monitor_id = ? AND datetime(maintenance.start_date) <= datetime('now', 'localtime') AND datetime(maintenance.end_date) >= datetime('now', 'localtime')", [this.id]); + if (this.isUpsideDown()) { bean.status = flipStatus(bean.status); } @@ -148,7 +156,11 @@ class Monitor extends BeanModel { } try { - if (this.type === "http" || this.type === "keyword") { + if (maintenance.length !== 0) { + 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(); @@ -387,8 +399,13 @@ class Monitor extends BeanModel { if (isImportant) { bean.important = true; - debug(`[${this.name}] sendNotification`); - await Monitor.sendNotification(isFirstBeat, this, bean); + if (Monitor.isImportantForNotification(isFirstBeat, previousBeat?.status, bean.status)) { + debug(`[${this.name}] sendNotification`); + await Monitor.sendNotification(isFirstBeat, this, bean); + } + else { + debug(`[${this.name}] will not sendNotification because it is (or was) under maintenance`); + } // Clear Status Page Cache debug(`[${this.name}] apicache clear`); @@ -405,6 +422,8 @@ class Monitor extends BeanModel { beatInterval = this.retryInterval; } console.warn(`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) { + console.warn(`Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`); } else { console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type}`); } @@ -598,7 +617,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 @@ -659,11 +678,42 @@ 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); + } + + 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; } static async sendNotification(isFirstBeat, monitor, bean) { diff --git a/server/routers/api-router.js b/server/routers/api-router.js index 1920cef7..19e4fcad 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -5,7 +5,7 @@ const server = require("../server"); const apicache = require("../modules/apicache"); const Monitor = require("../model/monitor"); const dayjs = require("dayjs"); -const { UP, flipStatus, debug } = require("../../src/util"); +const { UP, MAINTENANCE, flipStatus, debug} = require("../../src/util"); let router = express.Router(); let cache = apicache.middleware; @@ -51,6 +51,12 @@ router.get("/api/push/:pushToken", async (request, response) => { duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second"); } + const maintenance = await R.getAll("SELECT mm.*, maintenance.start_date, maintenance.end_date FROM monitor_maintenance mm JOIN maintenance ON mm.maintenance_id = maintenance.id WHERE mm.monitor_id = ? AND datetime(maintenance.start_date) <= datetime('now', 'localtime') AND datetime(maintenance.end_date) >= datetime('now', 'localtime')", [monitor.id]); + if (maintenance.length !== 0) { + msg = "Monitor under maintenance"; + status = MAINTENANCE; + } + debug("PreviousStatus: " + previousStatus); debug("Current Status: " + status); @@ -70,7 +76,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); } @@ -131,6 +137,34 @@ router.get("/api/status-page/incident", async (_, response) => { } }); +// Status Page - Maintenance List +// Can fetch only if published +router.get("/api/status-page/maintenance-list", async (_request, response) => { + allowDevAllOrigin(response); + + try { + await checkPublished(); + const publicMaintenanceList = []; + + let maintenanceBeanList = R.convertToBeans("maintenance", await R.getAll(` + SELECT maintenance.* + FROM maintenance + WHERE datetime(maintenance.start_date) <= datetime('now', 'localtime') + AND datetime(maintenance.end_date) >= datetime('now', 'localtime') + ORDER BY maintenance.end_date + `)); + + for (const bean of maintenanceBeanList) { + publicMaintenanceList.push(await bean.toPublicJSON()); + } + + response.json(publicMaintenanceList); + + } catch (error) { + send403(response, error.message); + } +}); + // Status Page - Monitor List // Can fetch only if published router.get("/api/status-page/monitor-list", cache("5 minutes"), async (_request, response) => { diff --git a/server/server.js b/server/server.js index 153cac4f..2b6933d7 100644 --- a/server/server.js +++ b/server/server.js @@ -132,6 +132,7 @@ const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sen const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); const TwoFA = require("./2fa"); +const apicache = require("./modules/apicache"); app.use(express.json()); @@ -162,6 +163,12 @@ let jwtSecret = null; */ let monitorList = {}; +/** +* Main maintenance list +* @type {{}} +*/ +let maintenanceList = {}; + /** * Show Setup Page * @type {boolean} @@ -625,6 +632,101 @@ exports.entryPage = "dashboard"; } }); + // Add a new maintenance + socket.on("addMaintenance", async (maintenance, callback) => { + try { + checkLogin(socket); + let bean = R.dispense("maintenance"); + + bean.import(maintenance); + bean.user_id = socket.userID; + let maintenanceID = await R.store(bean); + + await 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."); + } + + bean.title = maintenance.title; + bean.description = maintenance.description; + bean.start_date = maintenance.start_date; + bean.end_date = maintenance.end_date; + + await R.store(bean); + + await 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, + }); + } + }); + socket.on("getMonitorList", async (callback) => { try { checkLogin(socket); @@ -641,6 +743,22 @@ exports.entryPage = "dashboard"; } }); + socket.on("getMaintenanceList", async (callback) => { + try { + checkLogin(socket); + await sendMaintenanceList(socket); + callback({ + ok: true, + }); + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + socket.on("getMonitor", async (monitorID, callback) => { try { checkLogin(socket); @@ -665,6 +783,54 @@ exports.entryPage = "dashboard"; } }); + socket.on("getMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + console.log(`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("getMonitorMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + console.log(`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("getMonitorBeats", async (monitorID, period, callback) => { try { checkLogin(socket); @@ -769,6 +935,36 @@ exports.entryPage = "dashboard"; } }); + socket.on("deleteMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + console.log(`Delete Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + if (maintenanceID in maintenanceList) { + delete maintenanceList[maintenanceID]; + } + + await R.exec("DELETE FROM maintenance WHERE id = ? AND user_id = ? ", [ + maintenanceID, + socket.userID, + ]); + + callback({ + ok: true, + msg: "Deleted Successfully.", + }); + + await sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + socket.on("getTags", async (callback) => { try { checkLogin(socket); @@ -1394,11 +1590,18 @@ async function sendMonitorList(socket) { return list; } +async function sendMaintenanceList(socket) { + let list = await getMaintenanceJSONList(socket.userID); + io.to(socket.userID).emit("maintenanceList", list); + return list; +} + async function afterLogin(socket, user) { socket.userID = user.id; socket.join(user.id); let monitorList = await sendMonitorList(socket); + sendMaintenanceList(socket); sendNotificationList(socket); await sleep(500); @@ -1430,6 +1633,20 @@ async function getMonitorJSONList(userID) { return result; } +async function 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; +} + async function initDatabase(testMode = false) { if (! fs.existsSync(Database.path)) { console.log("Copying Database"); diff --git a/src/assets/app.scss b/src/assets/app.scss index cec64467..73b9d631 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -273,6 +273,7 @@ textarea.form-control { &.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 91ab917e..e48a6efb 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/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index be0b122e..abeed7cb 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -5,7 +5,7 @@ v-for="(beat, index) in shortBeatList" :key="index" class="beat" - :class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2) }" + :class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2), 'maintenance' : (beat.status === 3) }" :style="beatStyle" :title="getBeatTitle(beat)" /> @@ -200,6 +200,10 @@ export default { background-color: $warning; } + &.maintenance { + background-color: $maintenance; + } + &:not(.empty):hover { transition: all ease-in-out 0.15s; opacity: 0.8; diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index ef51e89c..d943efff 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -1,7 +1,12 @@ <template> <div class="shadow-box mb-3"> <div class="list-header"> - <div class="placeholder"></div> + <div class="search-wrapper float-start"> + <select v-model="selectedList" class="form-control"> + <option value="monitor" selected>{{$t('Monitor List')}}</option> + <option value="maintenance">{{$t('Maintenance List')}}</option> + </select> + </div> <div class="search-wrapper"> <a v-if="searchText == ''" class="search-icon"> <font-awesome-icon icon="search" /> @@ -13,11 +18,25 @@ </div> </div> <div class="monitor-list" :class="{ scrollbar: scrollbar }"> - <div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3"> + <div v-if="Object.keys($root.monitorList).length === 0 && selectedList === 'monitor'" class="text-center mt-3"> {{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link> </div> + <div v-if="Object.keys($root.maintenanceList).length === 0 && selectedList === 'maintenance'" class="text-center mt-3"> + {{ $t("No Maintenance, please") }} <router-link to="/addMaintenance">{{ $t("add one") }}</router-link> + </div> - <router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }"> + <router-link v-if="selectedList === 'maintenance'" v-for="(item, index) in sortedMaintenanceList" :key="index" :to="maintenanceURL(item.id)" class="item" :class="{ 'disabled': (Date.parse(item.end_date) < Date.now()) }"> + <div class="row"> + <div class="col-9 col-md-8 small-padding"> + <div class="info"> + <Uptime :monitor="null" type="maintenance" :pill="true" /> + {{ item.title }} + </div> + </div> + </div> + </router-link> + + <router-link v-if="selectedList === 'monitor'" v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }"> <div class="row"> <div class="col-9 col-md-8 small-padding" :class="{ 'monitorItem': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }"> <div class="info"> @@ -47,7 +66,7 @@ import HeartbeatBar from "../components/HeartbeatBar.vue"; import Uptime from "../components/Uptime.vue"; import Tag from "../components/Tag.vue"; -import { getMonitorRelativeURL } from "../util.ts"; +import {getMaintenanceRelativeURL, getMonitorRelativeURL} from "../util.ts"; export default { components: { @@ -63,9 +82,60 @@ export default { data() { return { searchText: "", + selectedList: "monitor" }; }, computed: { + sortedMaintenanceList() { + let result = Object.values(this.$root.maintenanceList); + + result.sort((m1, m2) => { + const now = Date.now(); + + if (Date.parse(m1.end_date) >= now !== Date.parse(m2.end_date) >= now) { + if (Date.parse(m2.end_date) < now) { + return -1; + } + if (Date.parse(m1.end_date) < now) { + return 1; + } + } + + if (Date.parse(m1.end_date) >= now && Date.parse(m2.end_date) >= now) { + if (Date.parse(m1.end_date) < Date.parse(m2.end_date)) { + return -1; + } + + if (Date.parse(m2.end_date) < Date.parse(m1.end_date)) { + return 1; + } + } + + if (Date.parse(m1.end_date) < now && Date.parse(m2.end_date) < now) { + if (Date.parse(m1.end_date) < Date.parse(m2.end_date)) { + return 1; + } + + if (Date.parse(m2.end_date) < Date.parse(m1.end_date)) { + return -1; + } + } + + return m1.title.localeCompare(m2.title); + }); + + // Simple filter by search text + // finds maintenance name + if (this.searchText !== "") { + const loweredSearchText = this.searchText.toLowerCase(); + result = result.filter(maintenance => { + return maintenance.title.toLowerCase().includes(loweredSearchText) + || maintenance.description.toLowerCase().includes(loweredSearchText); + }); + } + + return result; + }, sortedMonitorList() { let result = Object.values(this.$root.monitorList); @@ -96,7 +166,7 @@ export default { // Simple filter by search text // finds monitor name, tag name or tag value - if (this.searchText != "") { + if (this.searchText !== "") { const loweredSearchText = this.searchText.toLowerCase(); result = result.filter(monitor => { return monitor.name.toLowerCase().includes(loweredSearchText) @@ -112,6 +182,9 @@ export default { monitorURL(id) { return getMonitorRelativeURL(id); }, + maintenanceURL(id) { + return getMaintenanceRelativeURL(id); + }, clearSearchText() { this.searchText = ""; } @@ -174,4 +247,12 @@ export default { flex-wrap: wrap; gap: 0; } + +.bg-maintenance { + background-color: $maintenance; +} + +select { + text-align: center; +} </style> diff --git a/src/components/PingChart.vue b/src/components/PingChart.vue index aa209fab..fb380e04 100644 --- a/src/components/PingChart.vue +++ b/src/components/PingChart.vue @@ -24,7 +24,7 @@ import timezone from "dayjs/plugin/timezone"; import "chartjs-adapter-dayjs"; import { LineChart } from "vue-chart-3"; import { useToast } from "vue-toastification"; -import { UP, DOWN, PENDING } from "../util.ts"; +import { UP, DOWN, PENDING, MAINTENANCE } from "../util.ts"; dayjs.extend(utc); dayjs.extend(timezone); @@ -162,7 +162,8 @@ export default { }, chartData() { let pingData = []; // Ping Data for Line Chart, y-axis contains ping time - let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down, 0 if target is up + let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down (red color), under maintenance (blue color) or pending (orange color), 0 if target is up + let colorData = []; // Color Data for Bar Chart let heartbeatList = this.heartbeatList || (this.monitorId in this.$root.heartbeatList && this.$root.heartbeatList[this.monitorId]) || @@ -184,8 +185,9 @@ export default { }); downData.push({ x, - y: beat.status === DOWN ? 1 : 0, + y: (beat.status === DOWN || beat.status === MAINTENANCE || beat.status === PENDING) ? 1 : 0, }); + colorData.push((beat.status === MAINTENANCE) ? "rgba(23,71,245,0.41)" : ((beat.status === PENDING) ? "rgba(245,182,23,0.41)" : "#DC354568")) }); return { @@ -204,7 +206,7 @@ export default { type: "bar", data: downData, borderColor: "#00000000", - backgroundColor: "#DC354568", + backgroundColor: colorData, yAxisID: "y1", barThickness: "flex", barPercentage: 1, diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue index f30edcef..a6539b8b 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -146,4 +146,8 @@ export default { } } +.bg-maintenance { + background-color: $maintenance; +} + </style> diff --git a/src/components/Status.vue b/src/components/Status.vue index a3916adc..558ec3ee 100644 --- a/src/components/Status.vue +++ b/src/components/Status.vue @@ -22,6 +22,10 @@ export default { return "warning"; } + if (this.status === 3) { + return "maintenance"; + } + return "secondary"; }, @@ -38,6 +42,10 @@ export default { return this.$t("Pending"); } + if (this.status === 3) { + return this.$t("Maintenance"); + } + return this.$t("Unknown"); }, }, diff --git a/src/components/Uptime.vue b/src/components/Uptime.vue index 2717672c..d7aae6c6 100644 --- a/src/components/Uptime.vue +++ b/src/components/Uptime.vue @@ -15,6 +15,10 @@ export default { computed: { uptime() { + + if (this.type === "maintenance") { + return this.$t("Maintenance"); + } let key = this.monitor.id + "_" + this.type; @@ -26,6 +30,10 @@ export default { }, color() { + if (this.type === "maintenance" || this.monitor.maintenance) { + return "maintenance" + } + if (this.lastHeartBeat.status === 0) { return "danger" } diff --git a/src/icon.js b/src/icon.js index 88b8a8ec..3027d958 100644 --- a/src/icon.js +++ b/src/icon.js @@ -34,6 +34,7 @@ import { faAward, faLink, faChevronDown, + faWrench, } from "@fortawesome/free-solid-svg-icons"; library.add( @@ -67,6 +68,7 @@ library.add( faAward, faLink, faChevronDown, + faWrench, ); export { FontAwesomeIcon }; diff --git a/src/languages/en.js b/src/languages/en.js index 47513466..3edcc556 100644 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -7,11 +7,13 @@ export default { upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.", maxRedirectDescription: "Maximum number of redirects to follow. Set to 0 to disable redirects.", acceptedStatusCodesDescription: "Select status codes which are considered as a successful response.", + affectedMonitorsDescription: "Select monitors that are affected by current maintenance", passwordNotMatchMsg: "The repeat password does not match.", notificationDescription: "Notifications must be assigned to a monitor to function.", keywordDescription: "Search keyword in plain HTML or JSON response. The search is case-sensitive.", pauseDashboardHome: "Pause", deleteMonitorMsg: "Are you sure want to delete this monitor?", + deleteMaintenanceMsg: "Are you sure want to delete this maintenance?", deleteNotificationMsg: "Are you sure want to delete this notification for all monitors?", resoverserverDescription: "Cloudflare is the default server. You can change the resolver server anytime.", rrtypeDescription: "Select the RR type you want to monitor", diff --git a/src/languages/zh-TW.js b/src/languages/zh-TW.js index 0e905260..ec0f434d 100644 --- a/src/languages/zh-TW.js +++ b/src/languages/zh-TW.js @@ -340,7 +340,6 @@ export default { "No monitors available.": "沒有可用的監測器。", "Add one": "新增一個", "No Monitors": "無監測器", - "Add one": "新增一個", "Untitled Group": "未命名群組", Services: "服務", Discard: "捨棄", diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue index 75173e1f..4d6ca27d 100644 --- a/src/layouts/Layout.vue +++ b/src/layouts/Layout.vue @@ -51,7 +51,7 @@ <!-- Mobile Only --> <div v-if="$root.isMobile" style="width: 100%; height: 60px;" /> - <nav v-if="$root.isMobile" class="bottom-nav"> + <nav v-if="$root.isMobile" class="bottom-nav scroll"> <router-link to="/dashboard" class="nav-link"> <div><font-awesome-icon icon="tachometer-alt" /></div> {{ $t("Dashboard") }} @@ -64,7 +64,12 @@ <router-link to="/add" class="nav-link"> <div><font-awesome-icon icon="plus" /></div> - {{ $t("Add") }} + {{ $t("Add Monitor") }} + </router-link> + + <router-link to="/addMaintenance" class="nav-link"> + <div><font-awesome-icon icon="wrench" /></div> + {{ $t("Add Maintenance") }} </router-link> <router-link to="/settings" class="nav-link"> @@ -201,4 +206,21 @@ main { } } +.scroll { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.scroll::-webkit-scrollbar { + display: none; +} + +.scroll a { + flex: 0 0 auto; + min-width: fit-content; +} + </style> diff --git a/src/mixins/datetime.js b/src/mixins/datetime.js index 7cef22d2..08689520 100644 --- a/src/mixins/datetime.js +++ b/src/mixins/datetime.js @@ -22,6 +22,16 @@ export default { return this.datetimeFormat(value, "YYYY-MM-DD HH:mm:ss"); }, + datetimeMaintenance(value) { + const inputDate = new Date(value); + const now = new Date(Date.now()); + + if (inputDate.getFullYear() === now.getFullYear() && inputDate.getMonth() === now.getMonth() && inputDate.getDay() === now.getDay()) + return this.datetimeMaintenanceFormat(value, "HH:mm"); + else + return this.datetimeMaintenanceFormat(value, "YYYY-MM-DD HH:mm"); + }, + date(value) { return this.datetimeFormat(value, "YYYY-MM-DD"); }, @@ -41,6 +51,13 @@ export default { return dayjs.utc(value).tz(this.timezone).format(format); } return ""; + }, + + datetimeMaintenanceFormat(value, format) { + if (value !== undefined && value !== "") { + return dayjs(value).format(format); + } + return ""; } }, diff --git a/src/mixins/socket.js b/src/mixins/socket.js index affac4f8..3a475dc5 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -27,6 +27,7 @@ export default { allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed. loggedIn: false, monitorList: { }, + maintenanceList: { }, heartbeatList: { }, importantHeartbeatList: { }, avgPingList: { }, @@ -99,6 +100,10 @@ export default { this.monitorList = data; }); + socket.on("maintenanceList", (data) => { + this.maintenanceList = data; + }); + socket.on("notificationList", (data) => { this.notificationList = data; }); @@ -309,14 +314,37 @@ export default { socket.emit("getMonitorList", callback); }, + getMaintenanceList(callback) { + if (! callback) { + callback = () => { }; + } + socket.emit("getMaintenanceList", callback); + }, + add(monitor, callback) { socket.emit("add", monitor, callback); }, + addMaintenance(maintenance, callback) { + socket.emit("addMaintenance", maintenance, callback); + }, + + addMonitorMaintenance(maintenanceID, monitors, callback) { + socket.emit("addMonitorMaintenance", maintenanceID, monitors, callback); + }, + + getMonitorMaintenance(maintenanceID, callback) { + socket.emit("getMonitorMaintenance", maintenanceID, callback); + }, + deleteMonitor(monitorID, callback) { socket.emit("deleteMonitor", monitorID, callback); }, + deleteMaintenance(maintenanceID, callback) { + socket.emit("deleteMaintenance", maintenanceID, callback); + }, + clearData() { console.log("reset heartbeat list"); this.heartbeatList = {}; @@ -368,7 +396,13 @@ export default { for (let monitorID in this.lastHeartbeatList) { let lastHeartBeat = this.lastHeartbeatList[monitorID]; - if (! lastHeartBeat) { + if (this.monitorList[monitorID].maintenance) { + result[monitorID] = { + text: this.$t("Maintenance"), + color: "maintenance", + }; + } + else if (! lastHeartBeat) { result[monitorID] = unknown; } else if (lastHeartBeat.status === 1) { result[monitorID] = { diff --git a/src/pages/Dashboard.vue b/src/pages/Dashboard.vue index 1cf237ce..cad00963 100644 --- a/src/pages/Dashboard.vue +++ b/src/pages/Dashboard.vue @@ -4,6 +4,7 @@ <div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4"> <div> <router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link> + <router-link to="/addMaintenance" class="btn btn-primary mb-3 float-end"><font-awesome-icon icon="wrench" /> {{ $t("Add New Maintenance") }}</router-link> </div> <MonitorList :scrollbar="true" /> </div> diff --git a/src/pages/DashboardHome.vue b/src/pages/DashboardHome.vue index 16d07983..77ef90c7 100644 --- a/src/pages/DashboardHome.vue +++ b/src/pages/DashboardHome.vue @@ -15,6 +15,10 @@ <h3>{{ $t("Down") }}</h3> <span class="num text-danger">{{ stats.down }}</span> </div> + <div class="col"> + <h3>{{ $t("Maintenance") }}</h3> + <span class="num text-maintenance">{{ stats.maintenance }}</span> + </div> <div class="col"> <h3>{{ $t("Unknown") }}</h3> <span class="num text-secondary">{{ stats.unknown }}</span> @@ -38,7 +42,7 @@ </thead> <tbody> <tr v-for="(beat, index) in displayedRecords" :key="index" :class="{ 'shadow-box': $root.windowWidth <= 550}"> - <td><router-link :to="`/dashboard/${beat.monitorID}`">{{ beat.name }}</router-link></td> + <td><router-link :to="`/dashboard/monitor/${beat.monitorID}`">{{ beat.name }}</router-link></td> <td><Status :status="beat.status" /></td> <td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td> <td class="border-0">{{ beat.msg }}</td> @@ -93,6 +97,7 @@ export default { let result = { up: 0, down: 0, + maintenance: 0, unknown: 0, pause: 0, }; @@ -100,8 +105,11 @@ export default { for (let monitorID in this.$root.monitorList) { let beat = this.$root.lastHeartbeatList[monitorID]; let monitor = this.$root.monitorList[monitorID]; - - if (monitor && ! monitor.active) { + + if (monitor && monitor.maintenance) { + result.maintenance++; + } + else if (monitor && !monitor.active) { result.pause++; } else if (beat) { if (beat.status === 1) { @@ -173,6 +181,14 @@ export default { display: block; } +.text-maintenance { + color: $maintenance; +} + +.bg-maintenance { + background-color: $maintenance; +} + .shadow-box { padding: 20px; } diff --git a/src/pages/Details.vue b/src/pages/Details.vue index d40561fe..096f928b 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -499,4 +499,8 @@ table { margin-left: 0 !important; } +.bg-maintenance { + background-color: $maintenance; +} + </style> diff --git a/src/pages/EditMaintenance.vue b/src/pages/EditMaintenance.vue new file mode 100644 index 00000000..144b398a --- /dev/null +++ b/src/pages/EditMaintenance.vue @@ -0,0 +1,247 @@ +<template> + <transition name="slide-fade" appear> + <div> + <h1 class="mb-3">{{ pageName }}</h1> + <form @submit.prevent="submit"> + <div class="shadow-box"> + <div class="row"> + <div class="col-md-6"> + <h2 class="mb-2">{{ $t("General") }}</h2> + + <!-- Title --> + <div class="my-3"> + <label for="name" class="form-label">{{ $t("Title") }}</label> + <input id="name" v-model="maintenance.title" type="text" class="form-control" + :placeholder="titlePlaceholder" required> + </div> + + <!-- Description --> + <div class="my-3"> + <label for="description" class="form-label">{{ $t("Description") }}</label> + <textarea id="description" v-model="maintenance.description" class="form-control" + :placeholder="descriptionPlaceholder"></textarea> + </div> + + <!-- Affected Monitors --> + <div class="my-3"> + <label for="affected_monitors" class="form-label">{{ $t("Affected Monitors") }}</label> + + <VueMultiselect + id="affected_monitors" + v-model="affectedMonitors" + :options="affectedMonitorsOptions" + track-by="id" + label="name" + :multiple="true" + :allow-empty="false" + :close-on-select="false" + :clear-on-select="false" + :preserve-search="true" + :placeholder="$t('Pick Affected Monitors...')" + :preselect-first="false" + :max-height="600" + :taggable="false" + ></VueMultiselect> + + <div class="form-text"> + {{ $t("affectedMonitorsDescription") }} + </div> + </div> + + <!-- Start Date Time --> + <div class="my-3"> + <label for="start_date" class="form-label">{{ $t("Start of maintenance") }}</label> + <input :type="'datetime-local'" id="start_date" v-model="maintenance.start_date" + class="form-control" :class="{'darkCalendar': dark }" required> + </div> + + <!-- End Date Time --> + <div class="my-3"> + <label for="end_date" class="form-label">{{ $t("Expected end of maintenance") }}</label> + <input :type="'datetime-local'" id="end_date" v-model="maintenance.end_date" + class="form-control" :class="{'darkCalendar': dark }" required> + </div> + + <div class="mt-5 mb-1"> + <button id="monitor-submit-btn" class="btn btn-primary" type="submit" + :disabled="processing">{{ $t("Save") }} + </button> + </div> + </div> + </div> + </div> + </form> + </div> + </transition> +</template> + +<script> +import CopyableInput from "../components/CopyableInput.vue"; + +import {useToast} from "vue-toastification"; +import VueMultiselect from "vue-multiselect"; + +const toast = useToast(); + +export default { + components: { + CopyableInput, + VueMultiselect, + }, + + data() { + return { + processing: false, + maintenance: {}, + affectedMonitors: [], + affectedMonitorsOptions: [], + dark: (this.$root.theme === "dark"), + }; + }, + + computed: { + + pageName() { + return this.$t((this.isAdd) ? "Schedule maintenance" : "Edit"); + }, + + isAdd() { + return this.$route.path === "/addMaintenance"; + }, + + isEdit() { + return this.$route.path.startsWith("/editMaintenance"); + }, + + titlePlaceholder() { + return this.$t("Network infrastructure maintenance"); + }, + + descriptionPlaceholder() { + return this.$t("Example: Network infrastructure maintenance is underway which will affect some of our services."); + } + + }, + watch: { + + "$route.fullPath"() { + this.init(); + } + + }, + mounted() { + this.init(); + + this.$root.getMonitorList((res) => { + if (res.ok) { + Object.values(this.$root.monitorList).map(monitor => { + this.affectedMonitorsOptions.push({ + id: monitor.id, + name: monitor.name + }); + }); + } + }); + }, + methods: { + init() { + this.affectedMonitors = []; + + if (this.isAdd) { + this.maintenance = { + title: "", + description: "", + start_date: "", + end_date: "", + }; + } else if (this.isEdit) { + this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => { + if (res.ok) { + this.maintenance = res.maintenance; + + this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => { + if (res.ok) { + Object.values(res.monitors).map(monitor => { + this.affectedMonitors.push(monitor); + }); + } else { + toast.error(res.msg); + } + }); + } else { + toast.error(res.msg); + } + }); + } + + }, + + async submit() { + this.processing = true; + + if (this.affectedMonitors.length === 0) { + toast.error(this.$t("Select at least one affected monitor")); + return this.processing = false; + } + + if (this.isAdd) { + this.$root.addMaintenance(this.maintenance, async (res) => { + + if (res.ok) { + await this.addMonitorMaintenance(res.maintenanceID, () => { + toast.success(res.msg); + this.processing = false; + this.$root.getMaintenanceList(); + this.$router.push("/dashboard/maintenance/" + res.maintenanceID); + }); + } else { + toast.error(res.msg); + this.processing = false; + } + + }); + } else { + this.$root.getSocket().emit("editMaintenance", this.maintenance, async (res) => { + if (res.ok) { + await this.addMonitorMaintenance(res.maintenanceID, () => { + this.processing = false; + this.$root.toastRes(res); + this.init(); + }); + } + else { + this.processing = false; + toast.error(res.msg); + } + }); + } + }, + + async addMonitorMaintenance(maintenanceID, callback) { + await this.$root.addMonitorMaintenance(maintenanceID, this.affectedMonitors, async (res) => { + if (!res.ok) { + toast.error(res.msg); + } else { + this.$root.getMonitorList(); + } + + callback(); + }); + }, + }, +}; +</script> + +<style lang="scss" scoped> +.shadow-box { + padding: 20px; +} + +textarea { + min-height: 200px; +} + +.darkCalendar::-webkit-calendar-picker-indicator { + filter: invert(1); +} +</style> diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 4b6a920c..3ac05af3 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -509,7 +509,7 @@ export default { toast.success(res.msg); this.processing = false; this.$root.getMonitorList(); - this.$router.push("/dashboard/" + res.monitorID); + this.$router.push("/dashboard/monitor/" + res.monitorID); } else { toast.error(res.msg); this.processing = false; diff --git a/src/pages/MaintenanceDetails.vue b/src/pages/MaintenanceDetails.vue new file mode 100644 index 00000000..e3e4b59b --- /dev/null +++ b/src/pages/MaintenanceDetails.vue @@ -0,0 +1,141 @@ +<template> + <transition name="slide-fade" appear> + <div v-if="maintenance"> + <h1> {{ maintenance.title }}</h1> + <p class="url"> + <span>Start: {{ $root.datetimeMaintenance(maintenance.start_date) }}</span> + <br> + <span>End: {{ $root.datetimeMaintenance(maintenance.end_date) }}</span> + </p> + + <div class="functions" style="margin-top: 10px"> + <router-link :to=" '/editMaintenance/' + maintenance.id " class="btn btn-secondary"> + <font-awesome-icon icon="edit" /> {{ $t("Edit") }} + </router-link> + <button class="btn btn-danger" @click="deleteDialog"> + <font-awesome-icon icon="trash" /> {{ $t("Delete") }} + </button> + </div> + + <label for="description" class="form-label" style="margin-top: 20px">{{ $t("Description") }}</label> + <textarea id="description" class="form-control" disabled>{{ maintenance.description }}</textarea> + + <label for="affected_monitors" class="form-label" style="margin-top: 20px">{{ $t("Affected Monitors") }}</label> + <br> + <button v-for="monitor in this.affectedMonitors" class="btn btn-monitor" style="margin: 5px; cursor: auto; color: white; font-weight: bold"> + {{ monitor }} + </button> + + <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMaintenance"> + {{ $t("deleteMaintenanceMsg") }} + </Confirm> + </div> + </transition> +</template> + +<script> +import { useToast } from "vue-toastification"; +const toast = useToast(); +import Confirm from "../components/Confirm.vue"; + +export default { + components: { + Confirm, + }, + data() { + return { + affectedMonitors: [], + }; + }, + computed: { + maintenance() { + let id = this.$route.params.id; + return this.$root.maintenanceList[id]; + }, + }, + mounted() { + this.init(); + }, + methods: { + init() { + this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => { + if (res.ok) { + this.affectedMonitors = Object.values(res.monitors).map(monitor => monitor.name); + } else { + toast.error(res.msg); + } + }); + }, + + deleteDialog() { + this.$refs.confirmDelete.show(); + }, + + deleteMaintenance() { + this.$root.deleteMaintenance(this.maintenance.id, (res) => { + if (res.ok) { + toast.success(res.msg); + this.$router.push("/dashboard"); + } else { + toast.error(res.msg); + } + }); + }, + }, +}; +</script> + +<style lang="scss" scoped> +@import "../assets/vars.scss"; + +@media (max-width: 550px) { + .functions { + text-align: center; + + button, a { + margin-left: 10px !important; + margin-right: 10px !important; + } + } +} + +@media (max-width: 400px) { + .btn { + display: inline-flex; + flex-direction: column; + align-items: center; + padding-top: 10px; + } + + a.btn { + padding-left: 25px; + padding-right: 25px; + } +} + +.url { + color: $primary; + margin-bottom: 20px; + font-weight: bold; + + a { + color: $primary; + } +} + +.functions { + button, a { + margin-right: 20px; + } +} + +textarea { + min-height: 100px; + resize: none; +} + +.btn-monitor { + background-color: #5cdd8b; +} + +</style> diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 0dc49518..11716b45 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -144,6 +144,18 @@ </div> </div> + <!-- Maintenance --> + <div v-if="maintenance.length !== 0" v-for="maintenanceItem in maintenance" class="shadow-box alert mb-4 p-4 maintenance" role="alert" :class="maintenanceClass"> + <h4 v-text="maintenanceItem.title" class="alert-heading" /> + + <div v-text="maintenanceItem.description" class="content" /> + + <!-- Incident Date --> + <div class="date mt-3"> + {{ $t("End") }}: {{ $root.datetimeMaintenance(maintenanceItem.end_date) }} ({{ dateFromNowMaintenance(maintenanceItem.start_date) }})<br /> + </div> + </div> + <!-- Overall Status --> <div class="shadow-box list p-4 overall-status mb-4"> <div v-if="Object.keys($root.publicMonitorList).length === 0 && loadedData"> @@ -167,6 +179,11 @@ {{ $t("Degraded Service") }} </div> + <div v-else-if="isMaintenance"> + <font-awesome-icon icon="wrench" class="statusMaintenance" /> + {{ $t("Maintenance") }} + </div> + <div v-else> <font-awesome-icon icon="question-circle" style="color: #efefef;" /> </div> @@ -217,7 +234,14 @@ import axios from "axios"; import PublicGroupList from "../components/PublicGroupList.vue"; import ImageCropUpload from "vue-image-crop-upload"; -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"; import { useToast } from "vue-toastification"; import dayjs from "dayjs"; const toast = useToast(); @@ -259,6 +283,7 @@ export default { loadedTheme: false, loadedData: false, baseURL: "", + maintenance: [], }; }, computed: { @@ -320,6 +345,10 @@ export default { return "bg-" + this.incident.style; }, + maintenanceClass() { + return "bg-maintenance"; + }, + overallStatus() { if (Object.keys(this.$root.publicLastHeartbeatList).length === 0) { @@ -332,7 +361,10 @@ 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; @@ -358,6 +390,10 @@ export default { return this.overallStatus === STATUS_PAGE_ALL_DOWN; }, + isMaintenance() { + return this.overallStatus === STATUS_PAGE_MAINTENANCE; + }, + }, watch: { @@ -423,6 +459,10 @@ export default { } }); + axios.get("/api/status-page/maintenance-list").then((res) => { + this.maintenance = res.data; + }); + axios.get("/api/status-page/monitor-list").then((res) => { this.$root.publicGroupList = res.data; }); @@ -580,6 +620,10 @@ export default { return dayjs.utc(date).fromNow(); }, + dateFromNowMaintenance(date) { + return dayjs(date).fromNow(); + }, + } }; </script> @@ -671,6 +715,22 @@ footer { } } +.maintenance { + color: white; + + .date { + font-size: 12px; + } +} + +.bg-maintenance { + background-color: $maintenance; +} + +.statusMaintenance { + color: $maintenance; +} + .mobile { h1 { font-size: 22px; diff --git a/src/router.js b/src/router.js index a2414eb6..a78007ef 100644 --- a/src/router.js +++ b/src/router.js @@ -5,6 +5,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"; @@ -18,6 +19,7 @@ import MonitorHistory from "./components/settings/MonitorHistory.vue"; import Security from "./components/settings/Security.vue"; import Backup from "./components/settings/Backup.vue"; import About from "./components/settings/About.vue"; +import MaintenanceDetails from "./pages/MaintenanceDetails.vue"; const routes = [ { @@ -41,7 +43,7 @@ const routes = [ component: DashboardHome, children: [ { - path: "/dashboard/:id", + path: "/dashboard/monitor/:id", component: EmptyLayout, children: [ { @@ -54,10 +56,28 @@ const routes = [ }, ], }, + { + path: "/dashboard/maintenance/:id", + component: EmptyLayout, + children: [ + { + path: "", + component: MaintenanceDetails, + }, + { + path: "/editMaintenance/:id", + component: EditMaintenance, + }, + ], + }, { path: "/add", component: EditMonitor, }, + { + path: "/addMaintenance", + component: EditMaintenance, + }, { path: "/list", component: List, diff --git a/src/util.js b/src/util.js index b2df7ac7..dc5dea58 100644 --- a/src/util.js +++ b/src/util.js @@ -7,7 +7,7 @@ // 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.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; +exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = 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"); const dayjs = _dayjs; exports.isDev = process.env.NODE_ENV === "development"; @@ -15,9 +15,11 @@ 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; function flipStatus(s) { if (s === exports.UP) { return exports.DOWN; @@ -162,6 +164,10 @@ function genSecret(length = 64) { } exports.genSecret = genSecret; function getMonitorRelativeURL(id) { - return "/dashboard/" + id; + return "/dashboard/monitor/" + id; } exports.getMonitorRelativeURL = getMonitorRelativeURL; +function getMaintenanceRelativeURL(id) { + return "/dashboard/maintenance/" + id; +} +exports.getMaintenanceRelativeURL = getMaintenanceRelativeURL; diff --git a/src/util.ts b/src/util.ts index 633d933e..b9ce9e23 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,10 +14,12 @@ 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 function flipStatus(s: number) { @@ -185,5 +187,9 @@ export function genSecret(length = 64) { } export function getMonitorRelativeURL(id: string) { - return "/dashboard/" + id; + return "/dashboard/monitor/" + id; +} + +export function getMaintenanceRelativeURL(id: string) { + return "/dashboard/maintenance/" + id; }