diff --git a/README.md b/README.md
index ab42a5599..026f3b4d9 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ VPS is sponsored by Uptime Kuma sponsors on [Open Collective](https://opencollec
## ⭐ Features
-* Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server.
+* Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server / Docker Containers.
* Fancy, Reactive, Fast UI/UX.
* Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications).
* 20 second intervals.
diff --git a/db/patch-add-docker-columns.sql b/db/patch-add-docker-columns.sql
new file mode 100644
index 000000000..4cea448d7
--- /dev/null
+++ b/db/patch-add-docker-columns.sql
@@ -0,0 +1,18 @@
+-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
+BEGIN TRANSACTION;
+
+CREATE TABLE docker_host (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ user_id INT NOT NULL,
+ docker_daemon VARCHAR(255),
+ docker_type VARCHAR(255),
+ name VARCHAR(255)
+);
+
+ALTER TABLE monitor
+ ADD docker_host INTEGER REFERENCES docker_host(id);
+
+ALTER TABLE monitor
+ ADD docker_container VARCHAR(255);
+
+COMMIT;
diff --git a/db/patch-add-radius-monitor.sql b/db/patch-add-radius-monitor.sql
new file mode 100644
index 000000000..1fd5b44f4
--- /dev/null
+++ b/db/patch-add-radius-monitor.sql
@@ -0,0 +1,18 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE monitor
+ ADD radius_username VARCHAR(255);
+
+ALTER TABLE monitor
+ ADD radius_password VARCHAR(255);
+
+ALTER TABLE monitor
+ ADD radius_calling_station_id VARCHAR(50);
+
+ALTER TABLE monitor
+ ADD radius_called_station_id VARCHAR(50);
+
+ALTER TABLE monitor
+ ADD radius_secret VARCHAR(255);
+
+COMMIT
diff --git a/db/patch-monitor-add-resend-interval.sql b/db/patch-monitor-add-resend-interval.sql
new file mode 100644
index 000000000..8e28bf693
--- /dev/null
+++ b/db/patch-monitor-add-resend-interval.sql
@@ -0,0 +1,10 @@
+-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
+BEGIN TRANSACTION;
+
+ALTER TABLE monitor
+ ADD resend_interval INTEGER default 0 not null;
+
+ALTER TABLE heartbeat
+ ADD down_count INTEGER default 0 not null;
+
+COMMIT;
diff --git a/docker/alpine-base.dockerfile b/docker/alpine-base.dockerfile
index cde65bb64..1d74de05d 100644
--- a/docker/alpine-base.dockerfile
+++ b/docker/alpine-base.dockerfile
@@ -4,5 +4,5 @@ WORKDIR /app
# Install apprise, iputils for non-root ping, setpriv
RUN apk add --no-cache iputils setpriv dumb-init python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib && \
- pip3 --no-cache-dir install apprise==0.9.9 && \
+ pip3 --no-cache-dir install apprise==1.0.0 && \
rm -rf /root/.cache
diff --git a/docker/debian-base.dockerfile b/docker/debian-base.dockerfile
index f90968a8b..20bef3dd4 100644
--- a/docker/debian-base.dockerfile
+++ b/docker/debian-base.dockerfile
@@ -11,7 +11,7 @@ WORKDIR /app
RUN apt update && \
apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
sqlite3 iputils-ping util-linux dumb-init && \
- pip3 --no-cache-dir install apprise==0.9.9 && \
+ pip3 --no-cache-dir install apprise==1.0.0 && \
rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove
diff --git a/package-lock.json b/package-lock.json
index 778e6bc38..0cf62fa7f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -39,6 +39,7 @@
"mqtt": "^4.2.8",
"mssql": "^8.1.0",
"node-cloudflared-tunnel": "~1.0.9",
+ "node-radius-client": "^1.0.0",
"nodemailer": "~6.6.5",
"notp": "~2.0.3",
"password-hash": "~1.2.2",
@@ -8215,6 +8216,12 @@
"readable-stream": "^3.6.0"
}
},
+ "node_modules/hoek": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz",
+ "integrity": "sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ==",
+ "deprecated": "This module has moved and is now available at @hapi/hoek. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues."
+ },
"node_modules/homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
@@ -8915,6 +8922,17 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"devOptional": true
},
+ "node_modules/isemail": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz",
+ "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==",
+ "dependencies": {
+ "punycode": "2.x.x"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -12151,6 +12169,32 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true
},
+ "node_modules/node-radius-client": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-radius-client/-/node-radius-client-1.0.0.tgz",
+ "integrity": "sha512-FkR9cMV5hNoX+kKDUTzuagvEixlLiaEJQ1/ywOdhahsihKrGDhVZmnCvmrCStA589MT3yuC/J2eKc6z68IGdBw==",
+ "dependencies": {
+ "joi": "^14.3.1",
+ "node-radius-utils": "^1.2.0",
+ "radius": "^1.1.4"
+ }
+ },
+ "node_modules/node-radius-client/node_modules/joi": {
+ "version": "14.3.1",
+ "resolved": "https://registry.npmjs.org/joi/-/joi-14.3.1.tgz",
+ "integrity": "sha512-LQDdM+pkOrpAn4Lp+neNIFV3axv1Vna3j38bisbQhETPMANYRbFJFUyOZcOClYvM/hppMhGWuKSFEK9vjrB+bQ==",
+ "deprecated": "This module has moved and is now available at @hapi/joi. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues.",
+ "dependencies": {
+ "hoek": "6.x.x",
+ "isemail": "3.x.x",
+ "topo": "3.x.x"
+ }
+ },
+ "node_modules/node-radius-utils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/node-radius-utils/-/node-radius-utils-1.2.0.tgz",
+ "integrity": "sha512-i3Sf6khnenl0aXumo0whAlfPWTaBqHxEnVBBxpu3dZ7q69NkPPv71rvPjlDZ5wkeKCTNNUTECljerS5kcYQxRw=="
+ },
"node_modules/node-releases": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.5.tgz",
@@ -13429,6 +13473,14 @@
"node": ">=8"
}
},
+ "node_modules/radius": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/radius/-/radius-1.1.4.tgz",
+ "integrity": "sha512-UWuzdF6xf3NpsXFZZmUEkxtEalDXj8hdmMXgbGzn7vOk6zXNsiIY2I6SJ1euHt7PTQuMoz2qDEJB+AfJDJgQYw==",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -15261,6 +15313,15 @@
"node": ">=0.6"
}
},
+ "node_modules/topo": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.3.tgz",
+ "integrity": "sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==",
+ "deprecated": "This module has moved and is now available at @hapi/topo. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues.",
+ "dependencies": {
+ "hoek": "6.x.x"
+ }
+ },
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
@@ -22641,6 +22702,11 @@
"readable-stream": "^3.6.0"
}
},
+ "hoek": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz",
+ "integrity": "sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ=="
+ },
"homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
@@ -23123,6 +23189,14 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"devOptional": true
},
+ "isemail": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz",
+ "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==",
+ "requires": {
+ "punycode": "2.x.x"
+ }
+ },
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -25618,6 +25692,33 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true
},
+ "node-radius-client": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-radius-client/-/node-radius-client-1.0.0.tgz",
+ "integrity": "sha512-FkR9cMV5hNoX+kKDUTzuagvEixlLiaEJQ1/ywOdhahsihKrGDhVZmnCvmrCStA589MT3yuC/J2eKc6z68IGdBw==",
+ "requires": {
+ "joi": "^14.3.1",
+ "node-radius-utils": "^1.2.0",
+ "radius": "^1.1.4"
+ },
+ "dependencies": {
+ "joi": {
+ "version": "14.3.1",
+ "resolved": "https://registry.npmjs.org/joi/-/joi-14.3.1.tgz",
+ "integrity": "sha512-LQDdM+pkOrpAn4Lp+neNIFV3axv1Vna3j38bisbQhETPMANYRbFJFUyOZcOClYvM/hppMhGWuKSFEK9vjrB+bQ==",
+ "requires": {
+ "hoek": "6.x.x",
+ "isemail": "3.x.x",
+ "topo": "3.x.x"
+ }
+ }
+ }
+ },
+ "node-radius-utils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/node-radius-utils/-/node-radius-utils-1.2.0.tgz",
+ "integrity": "sha512-i3Sf6khnenl0aXumo0whAlfPWTaBqHxEnVBBxpu3dZ7q69NkPPv71rvPjlDZ5wkeKCTNNUTECljerS5kcYQxRw=="
+ },
"node-releases": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.5.tgz",
@@ -26532,6 +26633,11 @@
"integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
"dev": true
},
+ "radius": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/radius/-/radius-1.1.4.tgz",
+ "integrity": "sha512-UWuzdF6xf3NpsXFZZmUEkxtEalDXj8hdmMXgbGzn7vOk6zXNsiIY2I6SJ1euHt7PTQuMoz2qDEJB+AfJDJgQYw=="
+ },
"range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -27967,6 +28073,14 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
},
+ "topo": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.3.tgz",
+ "integrity": "sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==",
+ "requires": {
+ "hoek": "6.x.x"
+ }
+ },
"toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
diff --git a/package.json b/package.json
index ea6c5a791..981ca1912 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "uptime-kuma",
- "version": "1.17.1",
+ "version": "1.18.0-beta.0",
"license": "MIT",
"repository": {
"type": "git",
@@ -91,6 +91,7 @@
"mqtt": "^4.2.8",
"mssql": "^8.1.0",
"node-cloudflared-tunnel": "~1.0.9",
+ "node-radius-client": "^1.0.0",
"nodemailer": "~6.6.5",
"notp": "~2.0.3",
"password-hash": "~1.2.2",
diff --git a/server/client.js b/server/client.js
index 279acd3a6..a0c52e1e4 100644
--- a/server/client.js
+++ b/server/client.js
@@ -125,10 +125,35 @@ async function sendInfo(socket) {
});
}
+/**
+ * Send list of docker hosts to client
+ * @param {Socket} socket Socket.io socket instance
+ * @returns {Promise<Bean[]>}
+ */
+async function sendDockerHostList(socket) {
+ const timeLogger = new TimeLogger();
+
+ let result = [];
+ let list = await R.find("docker_host", " user_id = ? ", [
+ socket.userID,
+ ]);
+
+ for (let bean of list) {
+ result.push(bean.toJSON());
+ }
+
+ io.to(socket.userID).emit("dockerHostList", result);
+
+ timeLogger.print("Send Docker Host List");
+
+ return list;
+}
+
module.exports = {
sendNotificationList,
sendImportantHeartbeatList,
sendHeartbeatList,
sendProxyList,
sendInfo,
+ sendDockerHostList
};
diff --git a/server/database.js b/server/database.js
index 00fd48d9f..b1a23a475 100644
--- a/server/database.js
+++ b/server/database.js
@@ -53,6 +53,7 @@ class Database {
"patch-2fa-invalidate-used-token.sql": true,
"patch-notification_sent_history.sql": true,
"patch-monitor-basic-auth.sql": true,
+ "patch-add-docker-columns.sql": true,
"patch-status-page.sql": true,
"patch-proxy.sql": true,
"patch-monitor-expiry-notification.sql": true,
@@ -61,6 +62,8 @@ class Database {
"patch-add-clickable-status-page-link.sql": true,
"patch-add-sqlserver-monitor.sql": true,
"patch-add-other-auth.sql": { parents: [ "patch-monitor-basic-auth.sql" ] },
+ "patch-add-radius-monitor.sql": true,
+ "patch-monitor-add-resend-interval.sql": true,
};
/**
@@ -147,6 +150,9 @@ class Database {
await R.exec("PRAGMA cache_size = -12000");
await R.exec("PRAGMA auto_vacuum = FULL");
+ // Avoid error "SQLITE_BUSY: database is locked" by allowing SQLITE to wait up to 5 seconds to do a write
+ await R.exec("PRAGMA busy_timeout = 5000");
+
// This ensures that an operating system crash or power failure will not corrupt the database.
// FULL synchronous is very safe, but it is also slower.
// Read more: https://sqlite.org/pragma.html#pragma_synchronous
diff --git a/server/docker.js b/server/docker.js
new file mode 100644
index 000000000..177fa6cb6
--- /dev/null
+++ b/server/docker.js
@@ -0,0 +1,106 @@
+const axios = require("axios");
+const { R } = require("redbean-node");
+const version = require("../package.json").version;
+const https = require("https");
+
+class DockerHost {
+ /**
+ * Save a docker host
+ * @param {Object} dockerHost Docker host to save
+ * @param {?number} dockerHostID ID of the docker host to update
+ * @param {number} userID ID of the user who adds the docker host
+ * @returns {Promise<Bean>}
+ */
+ static async save(dockerHost, dockerHostID, userID) {
+ let bean;
+
+ if (dockerHostID) {
+ bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]);
+
+ if (!bean) {
+ throw new Error("docker host not found");
+ }
+
+ } else {
+ bean = R.dispense("docker_host");
+ }
+
+ bean.user_id = userID;
+ bean.docker_daemon = dockerHost.dockerDaemon;
+ bean.docker_type = dockerHost.dockerType;
+ bean.name = dockerHost.name;
+
+ await R.store(bean);
+
+ return bean;
+ }
+
+ /**
+ * Delete a Docker host
+ * @param {number} dockerHostID ID of the Docker host to delete
+ * @param {number} userID ID of the user who created the Docker host
+ * @returns {Promise<void>}
+ */
+ static async delete(dockerHostID, userID) {
+ let bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]);
+
+ if (!bean) {
+ throw new Error("docker host not found");
+ }
+
+ // Delete removed proxy from monitors if exists
+ await R.exec("UPDATE monitor SET docker_host = null WHERE docker_host = ?", [ dockerHostID ]);
+
+ await R.trash(bean);
+ }
+
+ /**
+ * Fetches the amount of containers on the Docker host
+ * @param {Object} dockerHost Docker host to check for
+ * @returns {number} Total amount of containers on the host
+ */
+ static async testDockerHost(dockerHost) {
+ const options = {
+ url: "/containers/json?all=true",
+ headers: {
+ "Accept": "*/*",
+ "User-Agent": "Uptime-Kuma/" + version
+ },
+ httpsAgent: new https.Agent({
+ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
+ rejectUnauthorized: false,
+ }),
+ };
+
+ if (dockerHost.dockerType === "socket") {
+ options.socketPath = dockerHost.dockerDaemon;
+ } else if (dockerHost.dockerType === "tcp") {
+ options.baseURL = dockerHost.dockerDaemon;
+ }
+
+ let res = await axios.request(options);
+
+ if (Array.isArray(res.data)) {
+
+ if (res.data.length > 1) {
+
+ if ("ImageID" in res.data[0]) {
+ return res.data.length;
+ } else {
+ throw new Error("Invalid Docker response, is it Docker really a daemon?");
+ }
+
+ } else {
+ return res.data.length;
+ }
+
+ } else {
+ throw new Error("Invalid Docker response, is it Docker really a daemon?");
+ }
+
+ }
+}
+
+module.exports = {
+ DockerHost,
+};
diff --git a/server/model/docker_host.js b/server/model/docker_host.js
new file mode 100644
index 000000000..205982922
--- /dev/null
+++ b/server/model/docker_host.js
@@ -0,0 +1,19 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+
+class DockerHost extends BeanModel {
+ /**
+ * Returns an object that ready to parse to JSON
+ * @returns {Object}
+ */
+ toJSON() {
+ return {
+ id: this.id,
+ userID: this.user_id,
+ dockerDaemon: this.docker_daemon,
+ dockerType: this.docker_type,
+ name: this.name,
+ };
+ }
+}
+
+module.exports = DockerHost;
diff --git a/server/model/monitor.js b/server/model/monitor.js
index 81149b52a..f96b4df06 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -7,7 +7,7 @@ dayjs.extend(timezone);
const axios = require("axios");
const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util");
-const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mqttAsync, setSetting, httpNtlm } = require("../util-server");
+const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mqttAsync, setSetting, httpNtlm, radius } = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification");
@@ -79,6 +79,7 @@ class Monitor extends BeanModel {
type: this.type,
interval: this.interval,
retryInterval: this.retryInterval,
+ resendInterval: this.resendInterval,
keyword: this.keyword,
expiryNotification: this.isEnabledExpiryNotification(),
ignoreTls: this.getIgnoreTls(),
@@ -88,6 +89,9 @@ 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,
+ docker_container: this.docker_container,
+ docker_host: this.docker_host,
proxyId: this.proxy_id,
notificationIDList,
tags: tags,
@@ -100,6 +104,11 @@ class Monitor extends BeanModel {
authMethod: this.authMethod,
authWorkstation: this.authWorkstation,
authDomain: this.authDomain,
+ radiusUsername: this.radiusUsername,
+ radiusPassword: this.radiusPassword,
+ radiusCalledStationId: this.radiusCalledStationId,
+ radiusCallingStationId: this.radiusCallingStationId,
+ radiusSecret: this.radiusSecret,
};
if (includeSensitiveData) {
@@ -206,6 +215,7 @@ class Monitor extends BeanModel {
bean.monitor_id = this.id;
bean.time = R.isoDateTimeMillis(dayjs.utc());
bean.status = DOWN;
+ bean.downCount = previousBeat?.downCount || 0;
if (this.isUpsideDown()) {
bean.status = flipStatus(bean.status);
@@ -468,6 +478,35 @@ class Monitor extends BeanModel {
} else {
throw new Error("Server not found on Steam");
}
+ } else if (this.type === "docker") {
+ log.debug(`[${this.name}] Prepare Options for Axios`);
+
+ const dockerHost = await R.load("docker_host", this.docker_host);
+
+ const options = {
+ url: `/containers/${this.docker_container}/json`,
+ headers: {
+ "Accept": "*/*",
+ "User-Agent": "Uptime-Kuma/" + version,
+ },
+ httpsAgent: new https.Agent({
+ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
+ rejectUnauthorized: ! this.getIgnoreTls(),
+ }),
+ };
+
+ if (dockerHost._dockerType === "socket") {
+ options.socketPath = dockerHost._dockerDaemon;
+ } else if (dockerHost._dockerType === "tcp") {
+ options.baseURL = dockerHost._dockerDaemon;
+ }
+
+ log.debug(`[${this.name}] Axios Request`);
+ let res = await axios.request(options);
+ if (res.data.State.Running) {
+ bean.status = UP;
+ bean.msg = "";
+ }
} else if (this.type === "mqtt") {
bean.msg = await mqttAsync(this.hostname, this.mqttTopic, this.mqttSuccessMessage, {
port: this.port,
@@ -492,6 +531,30 @@ class Monitor extends BeanModel {
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
+ } else if (this.type === "radius") {
+ let startTime = dayjs().valueOf();
+ try {
+ const resp = await radius(
+ this.hostname,
+ this.radiusUsername,
+ this.radiusPassword,
+ this.radiusCalledStationId,
+ this.radiusCallingStationId,
+ this.radiusSecret
+ );
+ if (resp.code) {
+ bean.msg = resp.code;
+ }
+ bean.status = UP;
+ } catch (error) {
+ bean.status = DOWN;
+ if (error.response?.code) {
+ bean.msg = error.response.code;
+ } else {
+ bean.msg = error.message;
+ }
+ }
+ bean.ping = dayjs().valueOf() - startTime;
} else {
bean.msg = "Unknown Monitor Type";
bean.status = PENDING;
@@ -533,12 +596,27 @@ class Monitor extends BeanModel {
log.debug("monitor", `[${this.name}] sendNotification`);
await Monitor.sendNotification(isFirstBeat, this, bean);
+ // Reset down count
+ bean.downCount = 0;
+
// Clear Status Page Cache
log.debug("monitor", `[${this.name}] apicache clear`);
apicache.clear();
} else {
bean.important = false;
+
+ if (bean.status === DOWN && this.resendInterval > 0) {
+ ++bean.downCount;
+ if (bean.downCount >= this.resendInterval) {
+ // Send notification again, because we are still DOWN
+ log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
+ await Monitor.sendNotification(isFirstBeat, this, bean);
+
+ // Reset down count
+ bean.downCount = 0;
+ }
+ }
}
if (bean.status === UP) {
@@ -549,7 +627,7 @@ class Monitor extends BeanModel {
}
log.warn("monitor", `Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`);
} else {
- log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type}`);
+ log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
}
log.debug("monitor", `[${this.name}] Send to socket`);
diff --git a/server/notification-providers/bark.js b/server/notification-providers/bark.js
index 092511d87..3258e7c52 100644
--- a/server/notification-providers/bark.js
+++ b/server/notification-providers/bark.js
@@ -12,9 +12,7 @@ const { default: axios } = require("axios");
// bark is an APN bridge that sends notifications to Apple devices.
-const barkNotificationGroup = "UptimeKuma";
const barkNotificationAvatar = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
-const barkNotificationSound = "telegraph";
const successMessage = "Successes!";
class Bark extends NotificationProvider {
@@ -50,13 +48,23 @@ class Bark extends NotificationProvider {
* @param {string} postUrl URL to append parameters to
* @returns {string}
*/
- appendAdditionalParameters(postUrl) {
- // grouping all our notifications
- postUrl += "?group=" + barkNotificationGroup;
+ appendAdditionalParameters(notification, postUrl) {
// set icon to uptime kuma icon, 11kb should be fine
postUrl += "&icon=" + barkNotificationAvatar;
+ // grouping all our notifications
+ if (notification.barkGroup != null) {
+ postUrl += "&group=" + notification.barkGroup;
+ } else {
+ // default name
+ postUrl += "&group=" + "UptimeKuma";
+ }
// picked a sound, this should follow system's mute status when arrival
- postUrl += "&sound=" + barkNotificationSound;
+ if (notification.barkSound != null) {
+ postUrl += "&sound=" + notification.barkSound;
+ } else {
+ // default sound
+ postUrl += "&sound=" + "telegraph";
+ }
return postUrl;
}
diff --git a/server/notification-providers/home-assistant.js b/server/notification-providers/home-assistant.js
new file mode 100644
index 000000000..285989eeb
--- /dev/null
+++ b/server/notification-providers/home-assistant.js
@@ -0,0 +1,38 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+const defaultNotificationService = "notify";
+
+class HomeAssistant extends NotificationProvider {
+ name = "HomeAssistant";
+
+ async send(notification, message, monitor = null, heartbeat = null) {
+ const notificationService = notification?.notificationService || defaultNotificationService;
+
+ try {
+ await axios.post(
+ `${notification.homeAssistantUrl}/api/services/notify/${notificationService}`,
+ {
+ title: "Uptime Kuma",
+ message,
+ ...(notificationService !== "persistent_notification" && { data: {
+ name: monitor?.name,
+ status: heartbeat?.status,
+ } }),
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${notification.longLivedAccessToken}`,
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ return "Sent Successfully.";
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = HomeAssistant;
diff --git a/server/notification.js b/server/notification.js
index ad1c8705a..8093572a1 100644
--- a/server/notification.js
+++ b/server/notification.js
@@ -12,6 +12,7 @@ const Feishu = require("./notification-providers/feishu");
const GoogleChat = require("./notification-providers/google-chat");
const Gorush = require("./notification-providers/gorush");
const Gotify = require("./notification-providers/gotify");
+const HomeAssistant = require("./notification-providers/home-assistant");
const Line = require("./notification-providers/line");
const LineNotify = require("./notification-providers/linenotify");
const LunaSea = require("./notification-providers/lunasea");
@@ -61,6 +62,7 @@ class Notification {
new GoogleChat(),
new Gorush(),
new Gotify(),
+ new HomeAssistant(),
new Line(),
new LineNotify(),
new LunaSea(),
diff --git a/server/server.js b/server/server.js
index 61bd9d93a..818bd7d12 100644
--- a/server/server.js
+++ b/server/server.js
@@ -118,13 +118,14 @@ if (config.demoMode) {
}
// Must be after io instantiation
-const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo, sendProxyList } = require("./client");
+const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo, sendProxyList, sendDockerHostList } = require("./client");
const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
const databaseSocketHandler = require("./socket-handlers/database-socket-handler");
const TwoFA = require("./2fa");
const StatusPage = require("./model/status_page");
const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler");
const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler");
+const { dockerSocketHandler } = require("./socket-handlers/docker-socket-handler");
app.use(express.json());
@@ -668,6 +669,7 @@ let needSetup = false;
bean.basic_auth_pass = monitor.basic_auth_pass;
bean.interval = monitor.interval;
bean.retryInterval = monitor.retryInterval;
+ bean.resendInterval = monitor.resendInterval;
bean.hostname = monitor.hostname;
bean.maxretries = monitor.maxretries;
bean.port = parseInt(monitor.port);
@@ -680,6 +682,8 @@ let needSetup = false;
bean.dns_resolve_type = monitor.dns_resolve_type;
bean.dns_resolve_server = monitor.dns_resolve_server;
bean.pushToken = monitor.pushToken;
+ bean.docker_container = monitor.docker_container;
+ bean.docker_host = monitor.docker_host;
bean.proxyId = Number.isInteger(monitor.proxyId) ? monitor.proxyId : null;
bean.mqttUsername = monitor.mqttUsername;
bean.mqttPassword = monitor.mqttPassword;
@@ -690,6 +694,11 @@ let needSetup = false;
bean.authMethod = monitor.authMethod;
bean.authWorkstation = monitor.authWorkstation;
bean.authDomain = monitor.authDomain;
+ bean.radiusUsername = monitor.radiusUsername;
+ bean.radiusPassword = monitor.radiusPassword;
+ bean.radiusCalledStationId = monitor.radiusCalledStationId;
+ bean.radiusCallingStationId = monitor.radiusCallingStationId;
+ bean.radiusSecret = monitor.radiusSecret;
await R.store(bean);
@@ -1270,6 +1279,7 @@ let needSetup = false;
authDomain: monitorListData[i].authDomain,
interval: monitorListData[i].interval,
retryInterval: retryInterval,
+ resendInterval: monitorListData[i].resendInterval || 0,
hostname: monitorListData[i].hostname,
maxretries: monitorListData[i].maxretries,
port: monitorListData[i].port,
@@ -1438,6 +1448,7 @@ let needSetup = false;
cloudflaredSocketHandler(socket);
databaseSocketHandler(socket);
proxySocketHandler(socket);
+ dockerSocketHandler(socket);
log.debug("server", "added all socket handlers");
@@ -1538,6 +1549,7 @@ async function afterLogin(socket, user) {
let monitorList = await server.sendMonitorList(socket);
sendNotificationList(socket);
sendProxyList(socket);
+ sendDockerHostList(socket);
await sleep(500);
diff --git a/server/socket-handlers/docker-socket-handler.js b/server/socket-handlers/docker-socket-handler.js
new file mode 100644
index 000000000..5a53494db
--- /dev/null
+++ b/server/socket-handlers/docker-socket-handler.js
@@ -0,0 +1,79 @@
+const { sendDockerHostList } = require("../client");
+const { checkLogin } = require("../util-server");
+const { DockerHost } = require("../docker");
+const { log } = require("../../src/util");
+
+/**
+ * Handlers for docker hosts
+ * @param {Socket} socket Socket.io instance
+ */
+module.exports.dockerSocketHandler = (socket) => {
+ socket.on("addDockerHost", async (dockerHost, dockerHostID, callback) => {
+ try {
+ checkLogin(socket);
+
+ let dockerHostBean = await DockerHost.save(dockerHost, dockerHostID, socket.userID);
+ await sendDockerHostList(socket);
+
+ callback({
+ ok: true,
+ msg: "Saved",
+ id: dockerHostBean.id,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteDockerHost", async (dockerHostID, callback) => {
+ try {
+ checkLogin(socket);
+
+ await DockerHost.delete(dockerHostID, socket.userID);
+ await sendDockerHostList(socket);
+
+ callback({
+ ok: true,
+ msg: "Deleted",
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("testDockerHost", async (dockerHost, callback) => {
+ try {
+ checkLogin(socket);
+
+ let amount = await DockerHost.testDockerHost(dockerHost);
+ let msg;
+
+ if (amount > 1) {
+ msg = "Connected Successfully. Amount of containers: " + amount;
+ } else {
+ msg = "Connected Successfully, but there are no containers?";
+ }
+
+ callback({
+ ok: true,
+ msg,
+ });
+
+ } catch (e) {
+ log.error("docker", e);
+
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+};
diff --git a/server/util-server.js b/server/util-server.js
index df711cf05..067da6fd5 100644
--- a/server/util-server.js
+++ b/server/util-server.js
@@ -15,6 +15,12 @@ const { Client } = require("pg");
const postgresConParse = require("pg-connection-string").parse;
const { NtlmClient } = require("axios-ntlm");
const { Settings } = require("./settings");
+const radiusClient = require("node-radius-client");
+const {
+ dictionaries: {
+ rfc2865: { file, attributes },
+ },
+} = require("node-radius-utils");
// From ping-lite
exports.WIN = /^win/.test(process.platform);
@@ -285,6 +291,30 @@ exports.postgresQuery = function (connectionString, query) {
});
};
+exports.radius = function (
+ hostname,
+ username,
+ password,
+ calledStationId,
+ callingStationId,
+ secret,
+) {
+ const client = new radiusClient({
+ host: hostname,
+ dictionaries: [ file ],
+ });
+
+ return client.accessRequest({
+ secret: secret,
+ attributes: [
+ [ attributes.USER_NAME, username ],
+ [ attributes.USER_PASSWORD, password ],
+ [ attributes.CALLING_STATION_ID, callingStationId ],
+ [ attributes.CALLED_STATION_ID, calledStationId ],
+ ],
+ });
+};
+
/**
* Retrieve value of setting based on key
* @param {string} key Key of setting to retrieve
diff --git a/src/components/DockerHostDialog.vue b/src/components/DockerHostDialog.vue
new file mode 100644
index 000000000..92a8ce455
--- /dev/null
+++ b/src/components/DockerHostDialog.vue
@@ -0,0 +1,177 @@
+<template>
+ <form @submit.prevent="submit">
+ <div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 id="exampleModalLabel" class="modal-title">
+ {{ $t("Setup Docker Host") }}
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
+ </div>
+ <div class="modal-body">
+ <div class="mb-3">
+ <label for="docker-name" class="form-label">{{ $t("Friendly Name") }}</label>
+ <input id="docker-name" v-model="dockerHost.name" type="text" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="docker-type" class="form-label">{{ $t("Connection Type") }}</label>
+ <select id="docker-type" v-model="dockerHost.dockerType" class="form-select">
+ <option v-for="type in connectionTypes" :key="type" :value="type">{{ $t(type) }}</option>
+ </select>
+ </div>
+
+ <div class="mb-3">
+ <label for="docker-daemon" class="form-label">{{ $t("Docker Daemon") }}</label>
+ <input id="docker-daemon" v-model="dockerHost.dockerDaemon" type="text" class="form-control" required>
+
+ <div class="form-text">
+ {{ $t("Examples") }}:
+ <ul>
+ <li>/var/run/docker.sock</li>
+ <li>tcp://localhost:2375</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
+ {{ $t("Delete") }}
+ </button>
+ <button type="button" class="btn btn-warning" :disabled="processing" @click="test">
+ {{ $t("Test") }}
+ </button>
+ <button type="submit" class="btn btn-primary" :disabled="processing">
+ <div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
+ {{ $t("Save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+
+ <Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteDockerHost">
+ {{ $t("deleteDockerHostMsg") }}
+ </Confirm>
+</template>
+
+<script lang="ts">
+import { Modal } from "bootstrap";
+import Confirm from "./Confirm.vue";
+import { useToast } from "vue-toastification";
+const toast = useToast();
+
+export default {
+ components: {
+ Confirm,
+ },
+ props: {},
+ emits: [ "added" ],
+ data() {
+ return {
+ model: null,
+ processing: false,
+ id: null,
+ connectionTypes: [ "socket", "tcp" ],
+ dockerHost: {
+ name: "",
+ dockerDaemon: "",
+ dockerType: "",
+ // Do not set default value here, please scroll to show()
+ }
+ };
+ },
+
+ mounted() {
+ this.modal = new Modal(this.$refs.modal);
+ },
+ methods: {
+
+ deleteConfirm() {
+ this.modal.hide();
+ this.$refs.confirmDelete.show();
+ },
+
+ show(dockerHostID) {
+ if (dockerHostID) {
+ let found = false;
+
+ this.id = dockerHostID;
+
+ for (let n of this.$root.dockerHostList) {
+ if (n.id === dockerHostID) {
+ this.dockerHost = n;
+ found = true;
+ break;
+ }
+ }
+
+ if (!found) {
+ toast.error("Docker Host not found!");
+ }
+
+ } else {
+ this.id = null;
+ this.dockerHost = {
+ name: "",
+ dockerType: "socket",
+ dockerDaemon: "/var/run/docker.sock",
+ };
+ }
+
+ this.modal.show();
+ },
+
+ submit() {
+ this.processing = true;
+ this.$root.getSocket().emit("addDockerHost", this.dockerHost, this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+
+ // Emit added event, doesn't emit edit.
+ if (! this.id) {
+ this.$emit("added", res.id);
+ }
+
+ }
+ });
+ },
+
+ test() {
+ this.processing = true;
+ this.$root.getSocket().emit("testDockerHost", this.dockerHost, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+ });
+ },
+
+ deleteDockerHost() {
+ this.processing = true;
+ this.$root.getSocket().emit("deleteDockerHost", this.id, (res) => {
+ this.$root.toastRes(res);
+ this.processing = false;
+
+ if (res.ok) {
+ this.modal.hide();
+ }
+ });
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars.scss";
+
+.dark {
+ .modal-dialog .form-text, .modal-dialog p {
+ color: $dark-font-color;
+ }
+}
+</style>
diff --git a/src/components/notifications/Bark.vue b/src/components/notifications/Bark.vue
index 014450dec..6cac73d36 100644
--- a/src/components/notifications/Bark.vue
+++ b/src/components/notifications/Bark.vue
@@ -2,9 +2,6 @@
<div class="mb-3">
<label for="Bark Endpoint" class="form-label">{{ $t("Bark Endpoint") }}<span style="color: red;"><sup>*</sup></span></label>
<input id="Bark Endpoint" v-model="$parent.notification.barkEndpoint" type="text" class="form-control" required>
- <div class="form-text">
- <p><span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}</p>
- </div>
<i18n-t tag="div" keypath="wayToGetTeamsURL" class="form-text">
<a
href="https://github.com/Finb/Bark"
@@ -12,4 +9,45 @@
>{{ $t("here") }}</a>
</i18n-t>
</div>
+ <div class="mb-3">
+ <label for="Bark Group" class="form-label">{{ $t("Bark Group") }}</label>
+ <input id="Bark Group" v-model="$parent.notification.barkGroup" type="text" class="form-control" required>
+ </div>
+ <div class="mb-3">
+ <label for="Bark Sound" class="form-label">{{ $t("Bark Sound") }}</label>
+ <select id="Bark Sound" v-model="$parent.notification.barkSound" class="form-select" required>
+ <option value="alarm">alarm</option>
+ <option value="anticipate">anticipate</option>
+ <option value="bell">bell</option>
+ <option value="birdsong">birdsong</option>
+ <option value="bloom">bloom</option>
+ <option value="calypso">calypso</option>
+ <option value="chime">chime</option>
+ <option value="choo">choo</option>
+ <option value="descent">descent</option>
+ <option value="electronic">electronic</option>
+ <option value="fanfare">fanfare</option>
+ <option value="glass">glass</option>
+ <option value="gotosleep">gotosleep</option>
+ <option value="healthnotification">healthnotification</option>
+ <option value="horn">horn</option>
+ <option value="ladder">ladder</option>
+ <option value="mailsent">mailsent</option>
+ <option value="minuet">minuet</option>
+ <option value="multiwayinvitation">multiwayinvitation</option>
+ <option value="newmail">newmail</option>
+ <option value="newsflash">newsflash</option>
+ <option value="noir">noir</option>
+ <option value="paymentsuccess">paymentsuccess</option>
+ <option value="shake">shake</option>
+ <option value="sherwoodforest">sherwoodforest</option>
+ <option value="silence">silence</option>
+ <option value="spell">spell</option>
+ <option value="suspense">suspense</option>
+ <option value="telegraph">telegraph</option>
+ <option value="tiptoes">tiptoes</option>
+ <option value="typewriters">typewriters</option>
+ <option value="update">update</option>
+ </select>
+ </div>
</template>
diff --git a/src/components/notifications/HomeAssistant.vue b/src/components/notifications/HomeAssistant.vue
new file mode 100644
index 000000000..67e370a15
--- /dev/null
+++ b/src/components/notifications/HomeAssistant.vue
@@ -0,0 +1,40 @@
+<template>
+ <div class="mb-3">
+ <label for="homeAssistantUrl" class="form-label">{{ $t("Home Assistant URL") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="homeAssistantUrl" v-model="$parent.notification.homeAssistantUrl" type="url" class="form-control" required>
+ </div>
+
+ <div class="mb-3">
+ <label for="longLivedAccessToken" class="form-label">{{ $t("Long-Lived Access Token") }}<span style="color: red;"><sup>*</sup></span></label>
+ <input id="longLivedAccessToken" v-model="$parent.notification.longLivedAccessToken" type="text" class="form-control" required>
+
+ <div class="form-text">
+ <p>{{ $t("Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ") }}</p>
+ </div>
+ </div>
+
+ <div class="mb-3">
+ <label for="notificationService" class="form-label">{{ $t("Notification Service") }}</label>
+ <input id="notificationService" v-model="$parent.notification.notificationService" type="text" :placeholder="$t('default: notify all devices')" class="form-control">
+
+ <div class="form-text">
+ <p>{{ $t("A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.") }}</p>
+ <p>{{ $t("Automations can optionally be triggered in Home Assistant:") }}</p>
+ <p>
+ {{ $t("Trigger type:") }} <code>Event</code><br />
+ {{ $t("Event type:") }} <code>call_service</code><br />
+ {{ $t("Event data:") }}
+ </p>
+ <pre>domain: notify
+service: mobile_app_my_phone # change to your device name
+service_data:
+ title: Uptime Kuma
+ data:
+ status: 0 # 0=down 1=up
+ # name: Optional Uptime Kuma Monitor Name to filter by</pre>
+ <p>
+ {{ $t("Then choose an action, for example switch the scene to where an RGB light is red.") }}
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/src/components/notifications/index.js b/src/components/notifications/index.js
index c1b7da4aa..ff523052e 100644
--- a/src/components/notifications/index.js
+++ b/src/components/notifications/index.js
@@ -10,6 +10,7 @@ import Feishu from "./Feishu.vue";
import GoogleChat from "./GoogleChat.vue";
import Gorush from "./Gorush.vue";
import Gotify from "./Gotify.vue";
+import HomeAssistant from "./HomeAssistant.vue";
import Line from "./Line.vue";
import LineNotify from "./LineNotify.vue";
import LunaSea from "./LunaSea.vue";
@@ -54,6 +55,7 @@ const NotificationFormList = {
"GoogleChat": GoogleChat,
"gorush": Gorush,
"gotify": Gotify,
+ "HomeAssistant": HomeAssistant,
"line": Line,
"LineNotify": LineNotify,
"lunasea": LunaSea,
diff --git a/src/components/settings/Docker.vue b/src/components/settings/Docker.vue
new file mode 100644
index 000000000..c411c307f
--- /dev/null
+++ b/src/components/settings/Docker.vue
@@ -0,0 +1,48 @@
+<template>
+ <div>
+ <div class="dockerHost-list my-4">
+ <p v-if="$root.dockerHostList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+
+ <ul class="list-group mb-3" style="border-radius: 1rem;">
+ <li v-for="(dockerHost, index) in $root.dockerHostList" :key="index" class="list-group-item">
+ {{ dockerHost.name }}<br>
+ <a href="#" @click="$refs.dockerHostDialog.show(dockerHost.id)">{{ $t("Edit") }}</a>
+ </li>
+ </ul>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.dockerHostDialog.show()">
+ {{ $t("Setup Docker Host") }}
+ </button>
+ </div>
+
+ <DockerHostDialog ref="dockerHostDialog" />
+ </div>
+</template>
+
+<script>
+import DockerHostDialog from "../../components/DockerHostDialog.vue";
+
+export default {
+ components: {
+ DockerHostDialog,
+ },
+
+ data() {
+ return {};
+ },
+
+ computed: {
+ settings() {
+ return this.$parent.$parent.$parent.settings;
+ },
+ saveSettings() {
+ return this.$parent.$parent.$parent.saveSettings;
+ },
+ settingsLoaded() {
+ return this.$parent.$parent.$parent.settingsLoaded;
+ },
+ }
+};
+</script>
diff --git a/src/languages/de-DE.js b/src/languages/de-DE.js
index 3df13b945..ef47909c7 100644
--- a/src/languages/de-DE.js
+++ b/src/languages/de-DE.js
@@ -165,7 +165,10 @@ export default {
Pink: "Pink",
"Search...": "Suchen...",
"Heartbeat Retry Interval": "Überprüfungsintervall",
+ "Resend Notification if Down X times consequently": "Benachrichtigung erneut senden, wenn Inaktiv X mal hintereinander",
retryCheckEverySecond: "Alle {0} Sekunden neu versuchen",
+ resendEveryXTimes: "Erneut versenden alle {0} mal",
+ resendDisabled: "Erneut versenden deaktiviert",
"Import Backup": "Backup importieren",
"Export Backup": "Backup exportieren",
"Avg. Ping": "Durchschn. Ping",
diff --git a/src/languages/en.js b/src/languages/en.js
index 352a63f6f..3bb025858 100644
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -2,6 +2,8 @@ export default {
languageName: "English",
checkEverySecond: "Check every {0} seconds",
retryCheckEverySecond: "Retry every {0} seconds",
+ resendEveryXTimes: "Resend every {0} times",
+ resendDisabled: "Resend disabled",
retriesDescription: "Maximum retries before the service is marked as down and a notification is sent",
ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites",
upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.",
@@ -72,6 +74,7 @@ export default {
"Heartbeat Interval": "Heartbeat Interval",
Retries: "Retries",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
+ "Resend Notification if Down X times consequently": "Resend Notification if Down X times consequently",
Advanced: "Advanced",
"Upside Down Mode": "Upside Down Mode",
"Max. Redirects": "Max. Redirects",
@@ -408,6 +411,8 @@ export default {
SignName: "SignName",
"Sms template must contain parameters: ": "Sms template must contain parameters: ",
"Bark Endpoint": "Bark Endpoint",
+ "Bark Group": "Bark Group",
+ "Bark Sound": "Bark Sound",
WebHookUrl: "WebHookUrl",
SecretKey: "SecretKey",
"For safety, must use secret key": "For safety, must use secret key",
@@ -467,6 +472,7 @@ export default {
"Domain Name Expiry Notification": "Domain Name Expiry Notification",
Proxy: "Proxy",
"Date Created": "Date Created",
+ HomeAssistant: "Home Assistant",
onebotHttpAddress: "OneBot HTTP Address",
onebotMessageType: "OneBot Message Type",
onebotGroupMessage: "Group",
@@ -479,6 +485,12 @@ export default {
"Domain Names": "Domain Names",
signedInDisp: "Signed in as {0}",
signedInDispDisabled: "Auth Disabled.",
+ RadiusSecret: "Radius Secret",
+ RadiusSecretDescription: "Shared Secret between client and server",
+ RadiusCalledStationId: "Called Station Id",
+ RadiusCalledStationIdDescription: "Identifier of the called device",
+ RadiusCallingStationId: "Calling Station Id",
+ RadiusCallingStationIdDescription: "Identifier of the calling device",
"Certificate Expiry Notification": "Certificate Expiry Notification",
"API Username": "API Username",
"API Key": "API Key",
@@ -487,7 +499,7 @@ export default {
"Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.",
"Octopush API Version": "Octopush API Version",
"Legacy Octopush-DM": "Legacy Octopush-DM",
- "endpoint": "endpoint",
+ endpoint: "endpoint",
octopushAPIKey: "\"API key\" from HTTP API credentials in control panel",
octopushLogin: "\"Login\" from HTTP API credentials in control panel",
promosmsLogin: "API Login Name",
@@ -531,9 +543,19 @@ export default {
"Coming Soon": "Coming Soon",
wayToGetClickSendSMSToken: "You can get API Username and API Key from {0} .",
"Connection String": "Connection String",
- "Query": "Query",
+ Query: "Query",
settingsCertificateExpiry: "TLS Certificate Expiry",
certificationExpiryDescription: "HTTPS Monitors trigger notification when TLS certificate expires in:",
+ "Setup Docker Host": "Setup Docker Host",
+ "Connection Type": "Connection Type",
+ "Docker Daemon": "Docker Daemon",
+ deleteDockerHostMsg: "Are you sure want to delete this docker host for all monitors?",
+ socket: "Socket",
+ tcp: "TCP / HTTP",
+ "Docker Container": "Docker Container",
+ "Container Name / ID": "Container Name / ID",
+ "Docker Host": "Docker Host",
+ "Docker Hosts": "Docker Hosts",
"ntfy Topic": "ntfy Topic",
"Domain": "Domain",
"Workstation": "Workstation",
diff --git a/src/languages/zh-CN.js b/src/languages/zh-CN.js
index 67077f389..8dbe05f0f 100644
--- a/src/languages/zh-CN.js
+++ b/src/languages/zh-CN.js
@@ -404,6 +404,8 @@ export default {
TemplateCode: "TemplateCode",
SignName: "SignName",
"Bark Endpoint": "Bark 接入点",
+ "Bark Group": "Bark 群组",
+ "Bark Sound": "Bark 铃声",
"Device Token": "Apple Device Token",
Platform: "平台",
iOS: "iOS",
diff --git a/src/languages/zh-TW.js b/src/languages/zh-TW.js
index be87c540a..3405c02ab 100644
--- a/src/languages/zh-TW.js
+++ b/src/languages/zh-TW.js
@@ -408,6 +408,8 @@ export default {
SignName: "SignName",
"Sms template must contain parameters: ": "Sms 範本必須包含參數:",
"Bark Endpoint": "Bark 端點",
+ "Bark Group": "Bark 群組",
+ "Bark Sound": "Bark 鈴聲",
WebHookUrl: "WebHookUrl",
SecretKey: "SecretKey",
"For safety, must use secret key": "為了安全起見,必須使用秘密金鑰",
diff --git a/src/mixins/socket.js b/src/mixins/socket.js
index ed1620bfe..52dd38919 100644
--- a/src/mixins/socket.js
+++ b/src/mixins/socket.js
@@ -39,6 +39,7 @@ export default {
uptimeList: { },
tlsInfoList: {},
notificationList: [],
+ dockerHostList: [],
statusPageListLoaded: false,
statusPageList: [],
proxyList: [],
@@ -147,6 +148,10 @@ export default {
});
});
+ socket.on("dockerHostList", (data) => {
+ this.dockerHostList = data;
+ });
+
socket.on("heartbeat", (data) => {
if (! (data.monitorID in this.heartbeatList)) {
this.heartbeatList[data.monitorID] = [];
diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue
index 3b2603187..99cbeb95f 100644
--- a/src/pages/EditMonitor.vue
+++ b/src/pages/EditMonitor.vue
@@ -27,6 +27,9 @@
<option value="dns">
DNS
</option>
+ <option value="docker">
+ {{ $t("Docker Container") }}
+ </option>
</optgroup>
<optgroup label="Passive Monitor Type">
@@ -48,6 +51,9 @@
<option value="postgres">
PostgreSQL
</option>
+ <option value="radius">
+ Radius
+ </option>
</optgroup>
</select>
</div>
@@ -84,8 +90,8 @@
</div>
<!-- Hostname -->
- <!-- TCP Port / Ping / DNS / Steam / MQTT only -->
- <div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'mqtt'" class="my-3">
+ <!-- TCP Port / Ping / DNS / Steam / MQTT / Radius only -->
+ <div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'mqtt' || monitor.type === 'radius'" class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${ipRegexPattern}|${hostnameRegexPattern}`" required>
</div>
@@ -141,6 +147,34 @@
</div>
</template>
+ <!-- Docker Container Name / ID -->
+ <!-- For Docker Type -->
+ <div v-if="monitor.type === 'docker'" class="my-3">
+ <label for="docker_container" class="form-label">{{ $t("Container Name / ID") }}</label>
+ <input id="docker_container" v-model="monitor.docker_container" type="text" class="form-control" required>
+ </div>
+
+ <!-- Docker Host -->
+ <!-- For Docker Type -->
+ <div v-if="monitor.type === 'docker'" class="my-3">
+ <h2 class="mb-2">{{ $t("Docker Host") }}</h2>
+ <p v-if="$root.dockerHostList.length === 0">
+ {{ $t("Not available, please setup.") }}
+ </p>
+
+ <div v-else class="mb-3">
+ <label for="docker-host" class="form-label">{{ $t("Docker Host") }}</label>
+ <select id="docket-host" v-model="monitor.docker_host" class="form-select">
+ <option v-for="host in $root.dockerHostList" :key="host.id" :value="host.id">{{ host.name }}</option>
+ </select>
+ <a href="#" @click="$refs.dockerHostDialog.show(monitor.docker_host)">{{ $t("Edit") }}</a>
+ </div>
+
+ <button class="btn btn-primary me-2" type="button" @click="$refs.dockerHostDialog.show()">
+ {{ $t("Setup Docker Host") }}
+ </button>
+ </div>
+
<!-- MQTT -->
<!-- For MQTT Type -->
<template v-if="monitor.type === 'mqtt'">
@@ -171,6 +205,36 @@
</div>
</template>
+ <template v-if="monitor.type === 'radius'">
+ <div class="my-3">
+ <label for="radius_username" class="form-label">Radius {{ $t("Username") }}</label>
+ <input id="radius_username" v-model="monitor.radiusUsername" type="text" class="form-control" required />
+ </div>
+
+ <div class="my-3">
+ <label for="radius_password" class="form-label">Radius {{ $t("Password") }}</label>
+ <input id="radius_password" v-model="monitor.radiusPassword" type="password" class="form-control" required />
+ </div>
+
+ <div class="my-3">
+ <label for="radius_secret" class="form-label">{{ $t("RadiusSecret") }}</label>
+ <input id="radius_secret" v-model="monitor.radiusSecret" type="password" class="form-control" required />
+ <div class="form-text"> {{ $t( "RadiusSecretDescription") }} </div>
+ </div>
+
+ <div class="my-3">
+ <label for="radius_called_station_id" class="form-label">{{ $t("RadiusCalledStationId") }}</label>
+ <input id="radius_called_station_id" v-model="monitor.radiusCalledStationId" type="text" class="form-control" required />
+ <div class="form-text"> {{ $t( "RadiusCalledStationIdDescription") }} </div>
+ </div>
+
+ <div class="my-3">
+ <label for="radius_calling_station_id" class="form-label">{{ $t("RadiusCallingStationId") }}</label>
+ <input id="radius_calling_station_id" v-model="monitor.radiusCallingStationId" type="text" class="form-control" required />
+ <div class="form-text"> {{ $t( "RadiusCallingStationIdDescription") }} </div>
+ </div>
+ </template>
+
<!-- SQL Server and PostgreSQL -->
<template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres'">
<div class="my-3">
@@ -211,6 +275,15 @@
<input id="retry-interval" v-model="monitor.retryInterval" type="number" class="form-control" required min="20" step="1">
</div>
+ <div class="my-3">
+ <label for="resend-interval" class="form-label">
+ {{ $t("Resend Notification if Down X times consequently") }}
+ <span v-if="monitor.resendInterval > 0">({{ $t("resendEveryXTimes", [ monitor.resendInterval ]) }})</span>
+ <span v-else>({{ $t("resendDisabled") }})</span>
+ </label>
+ <input id="resend-interval" v-model="monitor.resendInterval" type="number" class="form-control" required min="0" step="1">
+ </div>
+
<h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3 form-check">
@@ -424,6 +497,7 @@
</form>
<NotificationDialog ref="notificationDialog" @added="addedNotification" />
+ <DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
<ProxyDialog ref="proxyDialog" @added="addedProxy" />
</div>
</transition>
@@ -434,6 +508,7 @@ import VueMultiselect from "vue-multiselect";
import { useToast } from "vue-toastification";
import CopyableInput from "../components/CopyableInput.vue";
import NotificationDialog from "../components/NotificationDialog.vue";
+import DockerHostDialog from "../components/DockerHostDialog.vue";
import ProxyDialog from "../components/ProxyDialog.vue";
import TagsManager from "../components/TagsManager.vue";
import { genSecret, isDev } from "../util.ts";
@@ -445,6 +520,7 @@ export default {
ProxyDialog,
CopyableInput,
NotificationDialog,
+ DockerHostDialog,
TagsManager,
VueMultiselect,
},
@@ -593,6 +669,7 @@ export default {
method: "GET",
interval: 60,
retryInterval: this.interval,
+ resendInterval: 0,
maxretries: 0,
notificationIDList: {},
ignoreTls: false,
@@ -602,6 +679,8 @@ export default {
accepted_statuscodes: [ "200-299" ],
dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1",
+ docker_container: "",
+ docker_host: null,
proxyId: null,
mqttUsername: "",
mqttPassword: "",
@@ -729,6 +808,12 @@ export default {
addedProxy(id) {
this.monitor.proxyId = id;
},
+
+ // Added a Docker Host Event
+ // Enable it if the Docker Host is added in EditMonitor.vue
+ addedDockerHost(id) {
+ this.monitor.docker_host = id;
+ }
},
};
</script>
diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue
index e10137898..efd26ce37 100644
--- a/src/pages/Settings.vue
+++ b/src/pages/Settings.vue
@@ -89,6 +89,9 @@ export default {
"monitor-history": {
title: this.$t("Monitor History"),
},
+ "docker-hosts": {
+ title: this.$t("Docker Hosts"),
+ },
security: {
title: this.$t("Security"),
},
diff --git a/src/router.js b/src/router.js
index 726194776..7d29a1882 100644
--- a/src/router.js
+++ b/src/router.js
@@ -25,6 +25,7 @@ const Security = () => import("./components/settings/Security.vue");
import Proxies from "./components/settings/Proxies.vue";
import Backup from "./components/settings/Backup.vue";
import About from "./components/settings/About.vue";
+import DockerHosts from "./components/settings/Docker.vue";
const routes = [
{
@@ -95,6 +96,10 @@ const routes = [
path: "monitor-history",
component: MonitorHistory,
},
+ {
+ path: "docker-hosts",
+ component: DockerHosts,
+ },
{
path: "security",
component: Security,