diff --git a/db/knex_migrations/2024-10-1315-rabbitmq-monitor.js b/db/knex_migrations/2024-10-1315-rabbitmq-monitor.js
new file mode 100644
index 00000000..6a17f336
--- /dev/null
+++ b/db/knex_migrations/2024-10-1315-rabbitmq-monitor.js
@@ -0,0 +1,17 @@
+exports.up = function (knex) {
+ return knex.schema.alterTable("monitor", function (table) {
+ table.text("rabbitmq_nodes");
+ table.string("rabbitmq_username");
+ table.string("rabbitmq_password");
+ });
+
+};
+
+exports.down = function (knex) {
+ return knex.schema.alterTable("monitor", function (table) {
+ table.dropColumn("rabbitmq_nodes");
+ table.dropColumn("rabbitmq_username");
+ table.dropColumn("rabbitmq_password");
+ });
+
+};
diff --git a/package-lock.json b/package-lock.json
index a89b1b56..24f63d03 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -93,6 +93,7 @@
"@playwright/test": "~1.39.0",
"@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1",
+ "@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9",
"@types/node": "^20.8.6",
"@typescript-eslint/eslint-plugin": "^6.7.5",
@@ -4172,6 +4173,15 @@
"testcontainers": "^10.13.1"
}
},
+ "node_modules/@testcontainers/rabbitmq": {
+ "version": "10.13.2",
+ "resolved": "https://registry.npmjs.org/@testcontainers/rabbitmq/-/rabbitmq-10.13.2.tgz",
+ "integrity": "sha512-npBKBnq3c6hETmxGZ/gVMke9cc1J/pcftNW9S3WidL48hxFBIPjYNM9FdTfWuoNER/8kuf4xJ8yCuJEYGH3ZAg==",
+ "dev": true,
+ "dependencies": {
+ "testcontainers": "^10.13.2"
+ }
+ },
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -15925,11 +15935,10 @@
}
},
"node_modules/testcontainers": {
- "version": "10.13.1",
- "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.1.tgz",
- "integrity": "sha512-JBbOhxmygj/ouH/47GnoVNt+c55Telh/45IjVxEbDoswsLchVmJiuKiw/eF6lE5i7LN+/99xsrSCttI3YRtirg==",
+ "version": "10.13.2",
+ "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.2.tgz",
+ "integrity": "sha512-LfEll+AG/1Ks3n4+IA5lpyBHLiYh/hSfI4+ERa6urwfQscbDU+M2iW1qPQrHQi+xJXQRYy4whyK1IEHdmxWa3Q==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"@types/dockerode": "^3.3.29",
diff --git a/package.json b/package.json
index c6bef90e..5186cafc 100644
--- a/package.json
+++ b/package.json
@@ -155,6 +155,7 @@
"@playwright/test": "~1.39.0",
"@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1",
+ "@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9",
"@types/node": "^20.8.6",
"@typescript-eslint/eslint-plugin": "^6.7.5",
diff --git a/server/model/monitor.js b/server/model/monitor.js
index da0c0d5c..9a30a668 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -153,6 +153,7 @@ class Monitor extends BeanModel {
snmpOid: this.snmpOid,
jsonPathOperator: this.jsonPathOperator,
snmpVersion: this.snmpVersion,
+ rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
conditions: JSON.parse(this.conditions),
};
@@ -183,6 +184,8 @@ class Monitor extends BeanModel {
tlsCert: this.tlsCert,
tlsKey: this.tlsKey,
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
+ rabbitmqUsername: this.rabbitmqUsername,
+ rabbitmqPassword: this.rabbitmqPassword,
};
}
diff --git a/server/monitor-types/rabbitmq.js b/server/monitor-types/rabbitmq.js
new file mode 100644
index 00000000..165a0ed9
--- /dev/null
+++ b/server/monitor-types/rabbitmq.js
@@ -0,0 +1,67 @@
+const { MonitorType } = require("./monitor-type");
+const { log, UP, DOWN } = require("../../src/util");
+const { axiosAbortSignal } = require("../util-server");
+const axios = require("axios");
+
+class RabbitMqMonitorType extends MonitorType {
+ name = "rabbitmq";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, server) {
+ let baseUrls = [];
+ try {
+ baseUrls = JSON.parse(monitor.rabbitmqNodes);
+ } catch (error) {
+ 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
+ if ( !baseUrl.endsWith("/") ) {
+ baseUrl += "/";
+ }
+ const options = {
+ // Do not start with slash, it will strip the trailing slash from baseUrl
+ url: new URL("api/health/checks/alarms/", baseUrl).href,
+ method: "get",
+ timeout: monitor.timeout * 1000,
+ headers: {
+ "Accept": "application/json",
+ "Authorization": "Basic " + Buffer.from(`${monitor.rabbitmqUsername || ""}:${monitor.rabbitmqPassword || ""}`).toString("base64"),
+ },
+ signal: axiosAbortSignal((monitor.timeout + 10) * 1000),
+ // Capture reason for 503 status
+ validateStatus: (status) => status === 200 || status === 503,
+ };
+ log.debug("monitor", `[${monitor.name}] Axios Request: ${JSON.stringify(options)}`);
+ const res = await axios.request(options);
+ log.debug("monitor", `[${monitor.name}] Axios Response: status=${res.status} body=${JSON.stringify(res.data)}`);
+ if (res.status === 200) {
+ heartbeat.status = UP;
+ heartbeat.msg = "OK";
+ break;
+ } else if (res.status === 503) {
+ heartbeat.msg = res.data.reason;
+ } else {
+ heartbeat.msg = `${res.status} - ${res.statusText}`;
+ }
+ } catch (error) {
+ if (axios.isCancel(error)) {
+ heartbeat.msg = "Request timed out";
+ log.debug("monitor", `[${monitor.name}] Request timed out`);
+ } else {
+ log.debug("monitor", `[${monitor.name}] Axios Error: ${JSON.stringify(error.message)}`);
+ heartbeat.msg = error.message;
+ }
+ }
+ }
+ }
+}
+
+module.exports = {
+ RabbitMqMonitorType,
+};
diff --git a/server/server.js b/server/server.js
index db58ae82..c88daca8 100644
--- a/server/server.js
+++ b/server/server.js
@@ -718,6 +718,8 @@ let needSetup = false;
monitor.conditions = JSON.stringify(monitor.conditions);
+ monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
+
bean.import(monitor);
bean.user_id = socket.userID;
@@ -868,6 +870,9 @@ let needSetup = false;
bean.snmpOid = monitor.snmpOid;
bean.jsonPathOperator = monitor.jsonPathOperator;
bean.timeout = monitor.timeout;
+ bean.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
+ bean.rabbitmqUsername = monitor.rabbitmqUsername;
+ bean.rabbitmqPassword = monitor.rabbitmqPassword;
bean.conditions = JSON.stringify(monitor.conditions);
bean.validate();
diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js
index 76bf4256..062f098d 100644
--- a/server/uptime-kuma-server.js
+++ b/server/uptime-kuma-server.js
@@ -115,6 +115,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType();
UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
+ UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
// Allow all CORS origins (polling) in development
let cors = undefined;
@@ -552,4 +553,5 @@ const { DnsMonitorType } = require("./monitor-types/dns");
const { MqttMonitorType } = require("./monitor-types/mqtt");
const { SNMPMonitorType } = require("./monitor-types/snmp");
const { MongodbMonitorType } = require("./monitor-types/mongodb");
+const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
const Monitor = require("./model/monitor");
diff --git a/src/components/CreateGroupDialog.vue b/src/components/CreateGroupDialog.vue
index ba7fe6eb..8bac1ccd 100644
--- a/src/components/CreateGroupDialog.vue
+++ b/src/components/CreateGroupDialog.vue
@@ -42,6 +42,9 @@ export default {
mounted() {
this.modal = new Modal(this.$refs.modal);
},
+ beforeUnmount() {
+ this.cleanupModal();
+ },
methods: {
/**
* Show the confirm dialog
@@ -58,6 +61,19 @@ export default {
this.$emit("added", this.groupName);
this.modal.hide();
},
+ /**
+ * Clean up modal and restore scroll behavior
+ * @returns {void}
+ */
+ cleanupModal() {
+ if (this.modal) {
+ try {
+ this.modal.hide();
+ } catch (e) {
+ console.warn("Modal hide failed:", e);
+ }
+ }
+ }
},
};
diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue
index 96a62cf6..429ca9f9 100644
--- a/src/components/HeartbeatBar.vue
+++ b/src/components/HeartbeatBar.vue
@@ -4,11 +4,17 @@
+ >
+
+
this.maxBeat) {
- let width = -(this.beatWidth + this.beatMargin * 2);
+ let width = -(this.beatWidth + this.beatHoverAreaPadding * 2);
return {
transition: "all ease-in-out 0.25s",
@@ -137,12 +143,17 @@ export default {
},
+ beatHoverAreaStyle() {
+ return {
+ padding: this.beatHoverAreaPadding + "px",
+ "--hover-scale": this.hoverScale,
+ };
+ },
+
beatStyle() {
return {
width: this.beatWidth + "px",
height: this.beatHeight + "px",
- margin: this.beatMargin + "px",
- "--hover-scale": this.hoverScale,
};
},
@@ -152,7 +163,7 @@ export default {
*/
timeStyle() {
return {
- "margin-left": this.numPadding * (this.beatWidth + this.beatMargin * 2) + "px",
+ "margin-left": this.numPadding * (this.beatWidth + this.beatHoverAreaPadding * 2) + "px",
};
},
@@ -219,20 +230,20 @@ export default {
if (this.size !== "big") {
this.beatWidth = 5;
this.beatHeight = 16;
- this.beatMargin = 2;
+ this.beatHoverAreaPadding = 2;
}
// Suddenly, have an idea how to handle it universally.
// If the pixel * ratio != Integer, then it causes render issue, round it to solve it!!
const actualWidth = this.beatWidth * window.devicePixelRatio;
- const actualMargin = this.beatMargin * window.devicePixelRatio;
+ const actualHoverAreaPadding = this.beatHoverAreaPadding * window.devicePixelRatio;
if (!Number.isInteger(actualWidth)) {
this.beatWidth = Math.round(actualWidth) / window.devicePixelRatio;
}
- if (!Number.isInteger(actualMargin)) {
- this.beatMargin = Math.round(actualMargin) / window.devicePixelRatio;
+ if (!Number.isInteger(actualHoverAreaPadding)) {
+ this.beatHoverAreaPadding = Math.round(actualHoverAreaPadding) / window.devicePixelRatio;
}
window.addEventListener("resize", this.resize);
@@ -245,7 +256,7 @@ export default {
*/
resize() {
if (this.$refs.wrap) {
- this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatMargin * 2));
+ this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
}
},
@@ -273,31 +284,40 @@ export default {
}
.hp-bar-big {
- .beat {
+ .beat-hover-area {
display: inline-block;
- background-color: $primary;
- border-radius: $border-radius;
- &.empty {
- background-color: aliceblue;
+ &:not(.empty):hover {
+ transition: all ease-in-out 0.15s;
+ opacity: 0.8;
+ transform: scale(var(--hover-scale));
}
- &.down {
- background-color: $danger;
- }
+ .beat {
+ background-color: $primary;
+ border-radius: $border-radius;
- &.pending {
- background-color: $warning;
- }
+ /*
+ pointer-events needs to be changed because
+ tooltip momentarily disappears when crossing between .beat-hover-area and .beat
+ */
+ pointer-events: none;
- &.maintenance {
- background-color: $maintenance;
- }
+ &.empty {
+ background-color: aliceblue;
+ }
- &:not(.empty):hover {
- transition: all ease-in-out 0.15s;
- opacity: 0.8;
- transform: scale(var(--hover-scale));
+ &.down {
+ background-color: $danger;
+ }
+
+ &.pending {
+ background-color: $warning;
+ }
+
+ &.maintenance {
+ background-color: $maintenance;
+ }
}
}
}
diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue
index f6d72802..8a747072 100644
--- a/src/components/NotificationDialog.vue
+++ b/src/components/NotificationDialog.vue
@@ -232,6 +232,9 @@ export default {
mounted() {
this.modal = new Modal(this.$refs.modal);
},
+ beforeUnmount() {
+ this.cleanupModal();
+ },
methods: {
/**
@@ -336,6 +339,20 @@ export default {
});
} while (this.$root.notificationList.find(it => it.name === name));
return name;
+ },
+
+ /**
+ * Clean up modal and restore scroll behavior
+ * @returns {void}
+ */
+ cleanupModal() {
+ if (this.modal) {
+ try {
+ this.modal.hide();
+ } catch (e) {
+ console.warn("Modal hide failed:", e);
+ }
+ }
}
},
};
diff --git a/src/components/ProxyDialog.vue b/src/components/ProxyDialog.vue
index fc92359b..2f7ed7b6 100644
--- a/src/components/ProxyDialog.vue
+++ b/src/components/ProxyDialog.vue
@@ -125,11 +125,12 @@ export default {
}
};
},
-
mounted() {
this.modal = new Modal(this.$refs.modal);
},
-
+ beforeUnmount() {
+ this.cleanupModal();
+ },
methods: {
/**
* Show dialog to confirm deletion
@@ -209,6 +210,20 @@ export default {
}
});
},
+
+ /**
+ * Clean up modal and restore scroll behavior
+ * @returns {void}
+ */
+ cleanupModal() {
+ if (this.modal) {
+ try {
+ this.modal.hide();
+ } catch (e) {
+ console.warn("Modal hide failed:", e);
+ }
+ }
+ }
},
};
diff --git a/src/components/TagsManager.vue b/src/components/TagsManager.vue
index 19c8e481..368ea18c 100644
--- a/src/components/TagsManager.vue
+++ b/src/components/TagsManager.vue
@@ -248,6 +248,9 @@ export default {
this.modal = new Modal(this.$refs.modal);
this.getExistingTags();
},
+ beforeUnmount() {
+ this.cleanupModal();
+ },
methods: {
/**
* Show the add tag dialog
@@ -459,6 +462,19 @@ export default {
this.newTags = [];
this.deleteTags = [];
this.processing = false;
+ },
+ /**
+ * Clean up modal and restore scroll behavior
+ * @returns {void}
+ */
+ cleanupModal() {
+ if (this.modal) {
+ try {
+ this.modal.hide();
+ } catch (e) {
+ console.warn("Modal hide failed:", e);
+ }
+ }
}
},
};
diff --git a/src/lang/en.json b/src/lang/en.json
index 1d45b7a9..0187c021 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -1052,6 +1052,13 @@
"Can be found on:": "Can be found on: {0}",
"The phone number of the recipient in E.164 format.": "The phone number of the recipient in E.164 format.",
"Either a text sender ID or a phone number in E.164 format if you want to be able to receive replies.":"Either a text sender ID or a phone number in E.164 format if you want to be able to receive replies.",
+ "RabbitMQ Nodes": "RabbitMQ Management Nodes",
+ "rabbitmqNodesDescription": "Enter the URL for the RabbitMQ management nodes including protocol and port. Example: {0}",
+ "rabbitmqNodesRequired": "Please set the nodes for this monitor.",
+ "rabbitmqNodesInvalid": "Please use a fully qualified (starting with 'http') URL for RabbitMQ nodes.",
+ "RabbitMQ Username": "RabbitMQ Username",
+ "RabbitMQ Password": "RabbitMQ Password",
+ "rabbitmqHelpText": "To use the monitor, you will need to enable the Management Plugin in your RabbitMQ setup. For more information, please consult the {rabitmq_documentation}.",
"nextCheckIn": "Next in ",
"SendGrid API Key": "SendGrid API Key",
"Separate multiple email addresses with commas": "Separate multiple email addresses with commas"
diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue
index 5d999b59..677210c4 100644
--- a/src/pages/EditMonitor.vue
+++ b/src/pages/EditMonitor.vue
@@ -64,6 +64,9 @@
+
@@ -90,6 +93,13 @@
+
+
+
+ RabbitMQ documentation
+
+
+
@@ -233,6 +243,43 @@
+
+
+
+
+
+
+ {{ $t("rabbitmqNodesDescription", ["https://node1.rabbitmq.com:15672"]) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -549,7 +596,7 @@
-
+
@@ -1122,6 +1169,9 @@ const monitorDefaults = {
kafkaProducerAllowAutoTopicCreation: false,
gamedigGivenPortOnly: true,
remote_browser: null,
+ rabbitmqNodes: [],
+ rabbitmqUsername: "",
+ rabbitmqPassword: "",
conditions: []
};
@@ -1709,6 +1759,10 @@ message HealthCheckResponse {
this.monitor.kafkaProducerBrokers.push(newBroker);
},
+ addRabbitmqNode(newNode) {
+ this.monitor.rabbitmqNodes.push(newNode);
+ },
+
/**
* Validate form input
* @returns {boolean} Is the form input valid?
@@ -1736,6 +1790,17 @@ message HealthCheckResponse {
return false;
}
}
+
+ if (this.monitor.type === "rabbitmq") {
+ if (this.monitor.rabbitmqNodes.length === 0) {
+ toast.error(this.$t("rabbitmqNodesRequired"));
+ return false;
+ }
+ if (!this.monitor.rabbitmqNodes.every(node => node.startsWith("http://") || node.startsWith("https://"))) {
+ toast.error(this.$t("rabbitmqNodesInvalid"));
+ return false;
+ }
+ }
return true;
},
diff --git a/test/backend-test/test-rabbitmq.js b/test/backend-test/test-rabbitmq.js
new file mode 100644
index 00000000..5782ef25
--- /dev/null
+++ b/test/backend-test/test-rabbitmq.js
@@ -0,0 +1,53 @@
+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");
+
+describe("RabbitMQ Single Node", {
+ skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
+}, () => {
+ test("RabbitMQ is running", async () => {
+ // The default timeout of 30 seconds might not be enough for the container to start
+ const rabbitMQContainer = await new RabbitMQContainer().withStartupTimeout(60000).start();
+ const rabbitMQMonitor = new RabbitMqMonitorType();
+ const connectionString = `http://${rabbitMQContainer.getHost()}:${rabbitMQContainer.getMappedPort(15672)}`;
+
+ const monitor = {
+ rabbitmqNodes: JSON.stringify([ connectionString ]),
+ rabbitmqUsername: "guest",
+ rabbitmqPassword: "guest",
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await rabbitMQMonitor.check(monitor, heartbeat, {});
+ assert.strictEqual(heartbeat.status, UP);
+ assert.strictEqual(heartbeat.msg, "OK");
+ } finally {
+ rabbitMQContainer.stop();
+ }
+ });
+
+ test("RabbitMQ is not running", async () => {
+ const rabbitMQMonitor = new RabbitMqMonitorType();
+ const monitor = {
+ rabbitmqNodes: JSON.stringify([ "http://localhost:15672" ]),
+ rabbitmqUsername: "rabbitmqUser",
+ rabbitmqPassword: "rabbitmqPass",
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ await rabbitMQMonitor.check(monitor, heartbeat, {});
+ assert.strictEqual(heartbeat.status, DOWN);
+ });
+
+});