Merge branch 'master' into dj/discord-service-url

pull/5181/head
Dj Isaac 1 month ago committed by GitHub
commit 64f528456d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,6 +1,6 @@
# Project Info # Project Info
First of all, I want to thank everyone who have wrote issues or shared pull requests for Uptime Kuma. First of all, I want to thank everyone who has submitted issues or shared pull requests for Uptime Kuma.
I never thought the GitHub community would be so nice! I never thought the GitHub community would be so nice!
Because of this, I also never thought that other people would actually read and edit my code. Because of this, I also never thought that other people would actually read and edit my code.
Parts of the code are not very well-structured or commented, sorry about that. Parts of the code are not very well-structured or commented, sorry about that.
@ -9,7 +9,7 @@ The project was created with `vite.js` and is written in `vue3`.
Our backend lives in the `server`-directory and mostly communicates via websockets. Our backend lives in the `server`-directory and mostly communicates via websockets.
Both frontend and backend share the same `package.json`. Both frontend and backend share the same `package.json`.
For production, the frontend is build into `dist`-directory and the server (`express.js`) exposes the `dist` directory as the root of the endpoint. For production, the frontend is built into the `dist`-directory and the server (`express.js`) exposes the `dist` directory as the root of the endpoint.
For development, we run vite in development mode on another port. For development, we run vite in development mode on another port.
## Directories ## Directories
@ -28,7 +28,7 @@ For development, we run vite in development mode on another port.
## Can I create a pull request for Uptime Kuma? ## Can I create a pull request for Uptime Kuma?
Yes or no, it depends on what you will try to do. Yes or no, it depends on what you will try to do.
Both your and our maintainers time is precious, and we don't want to waste both time. Both yours and our maintainers' time is precious, and we don't want to waste either.
If you have any questions about any process/.. is not clear, you are likely not alone => please ask them ^^ If you have any questions about any process/.. is not clear, you are likely not alone => please ask them ^^
@ -49,11 +49,11 @@ Different guidelines exist for different types of pull requests (PRs):
<p> <p>
If you come across a bug and think you can solve, we appreciate your work. If you come across a bug and think you can solve, we appreciate your work.
Please make sure that you follow by these rules: Please make sure that you follow these rules:
- keep the PR as small as possible, fix only one thing at a time => keeping it reviewable - keep the PR as small as possible, fix only one thing at a time => keeping it reviewable
- test that your code does what you came it does. - test that your code does what you claim it does.
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub> <sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
</p> </p>
</details> </details>
- <details><summary><b>translations / internationalisation (i18n)</b></summary> - <details><summary><b>translations / internationalisation (i18n)</b></summary>
@ -68,7 +68,7 @@ Different guidelines exist for different types of pull requests (PRs):
- language keys need to be **added to `en.json`** to be visible in weblate. If this has not happened, a PR is appreciated. - language keys need to be **added to `en.json`** to be visible in weblate. If this has not happened, a PR is appreciated.
- **Adding a new language** requires a new file see [these instructions](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md) - **Adding a new language** requires a new file see [these instructions](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md)
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub> <sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
</p> </p>
</details> </details>
- <details><summary><b>new notification providers</b></summary> - <details><summary><b>new notification providers</b></summary>
@ -102,7 +102,7 @@ Different guidelines exist for different types of pull requests (PRs):
Therefore, making sure that they work is also really important. Therefore, making sure that they work is also really important.
Because testing notification providers is quite time intensive, we mostly offload this onto the person contributing a notification provider. Because testing notification providers is quite time intensive, we mostly offload this onto the person contributing a notification provider.
To make shure you have tested the notification provider, please include screenshots of the following events in the pull-request description: To make sure you have tested the notification provider, please include screenshots of the following events in the pull-request description:
- `UP`/`DOWN` - `UP`/`DOWN`
- Certificate Expiry via https://expired.badssl.com/ - Certificate Expiry via https://expired.badssl.com/
- Testing (the test button on the notification provider setup page) - Testing (the test button on the notification provider setup page)
@ -117,7 +117,7 @@ Different guidelines exist for different types of pull requests (PRs):
| Testing | paste-image-here | paste-image-here | | Testing | paste-image-here | paste-image-here |
``` ```
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub> <sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
</p> </p>
</details> </details>
- <details><summary><b>new monitoring types</b></summary> - <details><summary><b>new monitoring types</b></summary>
@ -138,14 +138,14 @@ Different guidelines exist for different types of pull requests (PRs):
- -
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub> <sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
</p> </p>
</details> </details>
- <details><summary><b>new features/ major changes / breaking bugfixes</b></summary> - <details><summary><b>new features/ major changes / breaking bugfixes</b></summary>
<p> <p>
be sure to **create an empty draft pull request or open an issue, so we can have a discussion first**. be sure to **create an empty draft pull request or open an issue, so we can have a discussion first**.
This is especially important for a large pull request or you don't know if it will be merged or not. This is especially important for a large pull request or when you don't know if it will be merged or not.
<sub>Because of the large impact of this work, only senior maintainers may merge PRs in this area.</sub> <sub>Because of the large impact of this work, only senior maintainers may merge PRs in this area.</sub>
</p> </p>
@ -201,7 +201,7 @@ The rationale behind this is that we can align the direction and scope of the fe
## Project Styles ## Project Styles
I personally do not like something that requires so many configurations before you can finally start the app. I personally do not like something that requires a lot of configuration before you can finally start the app.
The goal is to make the Uptime Kuma installation as easy as installing a mobile app. The goal is to make the Uptime Kuma installation as easy as installing a mobile app.
- Easy to install for non-Docker users - Easy to install for non-Docker users
@ -260,7 +260,7 @@ Port `3000` and port `3001` will be used.
npm run dev npm run dev
``` ```
But sometimes, you would like to restart the server, but not the frontend, you can run these commands in two terminals: But sometimes you may want to restart the server without restarting the frontend. In that case, you can run these commands in two terminals:
```bash ```bash
npm run start-frontend-dev npm run start-frontend-dev
@ -409,7 +409,7 @@ https://github.com/louislam/uptime-kuma/issues?q=sort%3Aupdated-desc
### What is a maintainer and what are their roles? ### What is a maintainer and what are their roles?
This project has multiple maintainers which specialise in different areas. This project has multiple maintainers who specialise in different areas.
Currently, there are 3 maintainers: Currently, there are 3 maintainers:
| Person | Role | Main Area | | Person | Role | Main Area |

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

1025
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -154,6 +154,8 @@
"@fortawesome/vue-fontawesome": "~3.0.0-5", "@fortawesome/vue-fontawesome": "~3.0.0-5",
"@playwright/test": "~1.39.0", "@playwright/test": "~1.39.0",
"@popperjs/core": "~2.10.2", "@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1",
"@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9", "@types/bootstrap": "~5.1.9",
"@types/node": "^20.8.6", "@types/node": "^20.8.6",
"@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/eslint-plugin": "^6.7.5",
@ -189,6 +191,7 @@
"stylelint-config-standard": "~25.0.0", "stylelint-config-standard": "~25.0.0",
"terser": "~5.15.0", "terser": "~5.15.0",
"test": "~3.3.0", "test": "~3.3.0",
"testcontainers": "^10.13.1",
"typescript": "~4.4.4", "typescript": "~4.4.4",
"v-pagination-3": "~0.1.7", "v-pagination-3": "~0.1.7",
"vite": "~5.2.8", "vite": "~5.2.8",

