-
-
-
+
+
+
+
+
+
+
@@ -87,6 +96,7 @@ import Draggable from "vuedraggable";
import HeartbeatBar from "./HeartbeatBar.vue";
import Uptime from "./Uptime.vue";
import Tag from "./Tag.vue";
+import GroupSortDropdown from "./GroupSortDropdown.vue";
export default {
components: {
@@ -95,6 +105,7 @@ export default {
HeartbeatBar,
Uptime,
Tag,
+ GroupSortDropdown,
},
props: {
/** Are we in edit mode? */
@@ -113,7 +124,6 @@ export default {
},
data() {
return {
-
};
},
computed: {
@@ -121,8 +131,11 @@ export default {
return (this.$root.publicGroupList.length >= 2);
}
},
+ watch: {
+ // No watchers needed - sorting is handled by GroupSortDropdown component
+ },
created() {
-
+ // Sorting is now handled by GroupSortDropdown component
},
methods: {
/**
@@ -136,8 +149,7 @@ export default {
/**
* Remove a monitor from a group
- * @param {number} groupIndex Index of group to remove monitor
- * from
+ * @param {number} groupIndex Index of group to remove monitor from
* @param {number} index Index of monitor to remove
* @returns {void}
*/
@@ -158,7 +170,9 @@ export default {
// We must check if there are any elements in monitorList to
// prevent undefined errors if it hasn't been loaded yet
if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) {
- return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query";
+ return this.$root.monitorList[monitor.element.id].type === "http" ||
+ this.$root.monitorList[monitor.element.id].type === "keyword" ||
+ this.$root.monitorList[monitor.element.id].type === "json-query";
}
return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://";
},
@@ -189,6 +203,32 @@ export default {
}
return "#DC2626";
},
+
+ /**
+ * Update group properties
+ * @param {number} groupIndex Index of group to update
+ * @param {object} updates Object with properties to update
+ * @returns {void}
+ */
+ updateGroup(groupIndex, updates) {
+ Object.assign(this.$root.publicGroupList[groupIndex], updates);
+ },
+
+ /**
+ * Get unique identifier for a group
+ * @param {object} group object
+ * @returns {string} group identifier
+ */
+ getGroupIdentifier(group) {
+ // Use the name directly if available
+ if (group.name) {
+ // Only remove spaces and use encodeURIComponent for URL safety
+ const cleanName = group.name.replace(/\s+/g, "");
+ return cleanName;
+ }
+ // Fallback to ID or index
+ return group.id ? `group${group.id}` : `group${this.$root.publicGroupList.indexOf(group)}`;
+ }
}
};
@@ -250,6 +290,15 @@ export default {
}
.group-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .title-section {
+ display: flex;
+ align-items: center;
+ }
+
span {
display: inline-block;
min-width: 15px;
@@ -260,10 +309,14 @@ export default {
.item {
padding: 13px 0 10px;
}
+
+ .group-title {
+ flex-direction: column;
+ align-items: flex-start;
+ }
}
.bg-maintenance {
background-color: $maintenance;
}
-
diff --git a/src/icon.js b/src/icon.js
index 7bdfe1ca0..fc50c8a1e 100644
--- a/src/icon.js
+++ b/src/icon.js
@@ -8,6 +8,8 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
// 2) add the icon name to the library.add() statement below.
import {
faArrowAltCircleUp,
+ faArrowDown,
+ faArrowUp,
faCog,
faEdit,
faEye,
@@ -54,6 +56,8 @@ import {
library.add(
faArrowAltCircleUp,
+ faArrowDown,
+ faArrowUp,
faCog,
faEdit,
faEye,
@@ -100,4 +104,3 @@ library.add(
);
export { FontAwesomeIcon };
-
From 082e4b97129764684c20e11abd8be7bd5da78eab Mon Sep 17 00:00:00 2001
From: Sn0r1ax
Date: Sun, 23 Nov 2025 12:46:32 +0800
Subject: [PATCH 14/29] fix: Clear all statistics and clear heartbeats not
resetting uptime statistics of monitors (#6398)
---
server/server.js | 14 ++++++------
server/uptime-calculator.js | 44 +++++++++++++++++++++++++++++++++++++
2 files changed, 51 insertions(+), 7 deletions(-)
diff --git a/server/server.js b/server/server.js
index f9366c4ab..5105100fc 100644
--- a/server/server.js
+++ b/server/server.js
@@ -109,6 +109,7 @@ const { login } = require("./auth");
const passwordHash = require("./password-hash");
const { Prometheus } = require("./prometheus");
+const { UptimeCalculator } = require("./uptime-calculator");
const hostname = config.hostname;
@@ -1592,9 +1593,11 @@ let needSetup = false;
log.info("manage", `Clear Heartbeats Monitor: ${monitorID} User ID: ${socket.userID}`);
- await R.exec("DELETE FROM heartbeat WHERE monitor_id = ?", [
- monitorID
- ]);
+ await UptimeCalculator.clearStatistics(monitorID);
+
+ if (monitorID in server.monitorList) {
+ await restartMonitor(socket.userID, monitorID);
+ }
await sendHeartbeatList(socket, monitorID, true, true);
@@ -1616,10 +1619,7 @@ let needSetup = false;
log.info("manage", `Clear Statistics User ID: ${socket.userID}`);
- await R.exec("DELETE FROM heartbeat");
- await R.exec("DELETE FROM stat_daily");
- await R.exec("DELETE FROM stat_hourly");
- await R.exec("DELETE FROM stat_minutely");
+ await UptimeCalculator.clearAllStatistics();
// Restart all monitors to reset the stats
for (let monitorID in server.monitorList) {
diff --git a/server/uptime-calculator.js b/server/uptime-calculator.js
index 48f7d80ae..7796791a3 100644
--- a/server/uptime-calculator.js
+++ b/server/uptime-calculator.js
@@ -90,6 +90,14 @@ class UptimeCalculator {
delete UptimeCalculator.list[monitorID];
}
+ /**
+ * Remove all monitors from the list
+ * @returns {Promise}
+ */
+ static async removeAll() {
+ UptimeCalculator.list = {};
+ }
+
/**
*
*/
@@ -845,6 +853,42 @@ class UptimeCalculator {
setMigrationMode(value) {
this.migrationMode = value;
}
+
+ /**
+ * Clear all statistics and heartbeats for a monitor
+ * @param {number} monitorID the id of the monitor
+ * @returns {Promise}
+ */
+ static async clearStatistics(monitorID) {
+ await R.exec("DELETE FROM heartbeat WHERE monitor_id = ?", [
+ monitorID
+ ]);
+
+ await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ?", [
+ monitorID
+ ]);
+ await R.exec("DELETE FROM stat_hourly WHERE monitor_id = ?", [
+ monitorID
+ ]);
+ await R.exec("DELETE FROM stat_daily WHERE monitor_id = ?", [
+ monitorID
+ ]);
+
+ await UptimeCalculator.remove(monitorID);
+ }
+
+ /**
+ * Clear all statistics and heartbeats for all monitors
+ * @returns {Promise}
+ */
+ static async clearAllStatistics() {
+ await R.exec("DELETE FROM heartbeat");
+ await R.exec("DELETE FROM stat_minutely");
+ await R.exec("DELETE FROM stat_hourly");
+ await R.exec("DELETE FROM stat_daily");
+
+ await UptimeCalculator.removeAll();
+ }
}
class UptimeDataResult {
From 0eebe86f81a16b488714a09dfc4fd3a53f938d11 Mon Sep 17 00:00:00 2001
From: Shaan
Date: Mon, 24 Nov 2025 11:30:13 +0600
Subject: [PATCH 15/29] feat: add SSL/STARTTLS option and certificate
monitoring to TCP Port monitor (#6401)
Co-authored-by: Jacques ROUSSEL
Co-authored-by: rouja
Co-authored-by: Nelson Chan <3271800+chakflying@users.noreply.github.com>
Co-authored-by: Louis Lam
Co-authored-by: Frank Elsinga
---
server/model/monitor.js | 7 +-
server/monitor-types/tcp.js | 158 +++++++++++++++++++++++++++++
server/uptime-kuma-server.js | 2 +
server/util-server.js | 28 ------
src/pages/EditMonitor.vue | 11 +-
test/backend-test/test-tcp.js | 182 ++++++++++++++++++++++++++++++++++
6 files changed, 353 insertions(+), 35 deletions(-)
create mode 100644 server/monitor-types/tcp.js
create mode 100644 test/backend-test/test-tcp.js
diff --git a/server/model/monitor.js b/server/model/monitor.js
index 75af6b813..97926f4c7 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI
PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../../src/util");
-const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
+const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
} = require("../util-server");
const { R } = require("redbean-node");
@@ -621,11 +621,6 @@ class Monitor extends BeanModel {
}
- } else if (this.type === "port") {
- bean.ping = await tcping(this.hostname, this.port);
- bean.msg = "";
- bean.status = UP;
-
} else if (this.type === "ping") {
bean.ping = await ping(this.hostname, this.ping_count, "", this.ping_numeric, this.packetSize, this.timeout, this.ping_per_request_timeout);
bean.msg = "";
diff --git a/server/monitor-types/tcp.js b/server/monitor-types/tcp.js
new file mode 100644
index 000000000..f0bc12a01
--- /dev/null
+++ b/server/monitor-types/tcp.js
@@ -0,0 +1,158 @@
+const { MonitorType } = require("./monitor-type");
+const { UP, DOWN, PING_GLOBAL_TIMEOUT_DEFAULT: TIMEOUT, log } = require("../../src/util");
+const { checkCertificate } = require("../util-server");
+const tls = require("tls");
+const net = require("net");
+const tcpp = require("tcp-ping");
+
+/**
+ * Send TCP request to specified hostname and port
+ * @param {string} hostname Hostname / address of machine
+ * @param {number} port TCP port to test
+ * @returns {Promise} Maximum time in ms rounded to nearest integer
+ */
+const tcping = (hostname, port) => {
+ return new Promise((resolve, reject) => {
+ tcpp.ping(
+ {
+ address: hostname,
+ port: port,
+ attempts: 1,
+ },
+ (err, data) => {
+ if (err) {
+ reject(err);
+ }
+
+ if (data.results.length >= 1 && data.results[0].err) {
+ reject(data.results[0].err);
+ }
+
+ resolve(Math.round(data.max));
+ }
+ );
+ });
+};
+
+class TCPMonitorType extends MonitorType {
+ name = "port";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ try {
+ const resp = await tcping(monitor.hostname, monitor.port);
+ heartbeat.ping = resp;
+ heartbeat.msg = `${resp} ms`;
+ heartbeat.status = UP;
+ } catch {
+ heartbeat.status = DOWN;
+ heartbeat.msg = "Connection failed";
+ return;
+ }
+
+ let socket_;
+
+ const preTLS = () =>
+ new Promise((resolve, reject) => {
+ let timeout;
+ socket_ = net.connect(monitor.port, monitor.hostname);
+
+ const onTimeout = () => {
+ log.debug(this.name, `[${monitor.name}] Pre-TLS connection timed out`);
+ reject("Connection timed out");
+ };
+
+ socket_.on("connect", () => {
+ log.debug(this.name, `[${monitor.name}] Pre-TLS connection: ${JSON.stringify(socket_)}`);
+ });
+
+ socket_.on("data", data => {
+ const response = data.toString();
+ const response_ = response.toLowerCase();
+ log.debug(this.name, `[${monitor.name}] Pre-TLS response: ${response}`);
+ switch (true) {
+ case response_.includes("start tls") || response_.includes("begin tls"):
+ timeout && clearTimeout(timeout);
+ resolve({ socket: socket_ });
+ break;
+ case response.startsWith("* OK") || response.match(/CAPABILITY.+STARTTLS/):
+ socket_.write("a001 STARTTLS\r\n");
+ break;
+ case response.startsWith("220") || response.includes("ESMTP"):
+ socket_.write(`EHLO ${monitor.hostname}\r\n`);
+ break;
+ case response.includes("250-STARTTLS"):
+ socket_.write("STARTTLS\r\n");
+ break;
+ default:
+ reject(`Unexpected response: ${response}`);
+ }
+ });
+ socket_.on("error", error => {
+ log.debug(this.name, `[${monitor.name}] ${error.toString()}`);
+ reject(error);
+ });
+ socket_.setTimeout(1000 * TIMEOUT, onTimeout);
+ timeout = setTimeout(onTimeout, 1000 * TIMEOUT);
+ });
+
+ const reuseSocket = monitor.smtpSecurity === "starttls" ? await preTLS() : {};
+
+ if ([ "secure", "starttls" ].includes(monitor.smtpSecurity) && monitor.isEnabledExpiryNotification()) {
+ let socket = null;
+ try {
+ const options = {
+ host: monitor.hostname,
+ port: monitor.port,
+ servername: monitor.hostname,
+ ...reuseSocket,
+ };
+
+ const tlsInfoObject = await new Promise((resolve, reject) => {
+ socket = tls.connect(options);
+
+ socket.on("secureConnect", () => {
+ try {
+ const info = checkCertificate(socket);
+ resolve(info);
+ } catch (error) {
+ reject(error);
+ }
+ });
+
+ socket.on("error", error => {
+ reject(error);
+ });
+
+ socket.setTimeout(1000 * TIMEOUT, () => {
+ reject(new Error("Connection timed out"));
+ });
+ });
+
+ await monitor.handleTlsInfo(tlsInfoObject);
+ if (!tlsInfoObject.valid) {
+ heartbeat.status = DOWN;
+ heartbeat.msg = "Certificate is invalid";
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ heartbeat.status = DOWN;
+ heartbeat.msg = `TLS Connection failed: ${message}`;
+ } finally {
+ if (socket && !socket.destroyed) {
+ socket.end();
+ }
+ }
+ }
+
+ if (socket_ && !socket_.destroyed) {
+ socket_.end();
+ }
+ }
+}
+
+module.exports = {
+ TCPMonitorType,
+};
diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js
index 3c84bf646..b95477664 100644
--- a/server/uptime-kuma-server.js
+++ b/server/uptime-kuma-server.js
@@ -118,6 +118,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
+ UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType();
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
@@ -560,6 +561,7 @@ const { GroupMonitorType } = require("./monitor-types/group");
const { SNMPMonitorType } = require("./monitor-types/snmp");
const { MongodbMonitorType } = require("./monitor-types/mongodb");
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
+const { TCPMonitorType } = require("./monitor-types/tcp.js");
const { ManualMonitorType } = require("./monitor-types/manual");
const { RedisMonitorType } = require("./monitor-types/redis");
const Monitor = require("./model/monitor");
diff --git a/server/util-server.js b/server/util-server.js
index 3de6e777f..6365b623c 100644
--- a/server/util-server.js
+++ b/server/util-server.js
@@ -1,4 +1,3 @@
-const tcpp = require("tcp-ping");
const ping = require("@louislam/ping");
const { R } = require("redbean-node");
const {
@@ -98,33 +97,6 @@ exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSe
return await client.grant(grantParams);
};
-/**
- * Send TCP request to specified hostname and port
- * @param {string} hostname Hostname / address of machine
- * @param {number} port TCP port to test
- * @returns {Promise} Maximum time in ms rounded to nearest integer
- */
-exports.tcping = function (hostname, port) {
- return new Promise((resolve, reject) => {
- tcpp.ping({
- address: hostname,
- port: port,
- attempts: 1,
- }, function (err, data) {
-
- if (err) {
- reject(err);
- }
-
- if (data.results.length >= 1 && data.results[0].err) {
- reject(data.results[0].err);
- }
-
- resolve(Math.round(data.max));
- });
- });
-};
-
/**
* Ping the specified machine
* @param {string} destAddr Hostname / IP address of machine to ping
diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue
index b5213ef5e..798c89f17 100644
--- a/src/pages/EditMonitor.vue
+++ b/src/pages/EditMonitor.vue
@@ -366,6 +366,15 @@
+
@@ -671,7 +680,7 @@
{{ $t("Advanced") }}
-
+
+
@@ -1196,6 +1286,7 @@ const monitorDefaults = {
name: "",
parent: null,
url: "https://",
+ wsSubprotocol: "",
method: "GET",
ipFamily: null,
interval: 60,
@@ -1610,6 +1701,9 @@ message HealthCheckResponse {
},
"monitor.type"(newType, oldType) {
+ if (oldType && this.monitor.type === "websocket-upgrade") {
+ this.monitor.url = "wss://";
+ }
if (this.monitor.type === "push") {
if (! this.monitor.pushToken) {
// ideally this would require checking if the generated token is already used
diff --git a/test/backend-test/test-websocket.js b/test/backend-test/test-websocket.js
new file mode 100644
index 000000000..a84930bdf
--- /dev/null
+++ b/test/backend-test/test-websocket.js
@@ -0,0 +1,187 @@
+const { WebSocketServer } = require("ws");
+const { describe, test } = require("node:test");
+const assert = require("node:assert");
+const { WebSocketMonitorType } = require("../../server/monitor-types/websocket-upgrade");
+const { UP, DOWN, PENDING } = require("../../src/util");
+
+describe("Websocket Test", {
+}, () => {
+ test("Non Websocket Server", {}, async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://example.org",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "Unexpected server response: 200",
+ status: DOWN,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Secure Websocket", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://echo.websocket.org",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "101 - OK",
+ status: UP,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Insecure Websocket", async (t) => {
+ t.after(() => wss.close());
+ const websocketMonitor = new WebSocketMonitorType();
+ const wss = new WebSocketServer({ port: 8080 });
+
+ const monitor = {
+ url: "ws://localhost:8080",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "101 - OK",
+ status: UP,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Non compliant WS server without IgnoreSecWebsocket", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://c.img-cdn.net/yE4s7KehTFyj/",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "Invalid Sec-WebSocket-Accept header",
+ status: DOWN,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Non compliant WS server with IgnoreSecWebsocket", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://c.img-cdn.net/yE4s7KehTFyj/",
+ wsIgnoreSecWebsocketAcceptHeader: true,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "101 - OK",
+ status: UP,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Compliant WS server with IgnoreSecWebsocket", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://echo.websocket.org",
+ wsIgnoreSecWebsocketAcceptHeader: true,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "101 - OK",
+ status: UP,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Non WS server with IgnoreSecWebsocket", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://example.org",
+ wsIgnoreSecWebsocketAcceptHeader: true,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "Unexpected server response: 200",
+ status: DOWN,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Secure Websocket with Subprotocol", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://echo.websocket.org",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ wsSubprotocol: "ocpp1.6",
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "Server sent no subprotocol",
+ status: DOWN,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+});
From bd2eb30e09c69fc883f9af6149ec0fe1762cb3f5 Mon Sep 17 00:00:00 2001
From: Couteau Arthur <60989517+Logorrheique@users.noreply.github.com>
Date: Thu, 27 Nov 2025 21:12:38 +0100
Subject: [PATCH 23/29] fix: Redirect to '/dashboard' on computer when
shrinking from '/list' on mobile (#5305)
Co-authored-by: Louis Lam
Co-authored-by: Frank Elsinga
---
src/pages/List.vue | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/pages/List.vue b/src/pages/List.vue
index dd2d46059..2d8db2dba 100644
--- a/src/pages/List.vue
+++ b/src/pages/List.vue
@@ -11,6 +11,18 @@ export default {
components: {
MonitorList,
},
+ watch: {
+ "$root.isMobile"(newVal) {
+ if (!newVal && this.$route.path === "/list") {
+ this.$router.replace({ path: "/dashboard" });
+ }
+ },
+ },
+ mounted() {
+ if (!this.$root.isMobile && this.$route.path === "/list") {
+ this.$router.replace({ path: "/dashboard" });
+ }
+ },
};
@@ -20,5 +32,4 @@ export default {
.shadow-box {
padding: 20px;
}
-
From 70329cc25962438dba9efeb84b4ccb5860e93f8d Mon Sep 17 00:00:00 2001
From: Dorian Grasset
Date: Fri, 28 Nov 2025 11:02:29 +0100
Subject: [PATCH 24/29] fix: dynamically adjust beat border radius (#6432)
---
src/components/HeartbeatBar.vue | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue
index de5c86dcc..4977d0b8b 100644
--- a/src/components/HeartbeatBar.vue
+++ b/src/components/HeartbeatBar.vue
@@ -85,7 +85,6 @@ export default {
tooltipTimeoutId: null,
// Canvas
hoveredBeatIndex: -1,
- beatBorderRadius: 2.5,
};
},
computed: {
@@ -578,19 +577,22 @@ export default {
offsetY = centerY - height / 2;
}
+ // Calculate border radius based on current width (pill shape = half of width)
+ const borderRadius = width / 2;
+
// Get color based on beat status
let color = this.getBeatColor(beat, colors);
// Draw beat rectangle
ctx.fillStyle = color;
- this.roundRect(ctx, offsetX, offsetY, width, height, this.beatBorderRadius);
+ this.roundRect(ctx, offsetX, offsetY, width, height, borderRadius);
ctx.fill();
// Apply hover opacity
if (isHovered && beat !== 0) {
ctx.globalAlpha = 0.8;
ctx.fillStyle = color;
- this.roundRect(ctx, offsetX, offsetY, width, height, this.beatBorderRadius);
+ this.roundRect(ctx, offsetX, offsetY, width, height, borderRadius);
ctx.fill();
ctx.globalAlpha = 1;
}
From 6e49601eed26cdf8afa5c1a8d8a5900181d6a196 Mon Sep 17 00:00:00 2001
From: Louis Lam
Date: Fri, 28 Nov 2025 20:25:06 +0800
Subject: [PATCH 25/29] Enforce UP status for non-custom status monitors
(#6433)
Co-authored-by: Frank Elsinga
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.github/PULL_REQUEST_TEMPLATE.md | 9 +++++
server/model/monitor.js | 5 +++
server/monitor-types/dns.js | 8 +++-
server/monitor-types/group.js | 1 +
server/monitor-types/manual.js | 2 +
server/monitor-types/monitor-type.js | 9 +++++
server/monitor-types/rabbitmq.js | 11 +++---
server/monitor-types/tcp.js | 12 ++----
server/monitor-types/websocket-upgrade.js | 11 ++++--
test/backend-test/test-rabbitmq.js | 11 ++++--
test/backend-test/test-tcp.js | 26 ++++++++-----
test/backend-test/test-websocket.js | 46 +++++++++--------------
12 files changed, 90 insertions(+), 61 deletions(-)
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index e351aa2e2..71a2f7fc4 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -78,3 +78,12 @@ Avoid using external image services as the image will be uploaded automatically.
| `DOWN` |  |  |
| Certificate-expiry |  |  |
| Testing |  |  |
+
+
diff --git a/server/model/monitor.js b/server/model/monitor.js
index aa5d6ba9b..0b6be92d7 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -860,6 +860,11 @@ class Monitor extends BeanModel {
let startTime = dayjs().valueOf();
const monitorType = UptimeKumaServer.monitorTypeList[this.type];
await monitorType.check(this, bean, UptimeKumaServer.getInstance());
+
+ if (!monitorType.allowCustomStatus && bean.status !== UP) {
+ throw new Error("The monitor implementation is incorrect, non-UP error must throw error inside check()");
+ }
+
if (!bean.ping) {
bean.ping = dayjs().valueOf() - startTime;
}
diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js
index 5a47e4591..77032b302 100644
--- a/server/monitor-types/dns.js
+++ b/server/monitor-types/dns.js
@@ -1,5 +1,5 @@
const { MonitorType } = require("./monitor-type");
-const { UP, DOWN } = require("../../src/util");
+const { UP } = require("../../src/util");
const dayjs = require("dayjs");
const { dnsResolve } = require("../util-server");
const { R } = require("redbean-node");
@@ -79,8 +79,12 @@ class DnsMonitorType extends MonitorType {
await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]);
}
+ if (!conditionsResult) {
+ throw new Error(dnsMessage);
+ }
+
heartbeat.msg = dnsMessage;
- heartbeat.status = conditionsResult ? UP : DOWN;
+ heartbeat.status = UP;
}
}
diff --git a/server/monitor-types/group.js b/server/monitor-types/group.js
index 8372b4f17..212244a39 100644
--- a/server/monitor-types/group.js
+++ b/server/monitor-types/group.js
@@ -4,6 +4,7 @@ const Monitor = require("../model/monitor");
class GroupMonitorType extends MonitorType {
name = "group";
+ allowCustomStatus = true;
/**
* @inheritdoc
diff --git a/server/monitor-types/manual.js b/server/monitor-types/manual.js
index e587b7409..7134d6c0f 100644
--- a/server/monitor-types/manual.js
+++ b/server/monitor-types/manual.js
@@ -8,6 +8,8 @@ class ManualMonitorType extends MonitorType {
supportsConditions = false;
conditionVariables = [];
+ allowCustomStatus = true;
+
/**
* @inheritdoc
*/
diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js
index 8f3cbcac4..aa7b1d200 100644
--- a/server/monitor-types/monitor-type.js
+++ b/server/monitor-types/monitor-type.js
@@ -14,8 +14,17 @@ class MonitorType {
*/
conditionVariables = [];
+ /**
+ * Allows setting any custom status to heartbeat, other than UP.
+ * @type {boolean}
+ */
+ allowCustomStatus = false;
+
/**
* Run the monitoring check on the given monitor
+ *
+ * Successful cases: Should update heartbeat.status to "up" and set response time.
+ * Failure cases: Throw an error with a descriptive message.
* @param {Monitor} monitor Monitor to check
* @param {Heartbeat} heartbeat Monitor heartbeat to update
* @param {UptimeKumaServer} server Uptime Kuma server
diff --git a/server/monitor-types/rabbitmq.js b/server/monitor-types/rabbitmq.js
index 165a0ed91..251f1b8ac 100644
--- a/server/monitor-types/rabbitmq.js
+++ b/server/monitor-types/rabbitmq.js
@@ -1,5 +1,5 @@
const { MonitorType } = require("./monitor-type");
-const { log, UP, DOWN } = require("../../src/util");
+const { log, UP } = require("../../src/util");
const { axiosAbortSignal } = require("../util-server");
const axios = require("axios");
@@ -17,7 +17,6 @@ class RabbitMqMonitorType extends MonitorType {
throw new Error("Invalid RabbitMQ Nodes");
}
- heartbeat.status = DOWN;
for (let baseUrl of baseUrls) {
try {
// Without a trailing slash, path in baseUrl will be removed. https://example.com/api -> https://example.com
@@ -45,17 +44,17 @@ class RabbitMqMonitorType extends MonitorType {
heartbeat.msg = "OK";
break;
} else if (res.status === 503) {
- heartbeat.msg = res.data.reason;
+ throw new Error(res.data.reason);
} else {
- heartbeat.msg = `${res.status} - ${res.statusText}`;
+ throw new Error(`${res.status} - ${res.statusText}`);
}
} catch (error) {
if (axios.isCancel(error)) {
- heartbeat.msg = "Request timed out";
log.debug("monitor", `[${monitor.name}] Request timed out`);
+ throw new Error("Request timed out");
} else {
log.debug("monitor", `[${monitor.name}] Axios Error: ${JSON.stringify(error.message)}`);
- heartbeat.msg = error.message;
+ throw new Error(error.message);
}
}
}
diff --git a/server/monitor-types/tcp.js b/server/monitor-types/tcp.js
index f0bc12a01..e71973b1f 100644
--- a/server/monitor-types/tcp.js
+++ b/server/monitor-types/tcp.js
@@ -1,5 +1,5 @@
const { MonitorType } = require("./monitor-type");
-const { UP, DOWN, PING_GLOBAL_TIMEOUT_DEFAULT: TIMEOUT, log } = require("../../src/util");
+const { UP, PING_GLOBAL_TIMEOUT_DEFAULT: TIMEOUT, log } = require("../../src/util");
const { checkCertificate } = require("../util-server");
const tls = require("tls");
const net = require("net");
@@ -47,9 +47,7 @@ class TCPMonitorType extends MonitorType {
heartbeat.msg = `${resp} ms`;
heartbeat.status = UP;
} catch {
- heartbeat.status = DOWN;
- heartbeat.msg = "Connection failed";
- return;
+ throw new Error("Connection failed");
}
let socket_;
@@ -133,13 +131,11 @@ class TCPMonitorType extends MonitorType {
await monitor.handleTlsInfo(tlsInfoObject);
if (!tlsInfoObject.valid) {
- heartbeat.status = DOWN;
- heartbeat.msg = "Certificate is invalid";
+ throw new Error("Certificate is invalid");
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
- heartbeat.status = DOWN;
- heartbeat.msg = `TLS Connection failed: ${message}`;
+ throw new Error(`TLS Connection failed: ${message}`);
} finally {
if (socket && !socket.destroyed) {
socket.end();
diff --git a/server/monitor-types/websocket-upgrade.js b/server/monitor-types/websocket-upgrade.js
index e85c4baa7..c53225306 100644
--- a/server/monitor-types/websocket-upgrade.js
+++ b/server/monitor-types/websocket-upgrade.js
@@ -1,6 +1,6 @@
const { MonitorType } = require("./monitor-type");
const WebSocket = require("ws");
-const { UP, DOWN } = require("../../src/util");
+const { UP } = require("../../src/util");
class WebSocketMonitorType extends MonitorType {
name = "websocket-upgrade";
@@ -10,8 +10,13 @@ class WebSocketMonitorType extends MonitorType {
*/
async check(monitor, heartbeat, _server) {
const [ message, code ] = await this.attemptUpgrade(monitor);
- heartbeat.status = code === 1000 ? UP : DOWN;
- heartbeat.msg = message;
+
+ if (code === 1000) {
+ heartbeat.status = UP;
+ heartbeat.msg = message;
+ } else {
+ throw new Error(message);
+ }
}
/**
diff --git a/test/backend-test/test-rabbitmq.js b/test/backend-test/test-rabbitmq.js
index 5782ef250..31f018aa9 100644
--- a/test/backend-test/test-rabbitmq.js
+++ b/test/backend-test/test-rabbitmq.js
@@ -2,7 +2,7 @@ const { describe, test } = require("node:test");
const assert = require("node:assert");
const { RabbitMQContainer } = require("@testcontainers/rabbitmq");
const { RabbitMqMonitorType } = require("../../server/monitor-types/rabbitmq");
-const { UP, DOWN, PENDING } = require("../../src/util");
+const { UP, PENDING } = require("../../src/util");
describe("RabbitMQ Single Node", {
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
@@ -46,8 +46,13 @@ describe("RabbitMQ Single Node", {
status: PENDING,
};
- await rabbitMQMonitor.check(monitor, heartbeat, {});
- assert.strictEqual(heartbeat.status, DOWN);
+ // regex match any string
+ const regex = /.+/;
+
+ await assert.rejects(
+ rabbitMQMonitor.check(monitor, heartbeat, {}),
+ regex
+ );
});
});
diff --git a/test/backend-test/test-tcp.js b/test/backend-test/test-tcp.js
index 3d389c154..46a4d12dd 100644
--- a/test/backend-test/test-tcp.js
+++ b/test/backend-test/test-tcp.js
@@ -1,7 +1,7 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { TCPMonitorType } = require("../../server/monitor-types/tcp");
-const { UP, DOWN, PENDING } = require("../../src/util");
+const { UP, PENDING } = require("../../src/util");
const net = require("net");
/**
@@ -77,9 +77,10 @@ describe("TCP Monitor", () => {
status: PENDING,
};
- await tcpMonitor.check(monitor, heartbeat, {});
-
- assert.strictEqual(heartbeat.status, DOWN);
+ await assert.rejects(
+ tcpMonitor.check(monitor, heartbeat, {}),
+ new Error("Connection failed")
+ );
});
/**
@@ -104,10 +105,13 @@ describe("TCP Monitor", () => {
status: PENDING,
};
- await tcpMonitor.check(monitor, heartbeat, {});
+ // Regex: contains with "TLS Connection failed:" or "Certificate is invalid"
+ const regex = /TLS Connection failed:|Certificate is invalid/;
- assert.strictEqual(heartbeat.status, DOWN);
- assert([ "Certificate is invalid", "TLS Connection failed:" ].some(prefix => heartbeat.msg.startsWith(prefix)));
+ await assert.rejects(
+ tcpMonitor.check(monitor, heartbeat, {}),
+ regex
+ );
});
test("TCP server with valid TLS certificate (SSL)", async t => {
@@ -174,9 +178,11 @@ describe("TCP Monitor", () => {
status: PENDING,
};
- await tcpMonitor.check(monitor, heartbeat, {});
+ const regex = /does not match certificate/;
- assert.strictEqual(heartbeat.status, DOWN);
- assert([ "does not match certificate" ].some(msg => heartbeat.msg.includes(msg)));
+ await assert.rejects(
+ tcpMonitor.check(monitor, heartbeat, {}),
+ regex
+ );
});
});
diff --git a/test/backend-test/test-websocket.js b/test/backend-test/test-websocket.js
index a84930bdf..33660d134 100644
--- a/test/backend-test/test-websocket.js
+++ b/test/backend-test/test-websocket.js
@@ -2,7 +2,7 @@ const { WebSocketServer } = require("ws");
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { WebSocketMonitorType } = require("../../server/monitor-types/websocket-upgrade");
-const { UP, DOWN, PENDING } = require("../../src/util");
+const { UP, PENDING } = require("../../src/util");
describe("Websocket Test", {
}, () => {
@@ -19,13 +19,10 @@ describe("Websocket Test", {
status: PENDING,
};
- const expected = {
- msg: "Unexpected server response: 200",
- status: DOWN,
- };
-
- await websocketMonitor.check(monitor, heartbeat, {});
- assert.deepStrictEqual(heartbeat, expected);
+ await assert.rejects(
+ websocketMonitor.check(monitor, heartbeat, {}),
+ new Error("Unexpected server response: 200")
+ );
});
test("Secure Websocket", async () => {
@@ -87,13 +84,10 @@ describe("Websocket Test", {
status: PENDING,
};
- const expected = {
- msg: "Invalid Sec-WebSocket-Accept header",
- status: DOWN,
- };
-
- await websocketMonitor.check(monitor, heartbeat, {});
- assert.deepStrictEqual(heartbeat, expected);
+ await assert.rejects(
+ websocketMonitor.check(monitor, heartbeat, {}),
+ new Error("Invalid Sec-WebSocket-Accept header")
+ );
});
test("Non compliant WS server with IgnoreSecWebsocket", async () => {
@@ -153,13 +147,10 @@ describe("Websocket Test", {
status: PENDING,
};
- const expected = {
- msg: "Unexpected server response: 200",
- status: DOWN,
- };
-
- await websocketMonitor.check(monitor, heartbeat, {});
- assert.deepStrictEqual(heartbeat, expected);
+ await assert.rejects(
+ websocketMonitor.check(monitor, heartbeat, {}),
+ new Error("Unexpected server response: 200")
+ );
});
test("Secure Websocket with Subprotocol", async () => {
@@ -176,12 +167,9 @@ describe("Websocket Test", {
status: PENDING,
};
- const expected = {
- msg: "Server sent no subprotocol",
- status: DOWN,
- };
-
- await websocketMonitor.check(monitor, heartbeat, {});
- assert.deepStrictEqual(heartbeat, expected);
+ await assert.rejects(
+ websocketMonitor.check(monitor, heartbeat, {}),
+ new Error("Server sent no subprotocol")
+ );
});
});
From f4ff234ec8c7c5946c7d28add750988f7536a287 Mon Sep 17 00:00:00 2001
From: Louis Lam
Date: Fri, 28 Nov 2025 20:41:44 +0800
Subject: [PATCH 26/29] [Push monitor] Fix: Prometheus update with incorrect
value (#6436)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
server/model/monitor.js | 6 +++++-
server/routers/api-router.js | 7 ++++++-
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/server/model/monitor.js b/server/model/monitor.js
index 0b6be92d7..bb2c9e852 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -359,7 +359,11 @@ class Monitor extends BeanModel {
let previousBeat = null;
let retries = 0;
- this.prometheus = new Prometheus(this, await this.getTags());
+ try {
+ this.prometheus = new Prometheus(this, await this.getTags());
+ } catch (e) {
+ log.error("prometheus", "Please submit an issue to our GitHub repo. Prometheus update error: ", e.message);
+ }
const beat = async () => {
diff --git a/server/routers/api-router.js b/server/routers/api-router.js
index b00dbc02d..9c9a7e7e9 100644
--- a/server/routers/api-router.js
+++ b/server/routers/api-router.js
@@ -119,7 +119,12 @@ router.all("/api/push/:pushToken", async (request, response) => {
io.to(monitor.user_id).emit("heartbeat", bean.toJSON());
Monitor.sendStats(io, monitor.id, monitor.user_id);
- new Prometheus(monitor).update(bean, undefined);
+
+ try {
+ new Prometheus(monitor, []).update(bean, undefined);
+ } catch (e) {
+ log.error("prometheus", "Please submit an issue to our GitHub repo. Prometheus update error: ", e.message);
+ }
response.json({
ok: true,
From 9b92db9e65bdb1390c8cb57ee4547ae6c3bf7ee4 Mon Sep 17 00:00:00 2001
From: Louis Lam
Date: Fri, 28 Nov 2025 22:48:04 +0800
Subject: [PATCH 27/29] Update security issue template to clarify reporting
(#6438)
---
.github/ISSUE_TEMPLATE/security_issue.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/ISSUE_TEMPLATE/security_issue.yml b/.github/ISSUE_TEMPLATE/security_issue.yml
index 247073102..fa86d9ddb 100644
--- a/.github/ISSUE_TEMPLATE/security_issue.yml
+++ b/.github/ISSUE_TEMPLATE/security_issue.yml
@@ -11,6 +11,10 @@ body:
value: |
## ❗ IMPORTANT: DO NOT SHARE VULNERABILITY DETAILS HERE
+ ## Do not report any upstream dependency issues / scan results by any tools.
+
+ It will be closed immediately without explanation, unless you have a PoC to prove that the upstream issue affects Uptime Kuma.
+
### ⚠️ Report a Security Vulnerability
**If you have discovered a security vulnerability, please report it securely using the GitHub Security Advisory.**
From 46b07953ad2347a38d85123e20825cd1c4f89fe1 Mon Sep 17 00:00:00 2001
From: Dorian Grasset
Date: Fri, 28 Nov 2025 18:40:33 +0100
Subject: [PATCH 28/29] fix: redraw HeartbeatBar canvas on theme change &
update empty beat color (#6435)
---
src/components/HeartbeatBar.vue | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue
index 4977d0b8b..55defd4e3 100644
--- a/src/components/HeartbeatBar.vue
+++ b/src/components/HeartbeatBar.vue
@@ -315,6 +315,13 @@ export default {
});
},
+ "$root.theme"() {
+ // Redraw canvas when theme changes (nextTick ensures .dark class is applied)
+ this.$nextTick(() => {
+ this.drawCanvas();
+ });
+ },
+
hoveredBeatIndex() {
this.drawCanvas();
},
@@ -550,13 +557,14 @@ export default {
const centerY = this.canvasHeight / 2;
// Cache CSS colors once per redraw
- const styles = getComputedStyle(document.documentElement);
+ const rootStyles = getComputedStyle(document.documentElement);
+ const canvasStyles = getComputedStyle(canvas.parentElement);
const colors = {
- empty: styles.getPropertyValue("--bs-body-bg") || "#f0f8ff",
- down: styles.getPropertyValue("--bs-danger") || "#dc3545",
- pending: styles.getPropertyValue("--bs-warning") || "#ffc107",
- maintenance: styles.getPropertyValue("--maintenance") || "#1d4ed8",
- up: styles.getPropertyValue("--bs-primary") || "#5cdd8b",
+ empty: canvasStyles.getPropertyValue("--beat-empty-color") || "#f0f8ff",
+ down: rootStyles.getPropertyValue("--bs-danger") || "#dc3545",
+ pending: rootStyles.getPropertyValue("--bs-warning") || "#ffc107",
+ maintenance: rootStyles.getPropertyValue("--maintenance") || "#1d4ed8",
+ up: rootStyles.getPropertyValue("--bs-primary") || "#5cdd8b",
};
// Draw each beat
@@ -815,6 +823,12 @@ export default {
}
.hp-bar-big {
+ --beat-empty-color: #f0f8ff;
+
+ .dark & {
+ --beat-empty-color: #848484;
+ }
+
.heartbeat-canvas {
display: block;
cursor: pointer;
From b230ab0a068742221a0eaaa433bb052d82ee657b Mon Sep 17 00:00:00 2001
From: Frank Elsinga
Date: Sat, 29 Nov 2025 16:21:45 +0100
Subject: [PATCH 29/29] migrated grpc keyword to the newer monitoringtype
(#4821)
Co-authored-by: Louis Lam
---
server/model/monitor.js | 33 +--
server/monitor-types/grpc.js | 89 ++++++
server/uptime-kuma-server.js | 2 +
server/util-server.js | 60 ----
test/backend-test/test-grpc.js | 306 ++++++++++++++++++++
test/manual-test-grpc/echo.proto | 7 +
test/manual-test-grpc/simple-grpc-server.js | 22 ++
7 files changed, 427 insertions(+), 92 deletions(-)
create mode 100644 server/monitor-types/grpc.js
create mode 100644 test/backend-test/test-grpc.js
create mode 100644 test/manual-test-grpc/echo.proto
create mode 100644 test/manual-test-grpc/simple-grpc-server.js
diff --git a/server/model/monitor.js b/server/model/monitor.js
index bb2c9e852..2ea2f958c 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI
PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../../src/util");
-const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
+const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius,
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
} = require("../util-server");
const { R } = require("redbean-node");
@@ -784,37 +784,6 @@ class Monitor extends BeanModel {
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
- } else if (this.type === "grpc-keyword") {
- let startTime = dayjs().valueOf();
- const options = {
- grpcUrl: this.grpcUrl,
- grpcProtobufData: this.grpcProtobuf,
- grpcServiceName: this.grpcServiceName,
- grpcEnableTls: this.grpcEnableTls,
- grpcMethod: this.grpcMethod,
- grpcBody: this.grpcBody,
- };
- const response = await grpcQuery(options);
- bean.ping = dayjs().valueOf() - startTime;
- log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
- let responseData = response.data;
- if (responseData.length > 50) {
- responseData = responseData.toString().substring(0, 47) + "...";
- }
- if (response.code !== 1) {
- bean.status = DOWN;
- bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`;
- } else {
- let keywordFound = response.data.toString().includes(this.keyword);
- if (keywordFound === !this.isInvertKeyword()) {
- bean.status = UP;
- bean.msg = `${responseData}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`;
- } else {
- log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response.data} + "]"`);
- bean.status = DOWN;
- bean.msg = `, but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${responseData} + "]`;
- }
- }
} else if (this.type === "postgres") {
let startTime = dayjs().valueOf();
diff --git a/server/monitor-types/grpc.js b/server/monitor-types/grpc.js
new file mode 100644
index 000000000..ebc03b8c8
--- /dev/null
+++ b/server/monitor-types/grpc.js
@@ -0,0 +1,89 @@
+const { MonitorType } = require("./monitor-type");
+const { UP, log } = require("../../src/util");
+const dayjs = require("dayjs");
+const grpc = require("@grpc/grpc-js");
+const protojs = require("protobufjs");
+
+class GrpcKeywordMonitorType extends MonitorType {
+ name = "grpc-keyword";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ const startTime = dayjs().valueOf();
+ const service = this.constructGrpcService(monitor.grpcUrl, monitor.grpcProtobuf, monitor.grpcServiceName, monitor.grpcEnableTls);
+ let response = await this.grpcQuery(service, monitor.grpcMethod, monitor.grpcBody);
+ heartbeat.ping = dayjs().valueOf() - startTime;
+ log.debug(this.name, "gRPC response:", response);
+ let keywordFound = response.toString().includes(monitor.keyword);
+ if (keywordFound !== !monitor.isInvertKeyword()) {
+ log.debug(this.name, `GRPC response [${response}] + ", but keyword [${monitor.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response} + "]"`);
+
+ let truncatedResponse = (response.length > 50) ? response.toString().substring(0, 47) + "..." : response;
+
+ throw new Error(`keyword [${monitor.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${truncatedResponse} + "]`);
+ }
+ heartbeat.status = UP;
+ heartbeat.msg = `${response}, keyword [${monitor.keyword}] ${keywordFound ? "is" : "not"} found`;
+ }
+
+ /**
+ * Create gRPC client
+ * @param {string} url grpc Url
+ * @param {string} protobufData grpc ProtobufData
+ * @param {string} serviceName grpc ServiceName
+ * @param {string} enableTls grpc EnableTls
+ * @returns {grpc.Service} grpc Service
+ */
+ constructGrpcService(url, protobufData, serviceName, enableTls) {
+ const protocObject = protojs.parse(protobufData);
+ const protoServiceObject = protocObject.root.lookupService(serviceName);
+ const Client = grpc.makeGenericClientConstructor({});
+ const credentials = enableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
+ const client = new Client(url, credentials);
+ return protoServiceObject.create((method, requestData, cb) => {
+ const fullServiceName = method.fullName;
+ const serviceFQDN = fullServiceName.split(".");
+ const serviceMethod = serviceFQDN.pop();
+ const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`;
+ log.debug(this.name, `gRPC method ${serviceMethodClientImpl}`);
+ client.makeUnaryRequest(
+ serviceMethodClientImpl,
+ arg => arg,
+ arg => arg,
+ requestData,
+ cb);
+ }, false, false);
+ }
+
+ /**
+ * Create gRPC client stib
+ * @param {grpc.Service} service grpc Url
+ * @param {string} method grpc Method
+ * @param {string} body grpc Body
+ * @returns {Promise} Result of gRPC query
+ */
+ async grpcQuery(service, method, body) {
+ return new Promise((resolve, reject) => {
+ try {
+ service[method](JSON.parse(body), (err, response) => {
+ if (err) {
+ if (err.code !== 1) {
+ reject(err);
+ }
+ log.debug(this.name, `ignoring ${err.code} ${err.details}, as code=1 is considered OK`);
+ resolve(`${err.code} is considered OK because ${err.details}`);
+ }
+ resolve(JSON.stringify(response));
+ });
+ } catch (err) {
+ reject(err);
+ }
+ });
+ }
+}
+
+module.exports = {
+ GrpcKeywordMonitorType,
+};
diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js
index 107b54671..d91f6be81 100644
--- a/server/uptime-kuma-server.js
+++ b/server/uptime-kuma-server.js
@@ -117,6 +117,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["smtp"] = new SMTPMonitorType();
UptimeKumaServer.monitorTypeList["group"] = new GroupMonitorType();
UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
+ UptimeKumaServer.monitorTypeList["grpc-keyword"] = new GrpcKeywordMonitorType();
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType();
@@ -561,6 +562,7 @@ const { MqttMonitorType } = require("./monitor-types/mqtt");
const { SMTPMonitorType } = require("./monitor-types/smtp");
const { GroupMonitorType } = require("./monitor-types/group");
const { SNMPMonitorType } = require("./monitor-types/snmp");
+const { GrpcKeywordMonitorType } = require("./monitor-types/grpc");
const { MongodbMonitorType } = require("./monitor-types/mongodb");
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
const { TCPMonitorType } = require("./monitor-types/tcp.js");
diff --git a/server/util-server.js b/server/util-server.js
index 6365b623c..250f897ba 100644
--- a/server/util-server.js
+++ b/server/util-server.js
@@ -16,8 +16,6 @@ const postgresConParse = require("pg-connection-string").parse;
const mysql = require("mysql2");
const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js");
const { Settings } = require("./settings");
-const grpc = require("@grpc/grpc-js");
-const protojs = require("protobufjs");
const RadiusClient = require("./radius-client");
const oidc = require("openid-client");
const tls = require("tls");
@@ -892,64 +890,6 @@ module.exports.timeObjectToLocal = (obj, timezone = undefined) => {
return timeObjectConvertTimezone(obj, timezone, false);
};
-/**
- * Create gRPC client stib
- * @param {object} options from gRPC client
- * @returns {Promise} Result of gRPC query
- */
-module.exports.grpcQuery = async (options) => {
- const { grpcUrl, grpcProtobufData, grpcServiceName, grpcEnableTls, grpcMethod, grpcBody } = options;
- const protocObject = protojs.parse(grpcProtobufData);
- const protoServiceObject = protocObject.root.lookupService(grpcServiceName);
- const Client = grpc.makeGenericClientConstructor({});
- const credentials = grpcEnableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
- const client = new Client(
- grpcUrl,
- credentials
- );
- const grpcService = protoServiceObject.create(function (method, requestData, cb) {
- const fullServiceName = method.fullName;
- const serviceFQDN = fullServiceName.split(".");
- const serviceMethod = serviceFQDN.pop();
- const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`;
- log.debug("monitor", `gRPC method ${serviceMethodClientImpl}`);
- client.makeUnaryRequest(
- serviceMethodClientImpl,
- arg => arg,
- arg => arg,
- requestData,
- cb);
- }, false, false);
- return new Promise((resolve, _) => {
- try {
- return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) {
- const responseData = JSON.stringify(response);
- if (err) {
- return resolve({
- code: err.code,
- errorMessage: err.details,
- data: ""
- });
- } else {
- log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
- return resolve({
- code: 1,
- errorMessage: "",
- data: responseData
- });
- }
- });
- } catch (err) {
- return resolve({
- code: -1,
- errorMessage: `Error ${err}. Please review your gRPC configuration option. The service name must not include package name value, and the method name must follow camelCase format`,
- data: ""
- });
- }
-
- });
-};
-
/**
* Returns an array of SHA256 fingerprints for all known root certificates.
* @returns {Set} A set of SHA256 fingerprints.
diff --git a/test/backend-test/test-grpc.js b/test/backend-test/test-grpc.js
new file mode 100644
index 000000000..31b588cff
--- /dev/null
+++ b/test/backend-test/test-grpc.js
@@ -0,0 +1,306 @@
+const { describe, test } = require("node:test");
+const assert = require("node:assert");
+const grpc = require("@grpc/grpc-js");
+const protoLoader = require("@grpc/proto-loader");
+const { GrpcKeywordMonitorType } = require("../../server/monitor-types/grpc");
+const { UP, PENDING } = require("../../src/util");
+const fs = require("fs");
+const path = require("path");
+const os = require("os");
+
+const testProto = `
+syntax = "proto3";
+package test;
+
+service TestService {
+ rpc Echo (EchoRequest) returns (EchoResponse);
+}
+
+message EchoRequest {
+ string message = 1;
+}
+
+message EchoResponse {
+ string message = 1;
+}
+`;
+
+/**
+ * Create a gRPC server for testing
+ * @param {number} port Port to listen on
+ * @param {object} methodHandlers Object with method handlers
+ * @returns {Promise} gRPC server instance
+ */
+async function createTestGrpcServer(port, methodHandlers) {
+ // Write proto to temp file
+ const tmpDir = os.tmpdir();
+ const protoPath = path.join(tmpDir, `test-${port}.proto`);
+ fs.writeFileSync(protoPath, testProto);
+
+ // Load proto file
+ const packageDefinition = protoLoader.loadSync(protoPath, {
+ keepCase: true,
+ longs: String,
+ enums: String,
+ defaults: true,
+ oneofs: true
+ });
+ const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
+ const testPackage = protoDescriptor.test;
+
+ const server = new grpc.Server();
+
+ // Add service implementation
+ server.addService(testPackage.TestService.service, {
+ Echo: (call, callback) => {
+ if (methodHandlers.Echo) {
+ methodHandlers.Echo(call, callback);
+ } else {
+ callback(null, { message: call.request.message });
+ }
+ },
+ });
+
+ return new Promise((resolve, reject) => {
+ server.bindAsync(
+ `0.0.0.0:${port}`,
+ grpc.ServerCredentials.createInsecure(),
+ (err) => {
+ if (err) {
+ reject(err);
+ } else {
+ server.start();
+ // Clean up temp file
+ fs.unlinkSync(protoPath);
+ resolve(server);
+ }
+ }
+ );
+ });
+}
+
+describe("GrpcKeywordMonitorType", {
+ skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
+}, () => {
+ test("gRPC keyword found in response", async () => {
+ const port = 50051;
+ const server = await createTestGrpcServer(port, {
+ Echo: (call, callback) => {
+ callback(null, { message: "Hello World with SUCCESS keyword" });
+ }
+ });
+
+ const grpcMonitor = new GrpcKeywordMonitorType();
+ const monitor = {
+ grpcUrl: `localhost:${port}`,
+ grpcProtobuf: testProto,
+ grpcServiceName: "test.TestService",
+ grpcMethod: "echo",
+ grpcBody: JSON.stringify({ message: "test" }),
+ keyword: "SUCCESS",
+ invertKeyword: false,
+ grpcEnableTls: false,
+ isInvertKeyword: () => false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await grpcMonitor.check(monitor, heartbeat, {});
+ assert.strictEqual(heartbeat.status, UP);
+ assert.ok(heartbeat.msg.includes("SUCCESS"));
+ assert.ok(heartbeat.msg.includes("is"));
+ } finally {
+ server.forceShutdown();
+ }
+ });
+
+ test("gRPC keyword not found in response", async () => {
+ const port = 50052;
+ const server = await createTestGrpcServer(port, {
+ Echo: (call, callback) => {
+ callback(null, { message: "Hello World without the expected keyword" });
+ }
+ });
+
+ const grpcMonitor = new GrpcKeywordMonitorType();
+ const monitor = {
+ grpcUrl: `localhost:${port}`,
+ grpcProtobuf: testProto,
+ grpcServiceName: "test.TestService",
+ grpcMethod: "echo",
+ grpcBody: JSON.stringify({ message: "test" }),
+ keyword: "MISSING",
+ invertKeyword: false,
+ grpcEnableTls: false,
+ isInvertKeyword: () => false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await assert.rejects(
+ grpcMonitor.check(monitor, heartbeat, {}),
+ (err) => {
+ assert.ok(err.message.includes("MISSING"));
+ assert.ok(err.message.includes("not"));
+ return true;
+ }
+ );
+ } finally {
+ server.forceShutdown();
+ }
+ });
+
+ test("gRPC inverted keyword - keyword present (should fail)", async () => {
+ const port = 50053;
+ const server = await createTestGrpcServer(port, {
+ Echo: (call, callback) => {
+ callback(null, { message: "Response with ERROR keyword" });
+ }
+ });
+
+ const grpcMonitor = new GrpcKeywordMonitorType();
+ const monitor = {
+ grpcUrl: `localhost:${port}`,
+ grpcProtobuf: testProto,
+ grpcServiceName: "test.TestService",
+ grpcMethod: "echo",
+ grpcBody: JSON.stringify({ message: "test" }),
+ keyword: "ERROR",
+ invertKeyword: true,
+ grpcEnableTls: false,
+ isInvertKeyword: () => true,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await assert.rejects(
+ grpcMonitor.check(monitor, heartbeat, {}),
+ (err) => {
+ assert.ok(err.message.includes("ERROR"));
+ assert.ok(err.message.includes("present"));
+ return true;
+ }
+ );
+ } finally {
+ server.forceShutdown();
+ }
+ });
+
+ test("gRPC inverted keyword - keyword not present (should pass)", async () => {
+ const port = 50054;
+ const server = await createTestGrpcServer(port, {
+ Echo: (call, callback) => {
+ callback(null, { message: "Response without error keyword" });
+ }
+ });
+
+ const grpcMonitor = new GrpcKeywordMonitorType();
+ const monitor = {
+ grpcUrl: `localhost:${port}`,
+ grpcProtobuf: testProto,
+ grpcServiceName: "test.TestService",
+ grpcMethod: "echo",
+ grpcBody: JSON.stringify({ message: "test" }),
+ keyword: "ERROR",
+ invertKeyword: true,
+ grpcEnableTls: false,
+ isInvertKeyword: () => true,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await grpcMonitor.check(monitor, heartbeat, {});
+ assert.strictEqual(heartbeat.status, UP);
+ assert.ok(heartbeat.msg.includes("ERROR"));
+ assert.ok(heartbeat.msg.includes("not"));
+ } finally {
+ server.forceShutdown();
+ }
+ });
+
+ test("gRPC connection failure", async () => {
+ const grpcMonitor = new GrpcKeywordMonitorType();
+ const monitor = {
+ grpcUrl: "localhost:50099",
+ grpcProtobuf: testProto,
+ grpcServiceName: "test.TestService",
+ grpcMethod: "echo",
+ grpcBody: JSON.stringify({ message: "test" }),
+ keyword: "SUCCESS",
+ invertKeyword: false,
+ grpcEnableTls: false,
+ isInvertKeyword: () => false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ await assert.rejects(
+ grpcMonitor.check(monitor, heartbeat, {}),
+ (err) => {
+ // Should fail with connection error
+ return true;
+ }
+ );
+ });
+
+ test("gRPC response truncation for long messages", async () => {
+ const port = 50055;
+ const longMessage = "A".repeat(100) + " with SUCCESS keyword";
+
+ const server = await createTestGrpcServer(port, {
+ Echo: (call, callback) => {
+ callback(null, { message: longMessage });
+ }
+ });
+
+ const grpcMonitor = new GrpcKeywordMonitorType();
+ const monitor = {
+ grpcUrl: `localhost:${port}`,
+ grpcProtobuf: testProto,
+ grpcServiceName: "test.TestService",
+ grpcMethod: "echo",
+ grpcBody: JSON.stringify({ message: "test" }),
+ keyword: "MISSING",
+ invertKeyword: false,
+ grpcEnableTls: false,
+ isInvertKeyword: () => false,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await assert.rejects(
+ grpcMonitor.check(monitor, heartbeat, {}),
+ (err) => {
+ // Should truncate message to 50 characters with "..."
+ assert.ok(err.message.includes("..."));
+ return true;
+ }
+ );
+ } finally {
+ server.forceShutdown();
+ }
+ });
+});
diff --git a/test/manual-test-grpc/echo.proto b/test/manual-test-grpc/echo.proto
new file mode 100644
index 000000000..39ae6a66a
--- /dev/null
+++ b/test/manual-test-grpc/echo.proto
@@ -0,0 +1,7 @@
+syntax = "proto3";
+package echo;
+service EchoService {
+ rpc Echo (EchoRequest) returns (EchoResponse);
+}
+message EchoRequest { string message = 1; }
+message EchoResponse { string message = 1; }
diff --git a/test/manual-test-grpc/simple-grpc-server.js b/test/manual-test-grpc/simple-grpc-server.js
new file mode 100644
index 000000000..91a401e47
--- /dev/null
+++ b/test/manual-test-grpc/simple-grpc-server.js
@@ -0,0 +1,22 @@
+const grpc = require("@grpc/grpc-js");
+const protoLoader = require("@grpc/proto-loader");
+const packageDef = protoLoader.loadSync("echo.proto", {});
+const grpcObject = grpc.loadPackageDefinition(packageDef);
+const { echo } = grpcObject;
+
+/**
+ * Echo service implementation
+ * @param {object} call Call object
+ * @param {Function} callback Callback function
+ * @returns {void}
+ */
+function Echo(call, callback) {
+ callback(null, { message: call.request.message });
+}
+
+const server = new grpc.Server();
+server.addService(echo.EchoService.service, { Echo });
+server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), () => {
+ console.log("gRPC server running on :50051");
+ server.start();
+});