diff --git a/client/src/Features/UI/uiSlice.js b/client/src/Features/UI/uiSlice.js
index 45ba267b0..2f31c350b 100644
--- a/client/src/Features/UI/uiSlice.js
+++ b/client/src/Features/UI/uiSlice.js
@@ -54,6 +54,9 @@ const uiSlice = createSlice({
state.showURL = action.payload;
},
setGreeting(state, action) {
+ if (!state.greeting) {
+ state.greeting = { index: 0, lastUpdate: null };
+ }
state.greeting.index = action.payload.index;
state.greeting.lastUpdate = action.payload.lastUpdate;
},
diff --git a/client/src/Hooks/useNotifications.js b/client/src/Hooks/useNotifications.js
index 7feebd44c..43bbd43a5 100644
--- a/client/src/Hooks/useNotifications.js
+++ b/client/src/Hooks/useNotifications.js
@@ -3,7 +3,11 @@ import { createToast } from "../Utils/toastUtils";
import { networkService } from "../main";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
-import { NOTIFICATION_TYPES } from "../Pages/Notifications/utils";
+import {
+ NOTIFICATION_TYPES,
+ NTFY_AUTH_METHODS,
+ NTFY_PRIORITIES,
+} from "../Pages/Notifications/utils";
const useCreateNotification = () => {
const navigate = useNavigate();
@@ -103,6 +107,18 @@ const useGetNotificationById = (id, setNotification) => {
address: notification?.address,
notificationName: notification?.notificationName,
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?._id,
+ // ntfy-specific fields
+ ntfyAuthMethod:
+ NTFY_AUTH_METHODS.find(
+ (method) => method.value === notification?.ntfyAuthMethod
+ )?._id || NTFY_AUTH_METHODS[0]._id,
+ ntfyUsername: notification?.ntfyUsername || "",
+ ntfyPassword: notification?.ntfyPassword || "",
+ ntfyBearerToken: notification?.ntfyBearerToken || "",
+ ntfyPriority:
+ NTFY_PRIORITIES.find(
+ (priority) => priority.value === notification?.ntfyPriority
+ )?._id || NTFY_PRIORITIES[2]._id,
};
setNotification(notificationData);
diff --git a/client/src/Pages/Notifications/create/index.jsx b/client/src/Pages/Notifications/create/index.jsx
index 6d8afe665..d5aa84e02 100644
--- a/client/src/Pages/Notifications/create/index.jsx
+++ b/client/src/Pages/Notifications/create/index.jsx
@@ -29,6 +29,8 @@ import {
DESCRIPTION_MAP,
LABEL_MAP,
PLACEHOLDER_MAP,
+ NTFY_AUTH_METHODS,
+ NTFY_PRIORITIES,
} from "../utils";
// Setup
@@ -57,6 +59,12 @@ const CreateNotifications = () => {
notificationName: "",
address: "",
type: NOTIFICATION_TYPES[0]._id,
+ // ntfy-specific fields
+ ntfyAuthMethod: NTFY_AUTH_METHODS[0]._id, // "None" option _id
+ ntfyUsername: "",
+ ntfyPassword: "",
+ ntfyBearerToken: "",
+ ntfyPriority: NTFY_PRIORITIES[2]._id, // "Default" option _id (3)
});
const [errors, setErrors] = useState({});
const { t } = useTranslation();
@@ -69,6 +77,16 @@ const CreateNotifications = () => {
return NOTIFICATION_TYPES.find((type) => type._id === typeId)?.value || "email";
};
+ const getNtfyAuthMethodValue = (authMethodId) => {
+ return (
+ NTFY_AUTH_METHODS.find((method) => method._id === authMethodId)?.value || "none"
+ );
+ };
+
+ const getNtfyPriorityValue = (priorityId) => {
+ return NTFY_PRIORITIES.find((priority) => priority._id === priorityId)?.value || 3;
+ };
+
const extractError = (error, field) =>
error?.details.find((d) => d.path.includes(field))?.message;
@@ -78,6 +96,9 @@ const CreateNotifications = () => {
const form = {
...notification,
type: getNotificationTypeValue(notification.type),
+ // Convert ntfy dropdown IDs to actual values
+ ntfyAuthMethod: getNtfyAuthMethodValue(notification.ntfyAuthMethod),
+ ntfyPriority: getNtfyPriorityValue(notification.ntfyPriority),
};
let error = null;
@@ -107,6 +128,9 @@ const CreateNotifications = () => {
let newNotification = {
...rawNotification,
type: getNotificationTypeValue(rawNotification.type),
+ // Convert ntfy dropdown IDs to actual values for validation
+ ntfyAuthMethod: getNtfyAuthMethodValue(rawNotification.ntfyAuthMethod),
+ ntfyPriority: getNtfyPriorityValue(rawNotification.ntfyPriority),
};
const { error } = notificationValidation.validate(newNotification, {
@@ -117,6 +141,12 @@ const CreateNotifications = () => {
if (name === "type") {
validationError["type"] = extractError(error, "type");
validationError["address"] = extractError(error, "address");
+ } else if (name === "ntfyAuthMethod") {
+ // When auth method changes, update all ntfy field errors
+ validationError["ntfyAuthMethod"] = extractError(error, "ntfyAuthMethod");
+ validationError["ntfyUsername"] = extractError(error, "ntfyUsername");
+ validationError["ntfyPassword"] = extractError(error, "ntfyPassword");
+ validationError["ntfyBearerToken"] = extractError(error, "ntfyBearerToken");
} else {
validationError[name] = extractError(error, name);
}
@@ -129,6 +159,9 @@ const CreateNotifications = () => {
const form = {
...notification,
type: getNotificationTypeValue(notification.type),
+ // Convert ntfy dropdown IDs to actual values
+ ntfyAuthMethod: getNtfyAuthMethodValue(notification.ntfyAuthMethod),
+ ntfyPriority: getNtfyPriorityValue(notification.ntfyPriority),
};
let error = null;
@@ -232,6 +265,76 @@ const CreateNotifications = () => {
error={Boolean(errors.address)}
helperText={errors["address"]}
/>
+
+ {/* ntfy-specific fields */}
+ {type === "ntfy" && (
+ <>
+
+
+ {getNtfyAuthMethodValue(notification.ntfyAuthMethod) ===
+ "username_password" && (
+ <>
+
+
+ >
+ )}
+
+ {getNtfyAuthMethodValue(notification.ntfyAuthMethod) ===
+ "bearer_token" && (
+
+ )}
+
+
+ >
+ )}
diff --git a/client/src/Pages/Notifications/utils.js b/client/src/Pages/Notifications/utils.js
index 0813f6868..06b3d47fa 100644
--- a/client/src/Pages/Notifications/utils.js
+++ b/client/src/Pages/Notifications/utils.js
@@ -4,6 +4,7 @@ export const NOTIFICATION_TYPES = [
{ _id: 3, name: "PagerDuty", value: "pager_duty" },
{ _id: 4, name: "Webhook", value: "webhook" },
{ _id: 5, name: "Discord", value: "discord" },
+ { _id: 6, name: "ntfy", value: "ntfy" },
];
export const TITLE_MAP = {
@@ -12,6 +13,7 @@ export const TITLE_MAP = {
pager_duty: "createNotifications.pagerdutySettings.title",
webhook: "createNotifications.webhookSettings.title",
discord: "createNotifications.discordSettings.title",
+ ntfy: "createNotifications.ntfySettings.title",
};
export const DESCRIPTION_MAP = {
@@ -20,6 +22,7 @@ export const DESCRIPTION_MAP = {
pager_duty: "createNotifications.pagerdutySettings.description",
webhook: "createNotifications.webhookSettings.description",
discord: "createNotifications.discordSettings.description",
+ ntfy: "createNotifications.ntfySettings.description",
};
export const LABEL_MAP = {
@@ -28,6 +31,7 @@ export const LABEL_MAP = {
pager_duty: "createNotifications.pagerdutySettings.integrationKeyLabel",
webhook: "createNotifications.webhookSettings.webhookLabel",
discord: "createNotifications.discordSettings.webhookLabel",
+ ntfy: "createNotifications.ntfySettings.urlLabel",
};
export const PLACEHOLDER_MAP = {
@@ -36,4 +40,20 @@ export const PLACEHOLDER_MAP = {
pager_duty: "createNotifications.pagerdutySettings.integrationKeyPlaceholder",
webhook: "createNotifications.webhookSettings.webhookPlaceholder",
discord: "createNotifications.discordSettings.webhookPlaceholder",
+ ntfy: "createNotifications.ntfySettings.urlPlaceholder",
};
+
+// ntfy-specific constants
+export const NTFY_AUTH_METHODS = [
+ { _id: 1, name: "None", value: "none" },
+ { _id: 2, name: "Username/Password", value: "username_password" },
+ { _id: 3, name: "Bearer Token", value: "bearer_token" },
+];
+
+export const NTFY_PRIORITIES = [
+ { _id: 1, name: "Min", value: 1 },
+ { _id: 2, name: "Low", value: 2 },
+ { _id: 3, name: "Default", value: 3 },
+ { _id: 4, name: "High", value: 4 },
+ { _id: 5, name: "Urgent", value: 5 },
+];
diff --git a/client/src/Utils/greeting.jsx b/client/src/Utils/greeting.jsx
index d2b7fa033..87ed76711 100644
--- a/client/src/Utils/greeting.jsx
+++ b/client/src/Utils/greeting.jsx
@@ -135,9 +135,9 @@ const Greeting = ({ type = "" }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
- const { firstName } = useSelector((state) => state.auth.user);
- const index = useSelector((state) => state.ui.greeting.index);
- const lastUpdate = useSelector((state) => state.ui.greeting.lastUpdate);
+ const { firstName } = useSelector((state) => state.auth.user || {});
+ const index = useSelector((state) => state.ui.greeting?.index ?? 0);
+ const lastUpdate = useSelector((state) => state.ui.greeting?.lastUpdate ?? null);
const now = new Date();
const hour = now.getHours();
@@ -153,7 +153,8 @@ const Greeting = ({ type = "" }) => {
let greetingArray =
hour < 6 ? early : hour < 12 ? morning : hour < 18 ? afternoon : evening;
- const { prepend, append, emoji } = greetingArray[index];
+ const safeIndex = index >= 0 && index < greetingArray.length ? index : 0;
+ const { prepend, append, emoji } = greetingArray[safeIndex];
return (
diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js
index 26e390ff4..6f9dceade 100644
--- a/client/src/Validation/validation.js
+++ b/client/src/Validation/validation.js
@@ -446,12 +446,13 @@ const notificationValidation = joi.object({
type: joi
.string()
- .valid("email", "webhook", "slack", "discord", "pager_duty")
+ .valid("email", "webhook", "slack", "discord", "pager_duty", "ntfy")
.required()
.messages({
"string.empty": "Notification type is required",
"any.required": "Notification type is required",
- "any.only": "Notification type must be email, webhook, or pager_duty",
+ "any.only":
+ "Notification type must be email, webhook, slack, discord, pager_duty, or ntfy",
}),
address: joi.when("type", {
@@ -483,8 +484,74 @@ const notificationValidation = joi.object({
"string.uri": "Please enter a valid Webhook URL",
}),
},
+ {
+ is: "ntfy",
+ then: joi.string().uri().required().messages({
+ "string.empty": "ntfy URL cannot be empty",
+ "any.required": "ntfy URL is required",
+ "string.uri": "Please enter a valid ntfy URL",
+ }),
+ },
],
}),
+
+ // ntfy-specific fields
+ ntfyAuthMethod: joi.when("type", {
+ is: "ntfy",
+ then: joi.string().valid("none", "username_password", "bearer_token").default("none"),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyUsername: joi.when("type", {
+ is: "ntfy",
+ then: joi.when("ntfyAuthMethod", {
+ is: "username_password",
+ then: joi.string().required().messages({
+ "string.empty":
+ "Username cannot be empty when using username/password authentication",
+ "any.required": "Username is required for username/password authentication",
+ }),
+ otherwise: joi.string().optional().allow(""),
+ }),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyPassword: joi.when("type", {
+ is: "ntfy",
+ then: joi.when("ntfyAuthMethod", {
+ is: "username_password",
+ then: joi.string().required().messages({
+ "string.empty":
+ "Password cannot be empty when using username/password authentication",
+ "any.required": "Password is required for username/password authentication",
+ }),
+ otherwise: joi.string().optional().allow(""),
+ }),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyBearerToken: joi.when("type", {
+ is: "ntfy",
+ then: joi.when("ntfyAuthMethod", {
+ is: "bearer_token",
+ then: joi.string().required().messages({
+ "string.empty":
+ "Bearer token cannot be empty when using bearer token authentication",
+ "any.required": "Bearer token is required for bearer token authentication",
+ }),
+ otherwise: joi.string().optional().allow(""),
+ }),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyPriority: joi.when("type", {
+ is: "ntfy",
+ then: joi.number().min(1).max(5).default(3).messages({
+ "number.min": "Priority must be between 1 and 5",
+ "number.max": "Priority must be between 1 and 5",
+ }),
+ otherwise: joi.forbidden(),
+ }),
});
const editUserValidation = joi.object({
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 991f57d72..b8045150f 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -690,6 +690,20 @@
"webhookLabel": "Webhook URL",
"webhookPlaceholder": "https://your-server.com/webhook"
},
+ "ntfySettings": {
+ "title": "ntfy",
+ "description": "Send push notifications via ntfy (self-hosted or ntfy.sh)",
+ "urlLabel": "ntfy Topic URL",
+ "urlPlaceholder": "https://ntfy.sh/my-topic",
+ "authMethodLabel": "Authentication Method",
+ "usernameLabel": "Username",
+ "usernamePlaceholder": "Enter username",
+ "passwordLabel": "Password",
+ "passwordPlaceholder": "Enter password",
+ "bearerTokenLabel": "Bearer Token",
+ "bearerTokenPlaceholder": "Enter bearer token",
+ "priorityLabel": "Priority"
+ },
"testNotification": "Test notification",
"dialogDeleteTitle": "Are you sure you want to delete this notification?",
"dialogDeleteConfirm": "Delete"
diff --git a/server/src/db/models/Notification.js b/server/src/db/models/Notification.js
index a6a3c7c09..88de5029a 100755
--- a/server/src/db/models/Notification.js
+++ b/server/src/db/models/Notification.js
@@ -16,7 +16,7 @@ const NotificationSchema = mongoose.Schema(
},
type: {
type: String,
- enum: ["email", "slack", "discord", "webhook", "pager_duty"],
+ enum: ["email", "slack", "discord", "webhook", "pager_duty", "ntfy"],
},
notificationName: {
type: String,
@@ -28,6 +28,27 @@ const NotificationSchema = mongoose.Schema(
phone: {
type: String,
},
+ // ntfy-specific fields
+ ntfyAuthMethod: {
+ type: String,
+ enum: ["none", "username_password", "bearer_token"],
+ default: "none",
+ },
+ ntfyUsername: {
+ type: String,
+ },
+ ntfyPassword: {
+ type: String, // Will be encrypted
+ },
+ ntfyBearerToken: {
+ type: String, // Will be encrypted
+ },
+ ntfyPriority: {
+ type: Number,
+ min: 1,
+ max: 5,
+ default: 3,
+ },
},
{
timestamps: true,
diff --git a/server/src/service/infrastructure/networkService.js b/server/src/service/infrastructure/networkService.js
index 72a117e74..9b08a8554 100644
--- a/server/src/service/infrastructure/networkService.js
+++ b/server/src/service/infrastructure/networkService.js
@@ -443,6 +443,52 @@ class NetworkService {
throw error;
}
}
+
+ async requestNtfy(url, message, title, notification) {
+ try {
+ // Build headers
+ const headers = {
+ Title: title,
+ Priority: notification.ntfyPriority?.toString() || "3",
+ Tags: "checkmate,monitoring",
+ "Content-Type": "text/plain",
+ };
+
+ // Add authentication headers based on method
+ if (notification.ntfyAuthMethod === "username_password" && notification.ntfyUsername && notification.ntfyPassword) {
+ const auth = Buffer.from(`${notification.ntfyUsername}:${notification.ntfyPassword}`).toString("base64");
+ headers["Authorization"] = `Basic ${auth}`;
+ } else if (notification.ntfyAuthMethod === "bearer_token" && notification.ntfyBearerToken) {
+ headers["Authorization"] = `Bearer ${notification.ntfyBearerToken}`;
+ }
+
+ // Send the notification
+ const response = await this.axios.post(url, message, { headers });
+
+ return {
+ type: "ntfy",
+ status: true,
+ code: response.status,
+ message: "Successfully sent ntfy notification",
+ payload: response.data,
+ };
+ } catch (error) {
+ this.logger.warn({
+ message: error.message,
+ service: this.SERVICE_NAME,
+ method: "requestNtfy",
+ url: url,
+ });
+
+ return {
+ type: "ntfy",
+ status: false,
+ code: error.response?.status || this.NETWORK_ERROR,
+ message: `Failed to send ntfy notification: ${error.message}`,
+ payload: error.response?.data,
+ };
+ }
+ }
}
export default NetworkService;
diff --git a/server/src/service/infrastructure/notificationService.js b/server/src/service/infrastructure/notificationService.js
index 8c34519f3..b7ab220ab 100644
--- a/server/src/service/infrastructure/notificationService.js
+++ b/server/src/service/infrastructure/notificationService.js
@@ -44,14 +44,23 @@ class NotificationService {
return response;
}
+ if (type === "ntfy") {
+ const response = await this.networkService.requestNtfy(address, content, subject, notification);
+ return response.status;
+ }
};
async handleNotifications(networkResponse) {
const { monitor, statusChanged, prevStatus } = networkResponse;
const { type } = monitor;
- if (type !== "hardware" && statusChanged === false) return false;
+
+ if (type !== "hardware" && statusChanged === false) {
+ return false;
+ }
// if prevStatus is undefined, monitor is resuming, we're done
- if (type !== "hardware" && prevStatus === undefined) return false;
+ if (type !== "hardware" && prevStatus === undefined) {
+ return false;
+ }
const notificationIDs = networkResponse.monitor?.notifications ?? [];
if (notificationIDs.length === 0) return false;
diff --git a/server/src/validation/joi.js b/server/src/validation/joi.js
index fa8dccde2..1633151a2 100755
--- a/server/src/validation/joi.js
+++ b/server/src/validation/joi.js
@@ -573,10 +573,10 @@ const createNotificationBodyValidation = joi.object({
"any.required": "Notification name is required",
}),
- type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty").required().messages({
+ type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty", "ntfy").required().messages({
"string.empty": "Notification type is required",
"any.required": "Notification type is required",
- "any.only": "Notification type must be email, webhook, or pager_duty",
+ "any.only": "Notification type must be email, webhook, slack, discord, pager_duty, or ntfy",
}),
address: joi.when("type", {
@@ -604,8 +604,71 @@ const createNotificationBodyValidation = joi.object({
"string.uri": "Please enter a valid Webhook URL",
}),
},
+ {
+ is: "ntfy",
+ then: joi.string().uri().required().messages({
+ "string.empty": "ntfy URL cannot be empty",
+ "any.required": "ntfy URL is required",
+ "string.uri": "Please enter a valid ntfy URL",
+ }),
+ },
],
}),
+
+ // ntfy-specific fields
+ ntfyAuthMethod: joi.when("type", {
+ is: "ntfy",
+ then: joi.string().valid("none", "username_password", "bearer_token").default("none"),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyUsername: joi.when("type", {
+ is: "ntfy",
+ then: joi.when("ntfyAuthMethod", {
+ is: "username_password",
+ then: joi.string().required().messages({
+ "string.empty": "Username cannot be empty when using username/password authentication",
+ "any.required": "Username is required for username/password authentication",
+ }),
+ otherwise: joi.string().optional().allow(""),
+ }),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyPassword: joi.when("type", {
+ is: "ntfy",
+ then: joi.when("ntfyAuthMethod", {
+ is: "username_password",
+ then: joi.string().required().messages({
+ "string.empty": "Password cannot be empty when using username/password authentication",
+ "any.required": "Password is required for username/password authentication",
+ }),
+ otherwise: joi.string().optional().allow(""),
+ }),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyBearerToken: joi.when("type", {
+ is: "ntfy",
+ then: joi.when("ntfyAuthMethod", {
+ is: "bearer_token",
+ then: joi.string().required().messages({
+ "string.empty": "Bearer token cannot be empty when using bearer token authentication",
+ "any.required": "Bearer token is required for bearer token authentication",
+ }),
+ otherwise: joi.string().optional().allow(""),
+ }),
+ otherwise: joi.forbidden(),
+ }),
+
+ ntfyPriority: joi.when("type", {
+ is: "ntfy",
+ then: joi.number().min(1).max(5).default(3).messages({
+ "number.min": "Priority must be between 1 and 5",
+ "number.max": "Priority must be between 1 and 5",
+ }),
+ otherwise: joi.forbidden(),
+ }),
});
//****************************************