From 448b5dbf0d2695dd9121d5101c9f3aec23b02a91 Mon Sep 17 00:00:00 2001 From: ksjaay Date: Fri, 14 Nov 2025 14:12:11 +0000 Subject: [PATCH 1/3] Adds email notifications and overhaul of notification system --- app/constant/notifications/layout/apprise.ts | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 app/constant/notifications/layout/apprise.ts diff --git a/app/constant/notifications/layout/apprise.ts b/app/constant/notifications/layout/apprise.ts new file mode 100644 index 00000000..f0e1325e --- /dev/null +++ b/app/constant/notifications/layout/apprise.ts @@ -0,0 +1,41 @@ +import type { NotificationInputLayoutType } from '../../../types/constant/notifications'; + +export const AppriseLayout: NotificationInputLayoutType[] = [ + { + type: 'group', + items: [ + { + type: 'input', + isDataField: false, + key: 'friendlyName', + title: 'notification.input.friendly_name', + placeholder: 'Lunalytics', + }, + { + type: 'password', + isDataField: false, + key: 'token', + title: 'notification.input.webhook_url', + placeholder: 'https://discord.com/api/webhooks', + subtitle: { + text: 'notification.discord.token_description', + link: 'http://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks', + }, + }, + { + type: 'input', + isDataField: true, + key: 'username', + title: 'notification.input.webhook_username', + placeholder: 'Lunalytics', + }, + { + type: 'input', + isDataField: true, + key: 'textMessage', + title: 'notification.input.text_message', + placeholder: 'Alert @everyone', + }, + ], + }, +]; From 68ebb251aed9844bef5cbebba79f81148334ed20 Mon Sep 17 00:00:00 2001 From: ksjaay Date: Fri, 14 Nov 2025 14:41:31 +0000 Subject: [PATCH 2/3] Adds create:notification script --- scripts/notification.js | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 scripts/notification.js diff --git a/scripts/notification.js b/scripts/notification.js new file mode 100644 index 00000000..fc85e0a8 --- /dev/null +++ b/scripts/notification.js @@ -0,0 +1,55 @@ +// import node_modules +import fs from 'fs'; +import path from 'path'; + +// import local files +import logger from '../server/utils/logger.js'; + +const [, , fileName] = process.argv; + +if (!fileName) { + logger.error('Please provide a file name as an argument.'); + process.exit(1); +} + +const appriseDirs = [ + { + directory: path.resolve(process.cwd(), 'shared', 'notifications'), + extension: '.js', + }, + { + directory: path.resolve(process.cwd(), 'server', 'notifications'), + extension: '.js', + }, + { + directory: path.resolve( + process.cwd(), + 'app', + 'constant', + 'notifications', + 'layout' + ), + extension: '.ts', + }, + { + directory: path.resolve(process.cwd(), 'shared', 'validators'), + extension: '.js', + }, +]; + +appriseDirs.forEach(({ directory, extension }) => { + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + const filePath = path.join(directory, fileName + extension); + try { + fs.writeFileSync(filePath, '', { flag: 'wx' }); + logger.info(`File created: ${filePath}`); + } catch (err) { + if (err.code === 'EEXIST') { + logger.warn(`File already exists: ${filePath}`); + } else { + logger.error(`Error creating file in ${filePath}: ${err.message}`); + } + } +}); From 65e3da58c39d858287218ce428b6dfc1d4b1b8e9 Mon Sep 17 00:00:00 2001 From: ksjaay Date: Fri, 14 Nov 2025 17:07:13 +0000 Subject: [PATCH 3/3] Adds Apprise notifications --- README.md | 2 + app/constant/notifications.json | 7 ++- app/constant/notifications/layout/apprise.ts | 26 ++++----- app/constant/notifications/layout/index.ts | 1 + package-lock.json | 18 +++---- package.json | 2 +- public/notifications/apprise.svg | 1 + scripts/notification.js | 48 ++++++++++++++++- server/notifications/apprise.js | 56 ++++++++++++++++++++ server/notifications/index.js | 2 + shared/notifications/apprise.js | 20 +++++++ shared/notifications/index.js | 2 + shared/validators/notifications/apprise.js | 40 ++++++++++++++ shared/validators/notifications/index.js | 2 + 14 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 public/notifications/apprise.svg create mode 100644 server/notifications/apprise.js create mode 100644 shared/notifications/apprise.js create mode 100644 shared/validators/notifications/apprise.js diff --git a/README.md b/README.md index 2d24154a..63f5e55f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ - Slack - Twitch - Support for notifications + - Apprise - Discord + - Email (SMTP) - Home Assistant - Pushover - Slack diff --git a/app/constant/notifications.json b/app/constant/notifications.json index bd0b84b6..ab0110bb 100644 --- a/app/constant/notifications.json +++ b/app/constant/notifications.json @@ -1,4 +1,9 @@ { + "Apprise": { + "id": "Apprise", + "name": "Apprise", + "icon": "apprise.svg" + }, "Discord": { "id": "Discord", "name": "Discord", @@ -34,4 +39,4 @@ "name": "Webhook", "icon": "webhook.svg" } -} +} \ No newline at end of file diff --git a/app/constant/notifications/layout/apprise.ts b/app/constant/notifications/layout/apprise.ts index f0e1325e..5228c924 100644 --- a/app/constant/notifications/layout/apprise.ts +++ b/app/constant/notifications/layout/apprise.ts @@ -15,26 +15,20 @@ export const AppriseLayout: NotificationInputLayoutType[] = [ type: 'password', isDataField: false, key: 'token', - title: 'notification.input.webhook_url', - placeholder: 'https://discord.com/api/webhooks', - subtitle: { - text: 'notification.discord.token_description', - link: 'http://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks', - }, + title: 'Apprise Webhook URL', + placeholder: 'https://apprise.lunalytics.xyz/notify', }, { type: 'input', isDataField: true, - key: 'username', - title: 'notification.input.webhook_username', - placeholder: 'Lunalytics', - }, - { - type: 'input', - isDataField: true, - key: 'textMessage', - title: 'notification.input.text_message', - placeholder: 'Alert @everyone', + key: 'urls', + title: 'Notification URL(s)', + placeholder: + 'discord://webhook_id/webhook_token, pushover://user_key/app_token', + subtitle: { + text: 'Seperate multiple URLs with commas to send to multiple services. For more information, see the Apprise documentation: ', + link: 'https://github.com/caronc/apprise-api', + }, }, ], }, diff --git a/app/constant/notifications/layout/index.ts b/app/constant/notifications/layout/index.ts index 582dd2f2..53f64892 100644 --- a/app/constant/notifications/layout/index.ts +++ b/app/constant/notifications/layout/index.ts @@ -1,3 +1,4 @@ +export { AppriseLayout as Apprise } from './apprise'; export { DiscordLayout as Discord } from './discord'; export { EmailLayout as Email } from './email'; export { HomeAssistantLayout as HomeAssistant } from './homeAssistant'; diff --git a/package-lock.json b/package-lock.json index e7f53f87..0717c62d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lunalytics", - "version": "0.10.9", + "version": "0.10.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lunalytics", - "version": "0.10.9", + "version": "0.10.10", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@dnd-kit/core": "6.3.1", @@ -37,7 +37,7 @@ "mobx": "6.13.7", "mobx-react-lite": "4.1.0", "nanoid": "5.1.5", - "nodemailer": "^7.0.10", + "nodemailer": "7.0.10", "pg": "8.16.0", "ping": "0.4.4", "react": "19.1.1", @@ -47,7 +47,7 @@ "react-icons": "5.5.0", "react-router-dom": "7.6.2", "react-toastify": "11.0.5", - "react-window": "^1.8.11", + "react-window": "1.8.11", "recharts": "3.2.1", "swapy": "1.0.5", "ua-parser-js": "2.0.3", @@ -56,10 +56,10 @@ "winston-daily-rotate-file": "5.0.0" }, "devDependencies": { - "@babel/core": "^7.28.5", - "@babel/preset-env": "^7.28.5", - "@babel/preset-react": "^7.28.5", - "@babel/preset-typescript": "^7.28.5", + "@babel/core": "7.28.5", + "@babel/preset-env": "7.28.5", + "@babel/preset-react": "7.28.5", + "@babel/preset-typescript": "7.28.5", "@eslint/compat": "1.3.1", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.33.0", @@ -83,7 +83,7 @@ "oxlint": "1.5.0", "rollup-plugin-visualizer": "6.0.3", "sass": "1.89.2", - "typescript": "^5.9.2", + "typescript": "5.9.2", "typescript-eslint": "8.39.1", "vite": "7.1.12", "vite-plugin-compression2": "2.2.0", diff --git a/package.json b/package.json index 3b10d872..97663a62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lunalytics", - "version": "0.10.9", + "version": "0.10.10", "description": "Open source Node.js server/website monitoring tool", "private": true, "author": "KSJaay ", diff --git a/public/notifications/apprise.svg b/public/notifications/apprise.svg new file mode 100644 index 00000000..1fb0dbfa --- /dev/null +++ b/public/notifications/apprise.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/notification.js b/scripts/notification.js index fc85e0a8..55faf27e 100644 --- a/scripts/notification.js +++ b/scripts/notification.js @@ -32,7 +32,12 @@ const appriseDirs = [ extension: '.ts', }, { - directory: path.resolve(process.cwd(), 'shared', 'validators'), + directory: path.resolve( + process.cwd(), + 'shared', + 'validators', + 'notifications' + ), extension: '.js', }, ]; @@ -41,7 +46,7 @@ appriseDirs.forEach(({ directory, extension }) => { if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } - const filePath = path.join(directory, fileName + extension); + const filePath = path.join(directory, fileName.toLowerCase() + extension); try { fs.writeFileSync(filePath, '', { flag: 'wx' }); logger.info(`File created: ${filePath}`); @@ -53,3 +58,42 @@ appriseDirs.forEach(({ directory, extension }) => { } } }); + +const notificationJson = path.resolve( + process.cwd(), + 'app', + 'constant', + 'notifications.json' +); + +const data = fs.readFileSync(notificationJson, 'utf-8'); +const notifications = JSON.parse(data); + +const notificationName = + fileName.charAt(0).toUpperCase() + fileName.slice(1).toLowerCase(); + +if (!notifications[notificationName]) { + notifications[notificationName] = { + id: notificationName, + name: notificationName, + icon: `${notificationName.toLowerCase()}.svg`, + }; + + fs.writeFileSync( + notificationJson, + JSON.stringify( + Object.keys(notifications) + .sort() + .reduce((obj, key) => { + obj[key] = notifications[key]; + return obj; + }, {}), + null, + 2 + ), + 'utf-8' + ); + logger.info( + `Notification entry added for ${notificationName} in notifications.json. Please add ${notificationName.toLowerCase()}.svg` + ); +} diff --git a/server/notifications/apprise.js b/server/notifications/apprise.js new file mode 100644 index 00000000..762b6ea1 --- /dev/null +++ b/server/notifications/apprise.js @@ -0,0 +1,56 @@ +import axios from 'axios'; +import NotificationBase from './base.js'; +import NotificationReplacers from '../../shared/notifications/replacers/notification.js'; +import { AppriseTemplateMessages } from '../../shared/notifications/apprise.js'; + +class Apprise extends NotificationBase { + name = 'Apprise'; + + async send(notification, monitor, heartbeat) { + try { + const template = + AppriseTemplateMessages[notification.messageType] || + notification.payload; + + const content = NotificationReplacers(template, monitor, heartbeat); + + await axios.post(notification.token, { + ...content, + urls: notification.data.urls.split(',').map((url) => url.trim()), + }); + return this.success; + } catch (error) { + this.handleError(error); + } + } + + async test(notification) { + try { + await axios.post(notification.token, { + title: 'This is a test message', + urls: notification.data.urls.split(',').map((url) => url.trim()), + }); + return this.success; + } catch (error) { + this.handleError(error); + } + } + + async sendRecovery(notification, monitor, heartbeat) { + try { + const template = AppriseTemplateMessages.recovery; + + const content = NotificationReplacers(template, monitor, heartbeat); + + await axios.post(notification.token, { + ...content, + urls: notification.data.urls.split(',').map((url) => url.trim()), + }); + return this.success; + } catch (error) { + this.handleError(error); + } + } +} + +export default Apprise; diff --git a/server/notifications/index.js b/server/notifications/index.js index 9ca7c650..ead9337b 100644 --- a/server/notifications/index.js +++ b/server/notifications/index.js @@ -1,3 +1,4 @@ +import Apprise from './apprise.js'; import Discord from './discord.js'; import Email from './email.js'; import HomeAssistant from './homeAssistant.js'; @@ -7,6 +8,7 @@ import Slack from './slack.js'; import Webhook from './webhook.js'; const NotificationServices = { + Apprise, Discord, Email, HomeAssistant, diff --git a/shared/notifications/apprise.js b/shared/notifications/apprise.js new file mode 100644 index 00000000..7f19bec8 --- /dev/null +++ b/shared/notifications/apprise.js @@ -0,0 +1,20 @@ +const AppriseSchema = {}; + +const AppriseTemplateMessages = { + basic: { + title: 'Triggered: Service {{service_name}} is currently down!', + }, + pretty: { + title: 'Triggered: Service {{service_name}} is currently down!', + body: '**Service Name**\n{{service_name}}\n\n**Service Address**\n{{service_address}}\n\n**Latency**\n{{heartbeat_latency}} ms\n\n**Error**\n{{heartbeat_message}}', + }, + nerdy: { + title: 'Triggered: Service {{service_name}} is currently down!', + body: '**Service**\n```{{service_parsed_json}}```\n\n**Heartbeat**\n```{{heartbeat_parsed_json}}```', + }, + recovery: { + title: 'Service {{service_name}} is back up!', + }, +}; + +export { AppriseSchema, AppriseTemplateMessages }; diff --git a/shared/notifications/index.js b/shared/notifications/index.js index 35125225..2f42b4cc 100644 --- a/shared/notifications/index.js +++ b/shared/notifications/index.js @@ -1,3 +1,4 @@ +import { AppriseTemplateMessages } from './apprise'; import { DiscordTemplateMessages } from './discord'; import { EmailTemplateMessages } from './email'; import { HomeAssistantTemplateMessages } from './homeAssistant'; @@ -7,6 +8,7 @@ import { TelegramTemplateMessages } from './telegram'; import { WebhookTemplateMessages } from './webhook'; const NotificationsTemplates = { + Apprise: AppriseTemplateMessages, Discord: DiscordTemplateMessages, Email: EmailTemplateMessages, HomeAssistant: HomeAssistantTemplateMessages, diff --git a/shared/validators/notifications/apprise.js b/shared/validators/notifications/apprise.js new file mode 100644 index 00000000..8d121fdf --- /dev/null +++ b/shared/validators/notifications/apprise.js @@ -0,0 +1,40 @@ +import { NotificationValidatorError } from '../../utils/errors.js'; + +const friendlyNameRegex = /^[a-zA-Z0-9_-]+$/; +const messageTypes = ['basic', 'pretty', 'nerdy']; + +const Apprise = ({ messageType, friendlyName, token, urls }) => { + if (friendlyNameRegex && !friendlyNameRegex.test(friendlyName)) { + throw new NotificationValidatorError( + 'friendlyName', + 'Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only.' + ); + } + + if (!messageTypes.includes(messageType)) { + throw new NotificationValidatorError('messageType', 'Invalid Message Type'); + } + + if (!token) { + throw new NotificationValidatorError( + 'token', + 'Invalid Apprise Webhook URL' + ); + } + + if (!urls) { + throw new NotificationValidatorError('urls', 'Invalid Apprise URLs'); + } + + return { + platform: 'Apprise', + messageType, + token, + friendlyName, + data: { + urls, + }, + }; +}; + +export default Apprise; diff --git a/shared/validators/notifications/index.js b/shared/validators/notifications/index.js index fd1ee47e..29b0df84 100644 --- a/shared/validators/notifications/index.js +++ b/shared/validators/notifications/index.js @@ -1,3 +1,4 @@ +import Apprise from './apprise.js'; import Discord from './discord.js'; import Email from './email.js'; import HomeAssistant from './homeAssistant.js'; @@ -7,6 +8,7 @@ import Telegram from './telegram.js'; import Webhook from './webhook.js'; const NotificationValidators = { + Apprise, Discord, Email, HomeAssistant,