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;
 }