add Push-based monitoring (#279)

This commit is contained in:
LouisLam 2021-10-01 00:09:43 +08:00
parent 9e95d568c2
commit 1ed4ac9494
13 changed files with 292 additions and 30 deletions

View File

@ -0,0 +1,7 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD push_token VARCHAR(20) DEFAULT NULL;
COMMIT;

View File

@ -48,6 +48,7 @@ class Database {
"patch-add-retry-interval-monitor.sql": true,
"patch-incident-table.sql": true,
"patch-group-table.sql": true,
"patch-monitor-push_token.sql": true,
}
/**

View File

@ -69,6 +69,7 @@ class Monitor extends BeanModel {
dns_resolve_type: this.dns_resolve_type,
dns_resolve_server: this.dns_resolve_server,
dns_last_result: this.dns_last_result,
pushToken: this.pushToken,
notificationIDList,
tags: tags,
};
@ -236,6 +237,28 @@ class Monitor extends BeanModel {
bean.msg = dnsMessage;
bean.status = UP;
} else if (this.type === "push") { // Type: Push
const time = R.isoDateTime(dayjs.utc().subtract(this.interval, "second"));
let heartbeatCount = await R.count("heartbeat", " monitor_id = ? AND time > ? ", [
this.id,
time
]);
debug("heartbeatCount" + heartbeatCount + " " + time);
if (heartbeatCount <= 0) {
throw new Error("No heartbeat in the time window");
} else {
// No need to insert successful heartbeat for push type, so end here
retries = 0;
this.heartbeatInterval = setTimeout(beat, this.interval * 1000);
return;
}
} else {
bean.msg = "Unknown Monitor Type";
bean.status = PENDING;
}
if (this.isUpsideDown()) {
@ -263,6 +286,8 @@ class Monitor extends BeanModel {
}
}
let beatInterval = this.interval;
// * ? -> ANY STATUS = important [isFirstBeat]
// UP -> PENDING = not important
// * UP -> DOWN = important
@ -312,8 +337,6 @@ class Monitor extends BeanModel {
bean.important = false;
}
let beatInterval = this.interval;
if (bean.status === UP) {
console.info(`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`);
} else if (bean.status === PENDING) {
@ -339,7 +362,14 @@ class Monitor extends BeanModel {
};
// Delay Push Type
if (this.type === "push") {
setTimeout(() => {
beat();
}, this.interval * 1000);
} else {
beat();
}
}
stop() {

View File

@ -4,15 +4,53 @@ const { R } = require("redbean-node");
const server = require("../server");
const apicache = require("../modules/apicache");
const Monitor = require("../model/monitor");
const dayjs = require("dayjs");
const { UP } = require("../../src/util");
let router = express.Router();
let cache = apicache.middleware;
let io = server.io;
router.get("/api/entry-page", async (_, response) => {
allowDevAllOrigin(response);
response.json(server.entryPage);
});
router.get("/api/push/:pushToken", async (request, response) => {
try {
let pushToken = request.params.pushToken;
let msg = request.query.msg || "OK";
let monitor = await R.findOne("monitor", " push_token = ? AND active = 1 ", [
pushToken
]);
if (! monitor) {
throw new Error("Monitor not found or not active.");
}
let bean = R.dispense("heartbeat");
bean.monitor_id = monitor.id;
bean.time = R.isoDateTime(dayjs.utc());
bean.status = UP;
bean.msg = msg;
await R.store(bean);
io.to(monitor.user_id).emit("heartbeat", bean.toJSON());
Monitor.sendStats(io, monitor.id, monitor.user_id);
response.json({
ok: true,
});
} catch (e) {
response.json({
ok: false,
msg: e.message
});
}
});
// Status Page Config
router.get("/api/status-page/config", async (_request, response) => {
allowDevAllOrigin(response);

View File

@ -6,7 +6,7 @@ if (! process.env.NODE_ENV) {
console.log("Node Env: " + process.env.NODE_ENV);
const { sleep, debug, TimeLogger, getRandomInt } = require("../src/util");
const { sleep, debug, getRandomInt, genSecret } = require("../src/util");
console.log("Importing Node libraries");
const fs = require("fs");
@ -37,7 +37,7 @@ console.log("Importing this project modules");
debug("Importing Monitor");
const Monitor = require("./model/monitor");
debug("Importing Settings");
const { getSettings, setSettings, setting, initJWTSecret, genSecret, allowDevAllOrigin, checkLogin } = require("./util-server");
const { getSettings, setSettings, setting, initJWTSecret, checkLogin } = require("./util-server");
debug("Importing Notification");
const { Notification } = require("./notification");
@ -71,7 +71,7 @@ if (demoMode) {
console.log("==== Demo Mode ====");
}
console.log("Creating express and socket.io instance")
console.log("Creating express and socket.io instance");
const app = express();
let server;
@ -511,6 +511,7 @@ exports.entryPage = "dashboard";
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
bean.dns_resolve_type = monitor.dns_resolve_type;
bean.dns_resolve_server = monitor.dns_resolve_server;
bean.pushToken = monitor.pushToken;
await R.store(bean);

View File

@ -272,16 +272,6 @@ exports.getTotalClientInRoom = (io, roomName) => {
}
};
exports.genSecret = () => {
let secret = "";
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let charsLength = chars.length;
for ( let i = 0; i < 64; i++ ) {
secret += chars.charAt(Math.floor(Math.random() * charsLength));
}
return secret;
};
exports.allowDevAllOrigin = (res) => {
if (process.env.NODE_ENV === "development") {
exports.allowAllOrigin(res);

View File

@ -180,6 +180,11 @@ h2 {
border-color: $dark-border-color;
}
.form-control:disabled, .form-control[readonly] {
background-color: #232f3b;
opacity: 1;
}
.table-hover > tbody > tr:hover {
--bs-table-accent-bg: #070a10;
color: $dark-font-color;

View File

@ -0,0 +1,122 @@
<template>
<div class="input-group mb-3">
<input
:id="id"
ref="input"
v-model="model"
:type="type"
class="form-control"
:placeholder="placeholder"
:autocomplete="autocomplete"
:required="required"
:readonly="readonly"
:disabled="disabled"
>
<a class="btn btn-outline-primary" @click="copyToClipboard(model)">
<font-awesome-icon :icon="icon" />
</a>
</div>
</template>
<script>
let timeout;
export default {
props: {
id: {
type: String,
default: ""
},
type: {
type: String,
default: "text"
},
modelValue: {
type: String,
default: ""
},
placeholder: {
type: String,
default: ""
},
autocomplete: {
type: String,
default: undefined,
},
required: {
type: Boolean
},
readonly: {
type: String,
default: undefined,
},
disabled: {
type: String,
default: undefined,
},
},
data() {
return {
visibility: "password",
icon: "copy",
};
},
computed: {
model: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
}
}
},
created() {
},
methods: {
showInput() {
this.visibility = "text";
},
hideInput() {
this.visibility = "password";
},
copyToClipboard(textToCopy) {
this.icon = "check";
clearTimeout(timeout);
timeout = setTimeout(() => {
this.icon = "copy";
}, 3000);
// navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
// navigator clipboard api method'
return navigator.clipboard.writeText(textToCopy);
} else {
// text area method
let textArea = document.createElement("textarea");
textArea.value = textToCopy;
// make the textarea out of viewport
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
return new Promise((res, rej) => {
// here the magic happens
document.execCommand("copy") ? res() : rej();
textArea.remove();
});
}
}
}
};
</script>

View File

@ -26,7 +26,10 @@ import {
faArrowsAltV,
faUnlink,
faQuestionCircle,
faImages, faUpload,
faImages,
faUpload,
faCopy,
faCheck,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@ -54,6 +57,8 @@ library.add(
faQuestionCircle,
faImages,
faUpload,
faCopy,
faCheck,
);
export { FontAwesomeIcon };

View File

@ -36,5 +36,13 @@ export default {
return result;
},
baseURL() {
if (env === "development" || localStorage.dev === "dev") {
return axios.defaults.baseURL;
} else {
return location.protocol + "//" + location.host;
}
}
}
};

View File

@ -26,19 +26,31 @@
<option value="dns">
DNS
</option>
<option value="push">
Push
</option>
</select>
</div>
<!-- Friendly Name -->
<div class="my-3">
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="name" v-model="monitor.name" type="text" class="form-control" required>
</div>
<!-- URL -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3">
<label for="url" class="form-label">{{ $t("URL") }}</label>
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required>
</div>
<!-- Push URL -->
<div v-if="monitor.type === 'push' " class="my-3">
<label for="push-url" class="form-label">{{ $t("Push URL") }}</label>
<CopyableInput id="push-url" v-model="pushURL" type="url" disabled="disabled" />
</div>
<!-- Keyword -->
<div v-if="monitor.type === 'keyword' " class="my-3">
<label for="keyword" class="form-label">{{ $t("Keyword") }}</label>
<input id="keyword" v-model="monitor.keyword" type="text" class="form-control" required>
@ -210,13 +222,17 @@
<script>
import NotificationDialog from "../components/NotificationDialog.vue";
import TagsManager from "../components/TagsManager.vue";
import { useToast } from "vue-toastification"
import VueMultiselect from "vue-multiselect"
import { isDev } from "../util.ts";
const toast = useToast()
import CopyableInput from "../components/CopyableInput.vue";
import { useToast } from "vue-toastification";
import VueMultiselect from "vue-multiselect";
import { genSecret, isDev } from "../util.ts";
const toast = useToast();
export default {
components: {
CopyableInput,
NotificationDialog,
TagsManager,
VueMultiselect,
@ -236,7 +252,7 @@ export default {
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*$))",
// Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
hostnameRegexPattern: "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$"
}
};
},
computed: {
@ -253,23 +269,42 @@ export default {
pageName() {
return this.$t((this.isAdd) ? "Add New Monitor" : "Edit");
},
isAdd() {
return this.$route.path === "/add";
},
isEdit() {
return this.$route.path.startsWith("/edit");
},
pushURL() {
return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?msg=OK";
}
},
watch: {
"$route.fullPath"() {
this.init();
},
"monitor.interval"(value, oldValue) {
// Link interval and retryInerval if they are the same value.
if (this.monitor.retryInterval === oldValue) {
this.monitor.retryInterval = value;
}
},
"monitor.type"() {
if (this.monitor.type === "push") {
if (! this.monitor.pushToken) {
this.monitor.pushToken = genSecret(10);
}
}
}
},
mounted() {
this.init();
@ -320,7 +355,7 @@ export default {
accepted_statuscodes: ["200-299"],
dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1",
}
};
for (let i = 0; i < this.$root.notificationList.length; i++) {
if (this.$root.notificationList[i].isDefault == true) {
@ -337,9 +372,9 @@ export default {
this.monitor.retryInterval = this.monitor.interval;
}
} else {
toast.error(res.msg)
toast.error(res.msg);
}
})
});
}
},
@ -356,13 +391,13 @@ export default {
toast.success(res.msg);
this.processing = false;
this.$root.getMonitorList();
this.$router.push("/dashboard/" + res.monitorID)
this.$router.push("/dashboard/" + res.monitorID);
} else {
toast.error(res.msg);
this.processing = false;
}
})
});
} else {
await this.$refs.tagsManager.submit(this.monitor.id);
@ -370,7 +405,7 @@ export default {
this.processing = false;
this.$root.toastRes(res);
this.init();
})
});
}
},
@ -380,7 +415,7 @@ export default {
this.monitor.notificationIDList[id] = true;
},
},
}
};
</script>
<style scoped>

View File

@ -7,7 +7,7 @@
// Backend uses the compiled file util.js
// Frontend uses util.ts
Object.defineProperty(exports, "__esModule", { value: true });
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.genSecret = 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;
const _dayjs = require("dayjs");
const dayjs = _dayjs;
exports.isDev = process.env.NODE_ENV === "development";
@ -102,3 +102,13 @@ function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
exports.getRandomInt = getRandomInt;
function genSecret(length = 64) {
let secret = "";
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let charsLength = chars.length;
for (let i = 0; i < length; i++) {
secret += chars.charAt(Math.floor(Math.random() * charsLength));
}
return secret;
}
exports.genSecret = genSecret;

View File

@ -113,3 +113,13 @@ export function getRandomInt(min: number, max: number) {
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function genSecret(length = 64) {
let secret = "";
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let charsLength = chars.length;
for ( let i = 0; i < length; i++ ) {
secret += chars.charAt(Math.floor(Math.random() * charsLength));
}
return secret;
}