diff --git a/db/patch-dependent-monitors-table.sql b/db/patch-dependent-monitors-table.sql new file mode 100644 index 000000000..3ba2aaff1 --- /dev/null +++ b/db/patch-dependent-monitors-table.sql @@ -0,0 +1,13 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +CREATE TABLE dependent_monitors +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + depends_on INTEGER NOT NULL, + CONSTRAINT FK_monitor_depends_on FOREIGN KEY (depends_on) REFERENCES monitor (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT FK_monitor_id FOREIGN KEY (monitor_id) REFERENCES monitor (id) ON DELETE CASCADE ON UPDATE CASCADE +); + +COMMIT; diff --git a/server/database.js b/server/database.js index afcace705..044c810d5 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-dependent-monitors-table.sql": true, } /** diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js index e0a77c069..3c8c851f7 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 + * 4 = DEGRADED */ class Heartbeat extends BeanModel { diff --git a/server/model/monitor.js b/server/model/monitor.js index c4441d63e..8d99b472f 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, DEGRADED, 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 + * 4 = DEGRADED */ class Monitor extends BeanModel { @@ -362,6 +363,11 @@ class Monitor extends BeanModel { retries = 0; + if (bean.status === UP && await Monitor.isDegraded(this.id)) { + bean.msg = "Monitor is degraded, because at least one dependent monitor is DOWN"; + bean.status = DEGRADED; + } + } catch (error) { bean.msg = error.message; @@ -387,8 +393,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 not required`); + } // Clear Status Page Cache debug(`[${this.name}] apicache clear`); @@ -405,6 +416,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 === DEGRADED) { + console.warn(`Monitor #${this.id} '${this.name}': Degraded: ${bean.msg} | Type: ${this.type}`); } else { console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type}`); } @@ -659,11 +672,42 @@ class Monitor extends BeanModel { // DOWN -> PENDING = this case not exists // DOWN -> DOWN = not important // * DOWN -> UP = important - let isImportant = isFirstBeat || + // * DEGRADED -> DOWN = important + // * DEGRADED -> UP = important + // * DOWN -> DEGRADED = important + // * UP -> DEGRADED = important + // DEGRADED -> PENDING = not important + return isFirstBeat || + (previousBeatStatus === DEGRADED && currentBeatStatus === DOWN) || + (previousBeatStatus === DEGRADED && currentBeatStatus === UP) || + (previousBeatStatus === DOWN && currentBeatStatus === DEGRADED) || + (previousBeatStatus === UP && currentBeatStatus === DEGRADED) || + (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 + // * DEGRADED -> DOWN = important + // DEGRADED -> UP = not important + // DOWN -> DEGRADED = not important + // UP -> DEGRADED = not important + // DEGRADED -> PENDING = not important + return isFirstBeat || + (previousBeatStatus === DEGRADED && currentBeatStatus === DOWN) || (previousBeatStatus === UP && currentBeatStatus === DOWN) || (previousBeatStatus === DOWN && currentBeatStatus === UP) || (previousBeatStatus === PENDING && currentBeatStatus === DOWN); - return isImportant; } static async sendNotification(isFirstBeat, monitor, bean) { @@ -763,6 +807,17 @@ class Monitor extends BeanModel { monitorID ]); } + + static async isDegraded(monitorID) { + const monitors = await R.getAll(` + SELECT hb.id FROM heartbeat hb JOIN dependent_monitors dm on hb.monitor_id = dm.depends_on JOIN (SELECT MAX(id) AS id FROM heartbeat GROUP BY monitor_id) USING (id) + WHERE dm.monitor_id = ? AND hb.status = 0 + `, [ + monitorID + ]); + + return monitors.length !== 0; + } } module.exports = Monitor; diff --git a/server/routers/api-router.js b/server/routers/api-router.js index 1920cef71..f31bbabc0 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, flipStatus, debug, DEGRADED } = require("../../src/util"); let router = express.Router(); let cache = apicache.middleware; @@ -51,6 +51,11 @@ router.get("/api/push/:pushToken", async (request, response) => { duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second"); } + if (status === UP && await Monitor.isDegraded(monitor.id)) { + msg = "Monitor is degraded, because at least one dependent monitor is DOWN"; + status = DEGRADED; + } + debug("PreviousStatus: " + previousStatus); debug("Current Status: " + status); @@ -70,7 +75,7 @@ router.get("/api/push/:pushToken", async (request, response) => { ok: true, }); - if (bean.important) { + if (Monitor.isImportantForNotification(isFirstBeat, previousStatus, status)) { await Monitor.sendNotification(isFirstBeat, monitor, bean); } diff --git a/server/server.js b/server/server.js index 153cac4fd..6a80605d5 100644 --- a/server/server.js +++ b/server/server.js @@ -625,6 +625,38 @@ exports.entryPage = "dashboard"; } }); + // Add a new dependent_monitors + socket.on("addDependentMonitors", async (monitorID, monitors, callback) => { + try { + checkLogin(socket); + + await R.exec("DELETE FROM dependent_monitors WHERE monitor_id = ?", [ + monitorID + ]); + + for await (const monitor of monitors) { + let bean = R.dispense("dependent_monitors"); + + bean.import({ + monitor_id: monitorID, + depends_on: monitor.id + }); + await R.store(bean); + } + + callback({ + ok: true, + msg: "Added Successfully.", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + socket.on("getMonitorList", async (callback) => { try { checkLogin(socket); @@ -665,6 +697,29 @@ exports.entryPage = "dashboard"; } }); + socket.on("getDependentMonitors", async (monitorID, callback) => { + try { + checkLogin(socket); + + console.log(`Get dependent Monitors for Monitor: ${monitorID} User ID: ${socket.userID}`); + + let monitors = await R.getAll("SELECT monitor.id, monitor.name FROM dependent_monitors dm JOIN monitor ON dm.depends_on = monitor.id WHERE dm.monitor_id = ? ", [ + monitorID, + ]); + + callback({ + ok: true, + monitors, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + socket.on("getMonitorBeats", async (monitorID, period, callback) => { try { checkLogin(socket); diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index be0b122ed..622de445c 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 || beat.status === 4) }" :style="beatStyle" :title="getBeatTitle(beat)" /> diff --git a/src/components/Status.vue b/src/components/Status.vue index a3916adce..b12474b6c 100644 --- a/src/components/Status.vue +++ b/src/components/Status.vue @@ -18,7 +18,7 @@ export default { return "primary"; } - if (this.status === 2) { + if (this.status === 2 || this.status === 4) { return "warning"; } @@ -38,6 +38,10 @@ export default { return this.$t("Pending"); } + if (this.status === 4) { + return this.$t("Degraded"); + } + return this.$t("Unknown"); }, }, diff --git a/src/components/Uptime.vue b/src/components/Uptime.vue index 2717672c4..dd65a1f97 100644 --- a/src/components/Uptime.vue +++ b/src/components/Uptime.vue @@ -34,7 +34,7 @@ export default { return "primary" } - if (this.lastHeartBeat.status === 2) { + if (this.lastHeartBeat.status === 2 || this.lastHeartBeat.status === 4) { return "warning" } diff --git a/src/languages/en.js b/src/languages/en.js index 47513466c..a885c6ce9 100644 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -6,6 +6,10 @@ export default { ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites", 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.", + Degraded: "Degraded", + "Dependent Monitors": "Dependent Monitors", + "Pick Dependent Monitors...": "Pick Dependent Monitors...", + dependentMonitorsDescription: "Select the monitor(s) on which this monitor depends. If the dependent monitor(s) fails, this monitor will be affected too.", acceptedStatusCodesDescription: "Select status codes which are considered as a successful response.", passwordNotMatchMsg: "The repeat password does not match.", notificationDescription: "Notifications must be assigned to a monitor to function.", diff --git a/src/mixins/socket.js b/src/mixins/socket.js index affac4f82..4685b90ac 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -317,6 +317,14 @@ export default { socket.emit("deleteMonitor", monitorID, callback); }, + addDependentMonitors(monitorID, monitors, callback) { + socket.emit("addDependentMonitors", monitorID, monitors, callback); + }, + + getDependentMonitors(monitorID, callback) { + socket.emit("getDependentMonitors", monitorID, callback); + }, + clearData() { console.log("reset heartbeat list"); this.heartbeatList = {}; @@ -385,6 +393,11 @@ export default { text: this.$t("Pending"), color: "warning", }; + } else if (lastHeartBeat.status === 4) { + result[monitorID] = { + text: this.$t("Degraded"), + color: "warning", + }; } else { result[monitorID] = unknown; } diff --git a/src/pages/DashboardHome.vue b/src/pages/DashboardHome.vue index 16d07983b..d9f910dd2 100644 --- a/src/pages/DashboardHome.vue +++ b/src/pages/DashboardHome.vue @@ -15,6 +15,10 @@

