diff --git a/__tests__/unit/client/components/settings-wrapper.test.tsx b/__tests__/unit/client/components/settings-wrapper.test.tsx index 205e8fc8..634a3981 100644 --- a/__tests__/unit/client/components/settings-wrapper.test.tsx +++ b/__tests__/unit/client/components/settings-wrapper.test.tsx @@ -19,6 +19,8 @@ const mockDeleteSettingsAction = jest.fn() const mockUpdateServersAction = jest.fn() const mockTestConnectionAction = jest.fn().mockResolvedValue('success') const mockTestInfluxConnectionAction = jest.fn() +const mockUpdateNotificationProvidersAction = jest.fn() +const mockTestNotificationProvidersAction = jest.fn() const renderComponent = () => render( @@ -33,6 +35,8 @@ const renderComponent = () => updateServersAction={mockUpdateServersAction} testConnectionAction={mockTestConnectionAction} testInfluxConnectionAction={mockTestInfluxConnectionAction} + updateNotificationProvidersAction={mockUpdateNotificationProvidersAction} + testNotificationProviderAction={mockTestNotificationProvidersAction} /> ) diff --git a/__tests__/unit/server/scheduler.test.ts b/__tests__/unit/server/scheduler.test.ts index 8f8f8a49..b63a802b 100644 --- a/__tests__/unit/server/scheduler.test.ts +++ b/__tests__/unit/server/scheduler.test.ts @@ -191,8 +191,8 @@ describe('Scheduler', () => { onChangeFn() // Check if job was removed and added again - expect(mockToadScheduler.existsById).toHaveBeenCalledWith('id_1') - expect(mockToadScheduler.addSimpleIntervalJob).toHaveBeenCalledTimes(2) // Once on init, once on change + expect(mockToadScheduler.existsById).toHaveBeenCalledWith('influxdb_job') + expect(mockToadScheduler.addSimpleIntervalJob).toHaveBeenCalledTimes(4) // Once on init, once on change } }) @@ -217,7 +217,7 @@ describe('Scheduler', () => { onChangeFn() // Job should not be updated because settings are incomplete - expect(mockToadScheduler.addSimpleIntervalJob).not.toHaveBeenCalled() + expect(mockToadScheduler.addSimpleIntervalJob).toHaveBeenCalledTimes(1) } }) diff --git a/src/app/actions.ts b/src/app/actions.ts index 820d679b..83777e9f 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,9 +1,20 @@ 'use server' import InfluxWriter from '@/server/influxdb' +import { + DEVICE, + NotificationTrigger, + NotificationProviders, + NotifierSettings, + server, + DeviceData, + VarDescription, + DevicesData, +} from '@/common/types' +import { Notifier } from '@/server/notifications/notifier' +import { NotifierFactory } from '@/server/notifications/notifier-factory' import { Nut } from '@/server/nut' import { YamlSettings, SettingsType } from '@/server/settings' -import { DEVICE, server, DeviceData, DevicesData, VarDescription } from '@/common/types' import chokidar from 'chokidar' import { AuthError } from 'next-auth' import { signIn, signOut } from '@/auth' @@ -286,6 +297,20 @@ export async function updateServers(servers: Array) { settings.set('NUT_SERVERS', servers) } +export async function testNotificationProvider( + name: (typeof NotificationProviders)[number], + triggers: NotificationTrigger[], + config: { [x: string]: string } | undefined +) { + const notificationProvider: Notifier = NotifierFactory({ name, triggers, config }) + return await notificationProvider.sendTestNotification() +} + +export async function updateNotificationProviders(notificationProviders: Array) { + const settings = new YamlSettings(settingsFile) + settings.set('NOTIFICATION_PROVIDERS', notificationProviders) +} + export async function deleteSettings(key: keyof SettingsType) { const settings = getCachedSettings() settings.delete(key) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index fab92006..99f94ab4 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -10,6 +10,8 @@ import { deleteSettings, testConnection, testInfluxConnection, + updateNotificationProviders, + testNotificationProvider, } from '@/app/actions' export default function Settings() { @@ -24,6 +26,8 @@ export default function Settings() { updateServersAction={updateServers} testConnectionAction={testConnection} testInfluxConnectionAction={testInfluxConnection} + updateNotificationProvidersAction={updateNotificationProviders} + testNotificationProviderAction={testNotificationProvider} /> ) } diff --git a/src/client/components/add-notification-provider.tsx b/src/client/components/add-notification-provider.tsx new file mode 100644 index 00000000..2cd738be --- /dev/null +++ b/src/client/components/add-notification-provider.tsx @@ -0,0 +1,280 @@ +import React, { useContext, useState, useTransition } from 'react' +import { useTranslation } from 'react-i18next' +import { Toaster, toast } from 'sonner' +import { HiOutlineXMark, HiOutlinePlus } from 'react-icons/hi2' +import { useTheme } from 'next-themes' +import { LanguageContext } from '@/client/context/language' +import { Button } from '@/client/components/ui/button' +import { Input } from '@/client/components/ui/input' +import { Label } from '@/client/components/ui/label' +import { Card } from '@/client/components/ui/card' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select' +import { NotificationProviders, NotificationTrigger, NotificationTriggerOperations } from '@/common/types' + +type AddNotificationProviderProps = { + initialName: (typeof NotificationProviders)[number] + initialTriggers: Array + initialConfig?: { [x: string]: string } + handleChange: ( + name: (typeof NotificationProviders)[number], + triggers: Array, + config?: { [x: string]: string } + ) => void + handleRemove: () => void + testNotificationProviderAction: ( + name: (typeof NotificationProviders)[number], + triggers: NotificationTrigger[], + config?: { [x: string]: string } + ) => Promise +} + +export default function AddNotificationProvider({ + initialName, + initialTriggers, + initialConfig, + handleChange, + handleRemove, + testNotificationProviderAction, +}: Readonly) { + const lng = useContext(LanguageContext) + const { t } = useTranslation(lng) + const { theme } = useTheme() + const [name, setName] = useState<(typeof NotificationProviders)[number]>(initialName) + const [triggers, setTriggers] = useState>(initialTriggers) + const [config, setConfig] = useState<{ [x: string]: string } | undefined>(initialConfig) + const [connecting, startTransition] = useTransition() + + const handleTestNotification = async () => { + if (name) { + startTransition(async () => { + const promise = testNotificationProviderAction(name, triggers, config) + toast.promise(promise, { + loading: t('notification.testing'), + success: t('notification.success'), + error: t('notification.error'), + }) + try { + await promise + } catch { + // Do nothing + } + }) + } + } + + return ( + + +
+ +
+
+
+
+ +
+

{t('notification.trigger.heading')}

+ {triggers.map((trigger, index) => ( +
+
+ +
+
+ + { + trigger.variable = e.target.value + setTriggers([...triggers]) + handleChange(name, triggers, config) + }} + className='w-full px-3 py-2' + data-testid={`${name}-trigger-variable`} + /> +
+
+ +
+
+ + { + const newTriggers = [...triggers] + newTriggers[index] = { + ...newTriggers[index], + targetValue: e.target.valueAsNumber, + } + setTriggers(newTriggers) + handleChange(name, newTriggers, config) + }} + className='w-full px-3 py-2' + data-testid={`${name}-trigger-targetValue`} + /> +
+
+ ))} +
+ +
+

