Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add RabbitMQ monitor #5199

Merged
merged 23 commits into from
Oct 20, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2879b53
Add Rabbitmq monitor
Suven-p Oct 15, 2024
eeb614e
Merge branch 'master' into 5066_add_rabbitmq_support
Suven-p Oct 15, 2024
4c47d2f
Add translation string
Suven-p Oct 16, 2024
1c02023
Add description and validation for nodes input field
Suven-p Oct 16, 2024
74da7e5
Merge branch '5066_add_rabbitmq_support' of https://github.com/Suven-…
Suven-p Oct 16, 2024
4fa640b
Update messages
Suven-p Oct 16, 2024
44cd675
Replace input type password with HiddenInput
Suven-p Oct 16, 2024
009b004
Make requested changes
Suven-p Oct 18, 2024
fd3dbff
Merge branch 'master' into 5066_add_rabbitmq_support
Suven-p Oct 18, 2024
21a2d3c
Change monitor type to specify management plugin
Suven-p Oct 18, 2024
0ba3773
Bugfix: Correct validation for rabbitmq nodes
Suven-p Oct 19, 2024
685043e
Add test containers
Suven-p Oct 19, 2024
7961ebb
Add translation for RabbitMQ monitor type
Suven-p Oct 19, 2024
bf32550
Fix linting error
Suven-p Oct 20, 2024
73ee628
Bugfix: Add support for base url with path
Suven-p Oct 20, 2024
444661e
Save message for 503 status code
Suven-p Oct 20, 2024
6380df9
Fix relative path in test
Suven-p Oct 20, 2024
fc68042
Removed unnessesary comment
CommanderStorm Oct 20, 2024
7ce46d6
Update src/lang/en.json
Suven-p Oct 20, 2024
2a2b840
Add helptext
Suven-p Oct 20, 2024
85f5552
Remove helptext from monitor type
Suven-p Oct 20, 2024
d1ad668
Apply suggestions from code review
CommanderStorm Oct 20, 2024
9f23b5c
Merge branch 'master' into 5066_add_rabbitmq_support
Suven-p Oct 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions db/knex_migrations/2024-10-1315-rabbitmq-monitor.js
Original file line number Diff line number Diff line change
@@ -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");
});

};
17 changes: 13 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions server/model/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};

Expand Down Expand Up @@ -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,
};
}

Expand Down
67 changes: 67 additions & 0 deletions server/monitor-types/rabbitmq.js
Original file line number Diff line number Diff line change
@@ -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,
};
5 changes: 5 additions & 0 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions server/uptime-kuma-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
8 changes: 8 additions & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,14 @@
"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",
"RabbitMQ": "RabbitMQ",
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
"rabbitmqHelpText": "To use the monitor, you will need to enable the Management Plugin in your RabbitMQ setup. For more information, please consult the {0}.",
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
"SendGrid API Key": "SendGrid API Key",
"Separate multiple email addresses with commas": "Separate multiple email addresses with commas"
}
65 changes: 64 additions & 1 deletion src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
<option value="mqtt">
MQTT
</option>
<option value="rabbitmq">
{{ $t("RabbitMQ") }}
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
</option>
<option value="kafka-producer">
Kafka Producer
</option>
Expand All @@ -90,6 +93,11 @@
</option>
</optgroup>
</select>
<i18n-t v-if="monitor.type === 'rabbitmq'" keypath="rabbitmqHelpText" tag="div" class="form-text">
<a href="https://www.rabbitmq.com/management" target="_blank" rel="noopener noreferrer">
RabbitMQ documentation
</a>.
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
</i18n-t>
</div>

<div v-if="monitor.type === 'tailscale-ping'" class="alert alert-warning" role="alert">
Expand Down Expand Up @@ -233,6 +241,43 @@
</div>
</template>

<template v-if="monitor.type === 'rabbitmq'">
<!-- RabbitMQ Nodes List -->
<div class="my-3">
<label for="rabbitmqNodes" class="form-label">{{ $t("RabbitMQ Nodes") }}</label>
<VueMultiselect
id="rabbitmqNodes"
v-model="monitor.rabbitmqNodes"
:required="true"
:multiple="true"
:options="[]"
:placeholder="$t('Enter the list of nodes')"
:tag-placeholder="$t('Press Enter to add node')"
:max-height="500"
:taggable="true"
:show-no-options="false"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="false"
:preselect-first="false"
@tag="addRabbitmqNode"
></VueMultiselect>
<div class="form-text">
{{ $t("rabbitmqNodesDescription", ["https://node1.rabbitmq.com:15672"]) }}
</div>
</div>

<div class="my-3">
<label for="rabbitmqUsername" class="form-label">RabbitMQ {{ $t("RabbitMQ Username") }}</label>
<input id="rabbitmqUsername" v-model="monitor.rabbitmqUsername" type="text" required class="form-control">
</div>

<div class="my-3">
<label for="rabbitmqPassword" class="form-label">{{ $t("RabbitMQ Password") }}</label>
<HiddenInput id="rabbitmqPassword" v-model="monitor.rabbitmqPassword" autocomplete="false" required="true"></HiddenInput>
</div>
</template>

<!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'snmp'" class="my-3">
Expand Down Expand Up @@ -549,7 +594,7 @@
</div>

<!-- Timeout: HTTP / Keyword / SNMP only -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'snmp' || monitor.type === 'rabbitmq'" class="my-3">
<label for="timeout" class="form-label">{{ $t("Request Timeout") }} ({{ $t("timeoutAfter", [ monitor.timeout || clampTimeout(monitor.interval) ]) }})</label>
<input id="timeout" v-model="monitor.timeout" type="number" class="form-control" required min="0" step="0.1">
</div>
Expand Down Expand Up @@ -1122,6 +1167,9 @@ const monitorDefaults = {
kafkaProducerAllowAutoTopicCreation: false,
gamedigGivenPortOnly: true,
remote_browser: null,
rabbitmqNodes: [],
rabbitmqUsername: "",
rabbitmqPassword: "",
conditions: []
};

Expand Down Expand Up @@ -1709,6 +1757,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?
Expand Down Expand Up @@ -1736,6 +1788,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;
},

Expand Down
53 changes: 53 additions & 0 deletions test/backend-test/test-rabbitmq.js
Original file line number Diff line number Diff line change
@@ -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"),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that this test blocked local testing on my Windows machine. It is also not able to run on a self-hosted X64 debian actions runner. What is the prerequisite to run this actually?

Copy link
Collaborator

@CommanderStorm CommanderStorm Oct 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the platform support for running docker containers in GHA without additional steps.
=> This is not about the actual test case, but rather it's environment

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the prerequisite to run this actually?

It assumes that docker is running on the system. AFAIK by default it searches for a unix socket at unix:///var/run/docker.sock in linux or a named pipe at npipe:////./pipe/docker_engine in windows.
On the debian machine, is the test skipped or does it throw an error?

Copy link
Owner

@louislam louislam Oct 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could not find a working container runtime strategy.

Full log:
https://github.com/louislam/uptime-kuma/actions/runs/11531916870/job/32103162510

Actually, I just realize the mqtt test has the same issue.

It assumes that docker is running on the system.

My actions runner on Debian indeed don't have Docker. Maybe that is the reason.

}, () => {
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",
Dismissed Show dismissed Hide dismissed
rabbitmqPassword: "guest",
Dismissed Show dismissed Hide dismissed
};

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);
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
});

});
Loading