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