{t('notification.config.heading')}

+ {config && + Object.keys(config).map((k) => ( +
+
+ +
+
+ + { + const newConfig = { + ...config, + } + console.log(k) + const configValue = newConfig[k] + delete newConfig[k] + newConfig[e.target.value] = configValue + setConfig(newConfig) + handleChange(name, triggers, newConfig) + }} + className='w-full px-3 py-2' + data-testid={`${name}-config-key`} + /> +
+
+ + { + const newConfig = { ...config, [k]: e.target.value } + setConfig(newConfig) + handleChange(name, triggers, newConfig) + }} + className='w-full px-3 py-2' + data-testid={`${name}-config-value`} + /> +
+
+ ))} +
+ +
+
+
+ +
+ +
+ + ) +} diff --git a/src/client/components/settings-wrapper.tsx b/src/client/components/settings-wrapper.tsx index ab8234de..33123d5c 100644 --- a/src/client/components/settings-wrapper.tsx +++ b/src/client/components/settings-wrapper.tsx @@ -18,6 +18,7 @@ import { HiOutlineServerStack, HiOutlinePlus, HiOutlineInformationCircle, + HiOutlineBellAlert, HiOutlineCodeBracket, HiOutlineLink, HiOutlineLinkSlash, @@ -27,9 +28,10 @@ import { LuTerminal, LuCircleHelp } from 'react-icons/lu' import { AiOutlineSave, AiOutlineDownload } from 'react-icons/ai' import Footer from '@/client/components/footer' import AddServer from '@/client/components/add-server' +import AddNotificationProvider from '@/client/components/add-notification-provider' import AddInflux from './add-influx' import { SettingsType } from '@/server/settings' -import { server } from '@/common/types' +import { NotificationProviders, NotificationTrigger, NotifierSettings, server } from '@/common/types' import { DEFAULT_INFLUX_INTERVAL } from '@/common/constants' import dynamic from 'next/dynamic' import { useSettings } from '../context/settings' @@ -46,6 +48,12 @@ type SettingsWrapperProps = Readonly<{ updateServersAction: (newServers: Array) => Promise testConnectionAction: (server: string, port: number, username?: string, password?: string) => Promise testInfluxConnectionAction: (server: string, token: string, org: string, bucket: string) => Promise + updateNotificationProvidersAction: (newNotificationProviders: Array) => Promise + testNotificationProviderAction: ( + name: (typeof NotificationProviders)[number], + triggers: NotificationTrigger[], + config: { [x: string]: string } | undefined + ) => Promise }> export default function SettingsWrapper({ @@ -58,6 +66,8 @@ export default function SettingsWrapper({ updateServersAction, testConnectionAction, testInfluxConnectionAction, + updateNotificationProvidersAction, + testNotificationProviderAction, }: SettingsWrapperProps) { const [config, setConfig] = useState('') const [settingsLoaded, setSettingsLoaded] = useState(false) @@ -67,6 +77,7 @@ export default function SettingsWrapper({ const [influxOrg, setInfluxOrg] = useState('') const [influxBucket, setInfluxBucket] = useState('') const [influxInterval, setInfluxInterval] = useState(10) + const [notificationProvidersList, setNotificationProvidersList] = useState>([]) const [dateFormat, setDateFormat] = useState('MM/DD/YYYY') const [timeFormat, setTimeFormat] = useState('12-hour') const [selectedServer, setSelectedServer] = useState('') @@ -77,17 +88,27 @@ export default function SettingsWrapper({ const { refreshSettings } = useSettings() const loadSettings = async () => { - const [servers, influxHost, influxToken, influxOrg, influxBucket, influxInterval, format, timeFormat] = - await Promise.all([ - getSettingsAction('NUT_SERVERS'), - getSettingsAction('INFLUX_HOST'), - getSettingsAction('INFLUX_TOKEN'), - getSettingsAction('INFLUX_ORG'), - getSettingsAction('INFLUX_BUCKET'), - getSettingsAction('INFLUX_INTERVAL'), - getSettingsAction('DATE_FORMAT'), - getSettingsAction('TIME_FORMAT'), - ]) + const [ + servers, + influxHost, + influxToken, + influxOrg, + influxBucket, + influxInterval, + notificationProviders, + format, + timeFormat, + ] = await Promise.all([ + getSettingsAction('NUT_SERVERS'), + getSettingsAction('INFLUX_HOST'), + getSettingsAction('INFLUX_TOKEN'), + getSettingsAction('INFLUX_ORG'), + getSettingsAction('INFLUX_BUCKET'), + getSettingsAction('INFLUX_INTERVAL'), + getSettingsAction('NOTIFICATION_PROVIDERS'), + getSettingsAction('DATE_FORMAT'), + getSettingsAction('TIME_FORMAT'), + ]) if (servers?.length) { setServerList([ ...servers.map((server: server) => ({ @@ -100,6 +121,9 @@ export default function SettingsWrapper({ setSelectedServer(`${servers[0].HOST}:${servers[0].PORT}`) } } + if (notificationProviders) { + setNotificationProvidersList([...notificationProviders]) + } if (influxHost && influxToken && influxOrg && influxBucket) { setInfluxServer(influxHost) setInfluxToken(influxToken) @@ -178,6 +202,30 @@ export default function SettingsWrapper({ toast.success(t('settings.saved')) } + const handleNotificationProviderChange = ( + name: (typeof NotificationProviders)[number], + triggers: Array, + config: { [x: string]: string } | undefined, + index: number + ) => { + const updatedNotificationProvidersList = [...notificationProvidersList] + updatedNotificationProvidersList[index].name = name + updatedNotificationProvidersList[index].triggers = triggers + updatedNotificationProvidersList[index].config = config + setNotificationProvidersList(updatedNotificationProvidersList) + } + + const handleNotificationProviderRemove = async (index: number) => { + const updatedNotificationProvidersList = notificationProvidersList.filter((_, i) => i !== index) + setNotificationProvidersList(updatedNotificationProvidersList) + await updateNotificationProvidersAction(updatedNotificationProvidersList) + } + + const handleSaveNotificationProviders = async () => { + await updateNotificationProvidersAction(notificationProvidersList) + toast.success(t('settings.saved')) + } + const handleSettingsImport = async () => { await importSettingsAction(config) toast.success(t('settings.saved')) @@ -206,6 +254,7 @@ export default function SettingsWrapper({ const menuItems = [ { label: t('settings.manageServers'), Icon: HiOutlineServerStack, value: 'servers' }, { label: t('settings.influxDb'), Icon: SiInfluxdb, value: 'influx' }, + { label: t('settings.manageNotifications'), Icon: HiOutlineBellAlert, value: 'notifications' }, { label: t('settings.configExport'), Icon: HiOutlineCodeBracket, value: 'config' }, { label: t('settings.terminal'), Icon: LuTerminal, value: 'terminal' }, { label: t('settings.general'), Icon: HiOutlineWrenchScrewdriver, value: 'general' }, @@ -344,6 +393,44 @@ export default function SettingsWrapper({
+ + +
+

{t('settings.manageNotifications')}

+ {notificationProvidersList.map((notificationProvider, index) => ( + + handleNotificationProviderChange(name, triggers, config, index) + } + handleRemove={() => handleNotificationProviderRemove(index)} + testNotificationProviderAction={testNotificationProviderAction} + /> + ))} +
+ +
+
+
+
+ +
+ +
diff --git a/src/client/i18n/locales/de/translation.json b/src/client/i18n/locales/de/translation.json index 1c94669b..17ad6b23 100644 --- a/src/client/i18n/locales/de/translation.json +++ b/src/client/i18n/locales/de/translation.json @@ -53,6 +53,7 @@ "save": "Speichern", "download": "Herunterladen", "viewConfig": "Klicken Sie, um die Konfiguration anzuzeigen.", + "manageNotifications": "Benachrichtigungen", "serversNotice": "Benutzername und Passwort sind für Befehle erforderlich", "terminal": "Terminal", "terminalNotice": "Geben Sie \"Hilfe\" für eine Liste von Befehlen ein.", diff --git a/src/client/i18n/locales/en/translation.json b/src/client/i18n/locales/en/translation.json index 4751c26b..6b501d55 100644 --- a/src/client/i18n/locales/en/translation.json +++ b/src/client/i18n/locales/en/translation.json @@ -43,6 +43,7 @@ "selectTimeFormat": "Select a time format", "manageServers": "Manage Servers", "influxDb": "InfluxDB", + "manageNotifications": "Notifications", "influxNotice": "This will only work with Influxdb 2.x", "influxInterval": "Influx Interval (s)", "addServer": "Add Server", @@ -73,6 +74,26 @@ "error": "Connection failed. Check the logs for more info.", "testing": "Testing connection..." }, + "notification": { + "name": "Notification Provider", + "buttonAdd": "Add New Notification Provider", + "trigger": { + "heading": "Triggers", + "buttonAdd": "Add New Trigger", + "variable": "Variable", + "operation": "Operation", + "targetValue": "Target Value" + }, + "config": { + "heading": "Configuration", + "buttonAdd": "Add New Property", + "propertyName": "Property Name", + "propertyValue": "Property Value" + }, + "success": "Successfully sent notification", + "error": "Failed to send notification. Check the logs for more info.", + "testing": "Sending test notification..." + }, "theme": { "dark": "Dark", "light": "Light", diff --git a/src/client/i18n/locales/es/translation.json b/src/client/i18n/locales/es/translation.json index 2f3cb53e..6b7d3377 100644 --- a/src/client/i18n/locales/es/translation.json +++ b/src/client/i18n/locales/es/translation.json @@ -52,6 +52,7 @@ "save": "Ahorrar", "download": "Descargar", "viewConfig": "Haga clic para ver la configuración.", + "manageNotifications": "Notificaciones", "serversNotice": "Se requieren nombre de usuario y contraseña para los comandos", "terminal": "Terminal", "terminalNotice": "Escriba \"Ayuda\" para una lista de comandos.", diff --git a/src/client/i18n/locales/fr/translation.json b/src/client/i18n/locales/fr/translation.json index bab6dec6..6b83c538 100644 --- a/src/client/i18n/locales/fr/translation.json +++ b/src/client/i18n/locales/fr/translation.json @@ -53,6 +53,7 @@ "save": "Sauvegarder", "download": "Télécharger", "viewConfig": "Cliquez pour afficher la configuration.", + "manageNotifications": "Notifications", "serversNotice": "Le nom d'utilisateur et le mot de passe sont requis pour les commandes", "terminal": "Terminal", "terminalNotice": "Tapez \"Aide\" pour une liste de commandes.", diff --git a/src/client/i18n/locales/it/translation.json b/src/client/i18n/locales/it/translation.json index 17c75582..4f955d7e 100644 --- a/src/client/i18n/locales/it/translation.json +++ b/src/client/i18n/locales/it/translation.json @@ -53,6 +53,7 @@ "save": "Salva", "download": "Scaricamento", "viewConfig": "Fare clic per visualizzare la configurazione.", + "manageNotifications": "Notifiche", "serversNotice": "Nome utente e password sono richiesti per i comandi", "terminal": "terminale", "terminalNotice": "Digita \"aiuto\" per un elenco di comandi.", diff --git a/src/client/i18n/locales/ro/translation.json b/src/client/i18n/locales/ro/translation.json index 0f4dad01..b0c5cd4d 100644 --- a/src/client/i18n/locales/ro/translation.json +++ b/src/client/i18n/locales/ro/translation.json @@ -53,6 +53,7 @@ "save": "Salva", "download": "Descărcați", "viewConfig": "Faceți clic pentru a vizualiza configurația.", + "manageNotifications": "Notificări", "serversNotice": "Numele de utilizator și parola sunt necesare pentru comenzi", "terminal": "Terminal", "terminalNotice": "Tastați „ajutor” pentru o listă de comenzi.", diff --git a/src/common/constants.ts b/src/common/constants.ts index 511702cc..0e583511 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -77,3 +77,4 @@ export const SUPPORTED_COMMANDS = { } export const DEFAULT_INFLUX_INTERVAL = 10 +export const DEFAULT_NOTIFICATION_INTERVAL = 10 diff --git a/src/common/types.ts b/src/common/types.ts index 3a02f6a7..f732ad86 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -41,3 +41,24 @@ export type VarDescription = { data: { [x: string]: string } | undefined error: string | undefined } + +export const NotificationProviders = ['ntfy', 'stdout'] as const +export const NotificationTriggerOperations = ['changes', 'is_below', 'is_above'] as const + +export type NotificationTrigger = { + variable: string + operation: (typeof NotificationTriggerOperations)[number] + targetValue?: number +} + +export type NotifierSettings = { + name: (typeof NotificationProviders)[number] + triggers: Array + config?: { [x: string]: string } +} + +export type Notification = { + title: string + message?: string + timestamp: Date +} diff --git a/src/server/notifications/notifier-factory.ts b/src/server/notifications/notifier-factory.ts new file mode 100644 index 00000000..298e8751 --- /dev/null +++ b/src/server/notifications/notifier-factory.ts @@ -0,0 +1,14 @@ +import { NotifierSettings } from '@/common/types' +import { Ntfy, NtfyConfig } from '@/server/notifications/ntfy' +import { Stdout, StdoutConfig } from '@/server/notifications/stdout' + +export function NotifierFactory(settings: NotifierSettings) { + switch (settings.name) { + case 'stdout': + return new Stdout(settings.name, settings.triggers, settings.config as unknown as StdoutConfig) + case 'ntfy': + return new Ntfy(settings.name, settings.triggers, settings.config as unknown as NtfyConfig) + default: + throw new Error('Unknown notification provider ' + settings.name) + } +} diff --git a/src/server/notifications/notifier.ts b/src/server/notifications/notifier.ts new file mode 100644 index 00000000..61b30999 --- /dev/null +++ b/src/server/notifications/notifier.ts @@ -0,0 +1,109 @@ +import { DEVICE, Notification, NotificationProviders, NotificationTrigger } from '@/common/types' + +export abstract class Notifier { + name: (typeof NotificationProviders)[number] + triggers: Array + config?: { [x: string]: any } + + constructor(name: (typeof NotificationProviders)[number], triggers: Array) { + this.name = name + this.triggers = triggers + } + + abstract send(notification: Notification): Promise + + async sendTestNotification(): Promise { + return new Promise((resolve, reject) => { + const testNotification: Notification = { + title: '[PeaNUT]: Test Notification', + message: 'Test notification sent from PeaNUT', + timestamp: new Date(), + } + this.send(testNotification) + .then(() => { + resolve('Notification sent') + }) + .catch((error: any) => { + console.error(error?.message) + reject(new Error(error?.message)) + }) + }) + } + + private validateDevices(dev1: DEVICE, dev2: DEVICE, trigger: NotificationTrigger): void { + if (dev1.name !== dev2.name) { + throw Error(`Device mismatch (${dev1.name} != ${dev2.name}) when processing triggers for ${this.name} notifier`) + } + if (!(Object.hasOwn(dev1.vars, trigger.variable) && Object.hasOwn(dev2.vars, trigger.variable))) { + throw Error(`Variable ${trigger.variable} not found in device ${dev1.name}`) + } + } + + private handleChangesTrigger( + dev1: DEVICE, + dev2: DEVICE, + trigger: NotificationTrigger, + timestamp: Date + ): Notification | null { + const p1 = dev1.vars[trigger.variable] + const p2 = dev2.vars[trigger.variable] + if (p1.value != p2.value) { + return { + title: `[PeaNUT]: ${dev1.name} ${trigger.variable} changed from ${p1.value} to ${p2.value}`, + timestamp, + } + } + return null + } + + private handleThresholdTrigger( + dev1: DEVICE, + dev2: DEVICE, + trigger: NotificationTrigger, + timestamp: Date, + isAbove: boolean + ): Notification | null { + const targetValue = trigger.targetValue as number + const p1 = dev1.vars[trigger.variable] + const p2 = dev2.vars[trigger.variable] + const p1Value = p1.value as number + const p2Value = p2.value as number + + if (isAbove && p1Value < targetValue && p2Value > targetValue) { + return { + title: `[PeaNUT]: ${dev1.name} ${trigger.variable} is above ${targetValue} (${p2.value})`, + timestamp, + } + } + if (!isAbove && p1Value > targetValue && p2Value < targetValue) { + return { + title: `[PeaNUT]: ${dev1.name} ${trigger.variable} is below ${targetValue} (${p2.value})`, + timestamp, + } + } + return null + } + + processTriggers(dev1: DEVICE, dev2: DEVICE): Array { + const timestamp = new Date() + const notifications: Array = [] + + for (const trigger of this.triggers) { + this.validateDevices(dev1, dev2, trigger) + + let notification: Notification | null = null + if (trigger.operation === 'changes') { + notification = this.handleChangesTrigger(dev1, dev2, trigger, timestamp) + } else if (trigger.operation === 'is_above') { + notification = this.handleThresholdTrigger(dev1, dev2, trigger, timestamp, true) + } else if (trigger.operation === 'is_below') { + notification = this.handleThresholdTrigger(dev1, dev2, trigger, timestamp, false) + } + + if (notification) { + notifications.push(notification) + } + } + return notifications + } +} diff --git a/src/server/notifications/ntfy.ts b/src/server/notifications/ntfy.ts new file mode 100644 index 00000000..a2a084ed --- /dev/null +++ b/src/server/notifications/ntfy.ts @@ -0,0 +1,65 @@ +import { Notification, NotificationProviders, NotificationTrigger } from '@/common/types' +import { Notifier } from '@/server/notifications/notifier' + +export type NtfyConfig = { + server_url: string + username?: string + password?: string + accessToken?: string + topic: string + priority: number | string + tags: string +} + +export class Ntfy extends Notifier { + config: NtfyConfig + authMethod: 'token' | 'password' | 'none' + + constructor(name: (typeof NotificationProviders)[number], triggers: Array, config: NtfyConfig) { + super(name, triggers) + this.config = config + this.authMethod = 'none' + if (this.config.accessToken && this.config.accessToken.length > 0) { + this.authMethod = 'token' + } else if ( + this.config.username && + this.config.username.length > 0 && + this.config.password && + this.config.password.length > 0 + ) { + this.authMethod = 'password' + } + } + + async send(notification: Notification) { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + } + if (this.authMethod == 'token') { + headers['Authorization'] = `Bearer ${this.config.accessToken}` + } else if (this.authMethod == 'password') { + headers['Authorization'] = + `Basic ${Buffer.from(this.config.username + ':' + this.config.password).toString('base64')}` + } + try { + const req = await fetch(this.config.server_url, { + method: 'POST', + headers, + body: JSON.stringify({ + topic: this.config.topic, + message: notification.message, + priority: +this.config.priority, + title: notification.title, + tags: this.config.tags.split(','), + }), + }) + const resp = await req.json() + if (resp.http < 200 || resp.http >= 400) { + throw new Error(JSON.stringify(resp)) + } + } catch (err) { + console.log(`Error sending ${this.name} notification`) + throw err + } + } +} diff --git a/src/server/notifications/stdout.ts b/src/server/notifications/stdout.ts new file mode 100644 index 00000000..de347c59 --- /dev/null +++ b/src/server/notifications/stdout.ts @@ -0,0 +1,28 @@ +import { Notification, NotificationProviders, NotificationTrigger } from '@/common/types' +import { Notifier } from '@/server/notifications/notifier' + +export type StdoutConfig = { + [key: string]: string +} + +export class Stdout extends Notifier { + constructor( + name: (typeof NotificationProviders)[number], + triggers: Array, + config: StdoutConfig + ) { + super(name, triggers) + this.config = { ...config } + } + + async send(notification: Notification) { + console.log( + notification.title, + notification.message, + '\n', + `\tTriggers => ${JSON.stringify(this.triggers)}`, + '\n', + `\tConfiguration => ${JSON.stringify(this.config)}` + ) + } +} diff --git a/src/server/scheduler.ts b/src/server/scheduler.ts index 7af447bd..c0020d99 100644 --- a/src/server/scheduler.ts +++ b/src/server/scheduler.ts @@ -3,7 +3,10 @@ import chokidar from 'chokidar' import { YamlSettings } from '@/server/settings' import { getDevices } from '@/app/actions' import InfluxWriter from '@/server/influxdb' -import { DEFAULT_INFLUX_INTERVAL } from '@/common/constants' +import { NotifierFactory } from '@/server/notifications/notifier-factory' +import { Notifier } from '@/server/notifications/notifier' +import { DEVICE } from '@/common/types' +import { DEFAULT_INFLUX_INTERVAL, DEFAULT_NOTIFICATION_INTERVAL } from '@/common/constants' const settingsFile = './config/settings.yml' @@ -13,9 +16,10 @@ const scheduler = new ToadScheduler() // Get the current interval from settings or default to DEFAULT_INFLUX_INTERVAL seconds const influxInterval = settings.get('INFLUX_INTERVAL') || DEFAULT_INFLUX_INTERVAL +const notificationInterval = settings.get('NOTIFICATION_INTERVAL') || DEFAULT_NOTIFICATION_INTERVAL // Define the task to write data to InfluxDB -const createTask = () => +const createInfluxDbTask = () => new Task('influx writer', () => { const taskSettings = new YamlSettings(settingsFile) const influxHost = taskSettings.get('INFLUX_HOST') @@ -37,19 +41,53 @@ const createTask = () => } }) -const addOrUpdateJob = (interval: number) => { - if (scheduler.existsById('id_1')) { - scheduler.removeById('id_1') +let _prevDeviceState: Array | undefined +const createNotificationTask = () => + new Task('notifications', () => { + const taskSettings = new YamlSettings(settingsFile) + const notification_providers = taskSettings.get('NOTIFICATION_PROVIDERS') + const allNotifiers: Array = [] + const allNotificationTasks: Array> = [] + for (const notifier_settings of notification_providers) { + const notifier = NotifierFactory(notifier_settings) + allNotifiers.push(notifier) + } + getDevices().then(({ devices }) => { + if (_prevDeviceState) { + for (const device of devices || []) { + const prevDevice = _prevDeviceState.find((d) => d.name === device.name) + if (!prevDevice) { + continue + } + for (const notifier of allNotifiers) { + const notifications = notifier.processTriggers(prevDevice, device) + for (const notification of notifications) { + allNotificationTasks.push(notifier.send(notification)) + } + } + } + return Promise.all(allNotificationTasks).catch((error) => { + console.error('Error sending notifications: ', error) + }) + } + _prevDeviceState = devices + }) + }) + +const addOrUpdateJob = (id: string, interval: number, job: Task) => { + if (scheduler.existsById(id)) { + scheduler.removeById(id) } scheduler.addSimpleIntervalJob( - new SimpleIntervalJob({ seconds: interval, runImmediately: true }, createTask(), { - id: 'id_1', + new SimpleIntervalJob({ seconds: interval, runImmediately: true }, job, { + id: id, preventOverrun: true, }) ) } -addOrUpdateJob(influxInterval) +addOrUpdateJob('influxdb_job', influxInterval, createInfluxDbTask()) +addOrUpdateJob('notifications_job', notificationInterval, createNotificationTask()) // Define the task to check and update the interval const watcher = chokidar.watch(settingsFile) @@ -63,6 +101,12 @@ watcher.on('change', () => { const newInterval = newSettings.get('INFLUX_INTERVAL') || DEFAULT_INFLUX_INTERVAL if (newInfluxHost && newInfluxToken && newInfluxOrg && newInfluxBucket) { - addOrUpdateJob(newInterval) + addOrUpdateJob('influxdb_job', newInterval, createInfluxDbTask()) + } + + const newNotificationProviders = newSettings.get('NOTIFICATION_PROVIDERS') + const newNotificationInterval = newSettings.get('NOTIFICATION_INTERVAL') || DEFAULT_NOTIFICATION_INTERVAL + if (newNotificationProviders && newNotificationInterval) { + addOrUpdateJob('notifications_job', newNotificationInterval, createNotificationTask()) } }) diff --git a/src/server/settings.ts b/src/server/settings.ts index 26db2f58..c6b0ab8e 100644 --- a/src/server/settings.ts +++ b/src/server/settings.ts @@ -1,8 +1,8 @@ import fs from 'fs' import path from 'path' import { load, dump } from 'js-yaml' -import { server } from '../common/types' -import { DEFAULT_INFLUX_INTERVAL } from '@/common/constants' +import { server, NotifierSettings } from '../common/types' +import { DEFAULT_INFLUX_INTERVAL, DEFAULT_NOTIFICATION_INTERVAL } from '@/common/constants' const ISettings = { NUT_SERVERS: [] as Array, @@ -11,6 +11,8 @@ const ISettings = { INFLUX_ORG: '', INFLUX_BUCKET: '', INFLUX_INTERVAL: DEFAULT_INFLUX_INTERVAL, + NOTIFICATION_INTERVAL: DEFAULT_NOTIFICATION_INTERVAL, + NOTIFICATION_PROVIDERS: [] as Array, DATE_FORMAT: 'MM/DD/YYYY', TIME_FORMAT: '12-hour', } @@ -40,7 +42,9 @@ export class YamlSettings { try { if (key === 'NUT_SERVERS') { this.data[key] = JSON.parse(envValue) as server[] - } else if (key === 'INFLUX_INTERVAL') { + } else if (key === 'NOTIFICATION_PROVIDERS') { + this.data[key] = JSON.parse(envValue) as NotifierSettings[] + } else if (key === 'INFLUX_INTERVAL' || key === 'NOTIFICATION_INTERVAL') { const parsed = Number(envValue) if (isNaN(parsed)) throw new Error(`Invalid number for ${key}`) this.data[key] = parsed @@ -91,6 +95,7 @@ export class YamlSettings { } catch (error) { console.error(`Error loading settings file: ${error instanceof Error ? error.message : error}`) } + this.data.NOTIFICATION_PROVIDERS ??= [] // Ensure NUT_SERVERS is always an array using nullish coalescing this.data.NUT_SERVERS ??= []