Added the ability to choose which monitors the current monitor depends on.

This commit is contained in:
Karel Krýda 2022-01-30 15:53:22 +01:00
parent a9df7b4a14
commit 64c8a90e8a
16 changed files with 269 additions and 20 deletions

View file

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

View file

@ -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,
}
/**

View file

@ -10,6 +10,7 @@ const { BeanModel } = require("redbean-node/dist/bean-model");
* 0 = DOWN
* 1 = UP
* 2 = PENDING
* 4 = DEGRADED
*/
class Heartbeat extends BeanModel {

View file

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

View file

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

View file

@ -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);

View file

@ -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)"
/>

View file

@ -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");
},
},

View file

@ -34,7 +34,7 @@ export default {
return "primary"
}
if (this.lastHeartBeat.status === 2) {
if (this.lastHeartBeat.status === 2 || this.lastHeartBeat.status === 4) {
return "warning"
}

View file

@ -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.",

View file

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

View file

@ -15,6 +15,10 @@
<h3>{{ $t("Down") }}</h3>
<span class="num text-danger">{{ stats.down }}</span>
</div>
<div class="col">
<h3>{{ $t("Degraded") }}</h3>
<span class="num text-warning">{{ stats.degraded }}</span>
</div>
<div class="col">
<h3>{{ $t("Unknown") }}</h3>
<span class="num text-secondary">{{ stats.unknown }}</span>
@ -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++;
}

View file

@ -154,6 +154,14 @@
</div>
</div>
<div class="shadow-box table-shadow-box">
<label for="dependent-monitors" class="form-label" style="margin-top: 20px">{{ $t("Dependent Monitors") }}</label>
<br>
<button v-for="monitor in this.dependentMonitors" class="btn btn-monitor" style="margin: 5px; cursor: auto; color: white; font-weight: bold">
{{ monitor }}
</button>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseMonitor">
{{ $t("pauseMonitorMsg") }}
</Confirm>
@ -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;
}
</style>

View file

@ -189,6 +189,32 @@
</div>
</template>
<!-- Dependent Monitors -->
<div class="my-3">
<label for="dependent-monitors" class="form-label">{{ $t("Dependent Monitors") }}</label>
<VueMultiselect
id="dependent-monitors"
v-model="dependentMonitors"
:options="dependentMonitorsOptions"
track-by="id"
label="name"
:multiple="true"
:allow-empty="false"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('Pick Dependent Monitors...')"
:preselect-first="false"
:max-height="600"
:taggable="false"
></VueMultiselect>
<div class="form-text">
{{ $t("dependentMonitorsDescription") }}
</div>
</div>
<div class="my-3">
<tags-manager ref="tagsManager" :pre-selected-tags="monitor.tags"></tags-manager>
</div>
@ -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) {

View file

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

View file

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