@ -153,6 +153,7 @@ class Monitor extends BeanModel {
snmpOid: this.snmpOid, snmpOid: this.snmpOid,
jsonPathOperator: this.jsonPathOperator, jsonPathOperator: this.jsonPathOperator,
snmpVersion: this.snmpVersion, snmpVersion: this.snmpVersion,
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
conditions: JSON.parse(this.conditions), conditions: JSON.parse(this.conditions),
}; };
@ -183,6 +184,8 @@ class Monitor extends BeanModel {
tlsCert: this.tlsCert, tlsCert: this.tlsCert,
tlsKey: this.tlsKey, tlsKey: this.tlsKey,
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions), kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
rabbitmqUsername: this.rabbitmqUsername,
rabbitmqPassword: this.rabbitmqPassword,
}; };
} }
@ -1508,10 +1511,8 @@ class Monitor extends BeanModel {
return await R.getAll(` return await R.getAll(`
SELECT monitor_notification.monitor_id, monitor_notification.notification_id SELECT monitor_notification.monitor_id, monitor_notification.notification_id
FROM monitor_notification FROM monitor_notification
WHERE monitor_notification.monitor_id IN (?) WHERE monitor_notification.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
`, [ `, monitorIDs);
monitorIDs,
]);
} }
/** /**
@ -1521,13 +1522,11 @@ class Monitor extends BeanModel {
*/ */
static async getMonitorTag(monitorIDs) { static async getMonitorTag(monitorIDs) {
return await R.getAll(` return await R.getAll(`
SELECT monitor_tag.monitor_id, tag.name, tag.color SELECT monitor_tag.monitor_id, monitor_tag.tag_id, tag.name, tag.color
FROM monitor_tag FROM monitor_tag
JOIN tag ON monitor_tag.tag_id = tag.id JOIN tag ON monitor_tag.tag_id = tag.id
WHERE monitor_tag.monitor_id IN (?) WHERE monitor_tag.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
`, [ `, monitorIDs);
monitorIDs,
]);
} }
/** /**
@ -1567,6 +1566,7 @@ class Monitor extends BeanModel {
tagsMap.set(row.monitor_id, []); tagsMap.set(row.monitor_id, []);
} }
tagsMap.get(row.monitor_id).push({ tagsMap.get(row.monitor_id).push({
tag_id: row.tag_id,
name: row.name, name: row.name,
color: row.color color: row.color
}); });

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

@ -0,0 +1,35 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Elks extends NotificationProvider {
name = "Elks";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
const url = "https://api.46elks.com/a1/sms";
try {
let data = new URLSearchParams();
data.append("from", notification.elksFromNumber);
data.append("to", notification.elksToNumber );
data.append("message", msg);
const config = {
headers: {
"Authorization": "Basic " + Buffer.from(`${notification.elksUsername}:${notification.elksAuthToken}`).toString("base64")
}
};
await axios.post(url, data, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Elks;

@ -0,0 +1,65 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class SendGrid extends NotificationProvider {
name = "SendGrid";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
try {
let config = {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${notification.sendgridApiKey}`,
},
};
let personalizations = {
to: [{ email: notification.sendgridToEmail }],
};
// Add CC recipients if provided
if (notification.sendgridCcEmail) {
personalizations.cc = notification.sendgridCcEmail
.split(",")
.map((email) => ({ email: email.trim() }));
}
// Add BCC recipients if provided
if (notification.sendgridBccEmail) {
personalizations.bcc = notification.sendgridBccEmail
.split(",")
.map((email) => ({ email: email.trim() }));
}
let data = {
personalizations: [ personalizations ],
from: { email: notification.sendgridFromEmail.trim() },
subject:
notification.sendgridSubject ||
"Notification from Your Uptime Kuma",
content: [
{
type: "text/plain",
value: msg,
},
],
};
await axios.post(
"https://api.sendgrid.com/v3/mail/send",
data,
config
);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = SendGrid;

@ -32,7 +32,7 @@ class Slack extends NotificationProvider {
* @param {object} monitorJSON The monitor config * @param {object} monitorJSON The monitor config
* @returns {Array} The relevant action objects * @returns {Array} The relevant action objects
*/ */
static buildActions(baseURL, monitorJSON) { buildActions(baseURL, monitorJSON) {
const actions = []; const actions = [];
if (baseURL) { if (baseURL) {
@ -73,7 +73,7 @@ class Slack extends NotificationProvider {
* @param {string} msg The message body * @param {string} msg The message body
* @returns {Array<object>} The rich content blocks for the Slack message * @returns {Array<object>} The rich content blocks for the Slack message
*/ */
static buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg) { buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg) {
//create an array to dynamically add blocks //create an array to dynamically add blocks
const blocks = []; const blocks = [];
@ -150,7 +150,7 @@ class Slack extends NotificationProvider {
data.attachments.push( data.attachments.push(
{ {
"color": (heartbeatJSON["status"] === UP) ? "#2eb886" : "#e01e5a", "color": (heartbeatJSON["status"] === UP) ? "#2eb886" : "#e01e5a",
"blocks": Slack.buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg), "blocks": this.buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg),
} }
); );
} else { } else {

@ -10,11 +10,22 @@ class TechulusPush extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully."; const okMsg = "Sent Successfully.";
let data = {
"title": notification?.pushTitle?.length ? notification.pushTitle : "Uptime-Kuma",
"body": msg,
"timeSensitive": notification.pushTimeSensitive ?? true,
};
if (notification.pushChannel) {
data.channel = notification.pushChannel;
}
if (notification.pushSound) {
data.sound = notification.pushSound;
}
try { try {
await axios.post(`https://push.techulus.com/api/v1/notify/${notification.pushAPIKey}`, { await axios.post(`https://push.techulus.com/api/v1/notify/${notification.pushAPIKey}`, data);
"title": "Uptime-Kuma",
"body": msg,
});
return okMsg; return okMsg;
} catch (error) { } catch (error) {
this.throwGeneralAxiosError(error); this.throwGeneralAxiosError(error);

@ -11,6 +11,7 @@ const CallMeBot = require("./notification-providers/call-me-bot");
const SMSC = require("./notification-providers/smsc"); const SMSC = require("./notification-providers/smsc");
const DingDing = require("./notification-providers/dingding"); const DingDing = require("./notification-providers/dingding");
const Discord = require("./notification-providers/discord"); const Discord = require("./notification-providers/discord");
const Elks = require("./notification-providers/46elks");
const Feishu = require("./notification-providers/feishu"); const Feishu = require("./notification-providers/feishu");
const FreeMobile = require("./notification-providers/freemobile"); const FreeMobile = require("./notification-providers/freemobile");
const GoogleChat = require("./notification-providers/google-chat"); const GoogleChat = require("./notification-providers/google-chat");
@ -67,6 +68,7 @@ const GtxMessaging = require("./notification-providers/gtx-messaging");
const Cellsynt = require("./notification-providers/cellsynt"); const Cellsynt = require("./notification-providers/cellsynt");
const Onesender = require("./notification-providers/onesender"); const Onesender = require("./notification-providers/onesender");
const Wpush = require("./notification-providers/wpush"); const Wpush = require("./notification-providers/wpush");
const SendGrid = require("./notification-providers/send-grid");
class Notification { class Notification {
@ -95,6 +97,7 @@ class Notification {
new SMSC(), new SMSC(),
new DingDing(), new DingDing(),
new Discord(), new Discord(),
new Elks(),
new Feishu(), new Feishu(),
new FreeMobile(), new FreeMobile(),
new GoogleChat(), new GoogleChat(),
@ -151,6 +154,7 @@ class Notification {
new GtxMessaging(), new GtxMessaging(),
new Cellsynt(), new Cellsynt(),
new Wpush(), new Wpush(),
new SendGrid()
]; ];
for (let item of list) { for (let item of list) {
if (! item.name) { if (! item.name) {

@ -718,6 +718,8 @@ let needSetup = false;
monitor.conditions = JSON.stringify(monitor.conditions); monitor.conditions = JSON.stringify(monitor.conditions);
monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
bean.import(monitor); bean.import(monitor);
bean.user_id = socket.userID; bean.user_id = socket.userID;
@ -868,6 +870,9 @@ let needSetup = false;
bean.snmpOid = monitor.snmpOid; bean.snmpOid = monitor.snmpOid;
bean.jsonPathOperator = monitor.jsonPathOperator; bean.jsonPathOperator = monitor.jsonPathOperator;
bean.timeout = monitor.timeout; 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.conditions = JSON.stringify(monitor.conditions);
bean.validate(); bean.validate();

@ -115,6 +115,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType();
UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType(); UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType(); UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
// Allow all CORS origins (polling) in development // Allow all CORS origins (polling) in development
let cors = undefined; let cors = undefined;
@ -552,4 +553,5 @@ const { DnsMonitorType } = require("./monitor-types/dns");
const { MqttMonitorType } = require("./monitor-types/mqtt"); const { MqttMonitorType } = require("./monitor-types/mqtt");
const { SNMPMonitorType } = require("./monitor-types/snmp"); const { SNMPMonitorType } = require("./monitor-types/snmp");
const { MongodbMonitorType } = require("./monitor-types/mongodb"); const { MongodbMonitorType } = require("./monitor-types/mongodb");
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
const Monitor = require("./model/monitor"); const Monitor = require("./model/monitor");

@ -4,11 +4,17 @@
<div <div
v-for="(beat, index) in shortBeatList" v-for="(beat, index) in shortBeatList"
:key="index" :key="index"
class="beat" class="beat-hover-area"
:class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }" :class="{ 'empty': (beat === 0) }"
:style="beatStyle" :style="beatHoverAreaStyle"
:title="getBeatTitle(beat)" :title="getBeatTitle(beat)"
/> >
<div
class="beat"
:class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
:style="beatStyle"
/>
</div>
</div> </div>
<div <div
v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'" v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'"
@ -47,7 +53,7 @@ export default {
beatWidth: 10, beatWidth: 10,
beatHeight: 30, beatHeight: 30,
hoverScale: 1.5, hoverScale: 1.5,
beatMargin: 4, beatHoverAreaPadding: 4,
move: false, move: false,
maxBeat: -1, maxBeat: -1,
}; };
@ -123,7 +129,7 @@ export default {
barStyle() { barStyle() {
if (this.move && this.shortBeatList.length > this.maxBeat) { if (this.move && this.shortBeatList.length > this.maxBeat) {
let width = -(this.beatWidth + this.beatMargin * 2); let width = -(this.beatWidth + this.beatHoverAreaPadding * 2);
return { return {
transition: "all ease-in-out 0.25s", transition: "all ease-in-out 0.25s",
@ -137,12 +143,17 @@ export default {
}, },
beatHoverAreaStyle() {
return {
padding: this.beatHoverAreaPadding + "px",
"--hover-scale": this.hoverScale,
};
},
beatStyle() { beatStyle() {
return { return {
width: this.beatWidth + "px", width: this.beatWidth + "px",
height: this.beatHeight + "px", height: this.beatHeight + "px",
margin: this.beatMargin + "px",
"--hover-scale": this.hoverScale,
}; };
}, },
@ -152,7 +163,7 @@ export default {
*/ */
timeStyle() { timeStyle() {
return { 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") { if (this.size !== "big") {
this.beatWidth = 5; this.beatWidth = 5;
this.beatHeight = 16; this.beatHeight = 16;
this.beatMargin = 2; this.beatHoverAreaPadding = 2;
} }
// Suddenly, have an idea how to handle it universally. // Suddenly, have an idea how to handle it universally.
// If the pixel * ratio != Integer, then it causes render issue, round it to solve it!! // If the pixel * ratio != Integer, then it causes render issue, round it to solve it!!
const actualWidth = this.beatWidth * window.devicePixelRatio; const actualWidth = this.beatWidth * window.devicePixelRatio;
const actualMargin = this.beatMargin * window.devicePixelRatio; const actualHoverAreaPadding = this.beatHoverAreaPadding * window.devicePixelRatio;
if (!Number.isInteger(actualWidth)) { if (!Number.isInteger(actualWidth)) {
this.beatWidth = Math.round(actualWidth) / window.devicePixelRatio; this.beatWidth = Math.round(actualWidth) / window.devicePixelRatio;
} }
if (!Number.isInteger(actualMargin)) { if (!Number.isInteger(actualHoverAreaPadding)) {
this.beatMargin = Math.round(actualMargin) / window.devicePixelRatio; this.beatHoverAreaPadding = Math.round(actualHoverAreaPadding) / window.devicePixelRatio;
} }
window.addEventListener("resize", this.resize); window.addEventListener("resize", this.resize);
@ -245,7 +256,7 @@ export default {
*/ */
resize() { resize() {
if (this.$refs.wrap) { 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 { .hp-bar-big {
.beat { .beat-hover-area {
display: inline-block; display: inline-block;
background-color: $primary;
border-radius: $border-radius;
&.empty { &:not(.empty):hover {
background-color: aliceblue; transition: all ease-in-out 0.15s;
opacity: 0.8;
transform: scale(var(--hover-scale));
} }
&.down { .beat {
background-color: $danger; 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 { &.empty {
background-color: $maintenance; background-color: aliceblue;
} }
&:not(.empty):hover { &.down {
transition: all ease-in-out 0.15s; background-color: $danger;
opacity: 0.8; }
transform: scale(var(--hover-scale));
&.pending {
background-color: $warning;
}
&.maintenance {
background-color: $maintenance;
}
} }
} }
} }

@ -118,6 +118,7 @@ export default {
"clicksendsms": "ClickSend SMS", "clicksendsms": "ClickSend SMS",
"CallMeBot": "CallMeBot (WhatsApp, Telegram Call, Facebook Messanger)", "CallMeBot": "CallMeBot (WhatsApp, Telegram Call, Facebook Messanger)",
"discord": "Discord", "discord": "Discord",
"Elks": "46elks",
"GoogleChat": "Google Chat (Google Workspace)", "GoogleChat": "Google Chat (Google Workspace)",
"gorush": "Gorush", "gorush": "Gorush",
"gotify": "Gotify", "gotify": "Gotify",
@ -164,6 +165,7 @@ export default {
"whapi": "WhatsApp (Whapi)", "whapi": "WhatsApp (Whapi)",
"gtxmessaging": "GtxMessaging", "gtxmessaging": "GtxMessaging",
"Cellsynt": "Cellsynt", "Cellsynt": "Cellsynt",
"SendGrid": "SendGrid"
}; };
// Put notifications here if it's not supported in most regions or its documentation is not in English // Put notifications here if it's not supported in most regions or its documentation is not in English

@ -33,7 +33,7 @@
<template #item="monitor"> <template #item="monitor">
<div class="item" data-testid="monitor"> <div class="item" data-testid="monitor">
<div class="row"> <div class="row">
<div class="col-6 col-md-4 small-padding"> <div class="col-9 col-md-8 small-padding">
<div class="info"> <div class="info">
<font-awesome-icon v-if="editMode" icon="arrows-alt-v" class="action drag me-3" /> <font-awesome-icon v-if="editMode" icon="arrows-alt-v" class="action drag me-3" />
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeMonitor(group.index, monitor.index)" /> <font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeMonitor(group.index, monitor.index)" />
@ -71,7 +71,7 @@
</div> </div>
</div> </div>
</div> </div>
<div :key="$root.userHeartbeatBar" class="col-6 col-md-8"> <div :key="$root.userHeartbeatBar" class="col-3 col-md-4">
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" /> <HeartbeatBar size="mid" :monitor-id="monitor.element.id" />
</div> </div>
</div> </div>

@ -0,0 +1,48 @@
<template>
<div class="mb-3">
<label for="ElksUsername" class="form-label">{{ $t("Username") }}</label>
<input id="ElksUsername" v-model="$parent.notification.elksUsername" type="text" class="form-control" required>
<label for="ElksPassword" class="form-label">{{ $t("Password") }}</label>
</div>
<div class="form-text">
<HiddenInput id="ElksPassword" v-model="$parent.notification.elksAuthToken" :required="true" autocomplete="new-password"></HiddenInput>
<i18n-t tag="p" keypath="Can be found on:">
<a href="https://46elks.com/account" target="_blank">https://46elks.com/account</a>
</i18n-t>
</div>
<div class="mb-3">
<label for="Elks-from-number" class="form-label">{{ $t("From") }}</label>
<input id="Elks-from-number" v-model="$parent.notification.elksFromNumber" type="text" class="form-control" required>
<div class="form-text">
{{ $t("Either a text sender ID or a phone number in E.164 format if you want to be able to receive replies.") }}
<i18n-t tag="p" keypath="More info on:">
<a href="https://46elks.se/kb/text-sender-id" target="_blank">https://46elks.se/kb/text-sender-id</a>
</i18n-t>
</div>
</div>
<div class="mb-3">
<label for="Elks-to-number" class="form-label">{{ $t("To Number") }}</label>
<input id="Elks-to-number" v-model="$parent.notification.elksToNumber" type="text" class="form-control" required>
<div class="form-text">
{{ $t("The phone number of the recipient in E.164 format.") }}
<i18n-t tag="p" keypath="More info on:">
<a href="https://46elks.se/kb/e164" target="_blank">https://46elks.se/kb/e164</a>
</i18n-t>
</div>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
<a href="https://46elks.com/docs/send-sms" target="_blank">https://46elks.com/docs/send-sms</a>
</i18n-t>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
};
</script>

@ -0,0 +1,47 @@
<template>
<div class="mb-3">
<label for="sendgrid-api-key" class="form-label">{{ $t("SendGrid API Key") }}</label>
<HiddenInput id="push-api-key" v-model="$parent.notification.sendgridApiKey" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">
<label for="sendgrid-from-email" class="form-label">{{ $t("From Email") }}</label>
<input id="sendgrid-from-email" v-model="$parent.notification.sendgridFromEmail" type="email" class="form-control" required>
</div>
<div class="mb-3">
<label for="sendgrid-to-email" class="form-label">{{ $t("To Email") }}</label>
<input id="sendgrid-to-email" v-model="$parent.notification.sendgridToEmail" type="email" class="form-control" required>
</div>
<div class="mb-3">
<label for="sendgrid-cc-email" class="form-label">{{ $t("smtpCC") }}</label>
<input id="sendgrid-cc-email" v-model="$parent.notification.sendgridCcEmail" type="email" class="form-control">
<div class="form-text">{{ $t("Separate multiple email addresses with commas") }}</div>
</div>
<div class="mb-3">
<label for="sendgrid-bcc-email" class="form-label">{{ $t("smtpBCC") }}</label>
<input id="sendgrid-bcc-email" v-model="$parent.notification.sendgridBccEmail" type="email" class="form-control">
<small class="form-text text-muted">{{ $t("Separate multiple email addresses with commas") }}</small>
</div>
<div class="mb-3">
<label for="sendgrid-subject" class="form-label">{{ $t("Subject:") }}</label>
<input id="sendgrid-subject" v-model="$parent.notification.sendgridSubject" type="text" class="form-control">
<small class="form-text text-muted">{{ $t("leave blank for default subject") }}</small>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
<a href="https://docs.sendgrid.com/api-reference/mail-send/mail-send" target="_blank">https://docs.sendgrid.com/api-reference/mail-send/mail-send</a>
</i18n-t>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
mounted() {
if (typeof this.$parent.notification.sendgridSubject === "undefined") {
this.$parent.notification.sendgridSubject = "Notification from Your Uptime Kuma";
}
},
};
</script>

@ -4,6 +4,53 @@
<HiddenInput id="push-api-key" v-model="$parent.notification.pushAPIKey" :required="true" autocomplete="new-password"></HiddenInput> <HiddenInput id="push-api-key" v-model="$parent.notification.pushAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
</div> </div>
<div class="mb-3">
<label for="push-api-title" class="form-label">{{ $t("Title") }}</label>
<input id="push-api-title" v-model="$parent.notification.pushTitle" type="text" class="form-control">
</div>
<div class="mb-3">
<label for="push-api-channel" class="form-label">{{ $t("Notification Channel") }}</label>
<input id="push-api-channel" v-model="$parent.notification.pushChannel" type="text" class="form-control" patttern="[A-Za-z0-9-]+">
<div class="form-text">
{{ $t("Alphanumerical string and hyphens only") }}
</div>
</div>
<div class="mb-3">
<label for="push-api-sound" class="form-label">{{ $t("Sound") }}</label>
<select id="push-api-sound" v-model="$parent.notification.pushSound" class="form-select">
<option value="default">{{ $t("Default") }}</option>
<option value="arcade">{{ $t("Arcade") }}</option>
<option value="correct">{{ $t("Correct") }}</option>
<option value="fail">{{ $t("Fail") }}</option>
<option value="harp">{{ $t("Harp") }}</option>
<option value="reveal">{{ $t("Reveal") }}</option>
<option value="bubble">{{ $t("Bubble") }}</option>
<option value="doorbell">{{ $t("Doorbell") }}</option>
<option value="flute">{{ $t("Flute") }}</option>
<option value="money">{{ $t("Money") }}</option>
<option value="scifi">{{ $t("Scifi") }}</option>
<option value="clear">{{ $t("Clear") }}</option>
<option value="elevator">{{ $t("Elevator") }}</option>
<option value="guitar">{{ $t("Guitar") }}</option>
<option value="pop">{{ $t("Pop") }}</option>
</select>
<div class="form-text">
{{ $t("Custom sound to override default notification sound") }}
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input v-model="$parent.notification.pushTimeSensitive" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t("Time Sensitive (iOS Only)") }}</label>
</div>
<div class="form-text">
{{ $t("Time sensitive notifications will be delivered immediately, even if the device is in do not disturb mode.") }}
</div>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;"> <i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
<a href="https://docs.push.techulus.com" target="_blank">https://docs.push.techulus.com</a> <a href="https://docs.push.techulus.com" target="_blank">https://docs.push.techulus.com</a>
</i18n-t> </i18n-t>
@ -16,5 +63,19 @@ export default {
components: { components: {
HiddenInput, HiddenInput,
}, },
mounted() {
if (typeof this.$parent.notification.pushTitle === "undefined") {
this.$parent.notification.pushTitle = "Uptime-Kuma";
}
if (typeof this.$parent.notification.pushChannel === "undefined") {
this.$parent.notification.pushChannel = "uptime-kuma";
}
if (typeof this.$parent.notification.pushSound === "undefined") {
this.$parent.notification.pushSound = "default";
}
if (typeof this.$parent.notification.pushTimeSensitive === "undefined") {
this.$parent.notification.pushTimeSensitive = true;
}
},
}; };
</script> </script>

@ -9,6 +9,7 @@ import CallMeBot from "./CallMeBot.vue";
import SMSC from "./SMSC.vue"; import SMSC from "./SMSC.vue";
import DingDing from "./DingDing.vue"; import DingDing from "./DingDing.vue";
import Discord from "./Discord.vue"; import Discord from "./Discord.vue";
import Elks from "./46elks.vue";
import Feishu from "./Feishu.vue"; import Feishu from "./Feishu.vue";
import FreeMobile from "./FreeMobile.vue"; import FreeMobile from "./FreeMobile.vue";
import GoogleChat from "./GoogleChat.vue"; import GoogleChat from "./GoogleChat.vue";
@ -65,6 +66,7 @@ import Whapi from "./Whapi.vue";
import Cellsynt from "./Cellsynt.vue"; import Cellsynt from "./Cellsynt.vue";
import WPush from "./WPush.vue"; import WPush from "./WPush.vue";
import SIGNL4 from "./SIGNL4.vue"; import SIGNL4 from "./SIGNL4.vue";
import SendGrid from "./SendGrid.vue";
/** /**
* Manage all notification form. * Manage all notification form.
@ -82,6 +84,7 @@ const NotificationFormList = {
"smsc": SMSC, "smsc": SMSC,
"DingDing": DingDing, "DingDing": DingDing,
"discord": Discord, "discord": Discord,
"Elks": Elks,
"Feishu": Feishu, "Feishu": Feishu,
"FreeMobile": FreeMobile, "FreeMobile": FreeMobile,
"GoogleChat": GoogleChat, "GoogleChat": GoogleChat,
@ -137,7 +140,8 @@ const NotificationFormList = {
"whapi": Whapi, "whapi": Whapi,
"gtxmessaging": GtxMessaging, "gtxmessaging": GtxMessaging,
"Cellsynt": Cellsynt, "Cellsynt": Cellsynt,
"WPush": WPush "WPush": WPush,
"SendGrid": SendGrid,
}; };
export default NotificationFormList; export default NotificationFormList;

@ -1011,5 +1011,45 @@
"OAuth2: Client Credentials": "OAuth2: přihlašovací údaje klienta", "OAuth2: Client Credentials": "OAuth2: přihlašovací údaje klienta",
"Authentication Method": "Metoda ověřování", "Authentication Method": "Metoda ověřování",
"Authorization Header": "Hlavička autorizace", "Authorization Header": "Hlavička autorizace",
"Form Data Body": "Tělo formuláře s daty" "Form Data Body": "Tělo formuláře s daty",
"threemaRecipientTypePhoneFormat": "E.164, bez počátečního +",
"jsonQueryDescription": "Pro zpracování a získání konkrétních dat z JSON odpovědi serveru použijte JSON dotaz - případně \"$\" pro zdrojovou (raw) odpověď, pokud neočekáváte JSON výstup. Výsledek bude následně porovnán jako řetězec vůči očekávaní hodnotě. Dokumentaci naleznete na {0} a pro testování dotazů můžete využít {1}.",
"shrinkDatabaseDescriptionSqlite": "Podmínka spuštění příkazu {vacuum} nad SQLite databází. Příkaz {auto_vacuum} je již zapnutý, ale nedochází k defragmentaci databáze ani k přebalení jednotlivých stránek databáze tak, jak to dělá příkaz {vacuum}.",
"Community String": "Řetězec komunity",
"Host Onesender": "Onesender hostitel",
"Token Onesender": "Onesender token",
"snmpOIDHelptext": "Zadejte OID senzoru nebo stavu, který chcete monitorovat. Pokud si nejste jisti identifikátorem OID, použijte nástroje pro správu sítě, jako jsou prohlížeče MIB nebo SNMP software.",
"snmpCommunityStringHelptext": "Tento řetězec slouží jako heslo pro ověřování a řízení přístupu k zařízením podporujícím protokol SNMP. Shodujte se s konfigurací zařízení SNMP.",
"record": "záznam",
"Go back to home page.": "Vrátit se domovskou stránku.",
"No tags found.": "Nenalezeny žádné štítky.",
"Lost connection to the socket server.": "Ztraceno socketové spojení se serverem.",
"Cannot connect to the socket server.": "Nelze navázat socketové spojení se serverem.",
"SIGNL4": "SIGNL4",
"SIGNL4 Webhook URL": "URL adresa webhooku SIGNL4",
"signl4Docs": "Další informace související s konfigurací SIGNL4 a postup jak získat URL webhooku SIGNL4 naleznete na {0}.",
"Conditions": "Podmínky",
"conditionAdd": "Přidat podmínku",
"conditionDelete": "Vymazat podmínku",
"conditionAddGroup": "Přidat skupinu",
"conditionDeleteGroup": "Smazat skupinu",
"conditionValuePlaceholder": "Hodnota",
"equals": "rovná se",
"not equals": "nerovná se",
"contains": "obsahuje",
"not contains": "neobsahuje",
"starts with": "začíná na",
"not starts with": "nezačíná na",
"ends with": "končí na",
"not ends with": "nekončí na",
"less than": "menší než",
"greater than": "větší než",
"less than or equal to": "menší nebo rovno",
"greater than or equal to": "větší nebo rovno",
"groupOnesenderDesc": "Ujistěte se, že jste zadali platné GroupID. Pro odeslání zprávy do skupiny zadejte například 628123456789-342345",
"OAuth Token URL": "URL OAuth tokenu",
"Client ID": "ID klienta",
"Client Secret": "Tajemství klienta",
"OAuth Scope": "OAuth rozsah",
"Optional: Space separated list of scopes": "Volitelné: seznam rozsahů oddělte mezerami"
} }

@ -1,7 +1,7 @@
{ {
"languageName": "Deutsch (Schweiz)", "languageName": "Deutsch (Schweiz)",
"Settings": "Einstellungen", "Settings": "Einstellungen",
"Dashboard": "Dashboard", "Dashboard": "Überblick",
"New Update": "Update verfügbar", "New Update": "Update verfügbar",
"Language": "Sprache", "Language": "Sprache",
"Appearance": "Erscheinungsbild", "Appearance": "Erscheinungsbild",
@ -1047,5 +1047,6 @@
"greater than": "mehr als", "greater than": "mehr als",
"less than or equal to": "kleiner als oder gleich", "less than or equal to": "kleiner als oder gleich",
"greater than or equal to": "grösser als oder gleich", "greater than or equal to": "grösser als oder gleich",
"record": "Eintrag" "record": "Eintrag",
"shrinkDatabaseDescriptionSqlite": "Datenbank {vacuum} für SQLite auslösen. {auto_vacuum} ist bereits aktiviert, aber dies defragmentiert die Datenbank nicht und packt auch nicht einzelne Datenbankseiten neu, wie es der Befehl {vacuum} tut."
} }

@ -1,7 +1,7 @@
{ {
"languageName": "Deutsch", "languageName": "Deutsch",
"Settings": "Einstellungen", "Settings": "Einstellungen",
"Dashboard": "Dashboard", "Dashboard": "Überblick",
"New Update": "Aktualisierung verfügbar", "New Update": "Aktualisierung verfügbar",
"Language": "Sprache", "Language": "Sprache",
"Appearance": "Erscheinungsbild", "Appearance": "Erscheinungsbild",
@ -1050,5 +1050,6 @@
"less than": "weniger als", "less than": "weniger als",
"less than or equal to": "kleiner als oder gleich", "less than or equal to": "kleiner als oder gleich",
"greater than or equal to": "größer als oder gleich", "greater than or equal to": "größer als oder gleich",
"record": "Eintrag" "record": "Eintrag",
"shrinkDatabaseDescriptionSqlite": "Datenbank {vacuum} für SQLite auslösen. {auto_vacuum} ist bereits aktiviert, aber dies defragmentiert die Datenbank nicht und packt auch nicht einzelne Datenbankseiten neu, wie es der Befehl {vacuum} tut."
} }

@ -1029,5 +1029,38 @@
"greater than": "greater than", "greater than": "greater than",
"less than or equal to": "less than or equal to", "less than or equal to": "less than or equal to",
"greater than or equal to": "greater than or equal to", "greater than or equal to": "greater than or equal to",
"record": "record" "record": "record",
"Notification Channel": "Notification Channel",
"Sound": "Sound",
"Alphanumerical string and hyphens only": "Alphanumerical string and hyphens only",
"Arcade": "Arcade",
"Correct": "Correct",
"Fail":"Fail",
"Harp":"Harp",
"Reveal":"Reveal",
"Bubble":"Bubble",
"Doorbell":"Doorbell",
"Flute":"Flute",
"Money":"Money",
"Scifi":"Scifi",
"Clear":"Clear",
"Elevator":"Elevator",
"Guitar":"Guitar",
"Pop":"Pop",
"Custom sound to override default notification sound": "Custom sound to override default notification sound",
"Time Sensitive (iOS Only)": "Time Sensitive (iOS Only)",
"Time sensitive notifications will be delivered immediately, even if the device is in do not disturb mode.": "Time sensitive notifications will be delivered immediately, even if the device is in do not disturb mode.",
"From":"From",
"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}.",
"SendGrid API Key": "SendGrid API Key",
"Separate multiple email addresses with commas": "Separate multiple email addresses with commas"
} }

@ -106,7 +106,7 @@
"disableauth.message2": "Egoera jakin batzuetarako diseinatuta dago, Uptime Kumaren {intendThirdPartyAuth} (Cloudflare Access, Authelia edo beste autentifikazio-mekanismo batzuk).", "disableauth.message2": "Egoera jakin batzuetarako diseinatuta dago, Uptime Kumaren {intendThirdPartyAuth} (Cloudflare Access, Authelia edo beste autentifikazio-mekanismo batzuk).",
"where you intend to implement third-party authentication": "aurrean hirugarrengo autentifikazio batzuek jartzeko", "where you intend to implement third-party authentication": "aurrean hirugarrengo autentifikazio batzuek jartzeko",
"Please use this option carefully!": "Mesedez, kontuz erabili aukera hau!", "Please use this option carefully!": "Mesedez, kontuz erabili aukera hau!",
"Logout": "Saioa amaitu", "Logout": "Itxi saioa",
"Leave": "Utzi", "Leave": "Utzi",
"I understand, please disable": "Ulertzen dut, mesedez desgaitu", "I understand, please disable": "Ulertzen dut, mesedez desgaitu",
"Confirm": "Baieztatu", "Confirm": "Baieztatu",
@ -115,7 +115,7 @@
"Username": "Erabiltzailea", "Username": "Erabiltzailea",
"Password": "Pasahitza", "Password": "Pasahitza",
"Remember me": "Gogora nazazu", "Remember me": "Gogora nazazu",
"Login": "Saioa hasi", "Login": "Hasi saioa",
"No Monitors, please": "Monitorizaziorik ez, mesedez", "No Monitors, please": "Monitorizaziorik ez, mesedez",
"add one": "gehitu bat", "add one": "gehitu bat",
"Notification Type": "Jakinarazpen mota", "Notification Type": "Jakinarazpen mota",
@ -164,11 +164,11 @@
"Add New below or Select...": "Gehitu beste bat behean edo hautatu…", "Add New below or Select...": "Gehitu beste bat behean edo hautatu…",
"Tag with this name already exist.": "Izen hau duen etiketa dagoeneko badago.", "Tag with this name already exist.": "Izen hau duen etiketa dagoeneko badago.",
"Tag with this value already exist.": "Balio hau duen etiketa dagoeneko badago.", "Tag with this value already exist.": "Balio hau duen etiketa dagoeneko badago.",
"color": "kolorea", "color": "Kolorea",
"value (optional)": "balioa (hautazkoa)", "value (optional)": "balioa (hautazkoa)",
"Gray": "Grisa", "Gray": "Grisa",
"Red": "Gorria", "Red": "Gorria",
"Orange": "Naranja", "Orange": "Laranja",
"Green": "Berdea", "Green": "Berdea",
"Blue": "Urdina", "Blue": "Urdina",
"Indigo": "Indigo", "Indigo": "Indigo",
@ -190,7 +190,7 @@
"Status Page": "Egoera orria", "Status Page": "Egoera orria",
"Status Pages": "Egoera orriak", "Status Pages": "Egoera orriak",
"defaultNotificationName": "Nire {notification} Alerta ({number})", "defaultNotificationName": "Nire {notification} Alerta ({number})",
"here": "Hemen", "here": "hemen",
"Required": "Beharrezkoa", "Required": "Beharrezkoa",
"telegram": "Telegram", "telegram": "Telegram",
"ZohoCliq": "ZohoCliq", "ZohoCliq": "ZohoCliq",
@ -582,6 +582,10 @@
"Mechanism": "Mekanismoa", "Mechanism": "Mekanismoa",
"Home": "Hasiera", "Home": "Hasiera",
"filterActive": "Aktibo", "filterActive": "Aktibo",
"filterActivePaused": "Geldituta", "filterActivePaused": "Pausatua",
"Expected Value": "Esperotako balioa" "Expected Value": "Esperotako balioa",
"statusPageRefreshIn": "{0} barru freskatuko da.",
"now": "orain",
"time ago": "duela {0}",
"-year": "-urte"
} }

@ -1050,5 +1050,6 @@
"greater than": "supérieur à", "greater than": "supérieur à",
"less than or equal to": "inférieur ou égal à", "less than or equal to": "inférieur ou égal à",
"greater than or equal to": "supérieur ou égal à", "greater than or equal to": "supérieur ou égal à",
"record": "enregistrer" "record": "enregistrer",
"shrinkDatabaseDescriptionSqlite": "Déclencher la commande {vacuum} pour la base de données SQLite. {auto_vacuum} est déjà activé, mais cela ne défragmente pas la base de données ni ne réorganise les pages individuelles de la base de données de la même manière que la commande {vacuum}."
} }

@ -1015,5 +1015,6 @@
"less than": "níos lú ná", "less than": "níos lú ná",
"greater than": "níos mó ná", "greater than": "níos mó ná",
"less than or equal to": "níos lú ná nó cothrom le", "less than or equal to": "níos lú ná nó cothrom le",
"record": "taifead" "record": "taifead",
"shrinkDatabaseDescriptionSqlite": "Bunachar sonraí truicear {vacuum} le haghaidh SQLite. Tá {auto_vacuum} cumasaithe cheana féin ach ní dhéanann sé seo scoilt ar an mbunachar sonraí ná athphacáil leathanaigh aonair an bhunachair sonraí mar a dhéanann an t-ordú {vacuum}."
} }

@ -1045,5 +1045,6 @@
"New Group": "Grup Baru", "New Group": "Grup Baru",
"Group Name": "Nama Grup", "Group Name": "Nama Grup",
"OAuth2: Client Credentials": "OAuth2: Kredensial Klien", "OAuth2: Client Credentials": "OAuth2: Kredensial Klien",
"Authentication Method": "Metode Autentikasi" "Authentication Method": "Metode Autentikasi",
"shrinkDatabaseDescriptionSqlite": "Memicu pangkalan data {vacuum} untuk SQLite. {auto_vacuum} sudah diaktifkan, tetapi tidak mendefragmentasi pangkalan data atau mengemas ulang halaman individual dari pangkalan data seperti yang dilakukan oleh perintah {vacuum}."
} }

@ -989,5 +989,9 @@
"wayToGetThreemaGateway": "Możesz zarejestrować się w Threema Gateway {0}.", "wayToGetThreemaGateway": "Możesz zarejestrować się w Threema Gateway {0}.",
"threemaSenderIdentityFormat": "8 znaków, zwykle zaczyna się od *", "threemaSenderIdentityFormat": "8 znaków, zwykle zaczyna się od *",
"threemaBasicModeInfo": "Uwaga: Ta integracja korzysta z Threema Gateway w trybie podstawowym (szyfrowanie po stronie serwera). Więcej szczegółów można znaleźć {0}.", "threemaBasicModeInfo": "Uwaga: Ta integracja korzysta z Threema Gateway w trybie podstawowym (szyfrowanie po stronie serwera). Więcej szczegółów można znaleźć {0}.",
"apiKeysDisabledMsg": "Klucze API są wyłączone, ponieważ wyłączone jest uwierzytelnianie." "apiKeysDisabledMsg": "Klucze API są wyłączone, ponieważ wyłączone jest uwierzytelnianie.",
"-year": "-rok",
"and": "i",
"now": "teraz",
"cacheBusterParam": "Dodaj parametr {0}"
} }

@ -1050,5 +1050,6 @@
"less than": "daha küçük", "less than": "daha küçük",
"greater than or equal to": "büyük veya eşit", "greater than or equal to": "büyük veya eşit",
"record": "kayıt", "record": "kayıt",
"jsonQueryDescription": "JSON sorgusunu kullanarak sunucunun JSON yanıtından belirli verileri ayrıştırın ve çıkarın. JSON beklemiyorsanız ham yanıt için \"$\" sembolünü kullanın. Sonuç daha sonra metin olarak beklenen değerle karşılaştırılır. Belgeler için {0}'a bakın ve sorgularla denemeler yapmak için {1}'i kullanın." "jsonQueryDescription": "JSON sorgusunu kullanarak sunucunun JSON yanıtından belirli verileri ayrıştırın ve çıkarın. JSON beklemiyorsanız ham yanıt için \"$\" sembolünü kullanın. Sonuç daha sonra metin olarak beklenen değerle karşılaştırılır. Belgeler için {0}'a bakın ve sorgularla denemeler yapmak için {1}'i kullanın.",
"shrinkDatabaseDescriptionSqlite": "SQLite için {vacuum} veritabanını tetikle. {auto_vacuum} zaten etkin ancak bu, {vacuum} komutunun yaptığı gibi veritabanını birleştirmez veya tek tek veritabanı sayfalarını yeniden paketlemez."
} }

@ -1056,5 +1056,6 @@
"greater than": "більше, ніж", "greater than": "більше, ніж",
"less than or equal to": "менше або дорівнює", "less than or equal to": "менше або дорівнює",
"greater than or equal to": "більше або дорівнює", "greater than or equal to": "більше або дорівнює",
"record": "запис" "record": "запис",
"shrinkDatabaseDescriptionSqlite": "Запускає команду {vacuum} для бази даних SQLite. Команда {auto_vacuum} вже увімкнена, але вона не дефрагментує базу даних і не перепаковує окремі сторінки бази даних так, як це робить команда {vacuum}."
} }

@ -1052,5 +1052,6 @@
"less than or equal to": "不多于", "less than or equal to": "不多于",
"greater than or equal to": "不少于", "greater than or equal to": "不少于",
"record": "记录", "record": "记录",
"jsonQueryDescription": "使用 JSON 查询解析并提取服务器 JSON 响应中的特定数据,或者,如果不期望得到 JSON 响应,则可使用 \"$\" 获取原始响应。然后将结果转为字符串并与期望值进行字符串比较。有关更多文档,请参阅 {0},亦可使用 {1} 来尝试查询。" "jsonQueryDescription": "使用 JSON 查询解析并提取服务器 JSON 响应中的特定数据,或者,如果不期望得到 JSON 响应,则可使用 \"$\" 获取原始响应。然后将结果转为字符串并与期望值进行字符串比较。有关更多文档,请参阅 {0},亦可使用 {1} 来尝试查询。",
"shrinkDatabaseDescriptionSqlite": "触发 SQLite 数据库的 {vacuum} 命令。{auto_vacuum} 已经启用,但它不会像 {vacuum} 命令那样对数据库进行碎片整理,也不会重新打包各个数据库页面。"
} }

@ -64,6 +64,9 @@
<option value="mqtt"> <option value="mqtt">
MQTT MQTT
</option> </option>
<option value="rabbitmq">
RabbitMQ
</option>
<option value="kafka-producer"> <option value="kafka-producer">
Kafka Producer Kafka Producer
</option> </option>
@ -90,6 +93,13 @@
</option> </option>
</optgroup> </optgroup>
</select> </select>
<i18n-t v-if="monitor.type === 'rabbitmq'" keypath="rabbitmqHelpText" tag="div" class="form-text">
<template #rabitmq_documentation>
<a href="https://www.rabbitmq.com/management" target="_blank" rel="noopener noreferrer">
RabbitMQ documentation
</a>
</template>
</i18n-t>
</div> </div>
<div v-if="monitor.type === 'tailscale-ping'" class="alert alert-warning" role="alert"> <div v-if="monitor.type === 'tailscale-ping'" class="alert alert-warning" role="alert">
@ -233,6 +243,43 @@
</div> </div>
</template> </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 --> <!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP only --> <!-- 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"> <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">
@ -549,7 +596,7 @@
</div> </div>
<!-- Timeout: HTTP / Keyword / SNMP only --> <!-- 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> <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"> <input id="timeout" v-model="monitor.timeout" type="number" class="form-control" required min="0" step="0.1">
</div> </div>
@ -1122,6 +1169,9 @@ const monitorDefaults = {
kafkaProducerAllowAutoTopicCreation: false, kafkaProducerAllowAutoTopicCreation: false,
gamedigGivenPortOnly: true, gamedigGivenPortOnly: true,
remote_browser: null, remote_browser: null,
rabbitmqNodes: [],
rabbitmqUsername: "",
rabbitmqPassword: "",
conditions: [] conditions: []
}; };
@ -1709,6 +1759,10 @@ message HealthCheckResponse {
this.monitor.kafkaProducerBrokers.push(newBroker); this.monitor.kafkaProducerBrokers.push(newBroker);
}, },
addRabbitmqNode(newNode) {
this.monitor.rabbitmqNodes.push(newNode);
},
/** /**
* Validate form input * Validate form input
* @returns {boolean} Is the form input valid? * @returns {boolean} Is the form input valid?
@ -1736,6 +1790,17 @@ message HealthCheckResponse {
return false; 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; return true;
}, },

@ -0,0 +1,102 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { HiveMQContainer } = require("@testcontainers/hivemq");
const mqtt = require("mqtt");
const { MqttMonitorType } = require("../../server/monitor-types/mqtt");
const { UP, PENDING } = require("../../src/util");
/**
* Runs an MQTT test with the
* @param {string} mqttSuccessMessage the message that the monitor expects
* @param {null|"keyword"|"json-query"} mqttCheckType the type of check we perform
* @param {string} receivedMessage what message is recieved from the mqtt channel
* @returns {Promise<Heartbeat>} the heartbeat produced by the check
*/
async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage) {
const hiveMQContainer = await new HiveMQContainer().start();
const connectionString = hiveMQContainer.getConnectionString();
const mqttMonitorType = new MqttMonitorType();
const monitor = {
jsonPath: "firstProp", // always return firstProp for the json-query monitor
hostname: connectionString.split(":", 2).join(":"),
mqttTopic: "test",
port: connectionString.split(":")[2],
mqttUsername: null,
mqttPassword: null,
interval: 20, // controls the timeout
mqttSuccessMessage: mqttSuccessMessage, // for keywords
expectedValue: mqttSuccessMessage, // for json-query
mqttCheckType: mqttCheckType,
};
const heartbeat = {
msg: "",
status: PENDING,
};
const testMqttClient = mqtt.connect(hiveMQContainer.getConnectionString());
testMqttClient.on("connect", () => {
testMqttClient.subscribe("test", (error) => {
if (!error) {
testMqttClient.publish("test", receivedMessage);
}
});
});
try {
await mqttMonitorType.check(monitor, heartbeat, {});
} finally {
testMqttClient.end();
hiveMQContainer.stop();
}
return heartbeat;
}
describe("MqttMonitorType", {
concurrency: true,
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64")
}, () => {
test("valid keywords (type=default)", async () => {
const heartbeat = await testMqtt("KEYWORD", null, "-> KEYWORD <-");
assert.strictEqual(heartbeat.status, UP);
assert.strictEqual(heartbeat.msg, "Topic: test; Message: -> KEYWORD <-");
});
test("valid keywords (type=keyword)", async () => {
const heartbeat = await testMqtt("KEYWORD", "keyword", "-> KEYWORD <-");
assert.strictEqual(heartbeat.status, UP);
assert.strictEqual(heartbeat.msg, "Topic: test; Message: -> KEYWORD <-");
});
test("invalid keywords (type=default)", async () => {
await assert.rejects(
testMqtt("NOT_PRESENT", null, "-> KEYWORD <-"),
new Error("Message Mismatch - Topic: test; Message: -> KEYWORD <-"),
);
});
test("invalid keyword (type=keyword)", async () => {
await assert.rejects(
testMqtt("NOT_PRESENT", "keyword", "-> KEYWORD <-"),
new Error("Message Mismatch - Topic: test; Message: -> KEYWORD <-"),
);
});
test("valid json-query", async () => {
// works because the monitors' jsonPath is hard-coded to "firstProp"
const heartbeat = await testMqtt("present", "json-query", "{\"firstProp\":\"present\"}");
assert.strictEqual(heartbeat.status, UP);
assert.strictEqual(heartbeat.msg, "Message received, expected value is found");
});
test("invalid (because query fails) json-query", async () => {
// works because the monitors' jsonPath is hard-coded to "firstProp"
await assert.rejects(
testMqtt("[not_relevant]", "json-query", "{}"),
new Error("Message received but value is not equal to expected value, value was: [undefined]"),
);
});
test("invalid (because successMessage fails) json-query", async () => {
// works because the monitors' jsonPath is hard-coded to "firstProp"
await assert.rejects(
testMqtt("[wrong_success_messsage]", "json-query", "{\"firstProp\":\"present\"}"),
new Error("Message received but value is not equal to expected value, value was: [present]")
);
});
});

@ -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);
});
});
Loading…
Cancel
Save