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 new file mode 100644 index 00000000..5228c924 --- /dev/null +++ b/app/constant/notifications/layout/apprise.ts @@ -0,0 +1,35 @@ +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: 'Apprise Webhook URL', + placeholder: 'https://apprise.lunalytics.xyz/notify', + }, + { + type: 'input', + isDataField: true, + 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 new file mode 100644 index 00000000..55faf27e --- /dev/null +++ b/scripts/notification.js @@ -0,0 +1,99 @@ +// 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', + 'notifications' + ), + extension: '.js', + }, +]; + +appriseDirs.forEach(({ directory, extension }) => { + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + const filePath = path.join(directory, fileName.toLowerCase() + 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}`); + } + } +}); + +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,