{{ $t("Down") }}

{{ stats.down }} +
+

{{ $t("Degraded") }}

+ {{ stats.degraded }} +

{{ $t("Unknown") }}

{{ stats.unknown }} @@ -93,6 +97,7 @@ export default { let result = { up: 0, down: 0, + degraded: 0, unknown: 0, pause: 0, }; @@ -110,6 +115,8 @@ export default { result.down++; } else if (beat.status === 2) { result.up++; + } else if (beat.status === 4) { + result.degraded++; } else { result.unknown++; } diff --git a/src/pages/Details.vue b/src/pages/Details.vue index d40561fe0..65a60209d 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -154,6 +154,14 @@
+
+ +
+ +
+ {{ $t("pauseMonitorMsg") }} @@ -212,6 +220,7 @@ export default { hideCount: true, chunksNavigation: "scroll", }, + dependentMonitors: [], }; }, computed: { @@ -286,9 +295,19 @@ export default { }, }, mounted() { - + this.init(); }, methods: { + init() { + this.$root.getSocket().emit("getDependentMonitors", this.$route.params.id, (res) => { + if (res.ok) { + this.dependentMonitors = Object.values(res.monitors).map(monitor => monitor.name); + } else { + toast.error(res.msg); + } + }); + }, + testNotification() { this.$root.getSocket().emit("testNotification", this.monitor.id); toast.success("Test notification is requested."); @@ -499,4 +518,8 @@ table { margin-left: 0 !important; } +.btn-monitor { + background-color: #5cdd8b; +} + diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 4b6a920c8..ed5646d93 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -189,6 +189,32 @@ + +
+ + + + +
+ {{ $t("dependentMonitorsDescription") }} +
+
+
@@ -317,6 +343,8 @@ export default { }, acceptedStatusCodeOptions: [], dnsresolvetypeOptions: [], + dependentMonitors: [], + dependentMonitorsOptions: [], // Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/ ipRegexPattern: "((^\\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\\s*$)|(^\\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?\\s*$))", @@ -392,6 +420,17 @@ export default { mounted() { this.init(); + this.$root.getMonitorList((res) => { + if (res.ok) { + Object.values(this.$root.monitorList).filter(monitor => monitor.id != this.$route.params.id).map(monitor => { + this.dependentMonitorsOptions.push({ + id: monitor.id, + name: monitor.name, + }); + }); + } + }); + let acceptedStatusCodeOptions = [ "100-199", "200-299", @@ -422,6 +461,8 @@ export default { }, methods: { init() { + this.dependentMonitors = []; + if (this.isAdd) { this.monitor = { @@ -451,6 +492,16 @@ export default { if (res.ok) { this.monitor = res.monitor; + this.$root.getSocket().emit("getDependentMonitors", this.$route.params.id, (res) => { + if (res.ok) { + Object.values(res.monitors).map(monitor => { + this.dependentMonitors.push(monitor); + }); + } else { + toast.error(res.msg); + } + }); + // Handling for monitors that are created before 1.7.0 if (this.monitor.retryInterval === 0) { this.monitor.retryInterval = this.monitor.interval; @@ -506,10 +557,12 @@ export default { if (res.ok) { await this.$refs.tagsManager.submit(res.monitorID); - toast.success(res.msg); - this.processing = false; - this.$root.getMonitorList(); - this.$router.push("/dashboard/" + res.monitorID); + await this.addDependentMonitors(res.monitorID, () => { + toast.success(res.msg); + this.processing = false; + this.$root.getMonitorList(); + this.$router.push("/dashboard/" + res.monitorID); + }); } else { toast.error(res.msg); this.processing = false; @@ -519,14 +572,27 @@ export default { } else { await this.$refs.tagsManager.submit(this.monitor.id); - this.$root.getSocket().emit("editMonitor", this.monitor, (res) => { - this.processing = false; - this.$root.toastRes(res); - this.init(); + this.$root.getSocket().emit("editMonitor", this.monitor, async (res) => { + await this.addDependentMonitors(this.monitor.id, () => { + this.processing = false; + this.$root.toastRes(res); + this.init(); + }); }); } }, + async addDependentMonitors(monitorID, callback) { + await this.$root.addDependentMonitors(monitorID, this.dependentMonitors, async (res) => { + if (!res.ok) { + toast.error(res.msg); + } else { + this.$root.getMonitorList(); + } + callback(); + }); + }, + // Added a Notification Event // Enable it if the notification is added in EditMonitor.vue addedNotification(id) { diff --git a/src/util.js b/src/util.js index b2df7ac79..9d7099d07 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.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.DEGRADED = 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,6 +15,7 @@ exports.appName = "Uptime Kuma"; exports.DOWN = 0; exports.UP = 1; exports.PENDING = 2; +exports.DEGRADED = 4; exports.STATUS_PAGE_ALL_DOWN = 0; exports.STATUS_PAGE_ALL_UP = 1; exports.STATUS_PAGE_PARTIAL_DOWN = 2; diff --git a/src/util.ts b/src/util.ts index 633d933ea..b63f8bb83 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,6 +14,7 @@ export const appName = "Uptime Kuma"; export const DOWN = 0; export const UP = 1; export const PENDING = 2; +export const DEGRADED = 4; export const STATUS_PAGE_ALL_DOWN = 0; export const STATUS_PAGE_ALL_UP = 1;