diff --git a/.dockerignore b/.dockerignore index b97db4abb..825d58038 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,13 @@ /dist /node_modules /data/kuma.db +/.do +**/.dockerignore +**/.git +**/.gitignore +**/docker-compose* +**/Dockerfile* +LICENSE +README.md +.editorconfig +.vscode diff --git a/.github/ISSUE_TEMPLATE/--please-go-to--discussion--tab-if-you-want-to-ask-or-share-something.md b/.github/ISSUE_TEMPLATE/--please-go-to--discussion--tab-if-you-want-to-ask-or-share-something.md new file mode 100644 index 000000000..eb8623709 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/--please-go-to--discussion--tab-if-you-want-to-ask-or-share-something.md @@ -0,0 +1,10 @@ +--- +name: ⚠ Please go to "Discussions" Tab if you want to ask or share something +about: BUG REPORT ONLY HERE +title: '' +labels: '' +assignees: '' + +--- + +BUG REPORT ONLY HERE diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..cea1fc16e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - Uptime Kuma Version: + - Using Docker?: Yes/No + - OS: + - Browser: + + +**Additional context** +Add any other context about the problem here. diff --git a/.gitignore b/.gitignore index 8d435974f..9caa313cb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist-ssr /data !/data/.gitkeep +.vscode \ No newline at end of file diff --git a/db/kuma.db b/db/kuma.db index 07c93cf89..6e02ccc01 100644 Binary files a/db/kuma.db and b/db/kuma.db differ diff --git a/db/patch1.sql b/db/patch1.sql new file mode 100644 index 000000000..6a31fa2f6 --- /dev/null +++ b/db/patch1.sql @@ -0,0 +1,37 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +-- Change Monitor.created_date from "TIMESTAMP" to "DATETIME" +-- SQL Generated by Intellij Idea +PRAGMA foreign_keys=off; + +BEGIN TRANSACTION; + +create table monitor_dg_tmp +( + id INTEGER not null + primary key autoincrement, + name VARCHAR(150), + active BOOLEAN default 1 not null, + user_id INTEGER + references user + on update cascade on delete set null, + interval INTEGER default 20 not null, + url TEXT, + type VARCHAR(20), + weight INTEGER default 2000, + hostname VARCHAR(255), + port INTEGER, + created_date DATETIME, + keyword VARCHAR(255) +); + +insert into monitor_dg_tmp(id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword from monitor; + +drop table monitor; + +alter table monitor_dg_tmp rename to monitor; + +create index user_id on monitor (user_id); + +COMMIT; + +PRAGMA foreign_keys=on; diff --git a/db/patch3.sql b/db/patch3.sql new file mode 100644 index 000000000..e615632f9 --- /dev/null +++ b/db/patch3.sql @@ -0,0 +1,37 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +-- Add maxretries column to monitor +PRAGMA foreign_keys=off; + +BEGIN TRANSACTION; + +create table monitor_dg_tmp +( + id INTEGER not null + primary key autoincrement, + name VARCHAR(150), + active BOOLEAN default 1 not null, + user_id INTEGER + references user + on update cascade on delete set null, + interval INTEGER default 20 not null, + url TEXT, + type VARCHAR(20), + weight INTEGER default 2000, + hostname VARCHAR(255), + port INTEGER, + created_date DATETIME, + keyword VARCHAR(255), + maxretries INTEGER NOT NULL DEFAULT 0 +); + +insert into monitor_dg_tmp(id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword from monitor; + +drop table monitor; + +alter table monitor_dg_tmp rename to monitor; + +create index user_id on monitor (user_id); + +COMMIT; + +PRAGMA foreign_keys=on; diff --git a/extra/healthcheck.js b/extra/healthcheck.js new file mode 100644 index 000000000..b547fbcba --- /dev/null +++ b/extra/healthcheck.js @@ -0,0 +1,19 @@ +var http = require("http"); +var options = { + host: "localhost", + port: "3001", + timeout: 2000, +}; +var request = http.request(options, (res) => { + console.log(`STATUS: ${res.statusCode}`); + if (res.statusCode == 200) { + process.exit(0); + } else { + process.exit(1); + } +}); +request.on("error", function (err) { + console.log("ERROR"); + process.exit(1); +}); +request.end(); diff --git a/extra/mark-as-nightly.js b/extra/mark-as-nightly.js new file mode 100644 index 000000000..28496511b --- /dev/null +++ b/extra/mark-as-nightly.js @@ -0,0 +1,40 @@ +/** + * String.prototype.replaceAll() polyfill + * https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/ + * @author Chris Ferdinandi + * @license MIT + */ +if (!String.prototype.replaceAll) { + String.prototype.replaceAll = function(str, newStr){ + + // If a regex pattern + if (Object.prototype.toString.call(str).toLowerCase() === '[object regexp]') { + return this.replace(str, newStr); + } + + // If a string + return this.replace(new RegExp(str, 'g'), newStr); + + }; +} + +const pkg = require('../package.json'); +const fs = require("fs"); +const oldVersion = pkg.version +const newVersion = oldVersion + "-nightly" + +console.log("Old Version: " + oldVersion) +console.log("New Version: " + newVersion) + +if (newVersion) { + // Process package.json + pkg.version = newVersion + pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion) + pkg.scripts["build-docker"] = pkg.scripts["build-docker"].replaceAll(oldVersion, newVersion) + fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n") + + // Process README.md + if (fs.existsSync("README.md")) { + fs.writeFileSync("README.md", fs.readFileSync("README.md", 'utf8').replaceAll(oldVersion, newVersion)) + } +} diff --git a/index.html b/index.html index 3dd55d3f1..66d58c1e6 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,8 @@ + + Uptime Kuma diff --git a/package.json b/package.json index a4624fd6d..d4fe68885 100644 --- a/package.json +++ b/package.json @@ -1,65 +1,55 @@ { "name": "uptime-kuma", - "version": "1.2.0", + "version": "1.0.6", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/louislam/uptime-kuma.git" + }, "scripts": { "dev": "vite --host", "start-server": "node server/server.js", "update": "", - "release": "release-it", "build": "vite build", "vite-preview-dist": "vite preview --host", - "build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.0.1 . --push", - "setup": "git checkout 1.0.1 && npm install && npm run build" + "build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.0.6 --target release . --push", + "build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push", + "build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push", + "setup": "git checkout 1.0.6 && npm install && npm run build", + "version-global-replace": "node extra/version-global-replace.js", + "mark-as-nightly": "node extra/mark-as-nightly.js" }, "dependencies": { - "@popperjs/core": "2.9.2", - "args-parser": "1.3.0", - "axios": "0.21.1", - "bcrypt": "5.0.1", - "bootstrap": "5.0.2", - "dayjs": "1.10.6", - "express": "4.17.1", - "form-data": "4.0.0", - "jsonwebtoken": "8.5.1", - "nodemailer": "6.6.3", - "password-hash": "1.2.2", + "@popperjs/core": "^2.9.2", + "args-parser": "^1.3.0", + "axios": "^0.21.1", + "bcrypt": "^5.0.1", + "bootstrap": "^5.0.2", + "command-exists": "^1.2.9", + "dayjs": "^1.10.6", + "express": "^4.17.1", + "form-data": "^4.0.0", + "http-graceful-shutdown": "^3.1.2", + "jsonwebtoken": "^8.5.1", + "nodemailer": "^6.6.3", + "password-hash": "^1.2.2", "redbean-node": "0.0.20", - "socket.io": "4.1.3", - "socket.io-client": "4.1.3", - "tcp-ping": "0.1.1", - "vue": "3.1.4", - "vue-confirm-dialog": "1.0.2", - "vue-router": "4.0.10", - "vue-toastification": "2.0.0-rc.1" + "socket.io": "^4.1.3", + "socket.io-client": "^4.1.3", + "sqlite3": "^5.0.2", + "tcp-ping": "^0.1.1", + "v-pagination-3": "^0.1.6", + "vue": "^3.0.5", + "vue-confirm-dialog": "^1.0.2", + "vue-router": "^4.0.10", + "vue-toastification": "^2.0.0-rc.1" }, "devDependencies": { - "@vitejs/plugin-legacy": "1.4.4", - "@vitejs/plugin-vue": "1.2.5", - "@vue/compiler-sfc": "3.1.4", - "auto-changelog": "2.3.0", - "core-js": "3.15.2", - "release-it": "14.10.0", - "sass": "1.35.2", - "vite": "2.4.2" - }, - "release-it": { - "git": { - "commit": true, - "requireCleanWorkingDir": false, - "commitMessage": "πŸš€RELEASE v${version}", - "push": false, - "tag": true, - "tagName": "v${version}", - "tagAnnotation": "v${version}" - }, - "npm": { - "publish": false - }, - "hooks": { - "after:bump": "auto-changelog --commit-limit false -p -u --hide-credit && git add CHANGELOG.md" - } - }, - "volta": { - "node": "16.4.2" + "@vitejs/plugin-legacy": "^1.4.4", + "@vitejs/plugin-vue": "^1.2.5", + "@vue/compiler-sfc": "^3.1.5", + "core-js": "^3.15.2", + "sass": "^1.35.2", + "vite": "^2.4.2" } } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index db02af7ba..0e9c109f3 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/server/database.js b/server/database.js new file mode 100644 index 000000000..49659e613 --- /dev/null +++ b/server/database.js @@ -0,0 +1,119 @@ +const fs = require("fs"); +const {sleep} = require("./util"); +const {R} = require("redbean-node"); +const {setSetting, setting} = require("./util-server"); + + +class Database { + + static templatePath = "./db/kuma.db" + static path = './data/kuma.db'; + static latestVersion = 1; + static noReject = true; + + static async patch() { + let version = parseInt(await setting("database_version")); + + if (! version) { + version = 0; + } + + console.info("Your database version: " + version); + console.info("Latest database version: " + this.latestVersion); + + if (version === this.latestVersion) { + console.info("Database no need to patch"); + } else { + console.info("Database patch is needed") + + console.info("Backup the db") + const backupPath = "./data/kuma.db.bak" + version; + fs.copyFileSync(Database.path, backupPath); + + // Try catch anything here, if gone wrong, restore the backup + try { + for (let i = version + 1; i <= this.latestVersion; i++) { + const sqlFile = `./db/patch${i}.sql`; + console.info(`Patching ${sqlFile}`); + await Database.importSQLFile(sqlFile); + console.info(`Patched ${sqlFile}`); + await setSetting("database_version", i); + } + console.log("Database Patched Successfully"); + } catch (ex) { + await Database.close(); + console.error("Patch db failed!!! Restoring the backup") + fs.copyFileSync(backupPath, Database.path); + console.error(ex) + + console.error("Start Uptime-Kuma failed due to patch db failed") + console.error("Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues") + process.exit(1); + } + } + } + + /** + * Sadly, multi sql statements is not supported by many sqlite libraries, I have to implement it myself + * @param filename + * @returns {Promise} + */ + static async importSQLFile(filename) { + + await R.getCell("SELECT 1"); + + let text = fs.readFileSync(filename).toString(); + + // Remove all comments (--) + let lines = text.split("\n"); + lines = lines.filter((line) => { + return ! line.startsWith("--") + }); + + // Split statements by semicolon + // Filter out empty line + text = lines.join("\n") + + let statements = text.split(";") + .map((statement) => { + return statement.trim(); + }) + .filter((statement) => { + return statement !== ""; + }) + + for (let statement of statements) { + await R.exec(statement); + } + } + + /** + * Special handle, because tarn.js throw a promise reject that cannot be caught + * @returns {Promise} + */ + static async close() { + const listener = (reason, p) => { + Database.noReject = false; + }; + process.addListener('unhandledRejection', listener); + + console.log("Closing DB") + + while (true) { + Database.noReject = true; + await R.close() + await sleep(2000) + + if (Database.noReject) { + break; + } else { + console.log("Waiting to close the db") + } + } + console.log("SQLite closed") + + process.removeListener('unhandledRejection', listener); + } +} + +module.exports = Database; diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js index 74e329811..01fb71ff9 100644 --- a/server/model/heartbeat.js +++ b/server/model/heartbeat.js @@ -3,8 +3,6 @@ const utc = require('dayjs/plugin/utc') var timezone = require('dayjs/plugin/timezone') dayjs.extend(utc) dayjs.extend(timezone) -const axios = require("axios"); -const {R} = require("redbean-node"); const {BeanModel} = require("redbean-node/dist/bean-model"); diff --git a/server/model/monitor.js b/server/model/monitor.js index 162772875..133088671 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -5,6 +5,7 @@ var timezone = require('dayjs/plugin/timezone') dayjs.extend(utc) dayjs.extend(timezone) const axios = require("axios"); +const {UP, DOWN, PENDING} = require("../util"); const {tcping, ping} = require("../util-server"); const {R} = require("redbean-node"); const {BeanModel} = require("redbean-node/dist/bean-model"); @@ -16,7 +17,6 @@ const {Notification} = require("../notification") * 1 = UP */ class Monitor extends BeanModel { - async toJSON() { let notificationIDList = {}; @@ -35,6 +35,7 @@ class Monitor extends BeanModel { url: this.url, hostname: this.hostname, port: this.port, + maxretries: this.maxretries, weight: this.weight, active: this.active, type: this.type, @@ -46,9 +47,9 @@ class Monitor extends BeanModel { start(io) { let previousBeat = null; + let retries = 0; const beat = async () => { - console.log(`Monitor ${this.id}: Heartbeat`) if (! previousBeat) { previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [ @@ -56,13 +57,15 @@ class Monitor extends BeanModel { ]) } + const isFirstBeat = !previousBeat; + let bean = R.dispense("heartbeat") bean.monitor_id = this.id; bean.time = R.isoDateTime(dayjs.utc()); - bean.status = 0; + bean.status = DOWN; // Duration - if (previousBeat) { + if (! isFirstBeat) { bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), 'second'); } else { bean.duration = 0; @@ -78,7 +81,7 @@ class Monitor extends BeanModel { bean.ping = dayjs().valueOf() - startTime; if (this.type === "http") { - bean.status = 1; + bean.status = UP; } else { let data = res.data; @@ -90,7 +93,7 @@ class Monitor extends BeanModel { if (data.includes(this.keyword)) { bean.msg += ", keyword is found" - bean.status = 1; + bean.status = UP; } else { throw new Error(bean.msg + ", but keyword is not found") } @@ -101,32 +104,52 @@ class Monitor extends BeanModel { } else if (this.type === "port") { bean.ping = await tcping(this.hostname, this.port); bean.msg = "" - bean.status = 1; + bean.status = UP; } else if (this.type === "ping") { bean.ping = await ping(this.hostname); bean.msg = "" - bean.status = 1; + bean.status = UP; } + retries = 0; + } catch (error) { + if ((this.maxretries > 0) && (retries < this.maxretries)) { + retries++; + bean.status = PENDING; + } bean.msg = error.message; } - // Mark as important if status changed - if (! previousBeat || previousBeat.status !== bean.status) { + // * ? -> 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 + let isImportant = isFirstBeat || + (previousBeat.status === UP && bean.status === DOWN) || + (previousBeat.status === DOWN && bean.status === UP) || + (previousBeat.status === PENDING && bean.status === DOWN); + + // Mark as important if status changed, ignore pending pings, + // Don't notify if disrupted changes to up + if (isImportant) { bean.important = true; - // Do not send if first beat is UP - if (previousBeat || bean.status !== 1) { + // Send only if the first beat is DOWN + if (!isFirstBeat || bean.status === DOWN) { let notificationList = await R.getAll(`SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id `, [ this.id ]) - let promiseList = []; - let text; - if (bean.status === 1) { + if (bean.status === UP) { text = "βœ… Up" } else { text = "πŸ”΄ Down" @@ -135,16 +158,26 @@ class Monitor extends BeanModel { let msg = `[${this.name}] [${text}] ${bean.msg}`; for(let notification of notificationList) { - promiseList.push(Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON())); + try { + await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON()) + } catch (e) { + console.error("Cannot send notification to " + notification.name) + } } - - await Promise.all(promiseList); } } else { bean.important = false; } + if (bean.status === UP) { + console.info(`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${this.interval} seconds | Type: ${this.type}`) + } else if (bean.status === PENDING) { + console.warn(`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Type: ${this.type}`) + } else { + console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Type: ${this.type}`) + } + io.to(this.user_id).emit("heartbeat", bean.toJSON()); await R.store(bean) @@ -233,7 +266,7 @@ class Monitor extends BeanModel { } total += value; - if (row.status === 0) { + if (row.status === 0 || row.status === 2) { downtime += value; } } diff --git a/server/notification.js b/server/notification.js index db4aec1f6..9da8a0dc8 100644 --- a/server/notification.js +++ b/server/notification.js @@ -1,10 +1,23 @@ const axios = require("axios"); -const { R } = require("redbean-node"); +const {R} = require("redbean-node"); const FormData = require('form-data'); const nodemailer = require("nodemailer"); +const child_process = require("child_process"); class Notification { + + /** + * + * @param notification + * @param msg + * @param monitorJSON + * @param heartbeatJSON + * @returns {Promise} Successful msg + * Throw Error with fail msg + */ static async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + let okMsg = "Sent Successfully. "; + if (notification.type === "telegram") { try { await axios.get(`https://api.telegram.org/bot${notification.telegramBotToken}/sendMessage`, { @@ -13,15 +26,16 @@ class Notification { text: msg, } }) - return true; + return okMsg; + } catch (error) { - console.log(error) - return false; + let msg = (error.response.data.description) ? error.response.data.description : "Error without description" + throw new Error(msg) } } else if (notification.type === "gotify") { try { - if (notification.gotifyserverurl.endsWith("/")) { + if (notification.gotifyserverurl && notification.gotifyserverurl.endsWith("/")) { notification.gotifyserverurl = notification.gotifyserverurl.slice(0, -1); } await axios.post(`${notification.gotifyserverurl}/message?token=${notification.gotifyapplicationToken}`, { @@ -29,135 +43,15 @@ class Notification { "priority": notification.gotifyPriority || 8, "title": "Uptime-Kuma" }) - return true; - } catch (error) { - console.log(error) - return false; - } - } else if (notification.type === "pushover") { - try { - await axios.post("https://api.pushover.net/1/messages.json", { - "message": msg, - "token": notification.pushoverAppToken, - "user": notification.pushoverUserKey, - "title": "Uptime-Kuma" - }) - return true; - } catch (error) { - console.log(error) - return false; - } + return okMsg; - } else if (notification.type === "pushy") { - try { - await axios.post(`https://api.pushy.me/push?api_key=${notification.pushyAPIKey}`, { - "to": notification.pushyToken, - "data": { - "message": "Uptime-Kuma" - }, - "notification": { - "body": msg, - "badge": 1, - "sound": "ping.aiff" - } - }) - return true; } catch (error) { - console.log(error) - return false; - } - - } else if (notification.type === "slack") { - try { - if (heartbeatJSON == null) { - let data = { - "blocks": [{ - "type": "header", - "text": { - "type": "plain_text", - "text": "Uptime Kuma - Slack Testing" - } - }, - { - "type": "section", - "fields": [{ - "type": "mrkdwn", - "text": "*Message*\nSlack Testing" - }, - { - "type": "mrkdwn", - "text": "*Time (UTC)*\nSlack Testing" - } - ] - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Visit Uptime Kuma", - }, - "value": "Uptime-Kuma", - "url": notification.slackbutton - } - ] - } - ] - } - let res = await axios.post(notification.slackwebhookURL, data) - return true; - } - - const time = heartbeatJSON["time"]; - let data = { - "blocks": [{ - "type": "header", - "text": { - "type": "plain_text", - "text": "Uptime Kuma Alert" - } - }, - { - "type": "section", - "fields": [{ - "type": "mrkdwn", - "text": '*Message*\n' + msg - }, - { - "type": "mrkdwn", - "text": "*Time (UTC)*\n" + time - } - ] - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Visit Uptime Kuma", - }, - "value": "Uptime-Kuma", - "url": notification.slackbutton - } - ] - } - ] - } - let res = await axios.post(notification.slackwebhookURL, data) - return true; - } catch (error) { - console.log(error) - return false; + throwGeneralAxiosError(error) } } else if (notification.type === "webhook") { try { - let data = { heartbeat: heartbeatJSON, monitor: monitorJSON, @@ -178,11 +72,11 @@ class Notification { finalData = data; } - let res = await axios.post(notification.webhookURL, finalData, config) - return true; + await axios.post(notification.webhookURL, finalData, config) + return okMsg; + } catch (error) { - console.log(error) - return false; + throwGeneralAxiosError(error) } } else if (notification.type === "smtp") { @@ -190,61 +84,146 @@ class Notification { } else if (notification.type === "discord") { try { - // If heartbeatJSON is null, assume we're testing. - if (heartbeatJSON == null) { - let data = { - username: 'Uptime-Kuma', - content: msg - } - let res = await axios.post(notification.discordWebhookUrl, data) - return true; - } - // If heartbeatJSON is not null, we go into the normal alerting loop. - if (heartbeatJSON['status'] == 0) { - var alertColor = "16711680"; - } else if (heartbeatJSON['status'] == 1) { - var alertColor = "65280"; - } + // If heartbeatJSON is null, assume we're testing. + if(heartbeatJSON == null) { let data = { - username: 'Uptime-Kuma', - embeds: [{ - title: "Uptime-Kuma Alert", - color: alertColor, - fields: [ - { - name: "Time (UTC)", - value: heartbeatJSON["time"] - }, - { - name: "Message", - value: msg - } - ] - }] + username: 'Uptime-Kuma', + content: msg } - let res = await axios.post(notification.discordWebhookUrl, data) - return true; - } catch (error) { - console.log(error) - return false; + await axios.post(notification.discordWebhookUrl, data) + return okMsg; + } + // If heartbeatJSON is not null, we go into the normal alerting loop. + if(heartbeatJSON['status'] == 0) { + var alertColor = "16711680"; + } else if(heartbeatJSON['status'] == 1) { + var alertColor = "65280"; + } + let data = { + username: 'Uptime-Kuma', + embeds: [{ + title: "Uptime-Kuma Alert", + color: alertColor, + fields: [ + { + name: "Time (UTC)", + value: heartbeatJSON["time"] + }, + { + name: "Message", + value: msg + } + ] + }] + } + await axios.post(notification.discordWebhookUrl, data) + return okMsg; + } catch(error) { + throwGeneralAxiosError(error) } } else if (notification.type === "signal") { - try { - let data = { - "message": msg, - "number": notification.signalNumber, - "recipients": notification.signalRecipients.replace(/\s/g, '').split(",") - }; - let config = {}; + try { + let data = { + "message": msg, + "number": notification.signalNumber, + "recipients": notification.signalRecipients.replace(/\s/g, '').split(",") + }; + let config = {}; - let res = await axios.post(notification.signalURL, data, config) - return true; + await axios.post(notification.signalURL, data, config) + return okMsg; + } catch (error) { + throwGeneralAxiosError(error) + } + + } else if (notification.type === "slack") { + try { + if (heartbeatJSON == null) { + let data = {'text': "Uptime Kuma Slack testing successful.", 'channel': notification.slackchannel, 'username': notification.slackusername, 'icon_emoji': notification.slackiconemo} + await axios.post(notification.slackwebhookURL, data) + return okMsg; + } + + const time = heartbeatJSON["time"]; + let data = { + "text": "Uptime Kuma Alert", + "channel":notification.slackchannel, + "username": notification.slackusername, + "icon_emoji": notification.slackiconemo, + "blocks": [{ + "type": "header", + "text": { + "type": "plain_text", + "text": "Uptime Kuma Alert" + } + }, + { + "type": "section", + "fields": [{ + "type": "mrkdwn", + "text": '*Message*\n'+msg + }, + { + "type": "mrkdwn", + "text": "*Time (UTC)*\n"+time + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Visit Uptime Kuma", + }, + "value": "Uptime-Kuma", + "url": notification.slackbutton || "https://github.com/louislam/uptime-kuma" + } + ] + } + ] + } + await axios.post(notification.slackwebhookURL, data) + return okMsg; } catch (error) { - console.log(error) - return false; + throwGeneralAxiosError(error) } + } else if (notification.type === "pushover") { + var pushoverlink = 'https://api.pushover.net/1/messages.json' + try { + if (heartbeatJSON == null) { + let data = {'message': "Uptime Kuma Pushover testing successful.", + 'user': notification.pushoveruserkey, 'token': notification.pushoverapptoken, 'sound':notification.pushoversounds, + 'priority': notification.pushoverpriority, 'title':notification.pushovertitle, 'retry': "30", 'expire':"3600", 'html': 1} + await axios.post(pushoverlink, data) + return okMsg; + } + + let data = { + "message": "Uptime Kuma Alert\n\nMessage:"+msg+ '\nTime (UTC):' +heartbeatJSON["time"], + "user":notification.pushoveruserkey, + "token": notification.pushoverapptoken, + "sound": notification.pushoversounds, + "priority": notification.pushoverpriority, + "title": notification.pushovertitle, + "retry": "30", + "expire": "3600", + "html": 1 + } + await axios.post(pushoverlink, data) + return okMsg; + } catch (error) { + throwGeneralAxiosError(error) + } + + } else if (notification.type === "apprise") { + + return Notification.apprise(notification, msg) + } else { throw new Error("Notification type is not supported") } @@ -259,7 +238,7 @@ class Notification { userID, ]) - if (!bean) { + if (! bean) { throw new Error("notification not found") } @@ -279,7 +258,7 @@ class Notification { userID, ]) - if (!bean) { + if (! bean) { throw new Error("notification not found") } @@ -299,27 +278,54 @@ class Notification { }); // send mail with defined transport object - let info = await transporter.sendMail({ + await transporter.sendMail({ from: `"Uptime Kuma" <${notification.smtpFrom}>`, to: notification.smtpTo, subject: msg, text: msg, }); - return true; + return "Sent Successfully."; } - static async discord(notification, msg) { - const client = new Discord.Client(); - await client.login(notification.discordToken) + static async apprise(notification, msg) { + let s = child_process.spawnSync("apprise", [ "-vv", "-b", msg, notification.appriseURL]) - const channel = await client.channels.fetch(notification.discordChannelID); - await channel.send(msg); - client.destroy() + let output = (s.stdout) ? s.stdout.toString() : 'ERROR: maybe apprise not found'; - return true; + if (output) { + + if (! output.includes("ERROR")) { + return "Sent Successfully"; + } else { + throw new Error(output) + } + } else { + return "" + } } + + static checkApprise() { + let commandExistsSync = require('command-exists').sync; + let exists = commandExistsSync('apprise'); + return exists; + } + +} + +function throwGeneralAxiosError(error) { + let msg = "Error: " + error + " "; + + if (error.response && error.response.data) { + if (typeof error.response.data === "string") { + msg += error.response.data; + } else { + msg += JSON.stringify(error.response.data) + } + } + + throw new Error(msg) } module.exports = { diff --git a/server/server.js b/server/server.js index 0a6d896ad..6bf48dc13 100644 --- a/server/server.js +++ b/server/server.js @@ -1,46 +1,75 @@ +console.log("Welcome to Uptime Kuma ") +console.log("Importing libraries") const express = require('express'); -const app = express(); const http = require('http'); -const server = http.createServer(app); const { Server } = require("socket.io"); -const io = new Server(server); const dayjs = require("dayjs"); -const { R } = require("redbean-node"); +const {R} = require("redbean-node"); const passwordHash = require('./password-hash'); const jwt = require('jsonwebtoken'); const Monitor = require("./model/monitor"); const fs = require("fs"); -const { getSettings } = require("./util-server"); -const { Notification } = require("./notification") +const {getSettings} = require("./util-server"); +const {Notification} = require("./notification") +const gracefulShutdown = require('http-graceful-shutdown'); +const Database = require("./database"); +const {sleep} = require("./util"); const args = require('args-parser')(process.argv); const version = require('../package.json').version; const hostname = args.host || "0.0.0.0" const port = args.port || 3001 +console.info("Version: " + version) + +console.log("Creating express and socket.io instance") +const app = express(); +const server = http.createServer(app); +const io = new Server(server); app.use(express.json()) +/** + * Total WebSocket client connected to server currently, no actual use + * @type {number} + */ let totalClient = 0; + +/** + * Use for decode the auth object + * @type {null} + */ let jwtSecret = null; + +/** + * Main monitor list + * @type {{}} + */ let monitorList = {}; + +/** + * Show Setup Page + * @type {boolean} + */ let needSetup = false; (async () => { await initDatabase(); + console.log("Adding route") app.use('/', express.static("dist")); - app.get('*', function (request, response, next) { + app.get('*', function(request, response, next) { response.sendFile(process.cwd() + '/dist/index.html'); }); + + console.log("Adding socket handler") io.on('connection', async (socket) => { socket.emit("info", { version, }) - console.log('a user connected'); totalClient++; if (needSetup) { @@ -49,7 +78,6 @@ let needSetup = false; } socket.on('disconnect', () => { - console.log('user disconnected'); totalClient--; }); @@ -155,10 +183,6 @@ let needSetup = false; msg: e.message }); } - - - - }); // Auth Only API @@ -198,7 +222,7 @@ let needSetup = false; try { checkLogin(socket) - let bean = await R.findOne("monitor", " id = ? ", [monitor.id]) + let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]) if (bean.user_id !== socket.userID) { throw new Error("Permission denied.") @@ -209,6 +233,7 @@ let needSetup = false; bean.url = monitor.url bean.interval = monitor.interval bean.hostname = monitor.hostname; + bean.maxretries = monitor.maxretries; bean.port = monitor.port; bean.keyword = monitor.keyword; @@ -229,7 +254,7 @@ let needSetup = false; }); } catch (e) { - console.log(e) + console.error(e) callback({ ok: false, msg: e.message @@ -336,7 +361,7 @@ let needSetup = false; try { checkLogin(socket) - if (!password.currentPassword) { + if (! password.currentPassword) { throw new Error("Invalid new password") } @@ -430,25 +455,36 @@ let needSetup = false; try { checkLogin(socket) - await Notification.send(notification, notification.name + " Testing") + let msg = await Notification.send(notification, notification.name + " Testing") callback({ ok: true, - msg: "Sent Successfully" + msg }); } catch (e) { + console.error(e) + callback({ ok: false, msg: e.message }); } }); + + socket.on("checkApprise", async (callback) => { + try { + checkLogin(socket) + callback(Notification.checkApprise()); + } catch (e) { + callback(false); + } + }); }); + console.log("Init") server.listen(port, hostname, () => { console.log(`Listening on ${hostname}:${port}`); - startMonitors(); }); @@ -475,7 +511,7 @@ async function checkOwner(userID, monitorID) { userID, ]) - if (!row) { + if (! row) { throw new Error("You do not own this monitor."); } } @@ -530,24 +566,27 @@ async function getMonitorJSONList(userID) { } function checkLogin(socket) { - if (!socket.userID) { + if (! socket.userID) { throw new Error("You are not logged in."); } } async function initDatabase() { - const path = './data/kuma.db'; - - if (!fs.existsSync(path)) { - console.log("Copy Database") - fs.copyFileSync("./db/kuma.db", path); + if (! fs.existsSync(Database.path)) { + console.log("Copying Database") + fs.copyFileSync(Database.templatePath, Database.path); } - console.log("Connect to Database") - + console.log("Connecting to Database") R.setup('sqlite', { - filename: path + filename: Database.path }); + console.log("Connected") + + // Patch the database + await Database.patch() + + // Auto map the model to a bean object R.freeze(true) await R.autoloadModels("./server/model"); @@ -555,17 +594,19 @@ async function initDatabase() { "jwtSecret" ]); - if (!jwtSecretBean) { + if (! jwtSecretBean) { console.log("JWT secret is not found, generate one.") jwtSecretBean = R.dispense("setting") jwtSecretBean.key = "jwtSecret" jwtSecretBean.value = passwordHash.generate(dayjs() + "") await R.store(jwtSecretBean) + console.log("Stored JWT secret into database") } else { console.log("Load JWT secret from database.") } + // If there is no record in user table, it is a new Uptime Kuma instance, need to setup if ((await R.count("user")) === 0) { console.log("No user, need setup") needSetup = true; @@ -642,7 +683,7 @@ async function sendHeartbeatList(socket, monitorID) { let result = []; for (let bean of list) { - result.unshift(bean.toJSON()) + result.unshift(bean.toJSON()) } socket.emit("heartbeatList", monitorID, result) @@ -660,3 +701,51 @@ async function sendImportantHeartbeatList(socket, monitorID) { socket.emit("importantHeartbeatList", monitorID, list) } + + + +const startGracefulShutdown = async () => { + console.log('Shutdown requested'); + + + await (new Promise((resolve) => { + server.close(async function () { + console.log('Stopped Express.'); + process.exit(0) + setTimeout(async () =>{ + await R.close(); + console.log("Stopped DB") + + resolve(); + }, 5000) + + }); + })); + + +} + +async function shutdownFunction(signal) { + console.log('Called signal: ' + signal); + + console.log("Stopping all monitors") + for (let id in monitorList) { + let monitor = monitorList[id] + monitor.stop() + } + await sleep(2000); + await Database.close(); +} + +function finalFunction() { + console.log('Graceful Shutdown') +} + +gracefulShutdown(server, { + signals: 'SIGINT SIGTERM', + timeout: 30000, // timeout: 30 secs + development: false, // not in dev mode + forceExit: true, // triggers process.exit() at the end of shutdown process + onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ... + finally: finalFunction // finally function (sync) - e.g. for logging +}); diff --git a/server/util-server.js b/server/util-server.js index 6904a65a4..b387f4c7c 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -45,6 +45,18 @@ exports.setting = async function (key) { ]) } +exports.setSetting = async function (key, value) { + let bean = await R.findOne("setting", " `key` = ? ", [ + key + ]) + if (! bean) { + bean = R.dispense("setting") + bean.key = key; + } + bean.value = value; + await R.store(bean) +} + exports.getSettings = async function (type) { let list = await R.getAll("SELECT * FROM setting WHERE `type` = ? ", [ type diff --git a/server/util.js b/server/util.js index dfe4eaa06..33b306b5c 100644 --- a/server/util.js +++ b/server/util.js @@ -1,15 +1,15 @@ -/* - * Common functions - can be used in frontend or backend - */ +// Common JS cannot be used in frontend sadly +// sleep, ucfirst is duplicated in ../src/util-frontend.js +exports.DOWN = 0; +exports.UP = 1; +exports.PENDING = 2; - - -export function sleep(ms) { +exports.sleep = function (ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -export function ucfirst(str) { +exports.ucfirst = function (str) { if (! str) { return str; } diff --git a/src/components/CountUp.vue b/src/components/CountUp.vue index b929e52eb..33904b6a9 100644 --- a/src/components/CountUp.vue +++ b/src/components/CountUp.vue @@ -5,7 +5,7 @@ diff --git a/src/pages/Details.vue b/src/pages/Details.vue index f925c2849..f8c4879ad 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -12,7 +12,7 @@
- + Edit
@@ -64,7 +64,7 @@ - + {{ beat.msg }} @@ -75,6 +75,13 @@ + +
+ +
@@ -95,6 +102,7 @@ import Status from "../components/Status.vue"; import Datetime from "../components/Datetime.vue"; import CountUp from "../components/CountUp.vue"; import Uptime from "../components/Uptime.vue"; +import Pagination from "v-pagination-3"; export default { components: { @@ -104,13 +112,16 @@ export default { HeartbeatBar, Confirm, Status, + Pagination, }, mounted() { }, data() { return { - + page: 1, + perPage: 25, + heartBeatList: [], } }, computed: { @@ -154,6 +165,7 @@ export default { importantHeartBeatList() { if (this.$root.importantHeartbeatList[this.monitor.id]) { + this.heartBeatList = this.$root.importantHeartbeatList[this.monitor.id]; return this.$root.importantHeartbeatList[this.monitor.id] } else { return []; @@ -166,8 +178,13 @@ export default { } else { return { } } - } + }, + displayedRecords() { + const startIndex = this.perPage * (this.page - 1); + const endIndex = startIndex + this.perPage; + return this.heartBeatList.slice(startIndex, endIndex); + }, }, methods: { testNotification() { diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index b38a57d48..75d7d4b9b 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -45,7 +45,13 @@
- + +
+ +
+ + +
Maximum retries before the service is marked as down and a notification is sent
@@ -61,7 +67,7 @@

Notifications

Not available, please setup.

-
+
@@ -36,7 +36,7 @@
- The repeat password is not match. + The repeat password does not match.
@@ -56,10 +56,10 @@

Notifications

Not available, please setup.

-

Please assign the notification to monitor(s) to get it works.

+

Please assign a notification to monitor(s) to get it to work.

    -
  • +
  • {{ notification.name }}
    Edit
  • @@ -77,8 +77,8 @@