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 @@ + + +
+ +
@@ -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); + }); + +});