Merge branch 'master' into smtp

This commit is contained in:
Frank Elsinga 2025-05-18 22:21:05 +02:00 committed by GitHub
commit 4b98a2191e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 297 additions and 18 deletions

View file

@ -0,0 +1,13 @@
// Add column custom_url to monitor_group table
exports.up = function (knex) {
return knex.schema
.alterTable("monitor_group", function (table) {
table.text("custom_url", "text");
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor_group", function (table) {
table.dropColumn("custom_url");
});
};

View file

@ -736,7 +736,7 @@ class Database {
if (Database.dbConfig.type === "sqlite") {
return "DATETIME('now', ? || ' hours')";
} else {
return "DATE_ADD(NOW(), INTERVAL ? HOUR)";
return "DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? HOUR)";
}
}

View file

@ -33,7 +33,7 @@ class Group extends BeanModel {
*/
async getMonitorList() {
return R.convertToBeans("monitor", await R.getAll(`
SELECT monitor.*, monitor_group.send_url FROM monitor, monitor_group
SELECT monitor.*, monitor_group.send_url, monitor_group.custom_url FROM monitor, monitor_group
WHERE monitor.id = monitor_group.monitor_id
AND group_id = ?
ORDER BY monitor_group.weight

View file

@ -53,7 +53,7 @@ class Monitor extends BeanModel {
};
if (this.sendUrl) {
obj.url = this.url;
obj.url = this.customUrl ?? this.url;
}
if (showTags) {

View file

@ -46,10 +46,10 @@ class Discord extends NotificationProvider {
name: "Service Name",
value: monitorJSON["name"],
},
{
...(!notification.disableUrl ? [{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: this.extractAddress(monitorJSON),
},
}] : []),
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],
@ -83,10 +83,10 @@ class Discord extends NotificationProvider {
name: "Service Name",
value: monitorJSON["name"],
},
{
...(!notification.disableUrl ? [{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: this.extractAddress(monitorJSON),
},
}] : []),
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],

View file

@ -0,0 +1,53 @@
const { getMonitorRelativeURL, UP } = require("../../src/util");
const { setting } = require("../util-server");
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Notifery extends NotificationProvider {
name = "notifery";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
const url = "https://api.notifery.com/event";
let data = {
title: notification.notiferyTitle || "Uptime Kuma Alert",
message: msg,
};
if (notification.notiferyGroup) {
data.group = notification.notiferyGroup;
}
// Link to the monitor
const baseURL = await setting("primaryBaseURL");
if (baseURL && monitorJSON) {
data.message += `\n\nMonitor: ${baseURL}${getMonitorRelativeURL(monitorJSON.id)}`;
}
if (heartbeatJSON) {
data.code = heartbeatJSON.status === UP ? 0 : 1;
if (heartbeatJSON.ping) {
data.duration = heartbeatJSON.ping;
}
}
try {
const headers = {
"Content-Type": "application/json",
"x-api-key": notification.notiferyApiKey,
};
await axios.post(url, data, { headers });
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Notifery;

View file

@ -145,6 +145,7 @@ class Slack extends NotificationProvider {
const title = "Uptime Kuma Alert";
let data = {
"text": msg,
"channel": notification.slackchannel,
"username": notification.slackusername,
"icon_emoji": notification.slackiconemo,

View file

@ -13,6 +13,7 @@ const DingDing = require("./notification-providers/dingding");
const Discord = require("./notification-providers/discord");
const Elks = require("./notification-providers/46elks");
const Feishu = require("./notification-providers/feishu");
const Notifery = require("./notification-providers/notifery");
const FreeMobile = require("./notification-providers/freemobile");
const GoogleChat = require("./notification-providers/google-chat");
const Gorush = require("./notification-providers/gorush");
@ -169,6 +170,7 @@ class Notification {
new YZJ(),
new SMSPlanet(),
new SpugPush(),
new Notifery(),
];
for (let item of list) {
if (! item.name) {

View file

@ -98,15 +98,15 @@ router.all("/api/push/:pushToken", async (request, response) => {
// Reset down count
bean.downCount = 0;
log.debug("monitor", `[${this.name}] sendNotification`);
log.debug("monitor", `[${monitor.name}] sendNotification`);
await Monitor.sendNotification(isFirstBeat, monitor, bean);
} else {
if (bean.status === DOWN && this.resendInterval > 0) {
if (bean.status === DOWN && monitor.resendInterval > 0) {
++bean.downCount;
if (bean.downCount >= this.resendInterval) {
if (bean.downCount >= monitor.resendInterval) {
// Send notification again, because we are still DOWN
log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
await Monitor.sendNotification(isFirstBeat, this, bean);
log.debug("monitor", `[${monitor.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${monitor.resendInterval}`);
await Monitor.sendNotification(isFirstBeat, monitor, bean);
// Reset down count
bean.downCount = 0;

View file

@ -211,6 +211,10 @@ module.exports.statusPageSocketHandler = (socket) => {
relationBean.send_url = monitor.sendUrl;
}
if (monitor.url !== undefined) {
relationBean.custom_url = monitor.url;
}
await R.store(relationBean);
}

View file

@ -10,7 +10,7 @@
</div>
<div class="modal-body">
<div class="my-3 form-check">
<input id="show-clickable-link" v-model="monitor.isClickAble" class="form-check-input" type="checkbox" @click="toggleLink(monitor.group_index, monitor.monitor_index)" />
<input id="show-clickable-link" v-model="monitor.isClickAble" class="form-check-input" type="checkbox" data-testid="show-clickable-link" @click="toggleLink(monitor.group_index, monitor.monitor_index)" />
<label class="form-check-label" for="show-clickable-link">
{{ $t("Show Clickable Link") }}
</label>
@ -19,6 +19,16 @@
</div>
</div>
<!-- Custom URL -->
<template v-if="monitor.isClickAble">
<label for="customUrl" class="form-label">{{ $t("Custom URL") }}</label>
<input id="customUrl" :value="monitor.url" type="url" class="form-control" data-testid="custom-url-input" @input="e => changeUrl(monitor.group_index, monitor.monitor_index, e.target!.value)">
<div class="form-text mb-3">
{{ $t("customUrlDescription") }}
</div>
</template>
<button
class="btn btn-primary btn-add-group me-2"
@click="$refs.badgeGeneratorDialog.show(monitor.id, monitor.name)"
@ -29,7 +39,7 @@
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal">
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal" data-testid="monitor-settings-close">
{{ $t("Close") }}
</button>
</div>
@ -78,6 +88,7 @@ export default {
monitor_index: monitor.index,
group_index: group.index,
isClickAble: this.showLink(monitor),
url: monitor.element.url,
};
this.MonitorSettingDialog.show();
@ -110,6 +121,17 @@ export default {
}
return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode;
},
/**
* Toggle the value of sendUrl
* @param {number} groupIndex Index of group monitor is member of
* @param {number} index Index of monitor within group
* @param {string} value The new value of the url
* @returns {void}
*/
changeUrl(groupIndex, index, value) {
this.$root.publicGroupList[groupIndex].monitorList[index].url = value;
},
},
};
</script>

View file

@ -168,7 +168,8 @@ export default {
"waha": "WhatsApp (WAHA)",
"gtxmessaging": "GtxMessaging",
"Cellsynt": "Cellsynt",
"SendGrid": "SendGrid"
"SendGrid": "SendGrid",
"notifery": "Notifery"
};
// Put notifications here if it's not supported in most regions or its documentation is not in English

View file

@ -58,6 +58,7 @@
v-if="editMode"
:class="{'link-active': true, 'btn-link': true}"
icon="cog" class="action me-3"
data-testid="monitor-settings"
@click="$refs.monitorSettingDialog.show(group, monitor)"
/>
</span>

View file

@ -53,6 +53,13 @@
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input id="discord-disable-url" v-model="$parent.notification.disableUrl" class="form-check-input" type="checkbox" role="switch">
<label class="form-check-label" for="discord-disable-url">{{ $t("Disable URL in Notification") }}</label>
</div>
</div>
</template>
<script>
export default {
@ -60,6 +67,9 @@ export default {
if (!this.$parent.notification.discordChannelType) {
this.$parent.notification.discordChannelType = "channel";
}
if (this.$parent.notification.disableUrl === undefined) {
this.$parent.notification.disableUrl = false;
}
}
};
</script>

View file

@ -0,0 +1,49 @@
<template>
<div class="mb-3">
<label for="notifery-api-key" class="form-label">{{
$t("API Key")
}}</label>
<HiddenInput
id="notifery-api-key"
v-model="$parent.notification.notiferyApiKey"
:required="true"
autocomplete="new-password"
></HiddenInput>
</div>
<div class="mb-3">
<label for="notifery-title" class="form-label">{{ $t("Title") }}</label>
<input
id="notifery-title"
v-model="$parent.notification.notiferyTitle"
type="text"
class="form-control"
placeholder="Uptime Kuma Alert"
/>
</div>
<div class="mb-3">
<label for="notifery-group" class="form-label">{{ $t("Group") }}</label>
<input
id="notifery-group"
v-model="$parent.notification.notiferyGroup"
type="text"
class="form-control"
:placeholder="$t('Optional')"
/>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
<a href="https://docs.notifery.com/api/event/" target="_blank">https://docs.notifery.com/api/event/</a>
</i18n-t>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
};
</script>

View file

@ -4,6 +4,7 @@ import AliyunSMS from "./AliyunSms.vue";
import Apprise from "./Apprise.vue";
import Bark from "./Bark.vue";
import Bitrix24 from "./Bitrix24.vue";
import Notifery from "./Notifery.vue";
import ClickSendSMS from "./ClickSendSMS.vue";
import CallMeBot from "./CallMeBot.vue";
import SMSC from "./SMSC.vue";
@ -149,6 +150,7 @@ const NotificationFormList = {
"ZohoCliq": ZohoCliq,
"SevenIO": SevenIO,
"whapi": Whapi,
"notifery": Notifery,
"waha": WAHA,
"gtxmessaging": GtxMessaging,
"Cellsynt": Cellsynt,

View file

@ -64,6 +64,7 @@
"Expected Value": "Expected Value",
"Json Query Expression": "Json Query Expression",
"Friendly Name": "Friendly Name",
"defaultFriendlyName": "New Monitor",
"URL": "URL",
"Hostname": "Hostname",
"Host URL": "Host URL",
@ -1074,6 +1075,8 @@
"SendGrid API Key": "SendGrid API Key",
"Separate multiple email addresses with commas": "Separate multiple email addresses with commas",
"smtpHelpText": "“SMTPS” tests that SMTP/TLS is working; “Ignore TLS” connects over plaintext; “STARTTLS” connects, issues a STARTTLS command and verifies the server certificate. None of these send an email.",
"Custom URL": "Custom URL",
"customUrlDescription": "Will be used as the clickable URL instead of the monitor's one.",
"OneChatAccessToken": "OneChat Access Token",
"OneChatUserIdOrGroupId": "OneChat User ID or Group ID",
"OneChatBotId": "OneChat Bot ID",
@ -1094,5 +1097,6 @@
"the smsplanet documentation": "the smsplanet documentation",
"Phone numbers": "Phone numbers",
"Sender name": "Sender name",
"smsplanetNeedToApproveName": "Needs to be approved in the client panel"
"smsplanetNeedToApproveName": "Needs to be approved in the client panel",
"Disable URL in Notification": "Disable URL in Notification"
}

View file

@ -112,7 +112,7 @@
<!-- Friendly Name -->
<div class="my-3">
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="name" v-model="monitor.name" type="text" class="form-control" required data-testid="friendly-name-input">
<input id="name" v-model="monitor.name" type="text" class="form-control" data-testid="friendly-name-input" :placeholder="defaultFriendlyName">
</div>
<!-- URL -->
@ -1172,6 +1172,25 @@ export default {
},
computed: {
defaultFriendlyName() {
if (this.monitor.hostname) {
return this.monitor.hostname;
}
if (this.monitor.url) {
if (this.monitor.url !== "http://" && this.monitor.url !== "https://") {
// Ensure monitor without a URL is not affected by invisible URL.
try {
const url = new URL(this.monitor.url);
return url.hostname;
} catch (e) {
return this.monitor.url.replace(/https?:\/\//, "");
}
}
}
// Default placeholder if neither hostname nor URL is available
return this.$t("defaultFriendlyName");
},
ipRegex() {
// Allow to test with simple dns server with port (127.0.0.1:5300)
@ -1715,6 +1734,10 @@ message HealthCheckResponse {
this.processing = true;
if (!this.monitor.name) {
this.monitor.name = this.defaultFriendlyName;
}
if (!this.isInputValid()) {
this.processing = false;
return;

View file

@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test";
import { login, restoreSqliteSnapshot, screenshot } from "../util-test";
test.describe("Friendly Name Tests", () => {
test.beforeEach(async ({ page }) => {
await restoreSqliteSnapshot(page);
});
test("hostname", async ({ page }, testInfo) => {
// Test DNS monitor with hostname
await page.goto("./add");
await login(page);
await screenshot(testInfo, page);
await page.getByTestId("monitor-type-select").selectOption("dns");
await page.getByTestId("hostname-input").fill("example.com");
await screenshot(testInfo, page);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
expect(page.getByTestId("monitor-list")).toContainText("example.com");
await screenshot(testInfo, page);
});
test("URL hostname", async ({ page }, testInfo) => {
// Test HTTP monitor with URL
await page.goto("./add");
await login(page);
await screenshot(testInfo, page);
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("url-input").fill("https://www.example.com/");
await screenshot(testInfo, page);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
expect(page.getByTestId("monitor-list")).toContainText("www.example.com");
await screenshot(testInfo, page);
});
test("custom friendly name", async ({ page }, testInfo) => {
// Test custom friendly name for HTTP monitor
await page.goto("./add");
await login(page);
await screenshot(testInfo, page);
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("url-input").fill("https://www.example.com/");
// Check if the friendly name placeholder is set to the hostname
const friendlyNameInput = page.getByTestId("friendly-name-input");
expect(friendlyNameInput).toHaveAttribute("placeholder", "www.example.com");
await screenshot(testInfo, page);
const customName = "Example Monitor";
await friendlyNameInput.fill(customName);
await screenshot(testInfo, page);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
expect(page.getByTestId("monitor-list")).toContainText(customName);
await screenshot(testInfo, page);
});
test("default friendly name", async ({ page }, testInfo) => {
// Test default friendly name when no custom name is provided
await page.goto("./add");
await login(page);
await screenshot(testInfo, page);
await page.getByTestId("monitor-type-select").selectOption("group");
await screenshot(testInfo, page);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
expect(page.getByTestId("monitor-list")).toContainText("New Monitor");
await screenshot(testInfo, page);
});
});

View file

@ -12,6 +12,8 @@ test.describe("Status Page", () => {
const monitorName = "Monitor for Status Page";
const tagName = "Client";
const tagValue = "Acme Inc";
const monitorUrl = "https://www.example.com/status";
const monitorCustomUrl = "https://www.example.com";
// Status Page
const footerText = "This is footer text.";
@ -30,7 +32,7 @@ test.describe("Status Page", () => {
await expect(page.getByTestId("monitor-type-select")).toBeVisible();
await page.getByTestId("monitor-type-select").selectOption("http");
await page.getByTestId("friendly-name-input").fill(monitorName);
await page.getByTestId("url-input").fill("https://www.example.com/");
await page.getByTestId("url-input").fill(monitorUrl);
await page.getByTestId("add-tag-button").click();
await page.getByTestId("tag-name-input").fill(tagName);
await page.getByTestId("tag-value-input").fill(tagValue);
@ -79,6 +81,13 @@ test.describe("Status Page", () => {
await page.getByTestId("monitor-select").getByRole("option", { name: monitorName }).click();
await expect(page.getByTestId("monitor")).toHaveCount(1);
await expect(page.getByTestId("monitor-name")).toContainText(monitorName);
await expect(page.getByTestId("monitor-name")).not.toHaveAttribute("href");
// Set public url on
await page.getByTestId("monitor-settings").click();
await page.getByTestId("show-clickable-link").check();
await page.getByTestId("custom-url-input").fill(monitorCustomUrl);
await page.getByTestId("monitor-settings-close").click();
// Save the changes
await screenshot(testInfo, page);
@ -94,6 +103,8 @@ test.describe("Status Page", () => {
await expect(page.getByTestId("footer-text")).toContainText(footerText);
await expect(page.getByTestId("powered-by")).toHaveCount(0);
await expect(page.getByTestId("monitor-name")).toHaveAttribute("href", monitorCustomUrl);
await expect(page.getByTestId("update-countdown-text")).toContainText("00:");
const updateCountdown = Number((await page.getByTestId("update-countdown-text").textContent()).match(/(\d+):(\d+)/)[2]);
expect(updateCountdown).toBeGreaterThanOrEqual(refreshInterval); // cant be certain when the timer will start, so ensure it's within expected range