@@ -33,6 +39,7 @@ Container.propTypes = {
children: PropTypes.node,
glassmorph: PropTypes.bool,
closeButton: PropTypes.func,
+ contentProps: PropTypes.object,
};
export default Container;
diff --git a/app/components/ui/modal/style.scss b/app/components/ui/modal/style.scss
index 4339bbe..6115229 100644
--- a/app/components/ui/modal/style.scss
+++ b/app/components/ui/modal/style.scss
@@ -1,4 +1,4 @@
-@import '../../../styles/global.scss';
+@use '../../../styles/pxToRem.scss' as *;
.modal-container {
position: fixed;
@@ -17,7 +17,9 @@
}
.modal-container--glassmorph {
- @include glassmorphism;
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(25px);
+ -webkit-backdrop-filter: blur(25px);
}
.modal-content {
diff --git a/app/components/ui/searchBar.scss b/app/components/ui/searchBar.scss
index f46edb6..901b830 100644
--- a/app/components/ui/searchBar.scss
+++ b/app/components/ui/searchBar.scss
@@ -1,4 +1,4 @@
-@import '../../styles/global.scss';
+@use '../../styles/pxToRem.scss' as *;
.search-bar-input {
width: 100%;
diff --git a/app/components/ui/select/select.scss b/app/components/ui/select/select.scss
index 09cc9d3..8dafcd4 100644
--- a/app/components/ui/select/select.scss
+++ b/app/components/ui/select/select.scss
@@ -1,4 +1,4 @@
-@import '../../../styles/global.scss';
+@use '../../../styles/pxToRem.scss' as *;
.select {
position: relative;
@@ -30,7 +30,9 @@
border-radius: 10px;
transition: all 0.2s ease-in-out;
- @include glassmorphism;
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(25px);
+ -webkit-backdrop-filter: blur(25px);
}
.select-body-open {
diff --git a/app/components/ui/statusBar.scss b/app/components/ui/statusBar.scss
index fe840db..9eda780 100644
--- a/app/components/ui/statusBar.scss
+++ b/app/components/ui/statusBar.scss
@@ -1,4 +1,4 @@
-@import '../../styles/global.scss';
+@use '../../styles/pxToRem.scss' as *;
.status-bar-container {
display: grid;
diff --git a/app/components/ui/textarea.jsx b/app/components/ui/textarea.jsx
new file mode 100644
index 0000000..010d68b
--- /dev/null
+++ b/app/components/ui/textarea.jsx
@@ -0,0 +1,29 @@
+import './textarea.scss';
+import PropTypes from 'prop-types';
+
+const Textarea = ({ label, error, id = 'text-input', children, ...props }) => {
+ return (
+
+ {label && }
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+};
+
+Textarea.displayName = 'Textarea';
+
+Textarea.propTypes = {
+ label: PropTypes.string,
+ error: PropTypes.string,
+ id: PropTypes.string,
+ children: PropTypes.node,
+};
+
+export default Textarea;
diff --git a/app/components/ui/textarea.scss b/app/components/ui/textarea.scss
new file mode 100644
index 0000000..0783d01
--- /dev/null
+++ b/app/components/ui/textarea.scss
@@ -0,0 +1,19 @@
+@use '../../styles/pxToRem.scss' as *;
+
+.textarea {
+ width: 100%;
+ background-color: var(--accent-800);
+ border: none;
+ outline: none;
+ color: var(--font-color);
+ font-size: var(--font-md);
+ font-weight: var(--weight-medium);
+ border-radius: var(--radius-md);
+ border: 2px solid #ffffff00;
+ padding: pxToRem(8);
+ font-family: var(--font-family);
+
+ &:focus {
+ border: 2px solid var(--primary-700);
+ }
+}
diff --git a/app/components/ui/tooltip.scss b/app/components/ui/tooltip.scss
index 6f0741f..431fc07 100644
--- a/app/components/ui/tooltip.scss
+++ b/app/components/ui/tooltip.scss
@@ -1,4 +1,4 @@
-@import '../../styles/global.scss';
+@use '../../styles/pxToRem.scss' as *;
.tooltip-container {
position: relative;
diff --git a/app/constant/notifications.json b/app/constant/notifications.json
new file mode 100644
index 0000000..534005d
--- /dev/null
+++ b/app/constant/notifications.json
@@ -0,0 +1,6 @@
+{
+ "Discord": { "name": "Discord", "icon": "discord.svg" },
+ "Slack": { "name": "Slack", "icon": "slack.svg" },
+ "Telegram": { "name": "Telegram", "icon": "telegram.svg" },
+ "Webhook": { "name": "Webhook", "icon": "webhook.svg" }
+}
diff --git a/app/context/global.js b/app/context/global.js
index a41e5be..00ffcde 100644
--- a/app/context/global.js
+++ b/app/context/global.js
@@ -1,4 +1,4 @@
-import { action, makeObservable, observable } from 'mobx';
+import { action, computed, makeObservable, observable } from 'mobx';
import { fetchMonitorById } from '../services/monitor/fetch';
class GlobalStore {
@@ -12,6 +12,7 @@ class GlobalStore {
setMonitor: action,
addMonitor: action,
removeMonitor: action,
+ allMonitors: computed,
});
}
@@ -84,9 +85,9 @@ class GlobalStore {
);
};
- getAllMonitors = () => {
+ get allMonitors() {
return Array.from(this.monitors.values()) || [];
- };
+ }
removeMonitor = (monitorId) => {
if (this.timeouts.has(monitorId)) {
diff --git a/app/context/index.js b/app/context/index.js
index 639622e..56b96ef 100644
--- a/app/context/index.js
+++ b/app/context/index.js
@@ -5,9 +5,11 @@ import GlobalStore from './global';
import ModalStore from './modal';
import UserStore from './user';
+import NotificationStore from './notifications';
const store = {
globalStore: new GlobalStore(),
+ notificationStore: new NotificationStore(),
modalStore: new ModalStore(),
userStore: new UserStore(),
};
diff --git a/app/context/notifications.js b/app/context/notifications.js
new file mode 100644
index 0000000..0783648
--- /dev/null
+++ b/app/context/notifications.js
@@ -0,0 +1,49 @@
+import { action, computed, makeObservable, observable } from 'mobx';
+
+class NotificationStore {
+ constructor() {
+ this.notifications = new Map();
+
+ makeObservable(this, {
+ notifications: observable,
+ setNotifications: action,
+ addNotification: action,
+ deleteNotification: action,
+ toggleNotification: action,
+ allNotifications: computed,
+ });
+ }
+
+ setNotifications = (notifications) => {
+ for (const notification of notifications) {
+ this.notifications.set(notification.id, notification);
+ }
+ };
+
+ addNotification = (notification) => {
+ this.notifications.set(notification.id, notification);
+ };
+
+ deleteNotification = (id) => {
+ this.notifications.delete(id);
+ };
+
+ toggleNotification = (id, enabled) => {
+ const notification = this.notifications.get(id);
+
+ if (notification) {
+ notification.isEnabled = enabled;
+ this.notifications.set(id, notification);
+ }
+ };
+
+ getNotifciationById = (id) => {
+ return this.notifications.get(id);
+ };
+
+ get allNotifications() {
+ return Array.from(this.notifications.values()) || [];
+ }
+}
+
+export default NotificationStore;
diff --git a/app/context/team.js b/app/context/team.js
index f79c5e8..407f4b0 100644
--- a/app/context/team.js
+++ b/app/context/team.js
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
-import { action, makeObservable, observable } from 'mobx';
+import { action, computed, makeObservable, observable } from 'mobx';
class TeamStore {
constructor() {
@@ -10,12 +10,13 @@ class TeamStore {
updateUserPermission: action,
updateUserVerified: action,
removeUser: action,
+ teamMembers: computed,
});
}
- getTeam = () => {
+ get teamMembers() {
return Array.from(this.team.values());
- };
+ }
setTeam = (data) => {
for (const user of data) {
diff --git a/app/handlers/monitor.js b/app/handlers/monitor.js
index 0633de3..6167b66 100644
--- a/app/handlers/monitor.js
+++ b/app/handlers/monitor.js
@@ -19,6 +19,8 @@ const handleMonitor = async (form, isEdit, closeModal, setMonitor) => {
retryInterval,
requestTimeout,
monitorId,
+ notificationId,
+ notificationType,
} = form;
const query = await createPostRequest(apiPath, {
@@ -32,6 +34,8 @@ const handleMonitor = async (form, isEdit, closeModal, setMonitor) => {
retryInterval,
requestTimeout,
monitorId,
+ notificationId,
+ notificationType,
});
setMonitor(query.data);
diff --git a/app/hooks/useMonitorForm.jsx b/app/hooks/useMonitorForm.jsx
index d5103bb..095a597 100644
--- a/app/hooks/useMonitorForm.jsx
+++ b/app/hooks/useMonitorForm.jsx
@@ -2,56 +2,14 @@ import { useState } from 'react';
import handleMonitor from '../handlers/monitor';
import monitorValidators from '../../shared/validators/monitor';
-const initialPage = {
- page: 1,
- name: 'initial',
- actions: ['Next'],
- inputs: [
- { name: 'type', validator: monitorValidators.type },
- { name: 'name', validator: monitorValidators.name },
- ],
-};
-
-const httpPages = {
- page: 2,
- name: 'http',
- actions: ['Previous', 'Next'],
- inputs: [
- { name: 'url', validator: monitorValidators.httpUrl },
- { name: 'method', validator: monitorValidators.httpMethod },
- {
- name: 'valid_status_codes',
- validator: monitorValidators.httpStatusCodes,
- },
- ],
-};
-
-const tcpPages = {
- page: 2,
- name: 'tcp',
- actions: ['Previous', 'Next'],
- inputs: [
- { name: 'url', validator: monitorValidators.tcpHost },
- { name: 'port', validator: monitorValidators.tcpPort },
- ],
-};
-
-const intervalPage = {
- page: 3,
- name: 'interval',
- actions: ['Previous', 'Submit'],
- inputs: [
- { name: 'interval', validator: monitorValidators.interval },
- { name: 'retryInterval', validator: monitorValidators.retryInterval },
- { name: 'requestTimeout', validator: monitorValidators.requestTimeout },
- ],
-};
-
const defaultInputs = {
- valid_status_codes: ['200-299'],
+ type: 'http',
+ method: 'HEAD',
interval: 60,
retryInterval: 60,
requestTimeout: 30,
+ notificationType: 'All',
+ valid_status_codes: ['200-299'],
};
const useMonitorForm = (
@@ -60,100 +18,24 @@ const useMonitorForm = (
closeModal,
setMonitor
) => {
- const [form, setForm] = useState(initialPage);
const [inputs, setInput] = useState(values);
const [errors, setErrors] = useState({});
const handleInput = (name, value) => {
- setInput({ ...inputs, [name]: value });
- };
-
- const handlePageNext = () => {
- const errorsObj = {};
-
- form.inputs.forEach((input) => {
- const { name, validator } = input;
- const value = inputs[name];
-
- if (validator) {
- const error = validator(value);
-
- if (error) {
- errorsObj[name] = error;
- }
- }
- });
-
- if (Object.keys(errorsObj).length) {
- setErrors(errorsObj);
- return;
- }
-
- const type = inputs.type;
-
- if (type === 'http' && form.page === 1) {
- setForm(httpPages);
- }
-
- if (type === 'tcp' && form.page === 1) {
- setForm(tcpPages);
- }
-
- if (form.page === 2) {
- setForm(intervalPage);
- }
-
- setErrors({});
- };
-
- const handlePagePrevious = () => {
- if (form.page === 1) {
- return;
- }
-
- if (form.page === 2) {
- setForm(initialPage);
- return;
- }
-
- if (inputs.type === 'http') {
- setForm(httpPages);
- return;
- }
-
- if (inputs.type === 'tcp') {
- setForm(tcpPages);
- return;
- }
+ setInput((prev) => ({ ...prev, [name]: value }));
};
const handleActionButtons = (action) => () => {
switch (action) {
- case 'Next':
- handlePageNext();
- break;
- case 'Previous':
- handlePagePrevious();
- break;
- case 'Submit': {
- const errorsObj = {};
-
- form.inputs.forEach((input) => {
- const { name, validator } = input;
- const value = inputs[name];
-
- if (validator) {
- const error = validator(value);
-
- if (error) {
- errorsObj[name] = error;
- }
- }
- });
+ case 'Create': {
+ const errorsObj =
+ inputs.type === 'http'
+ ? monitorValidators.http(inputs)
+ : monitorValidators.tcp(inputs);
- if (Object.keys(errorsObj).length) {
+ if (errorsObj) {
setErrors(errorsObj);
- return;
+ break;
}
setErrors({});
@@ -161,18 +43,18 @@ const useMonitorForm = (
handleMonitor(inputs, isEdit, closeModal, setMonitor);
break;
}
+
+ case 'Cancel': {
+ closeModal();
+ break;
+ }
+
default:
break;
}
};
- return {
- form,
- inputs,
- errors,
- handleActionButtons,
- handleInput,
- };
+ return { inputs, errors, handleActionButtons, handleInput };
};
export default useMonitorForm;
diff --git a/app/hooks/useNotificationForm.jsx b/app/hooks/useNotificationForm.jsx
new file mode 100644
index 0000000..a17c76b
--- /dev/null
+++ b/app/hooks/useNotificationForm.jsx
@@ -0,0 +1,64 @@
+import { useReducer } from 'react';
+import NotificationValidators from '../../shared/validators/notifications';
+import { NotificationValidatorError } from '../../shared/utils/errors';
+import { createPostRequest } from '../services/axios';
+
+const defaultInputs = {
+ platform: 'Discord',
+ messageType: 'basic',
+};
+
+const inputReducer = (state, action) => {
+ if (action.key === 'platform') {
+ return { [action.key]: action.value, messageType: state.messageType };
+ }
+
+ return { ...state, [action.key]: action.value };
+};
+
+const errorReducer = (state, action) => {
+ return { [action.key]: action.value };
+};
+
+const useNotificationForm = (values = defaultInputs, isEdit, closeModal) => {
+ const [inputs, handleInput] = useReducer(inputReducer, values);
+ const [errors, handleError] = useReducer(errorReducer, {});
+
+ const handleSubmit = async (addNotification) => {
+ try {
+ const validator = NotificationValidators[inputs.platform];
+ if (!validator) {
+ throw new Error('Invalid platform');
+ }
+
+ const result = validator(inputs);
+
+ const path = isEdit
+ ? '/api/notifications/edit'
+ : '/api/notifications/create';
+
+ const response = await createPostRequest(path, isEdit ? inputs : result);
+
+ if (response.status !== 201 && response.status !== 200) {
+ throw new Error(response.data.message);
+ }
+
+ addNotification(response.data);
+ closeModal();
+ } catch (error) {
+ if (error instanceof NotificationValidatorError) {
+ handleError({ key: error.key, value: error.message });
+ return;
+ }
+
+ handleError({
+ key: 'general',
+ value: 'Unknown error occured. Please try again.',
+ });
+ }
+ };
+
+ return { inputs, errors, handleInput, handleError, handleSubmit };
+};
+
+export default useNotificationForm;
diff --git a/app/hooks/useTheme.jsx b/app/hooks/useTheme.jsx
deleted file mode 100644
index db003fd..0000000
--- a/app/hooks/useTheme.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useState } from 'react';
-
-const useTheme = () => {
- const initialTheme = localStorage.getItem('theme') || 'dark';
- const initialColor = localStorage.getItem('color') || 'Green';
-
- const [theme, updateTheme] = useState({
- type: initialTheme,
- color: initialColor,
- });
-
- const isDark = theme?.type?.startsWith('dark');
-
- const setTheme = (input) => {
- window.localStorage.setItem('theme', input);
- document.documentElement.dataset.theme = input;
-
- return updateTheme({ ...theme, type: input });
- };
-
- const setColor = (color) => {
- window.localStorage.setItem('color', color);
- document.documentElement.dataset.color = color;
-
- return updateTheme({ ...theme, color });
- };
-
- return { theme, isDark, setTheme, setColor };
-};
-
-export default useTheme;
diff --git a/app/hooks/useTime.jsx b/app/hooks/useTime.jsx
deleted file mode 100644
index 73765e0..0000000
--- a/app/hooks/useTime.jsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useState } from 'react';
-
-const useTime = () => {
- const timezone =
- window?.localStorage?.getItem('timezone') ||
- Intl.DateTimeFormat().resolvedOptions().timeZone;
-
- const dateformat =
- window?.localStorage?.getItem('dateformat') || 'DD/MM/YYYY';
-
- const timeformat = window?.localStorage?.getItem('timeformat') || 'HH:mm:ss';
-
- const [state, setState] = useState({
- timezone,
- dateformat,
- timeformat,
- });
-
- const setTimezone = (timezone) => {
- setState((prevState) => ({
- ...prevState,
- timezone,
- }));
- window.localStorage.setItem('timezone', timezone);
- };
-
- const setDateformat = (dateformat) => {
- setState((prevState) => ({
- ...prevState,
- dateformat,
- }));
- window.localStorage.setItem('dateformat', dateformat);
- };
-
- const setTimeformat = (timeformat) => {
- setState((prevState) => ({
- ...prevState,
- timeformat,
- }));
- window.localStorage.setItem('timeformat', timeformat);
- };
-
- return {
- ...state,
- setTimezone,
- setDateformat,
- setTimeformat,
- };
-};
-
-export default useTime;
diff --git a/app/layout/global.jsx b/app/layout/global.jsx
index 3369927..55cecd9 100644
--- a/app/layout/global.jsx
+++ b/app/layout/global.jsx
@@ -19,6 +19,7 @@ const GlobalLayout = ({ children }) => {
modalStore: { isOpen, content, glassmorph },
globalStore: { setMonitors, setTimeouts },
userStore: { setUser },
+ notificationStore: { setNotifications },
} = useContextStore();
const navigate = useNavigate();
@@ -30,11 +31,13 @@ const GlobalLayout = ({ children }) => {
try {
const user = await createGetRequest('/api/user');
const monitors = await createGetRequest('/api/user/monitors');
+ const notifications = await createGetRequest('/api/notifications');
const data = monitors?.data || [];
setUser(user?.data);
setMonitors(data);
setTimeouts(data, fetchMonitorById);
+ setNotifications(notifications?.data || []);
} catch (error) {
console.log(error);
if (error.response?.status === 401) {
diff --git a/app/main.jsx b/app/main.jsx
index 115dac9..da9e8cc 100644
--- a/app/main.jsx
+++ b/app/main.jsx
@@ -18,6 +18,7 @@ import GlobalLayout from './layout/global';
import Setttings from './pages/settings';
import Verify from './pages/verify';
import ErrorPage from './pages/error';
+import Notifications from './pages/notifications';
ReactDOM.createRoot(document.getElementById('root')).render(
@@ -34,6 +35,16 @@ ReactDOM.createRoot(document.getElementById('root')).render(
}
/>
+
+
+
+
+
+ }
+ />
{
+ const navigate = useNavigate();
return (
@@ -14,7 +18,7 @@ const ErrorPage = () => {
Sorry, couldn't find what you're looking for!
-
diff --git a/app/pages/error.scss b/app/pages/error.scss
index 26adfc7..5764586 100644
--- a/app/pages/error.scss
+++ b/app/pages/error.scss
@@ -1,4 +1,4 @@
-@import '../styles/global.scss';
+@use '../styles/breakpoints.scss' as *;
.error-page-container {
display: flex;
diff --git a/app/pages/home.jsx b/app/pages/home.jsx
index be08a37..3dae015 100644
--- a/app/pages/home.jsx
+++ b/app/pages/home.jsx
@@ -1,10 +1,3 @@
-// import local files
-import {
- MonitorCard,
- MonitorList,
- MonitorCompact,
-} from '../components/home/monitor';
-
// import styles
import './home.scss';
@@ -13,6 +6,11 @@ import { useState } from 'react';
import { observer } from 'mobx-react-lite';
// import local files
+import {
+ MonitorCard,
+ MonitorList,
+ MonitorCompact,
+} from '../components/home/monitor';
import useContextStore from '../context';
import HomeMenu from '../components/home/menu';
import MonitorTable from '../components/home/monitor/layout/table';
@@ -20,7 +18,7 @@ import useLocalStorageContext from '../hooks/useLocalstorage';
const Home = () => {
const {
- globalStore: { getAllMonitors },
+ globalStore: { allMonitors },
} = useContextStore();
const [search, setSearch] = useState('');
@@ -31,9 +29,7 @@ const Home = () => {
setStatus('all');
};
- const monitors = getAllMonitors();
-
- const monitorsList = monitors
+ const monitorsList = allMonitors
.filter((monitor = {}) => {
const matchesSearch =
monitor.name?.toLowerCase().includes(search.toLowerCase()) ||
diff --git a/app/pages/home.scss b/app/pages/home.scss
index 79e4919..ce11de1 100644
--- a/app/pages/home.scss
+++ b/app/pages/home.scss
@@ -1,4 +1,4 @@
-@import '../styles/global.scss';
+@use '../styles/pxToRem.scss' as *;
.home-container {
display: flex;
diff --git a/app/pages/login.jsx b/app/pages/login.jsx
index 4e5e91d..6b16330 100644
--- a/app/pages/login.jsx
+++ b/app/pages/login.jsx
@@ -25,7 +25,7 @@ const Login = () => {
{errors['general'] && (
- {errors['general']}
+ {errors['general']}
)}
{
diff --git a/app/pages/monitor/style.scss b/app/pages/monitor.scss
similarity index 77%
rename from app/pages/monitor/style.scss
rename to app/pages/monitor.scss
index 1bedd12..a5a74c3 100644
--- a/app/pages/monitor/style.scss
+++ b/app/pages/monitor.scss
@@ -1,4 +1,4 @@
-@import '../../styles/global.scss';
+@use '../styles/pxToRem.scss' as *;
.monitor-container {
display: flex;
diff --git a/app/pages/notifications.jsx b/app/pages/notifications.jsx
new file mode 100644
index 0000000..bcef022
--- /dev/null
+++ b/app/pages/notifications.jsx
@@ -0,0 +1,85 @@
+// import styles
+import './notifications.scss';
+
+// import dependencies
+import { observer } from 'mobx-react-lite';
+
+// import local files
+import useContextStore from '../context';
+import NotificationsMenu from '../components/notifications/menu';
+import NotificationCard from '../components/notifications/layout/card';
+import { useMemo, useState } from 'react';
+import { MdNotifications } from '../components/icons';
+
+const Notifications = () => {
+ const {
+ notificationStore: { allNotifications },
+ } = useContextStore();
+
+ const [search, setSearch] = useState(null);
+ const [platform, setPlatform] = useState('All');
+
+ const handlePlatformUpdate = (platform) => {
+ setPlatform(platform);
+ };
+
+ const handleSearchUpdate = (search = '') => {
+ setSearch(search.trim());
+ };
+
+ const notifications = useMemo(
+ () =>
+ allNotifications.filter((notification) => {
+ if (platform !== 'All' && notification.platform !== platform) {
+ return false;
+ }
+
+ if (search) {
+ return (
+ notification.friendlyName
+ .toLowerCase()
+ .includes(search.toLowerCase()) ||
+ notification.platform.toLowerCase().includes(search.toLowerCase())
+ );
+ }
+ return true;
+ }),
+ [platform, search, allNotifications]
+ );
+
+ return (
+
+
+
+ {notifications.length === 0 && (
+
+
+
+
+
No notifications found
+
+ )}
+
+
+ {notifications.length > 0 &&
+ notifications.map((notification) => (
+
+ ))}
+
+
+ );
+};
+
+Notifications.displayName = 'Notifications';
+
+Notifications.propTypes = {};
+
+export default observer(Notifications);
diff --git a/app/pages/notifications.scss b/app/pages/notifications.scss
new file mode 100644
index 0000000..5c1e4f5
--- /dev/null
+++ b/app/pages/notifications.scss
@@ -0,0 +1,31 @@
+.notification-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+ gap: 10px;
+ flex: 1;
+
+ &-icon {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: var(--accent-200);
+ }
+
+ &-text {
+ font-size: 1.5rem;
+ color: var(--primary-500);
+ }
+}
+
+.notification-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 10px;
+ margin: 10px 0;
+}
diff --git a/app/pages/register.scss b/app/pages/register.scss
index 7292dae..805cdc9 100644
--- a/app/pages/register.scss
+++ b/app/pages/register.scss
@@ -1,4 +1,4 @@
-@import '../styles/global.scss';
+@use '../styles/pxToRem.scss' as *;
.auth-form-container {
width: 100vw;
diff --git a/app/pages/settings.jsx b/app/pages/settings.jsx
index 8deff02..6fa8cab 100644
--- a/app/pages/settings.jsx
+++ b/app/pages/settings.jsx
@@ -28,7 +28,7 @@ const Settings = () => {
return () => {
document.removeEventListener('keydown', handleKeydown);
};
- }, []);
+ }, [handleKeydown]);
return (
<>
diff --git a/app/pages/settings.scss b/app/pages/settings.scss
index f667a70..ed66681 100644
--- a/app/pages/settings.scss
+++ b/app/pages/settings.scss
@@ -1,4 +1,5 @@
-@import '../styles/global.scss';
+@use '../styles/pxToRem.scss' as *;
+@use '../styles/breakpoints.scss' as *;
.settings-content {
display: flex;
diff --git a/app/styles/breakpoints.scss b/app/styles/breakpoints.scss
index 99edc3e..5f13b13 100644
--- a/app/styles/breakpoints.scss
+++ b/app/styles/breakpoints.scss
@@ -1,4 +1,4 @@
-@import './pxToRem.scss';
+@use './pxToRem.scss' as *;
$BREAKPOINT_1: pxToRem(480);
$BREAKPOINT_2: pxToRem(768);
@@ -50,3 +50,57 @@ $FULL_WIDTH: 100%;
max-width: 968px;
}
}
+
+.mobile-hidden {
+ @include mobile {
+ display: none;
+ }
+}
+
+.tablet-hidden {
+ @include tablet {
+ display: none;
+ }
+}
+
+.laptop-hidden {
+ @include laptop {
+ display: none;
+ }
+}
+
+.desktop-hidden {
+ @include desktop {
+ display: none;
+ }
+}
+
+.mobile-shown {
+ display: none;
+
+ @include mobile {
+ display: flex;
+ }
+}
+
+.tablet-shown {
+ display: none;
+
+ @include tablet {
+ display: flex;
+ }
+}
+
+.laptop-shown {
+ @include laptop {
+ display: none;
+ }
+}
+
+.desktop-shown {
+ display: none;
+
+ @include desktop {
+ display: flex;
+ }
+}
diff --git a/app/styles/font.scss b/app/styles/font.scss
index 80f8951..bf6565d 100644
--- a/app/styles/font.scss
+++ b/app/styles/font.scss
@@ -1,7 +1,5 @@
+@use './pxToRem.scss' as *;
@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
-@import './pxToRem.scss';
-
-// "Montserrat", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif
:root {
--font-xs: #{pxToRem(12)};
@@ -28,5 +26,7 @@
--weight-extrabold: 800;
--weight-black: 900;
- --font-family: 'Montserrat', Helvetica, Arial, 'Roboto', sans-serif;
+ --font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI',
+ 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
+ 'Helvetica Neue', sans-serif;
}
diff --git a/app/styles/global.scss b/app/styles/global.scss
deleted file mode 100644
index 5da269d..0000000
--- a/app/styles/global.scss
+++ /dev/null
@@ -1,62 +0,0 @@
-@import './breakpoints.scss';
-@import './pxToRem.scss';
-
-@mixin glassmorphism {
- background: rgba(0, 0, 0, 0.3);
- backdrop-filter: blur(25px);
- -webkit-backdrop-filter: blur(25px);
-}
-
-.mobile-hidden {
- @include mobile {
- display: none;
- }
-}
-
-.tablet-hidden {
- @include tablet {
- display: none;
- }
-}
-
-.laptop-hidden {
- @include laptop {
- display: none;
- }
-}
-
-.desktop-hidden {
- @include desktop {
- display: none;
- }
-}
-
-.mobile-shown {
- display: none;
-
- @include mobile {
- display: flex;
- }
-}
-
-.tablet-shown {
- display: none;
-
- @include tablet {
- display: flex;
- }
-}
-
-.laptop-shown {
- @include laptop {
- display: none;
- }
-}
-
-.desktop-shown {
- display: none;
-
- @include desktop {
- display: flex;
- }
-}
diff --git a/app/styles/radius.scss b/app/styles/radius.scss
index 7c0111f..ddbf412 100644
--- a/app/styles/radius.scss
+++ b/app/styles/radius.scss
@@ -1,4 +1,4 @@
-@import './pxToRem.scss';
+@use './pxToRem.scss' as *;
:root {
--radius-xs: #{pxToRem(5)};
diff --git a/app/styles/reset.scss b/app/styles/reset.scss
index e8f54fa..3746685 100644
--- a/app/styles/reset.scss
+++ b/app/styles/reset.scss
@@ -160,3 +160,7 @@ html {
*:after {
box-sizing: inherit;
}
+
+a {
+ color: inherit;
+}
diff --git a/app/styles/styles.scss b/app/styles/styles.scss
index fbc7624..dc6ae51 100644
--- a/app/styles/styles.scss
+++ b/app/styles/styles.scss
@@ -1,10 +1,10 @@
-@import './colors.scss';
-@import './font.scss';
-@import './radius.scss';
-@import './reset.scss';
-@import './shadows.scss';
-@import './themes.scss';
-@import './transitions.scss';
+@use './colors.scss' as *;
+@use './font.scss' as *;
+@use './radius.scss' as *;
+@use './reset.scss' as *;
+@use './shadows.scss' as *;
+@use './themes.scss' as *;
+@use './transitions.scss' as *;
body {
font-size: var(--font-md);
@@ -35,3 +35,36 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--accent-900);
}
+
+.input-label {
+ display: block;
+ font-size: var(--font-md);
+ font-weight: var(--weight-semibold);
+ color: var(--font-color);
+ margin: 12px 0 4px 4px;
+}
+
+.input-error {
+ color: var(--red-700);
+ font-size: var(--font-sm);
+ margin: 4px;
+ display: block;
+}
+
+.input-error-general {
+ color: var(--red-700);
+ font-size: var(--font-md);
+ margin: 4px;
+ display: block;
+ text-align: center;
+}
+
+.input-description {
+ font-size: var(--font-sm);
+ margin: 8px 0 0 4px;
+}
+
+.input-required {
+ color: var(--red-600);
+ margin: 0 0 0 3px;
+}
diff --git a/cypress.config.js b/cypress.config.js
index 47b42a5..dea74ba 100644
--- a/cypress.config.js
+++ b/cypress.config.js
@@ -11,5 +11,6 @@ export default defineConfig({
videosFolder: 'test/e2e/setup/videos',
downloadsFolder: 'test/e2e/setup/downloads',
supportFile: 'test/e2e/setup/support/e2e.js',
+ experimentalRunAllSpecs: true,
},
});
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 6457f73..a58741f 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -28,6 +28,7 @@ export default defineConfig({
nav: [
{ text: 'Home', link: '/' },
{ text: 'Docs', link: '/intro' },
+ // { text: 'Guides', link: '/guides' },
{ text: 'API', link: '/api/monitor' },
// { text: 'Blog', link: '/blog' },
],
@@ -46,6 +47,7 @@ export default defineConfig({
text: 'API',
items: [
{ text: 'Monitor', link: '/api/monitor' },
+ { text: 'Notification', link: '/api/notification' },
{ text: 'User', link: '/api/user' },
],
collapsed: false,
diff --git a/docs/api/monitor.md b/docs/api/monitor.md
index cdb8d17..defae2a 100644
--- a/docs/api/monitor.md
+++ b/docs/api/monitor.md
@@ -286,10 +286,10 @@ There are various restrictions applied to the monitor data. The following are so
}
```
-
-
## Add a new monitor
+
+
### /api/monitor/add
@@ -375,10 +375,10 @@ axios('/api/monitor/add', {
-
-
## Edit a monitor
+
+
### /api/monitor/edit
@@ -467,10 +467,10 @@ axios('/api/monitor/edit', {
-
-
## Delete monitor
+
+
### /api/monitor/delete
@@ -524,10 +524,10 @@ axios('/api/monitor/delete', {
-
-
## Get monitor status
+
+
### /api/monitor/status
@@ -589,10 +589,10 @@ axios('/api/monitor/status', {
-
-
## Get a specific monitor
+
+
### /api/monitor/id
diff --git a/docs/api/notification.md b/docs/api/notification.md
new file mode 100644
index 0000000..435b7e6
--- /dev/null
+++ b/docs/api/notification.md
@@ -0,0 +1,557 @@
+---
+aside: false
+---
+
+
+
+# Notification Resource
+
+## Authorization
+
+Currently notifications are only able to access the API while they are signed into the application. The API requires the `access_token` cookie to be present in the request.
+
+## Restrictions
+
+There are various restrictions applied to the notification data. The following are some of the important restrictions when creating a notification. Each notification may also have unique values that are not required for others and will be stored in the `data` object field.
+
+#### Platform
+
+- Currently limited to the following platforms:
+ - Discord
+ - Slack
+ - Telegram
+ - Webhooks
+
+#### Message Type
+
+- Message type must be one of the following:
+ - Basic
+ - Pretty
+ - Nerdy
+
+#### Token
+
+- This is the url or authorization token for the notification:
+ - For Discord this is the webhook url
+ - For Slack this is the webhook url
+ - For Telegram this is the bot token
+ - For Webhooks this is the url
+
+### Platform specific requirements
+
+## Notification Object
+
+### Notification Structure
+
+| Field | Type | Description |
+| ------------ | ------- | ------------------------------------------------------------------------- |
+| id | string | Unique id for the monitor |
+| platform | string | One of the following: Discord, Slack, Telegram, Webhooks |
+| messageType | string | One of the following: basic, pretty, nerdy |
+| token | string | Token/url for the platform |
+| email | string | Email address for the user who created the monitor |
+| isEnabled | boolean | Boolean if the notification is enabled |
+| content | string | Additional content for the notification |
+| friendlyName | string | Friendly name for the notification |
+| data | object | Object containing information about the notification (Varies by platform) |
+| createdAt | date | Timestamp of when the notification was created |
+
+### Example Notification
+
+::: code-group
+
+```json [Discord]
+{
+ "id": "d8a53324-6c1b-410c-be0e-c17a99d862e6",
+ "platform": "Discord",
+ "messageType": "basic",
+ "token": "https://discord.com/api/webhooks/XXXXXXXXX/XXXXXXXXXXXXXXX",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true,
+ "content": "@everyone Ping! Alert from Lunalytics!",
+ "friendlyName": "Lunalytics",
+ "data": {},
+ "createdAt": "2024-11-03 12:00:00"
+}
+```
+
+```json [Slack]
+{
+ "id": "d8a53324-6c1b-410c-be0e-c17a99d862e6",
+ "platform": "Slack",
+ "messageType": "nerdy",
+ "token": "https://hooks.slack.com/services/XXXXXXX/XXXXXXXXXXX/X43XxxxXX2XxxxXX",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true,
+ "content": null,
+ "friendlyName": "Lunalytics",
+ "data": { "channel": "#general", "username": "Lunalytics" },
+ "createdAt": "2024-11-03 12:00:00"
+}
+```
+
+```json [Telegram]
+{
+ "id": "d8a53324-6c1b-410c-be0e-c17a99d862e6",
+ "platform": "Telegram",
+ "messageType": "pretty",
+ "token": "",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true,
+ "content": "",
+ "friendlyName": "Lunalytics",
+ "data": {},
+ "createdAt": "2024-11-03 12:00:00"
+}
+```
+
+```json [Webhooks]
+{
+ "id": "d8a53324-6c1b-410c-be0e-c17a99d862e6",
+ "platform": "Webhook",
+ "messageType": "nerdy",
+ "token": "https://lunalytics.xyz/api/webhook/alert",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true,
+ "content": null,
+ "friendlyName": "Lunalytics",
+ "data": { "requestType": "form-data" },
+ "createdAt": "2024-11-03 12:00:00"
+}
+```
+
+:::
+
+## Get all notifications
+
+
+
+
+
+### /api/notifications
+
+Returns an array of [notifications](#notification-structure). Only editors, admins, and owners are allowed to access this endpoint.
+
+### HTTP Response Codes
+
+| Status Code | Description |
+| ----------- | --------------------------------------------------------------------- |
+| 200 | Success, returns an array of [notifications](#notification-structure) |
+| 401 | Unauthorized (Missing `access_token` cookie or API Token) |
+
+
+
+
+
+::: code-group
+
+```[cURL]
+curl -X GET \
+ -H "Content-Type:application/json" \
+ -H "Authorization:API Token" \
+ 'https://lunalytics.xyz/api/notifications'
+```
+
+```js [axios]
+axios('/api/notifications', {
+ method: 'GET',
+ headers: {
+ Authorization: 'API Token',
+ 'Content-Type': 'application/json',
+ },
+});
+```
+
+:::
+
+
+
+
+## Get a specific notification
+
+
+
+
+
+### /api/notifications/id
+
+Returns a [notification](#notification-structure) for the given id. Only editors, admins, and owners are allowed to access this endpoint.
+
+### Query Parameters
+
+`notificationId`
+
+### HTTP Response Codes
+
+| Status Code | Description |
+| ----------- | ---------------------------------------------------------------------- |
+| 200 | Success, returns an object for [notification](#notification-structure) |
+| 401 | Unauthorized (Missing `access_token` cookie or API Token) |
+| 404 | Notification not found |
+
+
+
+
+
+::: code-group
+
+```[cURL]
+curl -X GET \
+ -H "Content-Type:application/json" \
+ -H "Authorization:API Token" \
+ -d "notificationId=4d048471-9e85-428b-8050-4238f6033478" \
+ 'https://lunalytics.xyz/api/notifications/id'
+```
+
+```js [axios]
+axios('/api/notifications/id', {
+ method: 'GET',
+ headers: {
+ Authorization: 'API Token',
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ notificationId: '4d048471-9e85-428b-8050-4238f6033478',
+ },
+});
+```
+
+:::
+
+
+
+
+## Create a new notification
+
+
+
+
+
+### /api/notifications/create
+
+Create a new [notification](#notification-structure) and returns the notification object. Only editors, admins, and owners are allowed to access this endpoint.
+
+### Payload
+
+::: code-group
+
+```json [Discord]
+{
+ "platform": "Disord",
+ "messageType": "pretty",
+ "friendlyName": "Lunalytics",
+ "textMessage": "Ping @everyone",
+ "token": "https://discord.com/api/webhook/xxxxxxxxxx/xxxxxxx",
+ "username": "Lunalytics"
+}
+```
+
+```json [Slack]
+{
+ "platform": "Slack",
+ "channel": "XXXXXXXXXXXX",
+ "friendlyName": "Lunalytics",
+ "messageType": "pretty",
+ "textMessage": "Ping @here",
+ "token": "https://hooks.slack.com/services/xxxxxxxxx/xxxxxx/xxxxx",
+ "username": "Lunalytics"
+}
+```
+
+```json [Telegram]
+{
+ "platform": "Telegram",
+ "chatId": "xxxxxxxxxx",
+ "disableNotification": false,
+ "friendlyName": "Lunalytics",
+ "messageType": "pretty",
+ "protectContent": false,
+ "token": "xxxxxxxxxxxxxxx"
+}
+```
+
+```json [Webhook]
+{
+ "platform": "Webhook",
+ "friendlyName": "Lunalytics",
+ "messageType": "pretty",
+ "requestType": "application/json",
+ "showAdditionalHeaders": true,
+ "additionalHeaders": {},
+ "token": "https://lunalytics.xyz/api/webhook/alert"
+}
+```
+
+:::
+
+### HTTP Response Codes
+
+| Status Code | Description |
+| ----------- | ---------------------------------------------------------------------- |
+| 201 | Success, returns an object for [notification](#notification-structure) |
+| 401 | Unauthorized (Missing `access_token` cookie or API Token) |
+| 422 | Return a notification error (Format: `{key: 'message'}`) |
+
+
+
+
+
+::: code-group
+
+```[cURL]
+curl -X POST \
+ -H "Content-Type:application/json" \
+ -H "Authorization:API Token" \
+ --data '{"messageType": "pretty", "friendlyName": "Lunalytics", "textMessage": "Ping @everyone", "token": "https://discord.com/api/webhook/xxxxxxxxxx/xxxxxxx", "username": "Lunalytics"}' \
+ 'https://lunalytics.xyz/api/notifications/create'
+```
+
+```js [axios]
+axios('/api/notifications/create', {
+ method: 'GET',
+ headers: {
+ Authorization: 'API Token',
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ messageType: 'pretty',
+ friendlyName: 'Lunalytics',
+ textMessage: 'Ping @everyone',
+ token: 'https://discord.com/api/webhook/xxxxxxxxxx/xxxxxxx',
+ username: 'Lunalytics',
+ },
+});
+```
+
+:::
+
+
+
+
+## Edit a notification
+
+
+
+
+
+### /api/notifications/edit
+
+Edit an existing [notification](#notification-structure) and returns the notification object. Only editors, admins, and owners are allowed to access this endpoint.
+
+### Payload
+
+::: code-group
+
+```json [Discord]
+{
+ "messageType": "pretty",
+ "friendlyName": "Lunalytics",
+ "textMessage": "Ping @everyone",
+ "token": "https://discord.com/api/webhook/xxxxxxxxxx/xxxxxxx",
+ "username": "Lunalytics",
+ "id": "4d048471-9e85-428b-8050-4238f6033478",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true
+}
+```
+
+```json [Slack]
+{
+ "channel": "XXXXXXXXXXXX",
+ "friendlyName": "Lunalytics",
+ "messageType": "pretty",
+ "textMessage": "Ping @here",
+ "token": "https://hooks.slack.com/services/xxxxxxxxx/xxxxxx/xxxxx",
+ "username": "Lunalytics",
+ "id": "4d048471-9e85-428b-8050-4238f6033478",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true
+}
+```
+
+```json [Telegram]
+{
+ "chatId": "xxxxxxxxxx",
+ "disableNotification": false,
+ "friendlyName": "Lunalytics",
+ "messageType": "pretty",
+ "protectContent": false,
+ "token": "xxxxxxxxxxxxxxx",
+ "id": "4d048471-9e85-428b-8050-4238f6033478",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true
+}
+```
+
+```json [Webhook]
+{
+ "friendlyName": "Lunalytics",
+ "messageType": "pretty",
+ "requestType": "application/json",
+ "showAdditionalHeaders": true,
+ "additionalHeaders": {},
+ "token": "https://lunalytics.xyz/api/webhook/alert",
+ "id": "4d048471-9e85-428b-8050-4238f6033478",
+ "email": "KSJaay@lunalytics.xyz",
+ "isEnabled": true
+}
+```
+
+:::
+
+### HTTP Response Codes
+
+| Status Code | Description |
+| ----------- | ---------------------------------------------------------------------- |
+| 200 | Success, returns an object for [notification](#notification-structure) |
+| 401 | Unauthorized (Missing `access_token` cookie or API Token) |
+| 422 | Return a notification error (Format: `{key: 'message'}`) |
+
+
+
+
+
+::: code-group
+
+```[cURL]
+curl -X POST \
+ -H "Content-Type:application/json" \
+ -H "Authorization:API Token" \
+ --data '{"messageType": "pretty", "friendlyName": "Lunalytics", "textMessage": "Ping @everyone", "token": "https://discord.com/api/webhook/xxxxxxxxxx/xxxxxxx", "username": "Lunalytics"}' \
+ 'https://lunalytics.xyz/api/notifications/edit'
+```
+
+```js [axios]
+axios('/api/notifications/edit', {
+ method: 'GET',
+ headers: {
+ Authorization: 'API Token',
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ messageType: 'pretty',
+ friendlyName: 'Lunalytics',
+ textMessage: 'Ping @everyone',
+ token: 'https://discord.com/api/webhook/xxxxxxxxxx/xxxxxxx',
+ username: 'Lunalytics',
+ },
+});
+```
+
+:::
+
+
+
+
+## Delete a specific notification
+
+
+
+
+
+### /api/notifications/delete
+
+Deletes the notification using the given notificationId. Only editors, admins, and owners are allowed to access this endpoint.
+
+### Query Parameters
+
+`notificationId`
+
+### HTTP Response Codes
+
+| Status Code | Description |
+| ----------- | --------------------------------------------------------- |
+| 200 | Success |
+| 401 | Unauthorized (Missing `access_token` cookie or API Token) |
+| 422 | No notificationId provided |
+
+
+
+
+
+::: code-group
+
+```[cURL]
+curl -X POST \
+ -H "Content-Type:application/json" \
+ -H "Authorization:API Token" \
+ -d "notificationId=4d048471-9e85-428b-8050-4238f6033478" \
+ 'https://lunalytics.xyz/api/notifications/delete'
+```
+
+```js [axios]
+axios('/api/notifications/delete', {
+ method: 'POST',
+ headers: {
+ Authorization: 'API Token',
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ notificationId: '4d048471-9e85-428b-8050-4238f6033478',
+ },
+});
+```
+
+:::
+
+
+
+
+## Toggle a specific notification
+
+
+
+
+
+### /api/notifications/toggle
+
+Toggle the notification using the given notificationId and isEnabled query parameter. Only editors, admins, and owners are allowed to access this endpoint.
+
+### Query Parameters
+
+`notificationId`
+
+`isEnabled`
+
+### HTTP Response Codes
+
+| Status Code | Description |
+| ----------- | --------------------------------------------------------- |
+| 200 | Success |
+| 401 | Unauthorized (Missing `access_token` cookie or API Token) |
+| 422 | No notificationId provided or isEnabled is not a boolean |
+
+
+
+
+
+::: code-group
+
+```[cURL]
+curl -X POST \
+ -H "Content-Type:application/json" \
+ -H "Authorization:API Token" \
+ -d "notificationId=4d048471-9e85-428b-8050-4238f6033478&isEnabled=true" \
+ 'https://lunalytics.xyz/api/notifications/toggle'
+```
+
+```js [axios]
+axios('/api/notifications/toggle', {
+ method: 'POST',
+ headers: {
+ Authorization: 'API Token',
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ notificationId: '4d048471-9e85-428b-8050-4238f6033478',
+ isEnabled: 'true',
+ },
+});
+```
+
+:::
+
+
+
diff --git a/docs/api/user.md b/docs/api/user.md
index 194f094..2fe2675 100644
--- a/docs/api/user.md
+++ b/docs/api/user.md
@@ -23,7 +23,7 @@ There are various restrictions applied to the user data. The following are some
#### Password
-- Password must be between 8 and 64 characters
+- Password must be between 8 and 48 characters
- Password must contain one letter, one number or special character
- Passwords can contain the following special characters: !@#$%^&\*~\_-+=
@@ -76,10 +76,10 @@ There are various restrictions applied to the user data. The following are some
# API Endpoints
-
-
## Get current user
+
+
### /api/user
@@ -122,10 +122,10 @@ axios('/api/user', {
-
-
## Check user exists
+
+
### /api/user/exists
@@ -173,10 +173,10 @@ axios('/api/user/exists', {
-
-
## Get user monitors
+
+
### /api/user/monitors
@@ -220,10 +220,10 @@ axios('/api/user/monitors', {
-
-
## Update user display name
+
+
### /api/user/update/username
@@ -277,10 +277,10 @@ axios('/api/user/update/username', {
-
-
## Update user password
+
+
### /api/user/update/password
@@ -336,10 +336,10 @@ axios('/api/user/update/password', {
-
-
## Update user avatar
+
+
### /api/user/update/avatar
@@ -393,10 +393,10 @@ axios('/api/user/update/avatar', {
-
-
## Get team members
+
+
### /api/user/team
@@ -439,10 +439,10 @@ axios('/api/user/team', {
-
-
## Decline member
+
+
### /api/user/access/decline
@@ -498,10 +498,10 @@ axios('/api/user/access/decline', {
-
-
## Approve member
+
+
### /api/user/access/approve
@@ -557,10 +557,10 @@ axios('/api/user/access/approve', {
-
-
## Remove Member
+
+
### /api/user/access/remove
@@ -616,10 +616,10 @@ axios('/api/user/access/remove', {
-
-
## Update User Permissions
+
+
### /api/user/permission/update
@@ -677,10 +677,10 @@ axios('/api/user/permission/update', {
-
-
## Transfer Ownership
+
+
### /api/user/transfer/ownership
@@ -734,10 +734,10 @@ axios('/api/user/transfer/ownership', {
-
-
## Delete Account
+
+
### /api/user/delete/account
diff --git a/docs/index.md b/docs/index.md
index 02a5cea..c5331f1 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -20,7 +20,7 @@ hero:
features:
- title: Focus on work
- details: Setup montiors once, and we'll monior take care of the rest
+ details: Setup your monitors once, and we'll take care of the rest
icon: 📝
- title: Multiple Users
details: Open up your project and invite other collaborators to the project
diff --git a/package-lock.json b/package-lock.json
index b8495d5..aee51a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lunalytics",
- "version": "0.5.3",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lunalytics",
- "version": "0.5.3",
+ "version": "0.6.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@canvasjs/react-charts": "^1.0.2",
diff --git a/package.json b/package.json
index 3086122..3369f96 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lunalytics",
- "version": "0.5.3",
+ "version": "0.6.0",
"description": "Open source Node.js server/website monitoring tool",
"private": true,
"author": "KSJaay ",
@@ -26,7 +26,7 @@
"prepare": "husky install",
"preview": "vite preview",
"reset:password": "node ./scripts/reset.js",
- "server:watch": "nodemon --delay 2 --watch server ./server/index.js",
+ "server:watch": "nodemon --delay 2 --watch server --watch shared ./server/index.js",
"setup": "npm install && node ./scripts/setup.js && npm run build",
"start": "cross-env NODE_ENV=production node server/index.js",
"docs:dev": "vitepress dev docs",
@@ -34,10 +34,11 @@
"docs:preview": "vitepress preview docs"
},
"dependencies": {
- "@canvasjs/react-charts": "^1.0.2",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.4.1",
+ "chart.js": "^4.4.4",
+ "chartjs-adapter-dayjs-3": "^1.2.3",
"classnames": "^2.5.1",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6",
@@ -51,11 +52,14 @@
"mobx-react-lite": "^4.0.5",
"prop-types": "^15.8.1",
"react": "^18.2.0",
+ "react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.2.1",
"react-router-dom": "^6.19.0",
"react-toastify": "^10.0.5",
- "uuid": "^9.0.1"
+ "uuid": "^9.0.1",
+ "winston": "^3.15.0",
+ "winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@vitejs/plugin-react-swc": "^3.7.0",
@@ -73,7 +77,7 @@
"node-mocks-http": "^1.14.1",
"nodemon": "^3.0.1",
"rollup-plugin-visualizer": "^5.12.0",
- "sass": "^1.69.5",
+ "sass": "^1.79.5",
"vite": "^5.0.10",
"vite-plugin-compression2": "^1.0.0",
"vitepress": "^1.1.3",
diff --git a/public/notifications/discord.svg b/public/notifications/discord.svg
new file mode 100644
index 0000000..4789597
--- /dev/null
+++ b/public/notifications/discord.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/notifications/slack.svg b/public/notifications/slack.svg
new file mode 100644
index 0000000..fb55f72
--- /dev/null
+++ b/public/notifications/slack.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/notifications/telegram.svg b/public/notifications/telegram.svg
new file mode 100644
index 0000000..c6ec2b3
--- /dev/null
+++ b/public/notifications/telegram.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/public/notifications/webhook.svg b/public/notifications/webhook.svg
new file mode 100644
index 0000000..3f086f1
--- /dev/null
+++ b/public/notifications/webhook.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/loadEnv.js b/scripts/loadEnv.js
index 8ca26cc..6e3cf3b 100644
--- a/scripts/loadEnv.js
+++ b/scripts/loadEnv.js
@@ -1,15 +1,14 @@
import { existsSync, readFileSync } from 'fs';
import path from 'path';
-import logger from '../shared/utils/logger.js';
+import logger from '../server/utils/logger.js';
const configPath = path.join(process.cwd(), 'config.json');
if (!existsSync(configPath)) {
- logger.log(
- 'SETUP',
- 'Configuration file not found. Please run "npm run setup" (or "yarn setup") to create it.',
- 'ERROR'
- );
+ logger.info('SETUP', {
+ message:
+ 'Configuration file not found. Please run "npm run setup" (or "yarn setup") to create it.',
+ });
process.exit(1);
}
@@ -24,11 +23,9 @@ process.env.CORS_LIST = config.cors;
if (process.env.NODE_ENV === 'test') {
process.env.DATABASE_NAME = 'e2e-test';
- logger.log(
- 'SETUP',
- 'Changed database name to "e2e-test" for testing purposes.',
- 'INFO'
- );
+ logger.info('SETUP', {
+ message: 'Changed database name to "e2e-test" for testing purposes.',
+ });
}
-logger.log('SETUP', 'Environment variables loaded successfully.', 'INFO');
+logger.info('SETUP', { message: 'Environment variables loaded successfully.' });
diff --git a/scripts/migrate.js b/scripts/migrate.js
index fb91941..3b1b0c4 100644
--- a/scripts/migrate.js
+++ b/scripts/migrate.js
@@ -3,14 +3,20 @@ import fs from 'fs';
import path from 'path';
// import local files
-import logger from '../shared/utils/logger.js';
-import migrationList from '../server/migrations/index.js';
+import logger from '../server/utils/logger.js';
+import migrationList from './migrations/index.js';
import { loadJSON } from '../shared/parseJson.js';
const config = loadJSON('../config.json');
const packageJson = loadJSON('../package.json');
const migrateDatabase = async () => {
+ if (config.migrationType !== 'automatic') {
+ return logger.info('MIGRATION', {
+ message: 'Manual migration selected. Skipping migration checks...',
+ });
+ }
+
const migrationListKeys = Object.keys(migrationList);
const [version, patch] = config.version.split('.');
@@ -24,26 +30,12 @@ const migrateDatabase = async () => {
});
if (validMigrations.length > 0) {
- if (config.migrationType !== 'automatic') {
- return logger.log(
- 'MIGRATION',
- 'Manual migration selected. Skipping migration...',
- 'INFO',
- false
- );
- }
-
- logger.log('MIGRATION', 'Starting automatic migration...', 'INFO', false);
+ logger.info('MIGRATION', { message: 'Starting automatic migration...' });
for (const migration of validMigrations) {
- logger.log('MIGRATION', `Running migration: ${migration}`, 'INFO', false);
+ logger.info('MIGRATION', { message: `Running migration: ${migration}` });
await migrationList[migration]();
- logger.log(
- 'MIGRATION',
- `Migration complete: ${migration}`,
- 'INFO',
- false
- );
+ logger.info('MIGRATION', { message: `Migration complete: ${migration}` });
}
const configPath = path.join(process.cwd(), 'config.json');
@@ -54,7 +46,7 @@ const migrateDatabase = async () => {
fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
- logger.log('MIGRATION', 'Automatic migration complete', 'INFO', false);
+ logger.info('MIGRATION', { message: 'Automatic migration complete' });
}
};
diff --git a/server/migrations/index.js b/scripts/migrations/index.js
similarity index 66%
rename from server/migrations/index.js
rename to scripts/migrations/index.js
index 5d647e7..e518354 100644
--- a/server/migrations/index.js
+++ b/scripts/migrations/index.js
@@ -2,9 +2,11 @@ import '../../scripts/loadEnv.js';
// import local files
import { migrate as migrateTcpUpdate } from './tcpUpdate-0-4-0.js';
+import { migrate as migrateNotifications } from './notifications-0-6-0.js';
const migrationList = {
'0.4.0': migrateTcpUpdate,
+ '0.6.0': migrateNotifications,
};
export default migrationList;
diff --git a/scripts/migrations/notifications-0-6-0.js b/scripts/migrations/notifications-0-6-0.js
new file mode 100644
index 0000000..535af3b
--- /dev/null
+++ b/scripts/migrations/notifications-0-6-0.js
@@ -0,0 +1,25 @@
+// import local files
+import SQLite from '../../server/database/sqlite/setup.js';
+import logger from '../../server/utils/logger.js';
+
+const infomation = {
+ title: 'Support for Notifications',
+ description:
+ 'Adds support for Notifications. This allows you to send notifications to users when a monitor goes down or up. This update also adds a page to the dashboard to view all notifications.',
+ version: '0.6.0',
+ breaking: true,
+};
+
+const migrate = async () => {
+ const client = await SQLite.connect();
+
+ // Add notifications object field to monitors table
+ await client.schema.alterTable('monitor', (table) => {
+ table.string('notificationId');
+ table.string('notificationType').defaultTo('All');
+ });
+
+ logger.info('Migrations', { message: '0.6.0 has been applied' });
+};
+
+export { infomation, migrate };
diff --git a/server/migrations/tcpUpdate-0-4-0.js b/scripts/migrations/tcpUpdate-0-4-0.js
similarity index 83%
rename from server/migrations/tcpUpdate-0-4-0.js
rename to scripts/migrations/tcpUpdate-0-4-0.js
index 02fda38..500b2eb 100644
--- a/server/migrations/tcpUpdate-0-4-0.js
+++ b/scripts/migrations/tcpUpdate-0-4-0.js
@@ -1,6 +1,6 @@
// import local files
-import SQLite from '../database/sqlite/setup.js';
-import logger from '../../shared/utils/logger.js';
+import SQLite from '../../server/database/sqlite/setup.js';
+import logger from '../../server/utils/logger.js';
const infomation = {
title: 'Support for TCP pings',
@@ -22,7 +22,7 @@ const migrate = async () => {
table.string('method').defaultTo(null).alter();
});
- logger.info('Migrations', '0.4.0 has been applied');
+ logger.info('Migrations', { message: '0.4.0 has been applied' });
};
export { infomation, migrate };
diff --git a/scripts/reset.js b/scripts/reset.js
index c93930b..8259276 100644
--- a/scripts/reset.js
+++ b/scripts/reset.js
@@ -4,9 +4,9 @@ import '../scripts/loadEnv.js';
import inquirer from 'inquirer';
// import local files
-import logger from '../shared/utils/logger.js';
+import logger from '../server/utils/logger.js';
import SQLite from '../server/database/sqlite/setup.js';
-import { generateHash } from '../shared/utils/hashPassword.js';
+import { generateHash } from '../server/utils/hashPassword.js';
const questions = [
{ type: 'input', name: 'email', message: 'Enter email added:' },
@@ -33,12 +33,9 @@ inquirer
.prompt(questions)
.then(async (answers) => {
if (!answers?.email) {
- logger.log(
- 'RESET PASSWORD',
- 'Please enter a valid email address.',
- 'ERROR',
- false
- );
+ logger.error('RESET PASSWORD', {
+ message: 'Please enter a valid email address.',
+ });
return;
}
@@ -48,12 +45,9 @@ inquirer
const emailExists = await client('user').where({ email }).first();
if (!emailExists) {
- logger.log(
- 'RESET PASSWORD',
- 'Email provided does not exist in the database.',
- 'ERROR',
- false
- );
+ logger.error('RESET PASSWORD', {
+ message: 'Email provided does not exist in the database.',
+ });
process.exit(0);
}
@@ -63,25 +57,19 @@ inquirer
await client('user').where({ email }).update({ password: hashedPassowrd });
- logger.log(
- 'RESET PASSWORD',
- `Password has been reset to: ${newPassword}`,
- 'INFO',
- false
- );
+ logger.notice('RESET PASSWORD', {
+ message: `Password has been reset to: ${newPassword}`,
+ });
await client.destroy();
process.exit(0);
})
.catch((error) => {
- logger.log(
- 'RESET PASSWORD',
- 'Error resetting password, please try again.',
- 'ERROR',
- false
- );
-
- logger.log('', error, 'ERROR', false);
+ logger.error('RESET PASSWORD', {
+ message: 'Error resetting password, please try again.',
+ error: error.message,
+ stack: error.stack,
+ });
process.exit(0);
});
diff --git a/scripts/setup.js b/scripts/setup.js
index aba7015..0180de3 100644
--- a/scripts/setup.js
+++ b/scripts/setup.js
@@ -5,7 +5,7 @@ import inquirer from 'inquirer';
import { v4 as uuidv4 } from 'uuid';
// import local files
-import logger from '../shared/utils/logger.js';
+import logger from '../server/utils/logger.js';
import { loadJSON } from '../shared/parseJson.js';
const packageJson = loadJSON('../package.json');
@@ -44,11 +44,10 @@ const configExists = () => {
};
if (configExists()) {
- logger.log(
- 'SETUP',
- 'Configuration file already exists. Please manually edit to overwrite or delete the file.',
- 'ERROR'
- );
+ logger.error('SETUP', {
+ message:
+ 'Configuration file already exists. Please manually edit to overwrite or delete the file.',
+ });
process.exit(0);
}
@@ -58,28 +57,25 @@ inquirer
const { port, jwtSecret, migrationType, databaseName } = answers;
if (!port || !jwtSecret || !migrationType || !databaseName) {
- logger.log('SETUP', 'Invalid input. Please try again.', 'ERROR', false);
+ logger.error('SETUP', { message: 'Invalid input. Please try again.' });
return;
}
const isIntRegex = /^\d+$/;
if (!isIntRegex.test(port)) {
- logger.log('SETUP', 'Invalid port. Please try again.', 'ERROR', false);
+ logger.error('SETUP', { message: 'Invalid port. Please try again.' });
return;
}
if (jwtSecret.length < 10) {
- logger.log(
- 'SETUP',
- 'JWT Secret Key must be at least 10 characters long.',
- 'ERROR',
- false
- );
+ logger.error('SETUP', {
+ message: 'JWT Secret Key must be at least 10 characters long.',
+ });
return;
}
- logger.log('SETUP', 'Setting up application...', 'INFO', false);
+ logger.info('SETUP', { message: 'Setting up application...' });
// write to config.json file
const configPath = path.join(process.cwd(), 'config.json');
@@ -92,18 +88,16 @@ inquirer
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
- logger.log('SETUP', 'Application setup successfully.', 'INFO', false);
+ logger.info('SETUP', { message: 'Application setup successfully.' });
process.exit(0);
})
.catch((error) => {
- logger.log('', error, 'ERROR', false);
- logger.log(
- 'SETUP',
- 'Enable to setup application. Please try again.',
- 'ERROR',
- false
- );
+ logger.error('SETUP', {
+ message: 'Enable to setup application. Please try again.',
+ error: error.message,
+ stack: error.stack,
+ });
process.exit(1);
});
diff --git a/server/cache/certificates.js b/server/cache/certificates.js
index 743409d..3d51d95 100644
--- a/server/cache/certificates.js
+++ b/server/cache/certificates.js
@@ -1,3 +1,4 @@
+import Collection from '../../shared/utils/collection.js';
import cleanCertificate from '../class/certificate.js';
import {
fetchCertificate,
@@ -7,7 +8,7 @@ import {
class Certificates {
constructor() {
- this.certificates = new Map();
+ this.certificates = new Collection();
}
async get(monitorId) {
diff --git a/server/cache/heartbeats.js b/server/cache/heartbeats.js
index 8bd7c3f..3dbf44e 100644
--- a/server/cache/heartbeats.js
+++ b/server/cache/heartbeats.js
@@ -3,6 +3,7 @@
// weekly (Last 7 days (Total of 168 monitors))
// monthly (Last 30 days (Total of 720 monitors))
+import Collection from '../../shared/utils/collection.js';
import {
fetchHeartbeats,
fetchHourlyHeartbeats,
@@ -14,9 +15,9 @@ import {
class Heartbeats {
constructor() {
- this.heartbeats = new Map();
- this.dailyHeartbeats = new Map();
- this.hourlyHeartbeats = new Map();
+ this.heartbeats = new Collection();
+ this.dailyHeartbeats = new Collection();
+ this.hourlyHeartbeats = new Collection();
}
async loadHeartbeats(monitors) {
@@ -101,8 +102,12 @@ class Heartbeats {
this.hourlyHeartbeats.set(monitorId, heartbeats);
}
- async addHeartbeat(heartbeat) {
- const heartbeats = this.heartbeats.get(heartbeat.monitorId) || [];
+ async getLastHeartbeat(monitorId) {
+ return fetchHeartbeats(monitorId, 1);
+ }
+
+ async addHeartbeat(monitorId, heartbeat) {
+ const heartbeats = this.heartbeats.get(monitorId) || [];
const databaseHeartbeat = await createHeartbeat(heartbeat);
@@ -115,7 +120,7 @@ class Heartbeats {
// add the new heartbeat to the beginning of the array (newest)
heartbeats.unshift(databaseHeartbeat);
- this.heartbeats.set(heartbeat.monitorId, heartbeats);
+ this.heartbeats.set(monitorId, heartbeats);
}
async delete(monitorId) {
diff --git a/server/cache/index.js b/server/cache/index.js
index 0a65368..4b4b674 100644
--- a/server/cache/index.js
+++ b/server/cache/index.js
@@ -2,19 +2,24 @@
import Certificates from './certificates.js';
import Heartbeats from './heartbeats.js';
import Monitor from './monitors.js';
+import Notifications from './notifications.js';
import getCertInfo from '../tools/checkCertificate.js';
import httpStatusCheck from '../tools/httpStatus.js';
import tcpStatusCheck from '../tools/tcpPing.js';
+import Collection from '../../shared/utils/collection.js';
+import NotificationServices from '../notifications/index.js';
class Master {
constructor() {
this.heartbeats = new Heartbeats();
this.certificates = new Certificates();
this.monitors = new Monitor();
- this.timeouts = new Map();
+ this.notifications = new Notifications();
+ this.timeouts = new Collection();
}
async initialise() {
+ await this.notifications.getAll();
const monitors = await this.monitors.getAll();
for (const monitor of monitors) {
@@ -24,21 +29,11 @@ class Master {
}
}
- async setTimeout(monitorId, interval = 30) {
- if (this.timeouts.has(monitorId)) {
- clearTimeout(this.timeouts.get(monitorId));
- }
-
- await this.checkStatus(monitorId);
-
- this.timeouts.set(
- monitorId,
- setTimeout(() => this.checkStatus(monitorId), interval * 1000)
- );
- }
-
async updateTcpStatus(monitor, heartbeat) {
- await this.heartbeats.addHeartbeat(heartbeat);
+ const [lastHeartbeat] = await this.heartbeats.getLastHeartbeat(
+ monitor.monitorId
+ );
+ await this.heartbeats.addHeartbeat(monitor.monitorId, heartbeat);
clearTimeout(this.timeouts.get(monitor.monitorId));
@@ -46,12 +41,13 @@ class Master {
monitor.nextCheck = monitor.lastCheck + monitor.interval * 1000;
await this.monitors.updateUptimePercentage(monitor);
+ await this.sendNotification(monitor, heartbeat, lastHeartbeat);
+
+ const timeout = heartbeat.isDown ? monitor.retryInterval : monitor.interval;
+
this.timeouts.set(
monitor.monitorId,
- setTimeout(
- () => this.checkStatus(monitor.monitorId),
- monitor.interval * 1000
- )
+ setTimeout(() => this.checkStatus(monitor.monitorId), timeout * 1000)
);
}
@@ -66,9 +62,14 @@ class Master {
return;
}
+ if (this.timeouts.has(monitorId)) {
+ clearTimeout(this.timeouts.get(monitorId));
+ }
+
if (monitor.type === 'http') {
+ const [lastHeartbeat] = await this.heartbeats.getLastHeartbeat(monitorId);
const heartbeat = await httpStatusCheck(monitor);
- await this.heartbeats.addHeartbeat(heartbeat);
+ await this.heartbeats.addHeartbeat(monitorId, heartbeat);
if (monitor.url?.toLowerCase().startsWith('https')) {
const certificate = await this.certificates.get(monitorId);
@@ -83,9 +84,15 @@ class Master {
monitor.nextCheck = monitor.lastCheck + monitor.interval * 1000;
await this.monitors.updateUptimePercentage(monitor);
+ await this.sendNotification(monitor, heartbeat, lastHeartbeat);
+
+ const timeout = heartbeat.isDown
+ ? monitor.retryInterval
+ : monitor.interval;
+
this.timeouts.set(
monitorId,
- setTimeout(() => this.checkStatus(monitorId), monitor.interval * 1000)
+ setTimeout(() => this.checkStatus(monitorId), timeout * 1000)
);
}
@@ -96,6 +103,54 @@ class Master {
clearTimeout(this.timeouts.get(monitorId));
}
}
+
+ async sendNotification(monitor, heartbeat, lastHeartbeat) {
+ try {
+ if (!lastHeartbeat) return;
+
+ const notifyOutage =
+ monitor.notificationType === 'All' ||
+ monitor.notificationType === 'Outage';
+
+ const notifyRecovery =
+ monitor.notificationType === 'All' ||
+ monitor.notificationType === 'Recovery';
+
+ const hasOutage =
+ notifyOutage && !lastHeartbeat?.isDown && heartbeat.isDown;
+
+ const hasRecovered =
+ notifyRecovery && lastHeartbeat?.isDown && !heartbeat.isDown;
+
+ if (!hasOutage && !hasRecovered) return;
+ if (!monitor.notificationId) return;
+
+ const notification = await this.notifications.getById(
+ monitor.notificationId
+ );
+
+ if (
+ !notification?.isEnabled ||
+ !NotificationServices[notification.platform]
+ ) {
+ return;
+ }
+
+ const ServiceClass = NotificationServices[notification.platform];
+
+ if (!ServiceClass) return;
+
+ const service = new ServiceClass();
+
+ if (notifyOutage) {
+ await service.send(notification, monitor, heartbeat);
+ } else {
+ await service.sendRecovery(notification, monitor, heartbeat);
+ }
+ } catch (error) {
+ console.log(error);
+ }
+ }
}
const cache = new Master();
diff --git a/server/cache/monitors.js b/server/cache/monitors.js
index 416e34f..0486347 100644
--- a/server/cache/monitors.js
+++ b/server/cache/monitors.js
@@ -1,3 +1,4 @@
+import Collection from '../../shared/utils/collection.js';
import { cleanPartialMonitor } from '../class/monitor.js';
import {
fetchMonitor,
@@ -10,7 +11,7 @@ import {
class Monitor {
constructor() {
- this.monitors = new Map();
+ this.monitors = new Collection();
}
async get(monitorId) {
@@ -29,7 +30,7 @@ class Monitor {
async getAll() {
if (this.monitors.size) {
- return this.monitors.values();
+ return this.monitors.toJSONValues();
}
const query = await fetchMonitors();
@@ -67,6 +68,8 @@ class Monitor {
interval,
retryInterval,
requestTimeout,
+ notificationId,
+ notificationType,
} = body;
const monitor = {
@@ -76,6 +79,8 @@ class Monitor {
interval,
retryInterval,
requestTimeout,
+ notificationId,
+ notificationType,
valid_status_codes: JSON.stringify(valid_status_codes),
email,
type: 'http',
@@ -86,17 +91,26 @@ class Monitor {
}
const data = await databaseFunction(monitor);
- const monitorData = {
+ const monitorData = cleanPartialMonitor({
...data,
uptimePercentage: 0,
averageHeartbeatLatency: 0,
- };
+ });
- this.monitors.set(data.monitorId, monitorData);
+ this.monitors.set(monitorData.monitorId, monitorData);
return monitorData;
} else {
- const { name, url, port, interval, retryInterval, requestTimeout } = body;
+ const {
+ name,
+ url,
+ port,
+ interval,
+ retryInterval,
+ requestTimeout,
+ notificationId,
+ notificationType,
+ } = body;
const monitor = {
name,
@@ -105,6 +119,8 @@ class Monitor {
interval,
retryInterval,
requestTimeout,
+ notificationId,
+ notificationType,
valid_status_codes: '',
email,
type: 'tcp',
@@ -115,13 +131,13 @@ class Monitor {
}
const data = await databaseFunction(monitor);
- const monitorData = {
+ const monitorData = cleanPartialMonitor({
...data,
uptimePercentage: 0,
averageHeartbeatLatency: 0,
- };
+ });
- this.monitors.set(data.monitorId, monitorData);
+ this.monitors.set(monitorData.monitorId, monitorData);
return monitorData;
}
diff --git a/server/cache/notifications.js b/server/cache/notifications.js
new file mode 100644
index 0000000..a82d120
--- /dev/null
+++ b/server/cache/notifications.js
@@ -0,0 +1,87 @@
+import Collection from '../../shared/utils/collection.js';
+import logger from '../utils/logger.js';
+import {
+ createNotification,
+ deleteNotification,
+ editNotification,
+ fetchNotificationById,
+ fetchNotifications,
+ fetchNotificationUniqueId,
+ toggleNotification,
+} from '../database/queries/notification.js';
+
+class Notifications {
+ constructor() {
+ this.notifications = new Collection();
+ }
+
+ async getAll() {
+ if (this.notifications.size) {
+ return this.notifications.toJSONValues();
+ }
+
+ const notifications = await fetchNotifications();
+ for (const notification of notifications) {
+ this.notifications.set(notification.id, notification);
+ }
+
+ return notifications;
+ }
+
+ async getById(id) {
+ if (!id) return;
+
+ const notification = this.notifications.get(id);
+
+ if (notification) {
+ return notification;
+ }
+
+ const query = await fetchNotificationById(id);
+
+ if (!query) {
+ logger.error('Notification - getById', {
+ id,
+ message: 'Notification does not exist',
+ });
+ return null;
+ }
+
+ this.notifications.set(id, query);
+
+ return query;
+ }
+
+ async create(notification, email) {
+ const uniqueId = await fetchNotificationUniqueId();
+
+ const query = await createNotification({
+ ...notification,
+ email,
+ id: uniqueId,
+ isEnabled: true,
+ });
+
+ this.notifications.set(uniqueId, query);
+ return query;
+ }
+
+ async edit(notification) {
+ const query = await editNotification(notification);
+
+ this.notifications.set(notification.id, query);
+ return query;
+ }
+
+ async toggle(id, enabled) {
+ await toggleNotification(id, enabled);
+ return;
+ }
+
+ async delete(id) {
+ await deleteNotification(id);
+ this.notifications.delete(id);
+ }
+}
+
+export default Notifications;
diff --git a/server/class/monitor.js b/server/class/monitor.js
index 3044b79..129b81d 100644
--- a/server/class/monitor.js
+++ b/server/class/monitor.js
@@ -1,6 +1,6 @@
import cleanCertificate from './certificate.js';
-const parseJson = (str) => {
+const parseJsonStatus = (str) => {
try {
return JSON.parse(str);
} catch (e) {
@@ -18,10 +18,12 @@ export const cleanPartialMonitor = (monitor) => ({
method: monitor.method,
headers: monitor.headers,
body: monitor.body,
- valid_status_codes: parseJson(monitor.valid_status_codes),
+ valid_status_codes: parseJsonStatus(monitor.valid_status_codes),
email: monitor.email,
type: monitor.type,
port: monitor.port,
+ notificationId: monitor.notificationId,
+ notificationType: monitor.notificationType,
uptimePercentage: monitor.uptimePercentage,
averageHeartbeatLatency: monitor.averageHeartbeatLatency,
});
@@ -36,10 +38,12 @@ export const cleanMonitor = ({ heartbeats = [], cert, ...monitor }) => ({
method: monitor.method,
headers: monitor.headers,
body: monitor.body,
- valid_status_codes: parseJson(monitor.valid_status_codes),
+ valid_status_codes: parseJsonStatus(monitor.valid_status_codes),
email: monitor.email,
type: monitor.type,
port: monitor.port,
+ notificationId: monitor.notificationId,
+ notificationType: monitor.notificationType,
uptimePercentage: monitor.uptimePercentage,
averageHeartbeatLatency: monitor.averageHeartbeatLatency,
lastCheck: monitor.lastCheck,
diff --git a/server/class/notification.js b/server/class/notification.js
new file mode 100644
index 0000000..624e388
--- /dev/null
+++ b/server/class/notification.js
@@ -0,0 +1,40 @@
+const parseJson = (str) => {
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ return str;
+ }
+};
+
+const stringifyJson = (obj) => {
+ try {
+ return JSON.stringify(obj);
+ } catch (e) {
+ return null;
+ }
+};
+
+export const cleanNotification = (notification) => ({
+ id: notification.id,
+ platform: notification.platform,
+ messageType: notification.messageType,
+ token: notification.token,
+ email: notification.email,
+ friendlyName: notification.friendlyName,
+ isEnabled: notification.isEnabled ? true : false,
+ data:
+ typeof notification.data === 'string'
+ ? parseJson(notification.data)
+ : notification.data,
+});
+
+export const stringifyNotification = (notification) => ({
+ id: notification.id,
+ platform: notification.platform,
+ messageType: notification.messageType,
+ token: notification.token,
+ email: notification.email,
+ friendlyName: notification.friendlyName,
+ isEnabled: notification.isEnabled ? true : false,
+ data: stringifyJson(notification.data),
+});
diff --git a/server/database/queries/heartbeat.js b/server/database/queries/heartbeat.js
index 8795f29..b121334 100644
--- a/server/database/queries/heartbeat.js
+++ b/server/database/queries/heartbeat.js
@@ -1,11 +1,11 @@
import SQLite from '../sqlite/setup.js';
-export const fetchHeartbeats = async (monitorId) => {
+export const fetchHeartbeats = async (monitorId, limit = 168) => {
const heartbeats = await SQLite.client('heartbeat')
.where({ monitorId })
.select('id', 'status', 'latency', 'date', 'isDown', 'message')
.orderBy('date', 'desc')
- .limit(168);
+ .limit(limit);
return heartbeats;
};
@@ -25,7 +25,7 @@ export const fetchLastDailyHeartbeat = async (monitorId) => {
const date = currentDate - (currentDate % 300000) - 300000;
const heartbeats = await SQLite.client('heartbeat')
- .where({ monitorId, isDown: 0 })
+ .where({ monitorId, isDown: false })
.andWhere('date', '>', date)
.orderBy('date', 'desc');
@@ -52,7 +52,7 @@ export const fetchDailyHeartbeats = async (monitorId) => {
const date = Date.now() - 86400000;
const heartbeats = await SQLite.client('heartbeat')
- .where({ monitorId, isDown: 0 })
+ .where({ monitorId, isDown: false })
.andWhere('date', '>', date)
.orderBy('date', 'desc');
diff --git a/server/database/queries/monitor.js b/server/database/queries/monitor.js
index c5dd4f4..f234ffa 100644
--- a/server/database/queries/monitor.js
+++ b/server/database/queries/monitor.js
@@ -1,5 +1,5 @@
import SQLite from '../sqlite/setup.js';
-import randomId from '../../../shared/utils/randomId.js';
+import randomId from '../../utils/randomId.js';
import { timeToMs } from '../../../shared/utils/ms.js';
import { UnprocessableError } from '../../../shared/utils/errors.js';
diff --git a/server/database/queries/notification.js b/server/database/queries/notification.js
new file mode 100644
index 0000000..fc7112f
--- /dev/null
+++ b/server/database/queries/notification.js
@@ -0,0 +1,63 @@
+import { v4 as uuidv4 } from 'uuid';
+import SQLite from '../sqlite/setup.js';
+import {
+ cleanNotification,
+ stringifyNotification,
+} from '../../class/notification.js';
+
+export const fetchNotifications = async () => {
+ const notifications = await SQLite.client('notifications').select();
+
+ return notifications.map((notification) => cleanNotification(notification));
+};
+
+export const fetchNotificationById = async (id) => {
+ if (!id) return null;
+
+ const notification = await SQLite.client('notifications')
+ .where({ id })
+ .select()
+ .first();
+
+ if (!notification) return null;
+
+ return cleanNotification(notification);
+};
+
+export const fetchNotificationUniqueId = async () => {
+ let id = uuidv4();
+
+ while (await SQLite.client('notifications').where({ id }).first()) {
+ id = uuidv4();
+ }
+
+ return id;
+};
+
+export const createNotification = async (notification) => {
+ await SQLite.client('notifications').insert(
+ stringifyNotification(notification)
+ );
+
+ return cleanNotification(notification);
+};
+
+export const editNotification = async (notification) => {
+ await SQLite.client('notifications')
+ .where({ id: notification.id })
+ .update(stringifyNotification(notification));
+
+ return cleanNotification(notification);
+};
+
+export const toggleNotification = async (id, isEnabled = true) => {
+ await SQLite.client('notifications').where({ id }).update({ isEnabled });
+
+ return;
+};
+
+export const deleteNotification = async (id) => {
+ await SQLite.client('notifications').where({ id }).del();
+ await SQLite.client('monitor').where({ id }).update({ notificationId: null });
+ return;
+};
diff --git a/server/database/queries/user.js b/server/database/queries/user.js
index a0674bb..43e9460 100644
--- a/server/database/queries/user.js
+++ b/server/database/queries/user.js
@@ -1,9 +1,6 @@
import SQLite from '../sqlite/setup.js';
-import {
- generateHash,
- verifyPassword,
-} from '../../../shared/utils/hashPassword.js';
-import { signCookie, verifyCookie } from '../../../shared/utils/jwt.js';
+import { generateHash, verifyPassword } from '../../utils/hashPassword.js';
+import { signCookie, verifyCookie } from '../../utils/jwt.js';
import {
AuthorizationError,
ConflictError,
diff --git a/server/database/sqlite/setup.js b/server/database/sqlite/setup.js
index e56c955..740d5ee 100644
--- a/server/database/sqlite/setup.js
+++ b/server/database/sqlite/setup.js
@@ -1,7 +1,7 @@
import { existsSync, closeSync, openSync } from 'fs';
import knex from 'knex';
-import logger from '../../../shared/utils/logger.js';
+import logger from '../../utils/logger.js';
export class SQLite {
constructor() {
@@ -27,7 +27,9 @@ export class SQLite {
useNullAsDefault: true,
});
- logger.info('SQLite', 'Connected to SQLite database');
+ logger.info('SQLite - connect', {
+ message: 'Connected to SQLite database',
+ });
return this.client;
}
@@ -70,12 +72,35 @@ export class SQLite {
table.text('headers');
table.text('body');
table.text('valid_status_codes').defaultTo('["200-299"]');
+ table.string('notificationId').defaultTo(null);
+ table.string('notificationType').defaultTo('All');
table.string('email').notNullable();
table.index('monitorId');
});
}
+ const notificationExists = await this.client.schema.hasTable(
+ 'notifications'
+ );
+
+ if (!notificationExists) {
+ await this.client.schema.createTable('notifications', (table) => {
+ table.string('id').notNullable().primary();
+ table.string('platform').notNullable();
+ table.string('messageType').notNullable();
+ table.text('token').notNullable();
+ table.text('email').notNullable();
+ table.boolean('isEnabled').defaultTo(1);
+ table.text('content').defaultTo(null);
+ table.string('friendlyName');
+ table.text('data');
+ table.timestamp('createdAt').defaultTo(this.client.fn.now());
+
+ table.index('id');
+ });
+ }
+
const heartbeatExists = await this.client.schema.hasTable('heartbeat');
if (!heartbeatExists) {
@@ -92,7 +117,7 @@ export class SQLite {
table.integer('status').notNullable();
table.integer('latency').notNullable();
table.timestamp('date').notNullable();
- table.boolean('isDown').defaultTo(0);
+ table.boolean('isDown').defaultTo(false);
table.text('message').notNullable();
table.index('monitorId');
diff --git a/server/index.js b/server/index.js
index c6c7949..349cab7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -8,10 +8,10 @@ import cookieParser from 'cookie-parser';
// import local files
import cache from './cache/index.js';
-import logger from '../shared/utils/logger.js';
+import logger from './utils/logger.js';
import initialiseRoutes from './routes/index.js';
import SQLite from './database/sqlite/setup.js';
-import initialiseCronJobs from './../shared/utils/cron.js';
+import initialiseCronJobs from './utils/cron.js';
import authorization from './middleware/authorization.js';
import migrateDatabase from '../scripts/migrate.js';
import isDemo from './middleware/demo.js';
@@ -23,6 +23,7 @@ const init = async () => {
await SQLite.connect();
await SQLite.setup();
await cache.initialise();
+
await migrateDatabase();
const monitors = await cache.monitors.getAll();
await cache.heartbeats.loadHeartbeats(monitors);
@@ -53,7 +54,7 @@ const init = async () => {
);
}
- logger.info('Express', 'Serving production static files');
+ logger.info('Express', { message: 'Serving production static files' });
app.use(express.static(path.join(process.cwd(), 'dist')));
}
@@ -68,14 +69,14 @@ const init = async () => {
}
app.use(authorization);
- logger.info('Express', 'Initialising routes');
+ logger.info('Express', { message: 'Initialising routes' });
initialiseRoutes(app);
if (
process.env.NODE_ENV === 'production' ||
process.env.NODE_ENV === 'test'
) {
- logger.info('Express', 'Serving production static files');
+ logger.info('Express', { message: 'Serving production static files' });
app.get('*', function (request, response) {
response.sendFile(path.join(process.cwd(), 'dist', 'index.html'));
});
@@ -84,15 +85,23 @@ const init = async () => {
// Start the server
const server_port = process.env.PORT || 2308;
app.listen(server_port, () => {
- logger.info('Express', `Server is running on port ${server_port}`);
+ logger.info('Express', {
+ message: `Server is running on port ${server_port}`,
+ });
});
process.on('uncaughtException', async (error) => {
- logger.error('Express Exception', error);
+ logger.error('Express Exception', {
+ error: error.message,
+ stack: error.stack,
+ });
});
process.on('unhandledRejection', async (error) => {
- logger.error('Express Rejection', error);
+ logger.error('Express Rejection', {
+ error: error.message,
+ stack: error.stack,
+ });
});
};
diff --git a/server/middleware/auth/login.js b/server/middleware/auth/login.js
index 9f7f4ce..25368de 100644
--- a/server/middleware/auth/login.js
+++ b/server/middleware/auth/login.js
@@ -1,10 +1,8 @@
// import local files
import { signInUser } from '../../database/queries/user.js';
import { setServerSideCookie } from '../../../shared/utils/cookies.js';
-import {
- handleError,
- UnprocessableError,
-} from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
import validators from '../../../shared/validators/index.js';
const login = async (request, response) => {
diff --git a/server/middleware/auth/logout.js b/server/middleware/auth/logout.js
index 4fdd248..1cc0da2 100644
--- a/server/middleware/auth/logout.js
+++ b/server/middleware/auth/logout.js
@@ -1,5 +1,5 @@
import { deleteCookie } from '../../../shared/utils/cookies.js';
-import { handleError } from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
import { createURL } from '../../../shared/utils/url.js';
const logout = (request, response) => {
diff --git a/server/middleware/auth/register.js b/server/middleware/auth/register.js
index 63844fe..670db4c 100644
--- a/server/middleware/auth/register.js
+++ b/server/middleware/auth/register.js
@@ -1,10 +1,8 @@
// import local files
import { registerUser, fetchMembers } from '../../database/queries/user.js';
import { setServerSideCookie } from '../../../shared/utils/cookies.js';
-import {
- handleError,
- UnprocessableError,
-} from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
import validators from '../../../shared/validators/index.js';
const register = async (request, response) => {
diff --git a/server/middleware/authorization.js b/server/middleware/authorization.js
index 4526813..6ec6074 100644
--- a/server/middleware/authorization.js
+++ b/server/middleware/authorization.js
@@ -1,6 +1,6 @@
import { userExists } from '../database/queries/user.js';
import { deleteCookie } from '../../shared/utils/cookies.js';
-import { handleError } from '../../shared/utils/errors.js';
+import { handleError } from '../utils/errors.js';
const authorization = async (request, response, next) => {
try {
diff --git a/server/middleware/monitor/add.js b/server/middleware/monitor/add.js
index 89dd974..7d8bf21 100644
--- a/server/middleware/monitor/add.js
+++ b/server/middleware/monitor/add.js
@@ -1,8 +1,6 @@
// import local files
-import {
- handleError,
- UnprocessableError,
-} from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
import validators from '../../../shared/validators/monitor.js';
import cache from '../../cache/index.js';
import { userExists } from '../../database/queries/user.js';
@@ -30,7 +28,7 @@ const monitorAdd = async (request, response) => {
isHttp
);
- await cache.setTimeout(data.monitorId, data.interval);
+ await cache.checkStatus(data.monitorId);
const heartbeats = await cache.heartbeats.get(data.monitorId);
const cert = await cache.certificates.get(data.monitorId);
diff --git a/server/middleware/monitor/delete.js b/server/middleware/monitor/delete.js
index 76ea8f7..d94302b 100644
--- a/server/middleware/monitor/delete.js
+++ b/server/middleware/monitor/delete.js
@@ -1,9 +1,7 @@
// import local files
import cache from '../../cache/index.js';
-import {
- UnprocessableError,
- handleError,
-} from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
const monitorDelete = async (request, response) => {
try {
diff --git a/server/middleware/monitor/edit.js b/server/middleware/monitor/edit.js
index 444befa..9ce456f 100644
--- a/server/middleware/monitor/edit.js
+++ b/server/middleware/monitor/edit.js
@@ -1,8 +1,6 @@
// import local files
-import {
- handleError,
- UnprocessableError,
-} from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
import validators from '../../../shared/validators/monitor.js';
import cache from '../../cache/index.js';
import { userExists } from '../../database/queries/user.js';
@@ -31,7 +29,7 @@ const monitorEdit = async (request, response) => {
true
);
- await cache.setTimeout(data.monitorId, data.interval);
+ await cache.checkStatus(data.monitorId);
const heartbeats = await cache.heartbeats.get(data.monitorId);
const cert = await cache.certificates.get(data.monitorId);
diff --git a/server/middleware/monitor/id.js b/server/middleware/monitor/id.js
index 9fe1acb..42c3fff 100644
--- a/server/middleware/monitor/id.js
+++ b/server/middleware/monitor/id.js
@@ -1,9 +1,7 @@
import cache from '../../cache/index.js';
import { cleanMonitor } from '../../class/monitor.js';
-import {
- UnprocessableError,
- handleError,
-} from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
const fetchMonitorUsingId = async (request, response) => {
try {
diff --git a/server/middleware/monitor/status.js b/server/middleware/monitor/status.js
index 8211217..e63347a 100644
--- a/server/middleware/monitor/status.js
+++ b/server/middleware/monitor/status.js
@@ -1,8 +1,6 @@
import cache from '../../cache/index.js';
-import {
- UnprocessableError,
- handleError,
-} from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
const validTypes = ['latest', 'day', 'week', 'month'];
const fetchMonitorStatus = async (request, response) => {
diff --git a/server/middleware/notifications/create.js b/server/middleware/notifications/create.js
new file mode 100644
index 0000000..d11780b
--- /dev/null
+++ b/server/middleware/notifications/create.js
@@ -0,0 +1,29 @@
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
+import NotificationValidators from '../../../shared/validators/notifications/index.js';
+import { userExists } from '../../database/queries/user.js';
+import cache from '../../cache/index.js';
+
+const NotificationCreateMiddleware = async (request, response) => {
+ const notification = request.body;
+
+ try {
+ const validator = NotificationValidators[notification?.platform];
+
+ if (!validator) {
+ throw new UnprocessableError('Invalid Notification Platform');
+ }
+
+ const result = validator({ ...notification, ...notification.data });
+
+ const user = await userExists(request.cookies.access_token);
+
+ const query = await cache.notifications.create(result, user.email);
+
+ return response.status(201).send(query);
+ } catch (error) {
+ handleError(error, response);
+ }
+};
+
+export default NotificationCreateMiddleware;
diff --git a/server/middleware/notifications/delete.js b/server/middleware/notifications/delete.js
new file mode 100644
index 0000000..719749b
--- /dev/null
+++ b/server/middleware/notifications/delete.js
@@ -0,0 +1,20 @@
+import { handleError } from '../../utils/errors.js';
+import cache from '../../cache/index.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
+
+const NotificationDeleteMiddleware = async (request, response) => {
+ const { notificationId } = request.query;
+
+ if (!notificationId) {
+ throw new UnprocessableError('No notificationId provided');
+ }
+
+ try {
+ await cache.notifications.delete(notificationId);
+ return response.status(200).send('Notification deleted');
+ } catch (error) {
+ handleError(error, response);
+ }
+};
+
+export default NotificationDeleteMiddleware;
diff --git a/server/middleware/notifications/disable.js b/server/middleware/notifications/disable.js
new file mode 100644
index 0000000..e407d2c
--- /dev/null
+++ b/server/middleware/notifications/disable.js
@@ -0,0 +1,24 @@
+import { handleError } from '../../utils/errors.js';
+import cache from '../../cache/index.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
+
+const NotificationToggleMiddleware = async (request, response) => {
+ const { notificationId, isEnabled } = request.query;
+
+ if (!notificationId) {
+ throw new UnprocessableError('No notificationId provided');
+ }
+
+ if (isEnabled !== 'true' && isEnabled !== 'false') {
+ throw new UnprocessableError('isEnabled is not a boolean');
+ }
+
+ try {
+ await cache.notifications.toggle(notificationId, isEnabled === 'true');
+ return response.sendStatus(200);
+ } catch (error) {
+ handleError(error, response);
+ }
+};
+
+export default NotificationToggleMiddleware;
diff --git a/server/middleware/notifications/edit.js b/server/middleware/notifications/edit.js
new file mode 100644
index 0000000..977271a
--- /dev/null
+++ b/server/middleware/notifications/edit.js
@@ -0,0 +1,31 @@
+import { handleError } from '../../utils/errors.js';
+import { UnprocessableError } from '../../../shared/utils/errors.js';
+import NotificationValidators from '../../../shared/validators/notifications/index.js';
+import cache from '../../cache/index.js';
+
+const NotificationEditMiddleware = async (request, response) => {
+ const notification = request.body;
+
+ try {
+ const validator = NotificationValidators[notification?.platform];
+
+ if (!validator) {
+ throw new UnprocessableError('Invalid Notification Platform');
+ }
+
+ const result = validator({ ...notification, ...notification.data });
+
+ const query = await cache.notifications.edit({
+ ...result,
+ id: notification.id,
+ email: notification.email,
+ isEnabled: notification.isEnabled,
+ });
+
+ return response.json(query);
+ } catch (error) {
+ handleError(error, response);
+ }
+};
+
+export default NotificationEditMiddleware;
diff --git a/server/middleware/notifications/getAll.js b/server/middleware/notifications/getAll.js
new file mode 100644
index 0000000..ad74ecf
--- /dev/null
+++ b/server/middleware/notifications/getAll.js
@@ -0,0 +1,14 @@
+import { handleError } from '../../utils/errors.js';
+import cache from '../../cache/index.js';
+
+const NotificationGetAllMiddleware = async (request, response) => {
+ try {
+ const notifications = await cache.notifications.getAll();
+
+ return response.json(notifications);
+ } catch (error) {
+ handleError(error, response);
+ }
+};
+
+export default NotificationGetAllMiddleware;
diff --git a/server/middleware/notifications/getUsingId.js b/server/middleware/notifications/getUsingId.js
new file mode 100644
index 0000000..ae7508c
--- /dev/null
+++ b/server/middleware/notifications/getUsingId.js
@@ -0,0 +1,22 @@
+import { handleError } from '../../utils/errors.js';
+import cache from '../../cache/index.js';
+
+const NotificationGetUsingIdMiddleware = async (request, response) => {
+ const { notificationId } = request.query;
+
+ try {
+ const notification = await cache.notifications.getById(notificationId);
+
+ if (!notification) {
+ return response.status(404).send({
+ message: 'Notification not found',
+ });
+ }
+
+ return response.status(200).send(notification);
+ } catch (error) {
+ handleError(error, response);
+ }
+};
+
+export default NotificationGetUsingIdMiddleware;
diff --git a/server/middleware/user/access/approveUser.js b/server/middleware/user/access/approveUser.js
index 3f22439..837e0cc 100644
--- a/server/middleware/user/access/approveUser.js
+++ b/server/middleware/user/access/approveUser.js
@@ -1,5 +1,5 @@
import { approveAccess } from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
+import { handleError } from '../../../utils/errors.js';
const accessApproveMiddleware = async (request, response) => {
try {
diff --git a/server/middleware/user/access/declineUser.js b/server/middleware/user/access/declineUser.js
index a8d1a23..ec52398 100644
--- a/server/middleware/user/access/declineUser.js
+++ b/server/middleware/user/access/declineUser.js
@@ -1,5 +1,5 @@
import { declineAccess } from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
+import { handleError } from '../../../utils/errors.js';
const accessDeclineMiddleware = async (request, response) => {
try {
diff --git a/server/middleware/user/access/removeUser.js b/server/middleware/user/access/removeUser.js
index 6316757..014ee5a 100644
--- a/server/middleware/user/access/removeUser.js
+++ b/server/middleware/user/access/removeUser.js
@@ -1,5 +1,5 @@
import { declineAccess } from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
+import { handleError } from '../../../utils/errors.js';
const accessRemoveMiddleware = async (request, response) => {
try {
diff --git a/server/middleware/user/deleteAccount.js b/server/middleware/user/deleteAccount.js
index d215ca4..975c0cb 100644
--- a/server/middleware/user/deleteAccount.js
+++ b/server/middleware/user/deleteAccount.js
@@ -1,5 +1,5 @@
import { userExists, declineAccess } from '../../database/queries/user.js';
-import { handleError } from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
const deleteAccountMiddleware = async (request, response) => {
try {
diff --git a/server/middleware/user/hasAdmin.js b/server/middleware/user/hasAdmin.js
index 8a9e4ac..7213bf6 100644
--- a/server/middleware/user/hasAdmin.js
+++ b/server/middleware/user/hasAdmin.js
@@ -1,5 +1,5 @@
import { userExists } from '../../database/queries/user.js';
-import { handleError } from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
const hasAdminPermissions = async (request, response, next) => {
try {
diff --git a/server/middleware/user/hasEditor.js b/server/middleware/user/hasEditor.js
index 0de5e55..78c4d87 100644
--- a/server/middleware/user/hasEditor.js
+++ b/server/middleware/user/hasEditor.js
@@ -1,5 +1,5 @@
import { userExists } from '../../database/queries/user.js';
-import { handleError } from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
const hasEditorPermissions = async (request, response, next) => {
try {
diff --git a/server/middleware/user/permission/update.js b/server/middleware/user/permission/update.js
index 8199b15..17b01bb 100644
--- a/server/middleware/user/permission/update.js
+++ b/server/middleware/user/permission/update.js
@@ -2,7 +2,7 @@ import {
userExists,
updateUserPermission,
} from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
+import { handleError } from '../../../utils/errors.js';
const permissionUpdateMiddleware = async (request, response) => {
try {
diff --git a/server/middleware/user/team/members.js b/server/middleware/user/team/members.js
index fedc1a4..1c1929b 100644
--- a/server/middleware/user/team/members.js
+++ b/server/middleware/user/team/members.js
@@ -1,5 +1,5 @@
import { fetchMembers } from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
+import { handleError } from '../../../utils/errors.js';
const teamMembersListMiddleware = async (request, response) => {
try {
diff --git a/server/middleware/user/transferOwnership.js b/server/middleware/user/transferOwnership.js
index 1afd4e5..afabd1d 100644
--- a/server/middleware/user/transferOwnership.js
+++ b/server/middleware/user/transferOwnership.js
@@ -3,7 +3,7 @@ import {
transferOwnership,
userExists,
} from '../../database/queries/user.js';
-import { handleError } from '../../../shared/utils/errors.js';
+import { handleError } from '../../utils/errors.js';
const transferOwnershipMiddleware = async (request, response) => {
try {
diff --git a/server/middleware/user/update/avatar.js b/server/middleware/user/update/avatar.js
index 3e5c1b3..fdb374b 100644
--- a/server/middleware/user/update/avatar.js
+++ b/server/middleware/user/update/avatar.js
@@ -2,7 +2,7 @@ import {
userExists,
updateUserAvatar,
} from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
+import { handleError } from '../../../utils/errors.js';
import validators from '../../../../shared/validators/index.js';
const userUpdateAvatar = async (request, response) => {
diff --git a/server/middleware/user/update/password.js b/server/middleware/user/update/password.js
index 3281d20..66c9017 100644
--- a/server/middleware/user/update/password.js
+++ b/server/middleware/user/update/password.js
@@ -2,8 +2,8 @@ import {
updateUserPassword,
userExists,
} from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
-import { verifyPassword } from '../../../../shared/utils/hashPassword.js';
+import { handleError } from '../../../utils/errors.js';
+import { verifyPassword } from '../../../utils/hashPassword.js';
import validators from '../../../../shared/validators/index.js';
const userUpdatePassword = async (request, response) => {
diff --git a/server/middleware/user/update/username.js b/server/middleware/user/update/username.js
index 7e15f80..39d6856 100644
--- a/server/middleware/user/update/username.js
+++ b/server/middleware/user/update/username.js
@@ -2,7 +2,7 @@ import {
updateUserDisplayname,
userExists,
} from '../../../database/queries/user.js';
-import { handleError } from '../../../../shared/utils/errors.js';
+import { handleError } from '../../../utils/errors.js';
import validators from '../../../../shared/validators/index.js';
const userUpdateUsername = async (request, response) => {
diff --git a/server/notifications/base.js b/server/notifications/base.js
new file mode 100644
index 0000000..ab94e9a
--- /dev/null
+++ b/server/notifications/base.js
@@ -0,0 +1,43 @@
+const parseErrorData = (data) => {
+ try {
+ JSON.stringify(data);
+ } catch (error) {
+ return data;
+ }
+};
+
+class NotificationBase {
+ name = undefined;
+ success = 'Sent Successfully!';
+
+ /**
+ * Send a notification
+ * @param {Object} notification Notification to send
+ * @param {object} monitor Monitor details
+ * @param {object} heartbeat Heartbeat details
+ * @returns {Promise} Return successful message
+ * @throws Throws error about you being a dummy :)
+ */
+
+ // eslint-disable-next-line no-unused-vars
+ async send(notification, monitor, heartbeat) {
+ throw new Error('Override this function dummy!');
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async sendRecovery(notification, monitor, heartbeat) {
+ throw new Error('Override this function dummy!');
+ }
+
+ handleError(error) {
+ let info = 'Error: ' + error + '\n';
+
+ if (error?.response?.data) {
+ info += parseErrorData(error.response.data);
+ }
+
+ throw new Error(info);
+ }
+}
+
+export default NotificationBase;
diff --git a/server/notifications/discord.js b/server/notifications/discord.js
new file mode 100644
index 0000000..d8035ad
--- /dev/null
+++ b/server/notifications/discord.js
@@ -0,0 +1,38 @@
+import axios from 'axios';
+import NotificationReplacers from '../../shared/notifications/replacers/notification.js';
+import NotificationBase from './base.js';
+import { DiscordTemplateMessages } from '../../shared/notifications/discord.js';
+
+class Discord extends NotificationBase {
+ name = 'Discord';
+
+ async send(notification, monitor, heartbeat) {
+ try {
+ const template =
+ DiscordTemplateMessages[notification.messageType] ||
+ notification.payload;
+
+ const embed = NotificationReplacers(template, monitor, heartbeat);
+
+ await axios.post(notification.token, { ...embed });
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+
+ async sendRecovery(notification, monitor, heartbeat) {
+ try {
+ const template = DiscordTemplateMessages.recovery;
+
+ const embed = NotificationReplacers(template, monitor, heartbeat);
+
+ await axios.post(notification.token, { ...embed });
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+}
+
+export default Discord;
diff --git a/server/notifications/index.js b/server/notifications/index.js
new file mode 100644
index 0000000..ca38a25
--- /dev/null
+++ b/server/notifications/index.js
@@ -0,0 +1,13 @@
+import Discord from './discord.js';
+import Telegram from './telegram.js';
+import Slack from './slack.js';
+import Webhook from './webhook.js';
+
+const NotificationServices = {
+ Discord,
+ Telegram,
+ Slack,
+ Webhook,
+};
+
+export default NotificationServices;
diff --git a/server/notifications/slack.js b/server/notifications/slack.js
new file mode 100644
index 0000000..be43591
--- /dev/null
+++ b/server/notifications/slack.js
@@ -0,0 +1,91 @@
+import axios from 'axios';
+import NotificationReplacers from '../../shared/notifications/replacers/notification.js';
+import NotificationBase from './base.js';
+import { checkObjectAgainstSchema } from '../../shared/utils/schema.js';
+import {
+ SlackSchema,
+ SlackTemplateMessages,
+} from '../../shared/notifications/slack.js';
+
+class Slack extends NotificationBase {
+ name = 'Slack';
+
+ async send(notification, monitor, heartbeat) {
+ try {
+ const payload =
+ SlackTemplateMessages[notification.messageType] || notification.payload;
+
+ if (!payload) {
+ throw new Error('Unable to find an payload');
+ }
+
+ const data = NotificationReplacers(payload, monitor, heartbeat);
+
+ if (
+ !checkObjectAgainstSchema(data, SlackSchema) ||
+ !this.validateSlackBlocks(data.blocks)
+ ) {
+ throw new Error('Parsed payload is invalid format');
+ }
+
+ await axios.post(notification.token, {
+ text: notification.text,
+ channel: notification.channel,
+ username: notification.username,
+ attachments: [data],
+ });
+
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+
+ async sendRecovery(notification, monitor, heartbeat) {
+ try {
+ const template = SlackTemplateMessages.recovery;
+
+ const data = NotificationReplacers(template, monitor, heartbeat);
+
+ if (
+ !checkObjectAgainstSchema(data, SlackSchema) ||
+ !this.validateSlackBlocks(data.blocks)
+ ) {
+ throw new Error('Parsed payload is invalid format');
+ }
+
+ await axios.post(notification.token, {
+ text: notification.text,
+ channel: notification.channel,
+ username: notification.username,
+ attachments: [data],
+ });
+
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+
+ validateSlackBlocks = (blocks) => {
+ if (!blocks?.length) {
+ return false;
+ }
+
+ return blocks.every((block = {}) => {
+ if (
+ block.type === 'section' &&
+ !block.text?.trim() &&
+ !block.fields?.length
+ ) {
+ return false; // Both fields and text is missing
+ }
+
+ if (block.fields?.length > 10) return false;
+
+ return true;
+ });
+ };
+}
+
+export default Slack;
diff --git a/server/notifications/telegram.js b/server/notifications/telegram.js
new file mode 100644
index 0000000..990f7bf
--- /dev/null
+++ b/server/notifications/telegram.js
@@ -0,0 +1,58 @@
+import axios from 'axios';
+import NotificationReplacers from '../../shared/notifications/replacers/notification.js';
+import NotificationBase from './base.js';
+import { TelegramTemplateMessages } from '../../shared/notifications/telegram.js';
+
+class Telegram extends NotificationBase {
+ name = 'Telegram';
+
+ async send(notification, monitor, heartbeat) {
+ try {
+ const url = 'https://api.telegram.org/bot';
+
+ const message =
+ TelegramTemplateMessages[notification.messageType] ||
+ notification.payload;
+
+ const params = {
+ text: message,
+ chat_id: notification.chatId,
+ disable_notification: notification.disableNotification ?? false,
+ parse_mode: notification.parseMode || 'MarkdownV2',
+ protect_content: notification.protectContent ?? false,
+ };
+
+ params.text = NotificationReplacers(message, monitor, heartbeat, true);
+
+ await axios.get(`${url}${notification.token}/sendMessage`, { params });
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+
+ async sendRecovery(notification, monitor, heartbeat) {
+ try {
+ const url = 'https://api.telegram.org/bot';
+
+ const message = TelegramTemplateMessages.recovery;
+
+ const params = {
+ text: message,
+ chat_id: notification.chatId,
+ disable_notification: notification.disableNotification ?? false,
+ parse_mode: notification.parseMode || 'MarkdownV2',
+ protect_content: notification.protectContent ?? false,
+ };
+
+ params.text = NotificationReplacers(message, monitor, heartbeat, true);
+
+ await axios.get(`${url}${notification.token}/sendMessage`, { params });
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+}
+
+export default Telegram;
diff --git a/server/notifications/webhook.js b/server/notifications/webhook.js
new file mode 100644
index 0000000..e1f7dc1
--- /dev/null
+++ b/server/notifications/webhook.js
@@ -0,0 +1,65 @@
+import axios from 'axios';
+import NotificationReplacers from '../../shared/notifications/replacers/notification.js';
+import NotificationBase from './base.js';
+import { WebhookTemplateMessages } from '../../shared/notifications/webhook.js';
+
+class Webhook extends NotificationBase {
+ name = 'Webhook';
+
+ async send(notification, monitor, heartbeat) {
+ try {
+ const message =
+ WebhookTemplateMessages[notification.messageType] ||
+ notification.payload;
+
+ let content = NotificationReplacers(message, monitor, heartbeat);
+ let headers = {};
+
+ if (notification.requestType === 'form-data') {
+ // Change to form data from json
+ const form = new FormData();
+ form.append('data', JSON.stringify(content));
+ headers = form.getHeaders();
+ content = form;
+ }
+
+ if (notification.customHeaders) {
+ headers = { ...headers, ...notification.customHeaders };
+ }
+
+ await axios.post(notification.token, content, { headers });
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+
+ async sendRecovery(notification, monitor, heartbeat) {
+ try {
+ const template = WebhookTemplateMessages.recovery;
+
+ let content = NotificationReplacers(template, monitor, heartbeat);
+
+ let headers = {};
+
+ if (notification.requestType === 'form-data') {
+ // Change to form data from json
+ const form = new FormData();
+ form.append('data', JSON.stringify(content));
+ headers = form.getHeaders();
+ content = form;
+ }
+
+ if (notification.customHeaders) {
+ headers = { ...headers, ...notification.customHeaders };
+ }
+
+ await axios.post(notification.token, content, { headers });
+ return this.success;
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+}
+
+export default Webhook;
diff --git a/server/routes/index.js b/server/routes/index.js
index 35f2360..e76a9fc 100644
--- a/server/routes/index.js
+++ b/server/routes/index.js
@@ -1,11 +1,13 @@
import authRoutes from './auth.js';
import monitorRoutes from './monitor.js';
+import notificationRoutes from './notifications.js';
import userRoutes from './user.js';
const initialiseRoutes = async (app) => {
app.use('/auth', authRoutes);
app.use('/api/monitor', monitorRoutes);
app.use('/api/user', userRoutes);
+ app.use('/api/notifications', notificationRoutes);
};
export default initialiseRoutes;
diff --git a/server/routes/notifications.js b/server/routes/notifications.js
new file mode 100644
index 0000000..6841b99
--- /dev/null
+++ b/server/routes/notifications.js
@@ -0,0 +1,19 @@
+import { Router } from 'express';
+import NotificationCreateMiddleware from '../middleware/notifications/create.js';
+import NotificationEditMiddleware from '../middleware/notifications/edit.js';
+import NotificationGetAllMiddleware from '../middleware/notifications/getAll.js';
+import NotificationGetUsingIdMiddleware from '../middleware/notifications/getUsingId.js';
+import NotificationDeleteMiddleware from '../middleware/notifications/delete.js';
+import NotificationToggleMiddleware from '../middleware/notifications/disable.js';
+import hasEditorPermissions from '../middleware/user/hasEditor.js';
+
+const router = Router();
+
+router.get('/', hasEditorPermissions, NotificationGetAllMiddleware);
+router.get('/id', hasEditorPermissions, NotificationGetUsingIdMiddleware);
+router.post('/create', hasEditorPermissions, NotificationCreateMiddleware);
+router.post('/edit', hasEditorPermissions, NotificationEditMiddleware);
+router.get('/delete', hasEditorPermissions, NotificationDeleteMiddleware);
+router.get('/toggle', hasEditorPermissions, NotificationToggleMiddleware);
+
+export default router;
diff --git a/server/routes/user.js b/server/routes/user.js
index af9ef8d..fff7e60 100644
--- a/server/routes/user.js
+++ b/server/routes/user.js
@@ -15,7 +15,7 @@ import { cleanMonitor } from '../class/monitor.js';
import userUpdatePassword from '../middleware/user/update/password.js';
import transferOwnershipMiddleware from '../middleware/user/transferOwnership.js';
import deleteAccountMiddleware from '../middleware/user/deleteAccount.js';
-import { handleError } from '../../shared/utils/errors.js';
+import { handleError } from '../utils/errors.js';
router.get('/', async (request, response) => {
try {
diff --git a/server/tools/checkCertificate.js b/server/tools/checkCertificate.js
index 3ebcf02..c100ffd 100644
--- a/server/tools/checkCertificate.js
+++ b/server/tools/checkCertificate.js
@@ -1,6 +1,6 @@
import https from 'https';
import axios from 'axios';
-import logger from '../../shared/utils/logger.js';
+import logger from '../utils/logger.js';
const getCertInfo = async (url) => {
try {
@@ -15,7 +15,10 @@ const getCertInfo = async (url) => {
return checkCertificate(response);
} catch (error) {
- logger.error('getCertInfo', error);
+ logger.error('getCertInfo', {
+ error: error.message,
+ stack: error.stack,
+ });
return { isValid: false };
}
@@ -23,7 +26,9 @@ const getCertInfo = async (url) => {
const checkCertificate = (res) => {
if (!res.request.socket) {
- logger.error('checkCertificate', 'Socket not found');
+ logger.error('checkCertificate', {
+ message: 'Socket not found',
+ });
return { isValid: false };
}
diff --git a/server/tools/httpStatus.js b/server/tools/httpStatus.js
index 335d933..2a81d36 100644
--- a/server/tools/httpStatus.js
+++ b/server/tools/httpStatus.js
@@ -2,7 +2,7 @@
import axios from 'axios';
// import local files
-import logger from '../../shared/utils/logger.js';
+import logger from '../utils/logger.js';
const httpStatusCheck = async (monitor) => {
const options = {
@@ -19,7 +19,7 @@ const httpStatusCheck = async (monitor) => {
const latency = Date.now() - startTime;
const message = `${query.status} - ${query.statusText}`;
- const isDown = query.status >= 400 ? 1 : 0;
+ const isDown = query.status >= 400 ? true : false;
const status = query.status;
return {
@@ -32,10 +32,9 @@ const httpStatusCheck = async (monitor) => {
} catch (error) {
const endTime = Date.now();
- logger.error(
- 'HTTP Status Check',
- `Issue checking monitor ${monitor.monitorId}: ${error.message}`
- );
+ logger.error('HTTP Status Check', {
+ message: `Issue checking monitor ${monitor.monitorId}: ${error.message}`,
+ });
if (error.response) {
const failedResponse = error.response;
@@ -58,7 +57,7 @@ const httpStatusCheck = async (monitor) => {
status: 0,
latency: endTime - startTime,
message: error.message,
- isDown: 1,
+ isDown: true,
};
}
};
diff --git a/server/tools/tcpPing.js b/server/tools/tcpPing.js
index 7935b68..4e6ef4e 100644
--- a/server/tools/tcpPing.js
+++ b/server/tools/tcpPing.js
@@ -2,7 +2,7 @@
import net from 'net';
// import local files
-import logger from '../../shared/utils/logger.js';
+import logger from '../utils/logger.js';
const tcpStatusCheck = async (monitor, callback) => {
const socket = new net.Socket();
@@ -18,17 +18,16 @@ const tcpStatusCheck = async (monitor, callback) => {
const latency = Date.now() - startTime;
socket.destroy();
- logger.error(
- 'TCP Status Check',
- `Issue checking monitor ${monitor.monitorId}: TIMED OUT`
- );
+ logger.error('TCP Status Check', {
+ message: `Issue checking monitor ${monitor.monitorId}: TIMED OUT`,
+ });
callback(monitor, {
monitorId: monitor.monitorId,
status: 'TIMEOUT',
latency,
message: 'Ping timed out!',
- isDown: 1,
+ isDown: true,
});
});
@@ -41,7 +40,7 @@ const tcpStatusCheck = async (monitor, callback) => {
status: 'ALIVE',
latency,
message: 'Up and running!',
- isDown: 0,
+ isDown: false,
});
});
@@ -49,17 +48,16 @@ const tcpStatusCheck = async (monitor, callback) => {
const latency = Date.now() - startTime;
socket.destroy();
- logger.error(
- 'TCP Status Check',
- `Issue checking monitor ${monitor.monitorId}: ${error.message} `
- );
+ logger.error('TCP Status Check', {
+ message: `Issue checking monitor ${monitor.monitorId}: ${error.message}`,
+ });
callback(monitor, {
monitorId: monitor.monitorId,
status: 'ERROR',
latency,
message: error.message,
- isDown: 1,
+ isDown: true,
});
});
};
diff --git a/shared/utils/cron.js b/server/utils/cron.js
similarity index 82%
rename from shared/utils/cron.js
rename to server/utils/cron.js
index 16f4137..f758d86 100644
--- a/shared/utils/cron.js
+++ b/server/utils/cron.js
@@ -2,12 +2,12 @@
import { CronJob } from 'cron';
// import local files
-import cache from '../../server/cache/index.js';
-import logger from './logger.js';
+import cache from '../cache/index.js';
+import logger from '../utils/logger.js';
import {
fetchHeartbeatsByDate,
fetchLastDailyHeartbeat,
-} from '../../server/database/queries/heartbeat.js';
+} from '../database/queries/heartbeat.js';
// fetch all monitors
// fetch only heartbeats that are up for each monitor
@@ -15,12 +15,15 @@ import {
// create a new record in the database
async function initialiseCronJobs() {
- logger.info('Cron', 'Initialising cron jobs');
+ logger.info('Cron', { message: 'Initialising cron jobs' });
+ // Evert hour
new CronJob(
'0 * * * *',
async function () {
- logger.info('Cron', 'Running hourly cron job for creating heartbeat');
+ logger.info('Cron', {
+ message: 'Running hourly cron job for creating heartbeat',
+ });
const monitors = await cache.monitors.getKeys();
@@ -71,7 +74,9 @@ async function initialiseCronJobs() {
new CronJob(
'*/5 * * * *',
async function () {
- logger.info('Cron', 'Running 5 minute cron job for fetching heartbeats');
+ logger.info('Cron', {
+ message: 'Running 5 minute cron job for fetching heartbeats',
+ });
const monitors = await cache.monitors.getKeys();
diff --git a/server/utils/errors.js b/server/utils/errors.js
new file mode 100644
index 0000000..b1a1951
--- /dev/null
+++ b/server/utils/errors.js
@@ -0,0 +1,43 @@
+import {
+ AuthorizationError,
+ ConflictError,
+ UnprocessableError,
+ NotificationValidatorError,
+} from '../../shared/utils/errors.js';
+import logger from '../utils/logger.js';
+
+const handleError = (error, response) => {
+ logger.error('Error handler', { error: error.message, stack: error.stack });
+
+ if (!response.headersSent) {
+ if (error instanceof AuthorizationError) {
+ return response.status(401).send({
+ message: error.error,
+ });
+ }
+
+ if (error instanceof ConflictError) {
+ return response.status(409).send({
+ message: error.error,
+ });
+ }
+
+ if (error instanceof UnprocessableError) {
+ return response.status(422).send({
+ message: error.error,
+ });
+ }
+
+ if (error instanceof NotificationValidatorError) {
+ return response.status(422).send({
+ [error.key]: error.message,
+ });
+ }
+
+ return response.status(500).send({
+ message: 'Something went wrong',
+ });
+ }
+};
+
+export { handleError };
diff --git a/shared/utils/hashPassword.js b/server/utils/hashPassword.js
similarity index 100%
rename from shared/utils/hashPassword.js
rename to server/utils/hashPassword.js
diff --git a/shared/utils/jwt.js b/server/utils/jwt.js
similarity index 100%
rename from shared/utils/jwt.js
rename to server/utils/jwt.js
diff --git a/server/utils/logger.js b/server/utils/logger.js
new file mode 100644
index 0000000..d25b8f5
--- /dev/null
+++ b/server/utils/logger.js
@@ -0,0 +1,71 @@
+import path from 'path';
+import winston from 'winston';
+import 'winston-daily-rotate-file';
+
+const buildLogger = () => {
+ const isProduction = process.env.NODE_ENV === 'production';
+
+ const levels = { error: 0, warn: 1, info: 2, notice: 3, debug: 4 };
+
+ const colors = {
+ error: 'red',
+ warn: 'yellow',
+ info: 'green',
+ notice: 'magenta',
+ debug: 'cyan',
+ };
+
+ class TimestampFirst {
+ transform(obj) {
+ return Object.assign({ timestamp: Date.now() }, obj);
+ }
+ }
+
+ const timestampFormat = winston.format.combine(
+ new TimestampFirst(),
+ winston.format.json()
+ );
+
+ const winLogger = winston.createLogger({
+ format: timestampFormat,
+ defaultMeta: { service: 'bot' },
+ levels,
+ });
+
+ if (!isProduction) {
+ const colorize = new winston.transports.Console({
+ format: winston.format.combine(
+ winston.format.colorize({ colors }),
+ winston.format.simple()
+ ),
+ });
+
+ winLogger.add(colorize);
+ }
+
+ const transporter = new winston.transports.DailyRotateFile({
+ filename: path.join(process.cwd(), `/logs/log-%DATE%.log`),
+ datePattern: 'w-YYYY',
+ format: winston.format.combine(winston.format.json()),
+ zippedArchive: true,
+ maxFiles: 7,
+ maxSize: '50m',
+ });
+
+ winLogger.add(transporter);
+
+ return {
+ error: (message, data = {}) => winLogger.error(message, data),
+ warn: (message, data = {}) =>
+ isProduction ? null : winLogger.warn(message, data),
+ info: (message, data = {}) =>
+ isProduction ? null : winLogger.info(message, data),
+ notice: (message, data = {}) => winLogger.notice(message, data),
+ debug: (message, data = {}) =>
+ isProduction ? null : winLogger.debug(message, data),
+ };
+};
+
+const logger = buildLogger();
+
+export default logger;
diff --git a/shared/utils/randomId.js b/server/utils/randomId.js
similarity index 100%
rename from shared/utils/randomId.js
rename to server/utils/randomId.js
diff --git a/shared/notifications/discord.js b/shared/notifications/discord.js
new file mode 100644
index 0000000..7049768
--- /dev/null
+++ b/shared/notifications/discord.js
@@ -0,0 +1,162 @@
+const DiscordSchema = {
+ content: {
+ _type: 'string',
+ _validate: (value) => value?.length <= 2000,
+ _required: false,
+ },
+ tts: {
+ _type: 'boolean',
+ _required: false,
+ },
+ embeds: {
+ _type: 'array',
+ _required: false,
+ _keys: {
+ color: {
+ _type: 'number',
+ _validate: (value) => value >= 0 && value <= 16777215,
+ _required: false,
+ },
+ author: {
+ _type: 'object',
+ _required: false,
+ _keys: {
+ name: {
+ _type: 'string',
+ },
+ url: {
+ _type: 'string',
+ },
+ icon_url: {
+ _type: 'string',
+ },
+ },
+ },
+ title: {
+ _type: 'string',
+ _validate: (value) => value.length > 0 && value.length <= 3000,
+ _required: false,
+ },
+ url: {
+ _type: 'string',
+ _validate: (value) => value.length > 0 && value.length <= 3000,
+ _required: false,
+ },
+ description: {
+ _type: 'string',
+ _validate: (value) => value.length > 0 && value.length <= 3000,
+ _required: false,
+ },
+ fields: {
+ _type: 'array',
+ _required: false,
+ _keys: {
+ name: {
+ _type: 'string',
+ _validate: (value) => value.length > 0 && value.length <= 3000,
+ _required: true,
+ },
+ value: {
+ _type: 'string',
+ _validate: (value) => value.length > 0 && value.length <= 3000,
+ _required: true,
+ },
+ inline: {
+ _type: 'boolean',
+ _required: false,
+ },
+ },
+ },
+ thumbnail: {
+ _type: 'object',
+ _required: false,
+ _keys: {
+ url: {
+ _type: 'string',
+ },
+ },
+ },
+ image: {
+ _type: 'object',
+ _required: false,
+ _keys: {
+ url: {
+ _type: 'string',
+ },
+ },
+ },
+ footer: {
+ _type: 'object',
+ _required: false,
+ _keys: {
+ text: {
+ _type: 'string',
+ },
+ icon_url: {
+ _type: 'string',
+ },
+ },
+ },
+ },
+ },
+ allowed_mentions: {
+ _type: 'object',
+ _required: false,
+ _keys: {
+ parse: {
+ _type: 'array',
+ _required: false,
+ _validate: (value) => {
+ if (!value?.length) return true; // Nothing has been passed in
+ const validParseTypes = ['roles', 'users', 'everyone'];
+ return value.every((value) => validParseTypes.includes(value));
+ },
+ },
+ roles: { _type: 'array' },
+ users: { _type: 'array' },
+ },
+ },
+};
+
+const DiscordTemplateMessages = {
+ basic: {
+ embeds: [
+ {
+ color: 12061255,
+ title: 'Triggered: Service {{service_name}} is currently down!',
+ },
+ ],
+ },
+ pretty: {
+ embeds: [
+ {
+ color: 12061255,
+ title: 'Triggered: Service {{service_name}} is currently down!',
+ description:
+ '**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: {
+ embeds: [
+ {
+ color: 12061255,
+ title: 'Triggered: Service {{service_name}} is currently down!',
+ description:
+ '**Service**\n```{{service_parsed_json}}```\n\n**Heartbeat**\n```{{heartbeat_parsed_json}}```',
+ footer: { text: `{{date[YYYY-MM-DDTHH:mm:ssZ[Z]]}}` },
+ },
+ ],
+ },
+ recovery: {
+ embeds: [
+ {
+ color: 12061255,
+ title: 'Service {{service_name}} is back up!',
+ footer: { text: `{{date[YYYY-MM-DDTHH:mm:ssZ[Z]]}}` },
+ },
+ ],
+ },
+};
+
+export { DiscordSchema, DiscordTemplateMessages };
diff --git a/shared/notifications/index.js b/shared/notifications/index.js
new file mode 100644
index 0000000..4056b41
--- /dev/null
+++ b/shared/notifications/index.js
@@ -0,0 +1,13 @@
+import { DiscordTemplateMessages } from './discord';
+import { SlackTemplateMessages } from './slack';
+import { TelegramTemplateMessages } from './telegram';
+import { WebhookTemplateMessages } from './webhook';
+
+const NotificationsTemplates = {
+ Discord: DiscordTemplateMessages,
+ Slack: SlackTemplateMessages,
+ Telegram: TelegramTemplateMessages,
+ Webhook: WebhookTemplateMessages,
+};
+
+export default NotificationsTemplates;
diff --git a/shared/notifications/replacers/date.js b/shared/notifications/replacers/date.js
new file mode 100644
index 0000000..d3ccf27
--- /dev/null
+++ b/shared/notifications/replacers/date.js
@@ -0,0 +1,14 @@
+import dayjs from 'dayjs';
+
+const dateFormatRegex = /\{\{\s*date\s*\[(.+?)\]\s*\}\}/g;
+
+const DateReplacer = (text, heartbeat = {}) => {
+ return text.replace(dateFormatRegex, (_, format) => {
+ const date = dayjs(heartbeat.date).format(format);
+ return date;
+ });
+};
+
+const hasDate = (text) => dateFormatRegex.test(text);
+
+export { hasDate, DateReplacer };
diff --git a/shared/notifications/replacers/notification.js b/shared/notifications/replacers/notification.js
new file mode 100644
index 0000000..d49b48d
--- /dev/null
+++ b/shared/notifications/replacers/notification.js
@@ -0,0 +1,116 @@
+import { hasDate, DateReplacer } from './date.js';
+
+const parseJson = (value, isString) => {
+ try {
+ return isString
+ ? JSON.stringify(value, null, 2)
+ : JSON.stringify(value)
+ .replace(/[{}]/g, '')
+ .replace(/":/g, ': ')
+ .replace(/"/g, '')
+ .replace(/,/g, '\\n');
+ } catch (error) {
+ return value;
+ }
+};
+
+const parseService = (service = {}) => {
+ const {
+ monitorId,
+ name,
+ url,
+ interval,
+ retryInterval,
+ requestTimeout,
+ method,
+ valid_status_codes,
+ email,
+ type,
+ port,
+ } = service;
+
+ return parseJson({
+ monitorId,
+ name,
+ url,
+ interval,
+ retryInterval,
+ requestTimeout,
+ method,
+ validStatusCodes: valid_status_codes,
+ email,
+ type,
+ port,
+ });
+};
+
+const parseHeartbeat = (heartbeat = {}) => {
+ const { monitorId, status, latency, date, isDown, message } = heartbeat;
+ return parseJson({ monitorId, status, latency, date, isDown, message });
+};
+
+const NotificationReplacers = (
+ input,
+ service = {},
+ heartbeat = {},
+ isString = false
+) => {
+ try {
+ const text = typeof input === 'object' ? JSON.stringify(input) : input;
+
+ const replacers = {
+ '{{service_monitorId}}': service.monitorId,
+ '{{service_name}}': service.name,
+ '{{service_url}}': service.url,
+ '{{service_interval}}': service.interval,
+ '{{service_retryInterval}}': service.retryInterval,
+ '{{service_requestTimeout}}': service.requestTimeout,
+ '{{service_method}}': service.method,
+ '{{service_validStatusCodes}}': service.valid_status_codes,
+ '{{service_email}}': service.email,
+ '{{service_type}}': service.type,
+ '{{service_port}}': service.port,
+ '{{service_address}}':
+ service.type === 'http'
+ ? service.url
+ : `${service.url}:${service.port}`,
+ '{{service_json}}': parseJson(service, true),
+ '{{service_parsed_json}}': parseService(service),
+ '{{heartbeat_status}}': heartbeat.status,
+ '{{heartbeat_latency}}': heartbeat.latency,
+ '{{heartbeat_date}}': heartbeat.date,
+ '{{heartbeat_isDown}}': heartbeat.isDown,
+ '{{heartbeat_message}}': heartbeat.message,
+ '{{heartbeat_json}}': parseJson(heartbeat, true),
+ '{{heartbeat_parsed_json}}': parseHeartbeat(heartbeat),
+ };
+
+ const notificationRegex = new RegExp(
+ Object.keys(replacers)
+ .map((item) => {
+ const escapedItem = item.replace(/[{}]/g, '');
+ return `{{\\s*${escapedItem}\\s*}}`;
+ })
+ .join('|'),
+ 'gi'
+ );
+
+ const updatedText = text.replace(
+ notificationRegex,
+ (matched) => replacers[matched.replace(/ /g, '')]
+ );
+
+ if (hasDate(updatedText)) {
+ return isString
+ ? DateReplacer(updatedText, heartbeat)
+ : JSON.parse(DateReplacer(updatedText, heartbeat));
+ }
+
+ return isString ? updatedText : JSON.parse(updatedText);
+ } catch (error) {
+ console.log(error);
+ throw error;
+ }
+};
+
+export default NotificationReplacers;
diff --git a/shared/notifications/slack.js b/shared/notifications/slack.js
new file mode 100644
index 0000000..7dbfc55
--- /dev/null
+++ b/shared/notifications/slack.js
@@ -0,0 +1,121 @@
+const SlackSchema = {
+ text: { _type: 'string', _validate: (value) => value?.length <= 4000 },
+ color: {
+ _type: 'string',
+ _validate: (value) => /^#(?:[0-9a-f]{3}){1,2}$/i.test(value),
+ },
+ blocks: {
+ _type: 'array',
+ _keys: {
+ type: {
+ _type: 'string',
+ _validate: (value) =>
+ value === 'divider' || value === 'header' || value === 'section',
+ _required: true,
+ },
+ text: {
+ _type: 'object',
+ _required: true,
+ _keys: {
+ type: {
+ _type: 'string',
+ _validate: (value) => value === 'plain_text',
+ _required: true,
+ },
+ text: {
+ _type: 'string',
+ _validate: (value) => value?.length > 0 && value?.length <= 150,
+ _required: true,
+ },
+ },
+ },
+ fields: {
+ _type: 'array',
+ _required: false,
+ _keys: {
+ type: {
+ _type: 'string',
+ _validate: (value) => value === 'plain_text' || value === 'mrkdwn',
+ _required: true,
+ },
+ text: {
+ _type: 'string',
+ _validate: (value) => value?.length > 0 && value?.length <= 3000,
+ _required: true,
+ },
+ },
+ },
+ },
+ },
+};
+
+const SlackTemplateMessages = {
+ basic: {
+ color: '#b80a47',
+ blocks: [
+ {
+ type: 'header',
+ text: {
+ type: 'plain_text',
+ text: 'Triggered: Service {{service_name}} is currently down!',
+ },
+ },
+ ],
+ },
+ pretty: {
+ color: '#b80a47',
+ blocks: [
+ {
+ type: 'header',
+ text: {
+ type: 'plain_text',
+ text: 'Triggered: Service {{service_name}} is currently down!',
+ },
+ },
+ {
+ type: 'section',
+ fields: [
+ {
+ type: 'mrkdwn',
+ text: '*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: {
+ color: '#b80a47',
+ blocks: [
+ {
+ type: 'header',
+ text: {
+ type: 'plain_text',
+ text: 'Triggered: Service {{service_name}} is currently down!',
+ },
+ },
+ {
+ type: 'section',
+ fields: [
+ {
+ type: 'mrkdwn',
+ text: '*Service*\n```{{service_parsed_json}}```\n\n*Heartbeat*\n```{{heartbeat_parsed_json}}```',
+ },
+ ],
+ },
+ ],
+ },
+ recovery: {
+ color: '#b80a47',
+ blocks: [
+ {
+ type: 'header',
+ text: {
+ type: 'plain_text',
+ text: 'Service {{service_name}} is back up!',
+ },
+ },
+ ],
+ },
+};
+
+export { SlackSchema, SlackTemplateMessages };
diff --git a/shared/notifications/telegram.js b/shared/notifications/telegram.js
new file mode 100644
index 0000000..8c07b4b
--- /dev/null
+++ b/shared/notifications/telegram.js
@@ -0,0 +1,10 @@
+const TelegramTemplateMessages = {
+ basic: '*Triggered: Service {{service_name}} is currently down!*',
+ pretty:
+ '*Triggered: Service {{service_name}} is currently down!*\n\n*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:
+ '*Triggered: Service {{service_name}} is currently down!*\n\n*Service*\n```{{service_json}}```\n\n*Heartbeat*\n```{{heartbeat_json}}```',
+ recovery: '*Service {{service_name}} is back up!*',
+};
+
+export { TelegramTemplateMessages };
diff --git a/shared/notifications/webhook.js b/shared/notifications/webhook.js
new file mode 100644
index 0000000..9183ffd
--- /dev/null
+++ b/shared/notifications/webhook.js
@@ -0,0 +1,45 @@
+const WebhookTemplateMessages = {
+ basic: {
+ title: 'Triggered: Service {{service_name}} is currently down!',
+ time: '{{date[YYYY-MM-DDTHH:mm:ssZ[Z]]}}',
+ },
+ pretty: {
+ title: 'Triggered: Service {{service_name}} is currently down!',
+ time: '{{date[YYYY-MM-DDTHH:mm:ssZ[Z]]}}',
+ service_name: '{{service_name}}',
+ service_address: '{{service_address}}',
+ heartbeat_latency: '{{heartbeat_latency}}',
+ heartbeat_error: '{{heartbeat_message}}',
+ },
+ nerdy: {
+ title: 'Triggered: Service {{service_name}} is currently down!',
+ time: '{{date[YYYY-MM-DDTHH:mm:ssZ[Z]]}}',
+ service: {
+ monitorId: '{{service_monitorId}}',
+ name: '{{service_name}}',
+ url: '{{service_url}}',
+ interval: '{{service_interval}}',
+ retryInterval: '{{service_retryInterval}}',
+ requestTimeout: '{{service_requestTimeout}}',
+ method: '{{service_method}}',
+ validStatusCodes: '{{service_validStatusCodes}}',
+ email: '{{service_email}}',
+ type: '{{service_type}}',
+ port: '{{service_port}}',
+ address: '{{service_address}}',
+ },
+ heartbeat: {
+ status: '{{heartbeat_status}}',
+ latency: '{{heartbeat_latency}}',
+ date: '{{heartbeat_date}}',
+ isDown: '{{heartbeat_isDown}}',
+ message: '{{heartbeat_message}}',
+ },
+ },
+ recovery: {
+ title: 'Service {{service_name}} is back up!',
+ time: '{{date[YYYY-MM-DDTHH:mm:ssZ[Z]]}}',
+ },
+};
+
+export { WebhookTemplateMessages };
diff --git a/shared/utils/collection.js b/shared/utils/collection.js
new file mode 100644
index 0000000..f8f5eff
--- /dev/null
+++ b/shared/utils/collection.js
@@ -0,0 +1,136 @@
+class Collection extends Map {
+ constructor() {
+ super();
+ }
+
+ static defaultSort(firstValue, secondValue) {
+ return (
+ Number(firstValue > secondValue) || Number(firstValue === secondValue) - 1
+ );
+ }
+
+ hasAll(...keys) {
+ return keys.every((key) => super.has(key));
+ }
+
+ hasAny(...keys) {
+ return keys.some((key) => super.has(key));
+ }
+
+ every(fn, thisArg) {
+ if (typeof fn !== 'function')
+ throw new TypeError(`${fn} is not a function`);
+ if (thisArg !== undefined) fn = fn.bind(thisArg);
+ for (const [key, val] of this) {
+ if (!fn(val, key, this)) return false;
+ }
+
+ return true;
+ }
+
+ some(fn, thisArg) {
+ if (typeof fn !== 'function')
+ throw new TypeError(`${fn} is not a function`);
+ if (thisArg !== undefined) fn = fn.bind(thisArg);
+ for (const [key, val] of this) {
+ if (fn(val, key, this)) return true;
+ }
+
+ return false;
+ }
+
+ find(fn, thisArg) {
+ if (typeof fn !== 'function')
+ throw new TypeError(`${fn} is not a function`);
+ if (thisArg !== undefined) fn = fn.bind(thisArg);
+ for (const [key, val] of this) {
+ if (fn(val, key, this)) return val;
+ }
+
+ return undefined;
+ }
+
+ findKey(fn, thisArg) {
+ if (typeof fn !== 'function')
+ throw new TypeError(`${fn} is not a function`);
+ if (thisArg !== undefined) fn = fn.bind(thisArg);
+ for (const [key, val] of this) {
+ if (fn(val, key, this)) return key;
+ }
+
+ return undefined;
+ }
+
+ map(fn, thisArg) {
+ if (typeof fn !== 'function')
+ throw new TypeError(`${fn} is not a function`);
+ if (thisArg !== undefined) fn = fn.bind(thisArg);
+ const iter = this.entries();
+ return Array.from({ length: this.size }, () => {
+ const [key, value] = iter.next().value;
+ return fn(value, key, this);
+ });
+ }
+
+ filter(fn, thisArg) {
+ if (typeof fn !== 'function')
+ throw new TypeError(`${fn} is not a function`);
+ if (thisArg !== undefined) fn = fn.bind(thisArg);
+ const results = new this.constructor[Symbol.species]();
+ for (const [key, val] of this) {
+ if (fn(val, key, this)) results.set(key, val);
+ }
+
+ return results;
+ }
+
+ reduce(fn, initialValue) {
+ if (typeof fn !== 'function')
+ throw new TypeError(`${fn} is not a function`);
+ let accumulator;
+
+ const iterator = this.entries();
+ if (initialValue === undefined) {
+ if (this.size === 0)
+ throw new TypeError('Reduce of empty collection with no initial value');
+ accumulator = iterator.next().value[1];
+ } else {
+ accumulator = initialValue;
+ }
+
+ for (const [key, value] of iterator) {
+ accumulator = fn(accumulator, value, key, this);
+ }
+
+ return accumulator;
+ }
+
+ sort(compareFunction = Collection.defaultSort) {
+ const entries = [...this.entries()];
+ entries.sort((a, b) => compareFunction(a[1], b[1], a[0], b[0]));
+
+ // Perform clean-up
+ super.clear();
+
+ // Set the new entries
+ for (const [key, value] of entries) {
+ super.set(key, value);
+ }
+
+ return this;
+ }
+
+ toJSON() {
+ return [...this.entries()];
+ }
+
+ toJSONValues() {
+ return [...this.values()];
+ }
+
+ toJSONKeys() {
+ return [...this.keys()];
+ }
+}
+
+export default Collection;
diff --git a/shared/utils/errors.js b/shared/utils/errors.js
index a8d6e62..42b4b80 100644
--- a/shared/utils/errors.js
+++ b/shared/utils/errors.js
@@ -1,5 +1,3 @@
-import logger from './logger.js';
-
class AuthorizationError extends Error {
constructor(error) {
super();
@@ -24,32 +22,18 @@ class ConflictError extends Error {
}
}
-const handleError = (error, response) => {
- logger.error('Error handler', error.message + error.stack);
-
- if (!response.headersSent) {
- if (error instanceof AuthorizationError) {
- return response.status(401).send({
- message: error.error,
- });
- }
-
- if (error instanceof ConflictError) {
- return response.status(409).send({
- message: error.error,
- });
- }
-
- if (error instanceof UnprocessableError) {
- return response.status(422).send({
- message: error.error,
- });
- }
-
- return response.status(500).send({
- message: 'Something went wrong',
- });
+class NotificationValidatorError extends Error {
+ constructor(key, error) {
+ super();
+ this.name = 'NotificationValidatorError';
+ this.key = key;
+ this.message = error;
}
-};
+}
-export { AuthorizationError, ConflictError, UnprocessableError, handleError };
+export {
+ AuthorizationError,
+ ConflictError,
+ NotificationValidatorError,
+ UnprocessableError,
+};
diff --git a/shared/utils/logger.js b/shared/utils/logger.js
deleted file mode 100644
index 69b0e2c..0000000
--- a/shared/utils/logger.js
+++ /dev/null
@@ -1,40 +0,0 @@
-class LogMethods {
- log(section, msg, level, withDate = true) {
- let importance = {
- INFO: '\x1b[96m[INFO]\x1b[39m',
- WARN: '\x1b[33m[WARN]\x1b[39m',
- ERROR: '\x1b[91m[ERROR]\x1b[39m',
- };
- const message = `${
- typeof msg === 'string'
- ? `${withDate ? `${new Date().toISOString()} ` : ''}[${section}] ${
- importance[level]
- }: ${msg}`
- : msg
- }`;
- if (level === 'INFO') {
- console.info(message);
- } else if (level === 'WARN') {
- console.warn(message);
- } else if (level === 'ERROR') {
- console.error(message);
- } else {
- console.log(message);
- }
- }
- info(section, msg) {
- this.log(section, msg, 'INFO');
- }
-
- warn(section, msg) {
- this.log(section, msg, 'WARN');
- }
-
- error(section, msg) {
- this.log(section, msg, 'ERROR');
- }
-}
-
-const logger = new LogMethods();
-
-export default logger;
diff --git a/shared/utils/propTypes.js b/shared/utils/propTypes.js
index 4b29332..37e0bb7 100644
--- a/shared/utils/propTypes.js
+++ b/shared/utils/propTypes.js
@@ -32,7 +32,7 @@ const heartbeatPropType = PropTypes.shape({
status: PropTypes.number.isRequired,
latency: PropTypes.number.isRequired,
date: PropTypes.number.isRequired,
- isDown: PropTypes.number.isRequired,
+ isDown: PropTypes.bool.isRequired,
message: PropTypes.string.isRequired,
});
diff --git a/shared/utils/schema.js b/shared/utils/schema.js
new file mode 100644
index 0000000..1b8b5f7
--- /dev/null
+++ b/shared/utils/schema.js
@@ -0,0 +1,40 @@
+function checkObjectAgainstSchema(object, schema) {
+ for (const key in object) {
+ // If key doesn't exist then throw an error
+
+ if (!schema?.hasOwnProperty(key)) {
+ throw new Error('Invalid key provided: ' + key);
+ } else {
+ const requirements = schema[key];
+ const data = object[key];
+
+ if (requirements._type === 'object' || requirements._type === 'array') {
+ if (Array.isArray(data)) {
+ if (!requirements._keys) {
+ if (requirements._validate && !requirements._validate(data)) {
+ throw new Error('Unable to validate key: ' + key);
+ }
+ } else {
+ checkArrayAgainstSchema(data, requirements._keys);
+ }
+ } else {
+ checkObjectAgainstSchema(data, requirements._keys);
+ }
+ } else if (typeof data === requirements._type) {
+ if (requirements._validate && !requirements._validate(data)) {
+ throw new Error('Unable to validate key: ' + key);
+ }
+ } else {
+ throw new Error('Invalid key type: ' + key);
+ }
+ }
+ }
+
+ return true;
+}
+
+function checkArrayAgainstSchema(objArr, schema) {
+ return objArr.map((obj) => checkObjectAgainstSchema(obj, schema));
+}
+
+export { checkArrayAgainstSchema, checkObjectAgainstSchema };
diff --git a/shared/validators/auth.js b/shared/validators/auth.js
index b9d306e..ce43c55 100644
--- a/shared/validators/auth.js
+++ b/shared/validators/auth.js
@@ -2,8 +2,8 @@
const usernameRegex = /^[a-zA-Z0-9_\- ]{3,32}$/;
// regex to check if email is valid
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,254}$/;
-// regex to check if one letter, one number or special character, atleast 8 characters long and max of 64 characters long
-const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[0-9!@#$%^&*~_\-+=]).{8,64}$/;
+// regex to check if one letter, one number or special character, atleast 8 characters long and max of 48 characters long
+const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[0-9!@#$%^&*~_\-+=]).{8,48}$/;
const email = (email = '') => {
if (email.length < 3 || email.length > 254) {
@@ -33,8 +33,8 @@ const username = (username = '') => {
};
const password = (password = '') => {
- if (password.length < 8 || password.length > 64) {
- return { password: 'Password must be between 8 and 64 characters.' };
+ if (password.length < 8 || password.length > 48) {
+ return { password: 'Password must be between 8 and 48 characters.' };
}
if (!passwordRegex.test(password)) {
diff --git a/shared/validators/monitor.js b/shared/validators/monitor.js
index b8fd664..a56f879 100644
--- a/shared/validators/monitor.js
+++ b/shared/validators/monitor.js
@@ -9,6 +9,8 @@ const validMethods = [
];
const validTypes = ['http', 'tcp'];
+const notificationTypes = ['All', 'Outage', 'Recovery'];
+const urlRegex = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}(\/[^\s]*)?$/;
export const type = (type) => {
if (!type || !validTypes.includes(type)) {
@@ -23,8 +25,6 @@ export const name = (name) => {
};
export const httpUrl = (url) => {
- const urlRegex = /^(http|https):\/\/[^ "]+$/;
-
if (!url || !urlRegex.test(url)) {
return 'Please enter a valid URL.';
}
@@ -81,11 +81,17 @@ export const requestTimeout = (requestTimeout) => {
return 'Please enter a valid request timeout.';
}
- if (requestTimeout < 20 || requestTimeout > 600) {
+ if (requestTimeout < 5 || requestTimeout > 600) {
return 'Please enter a valid request timeout. Request timeout should be between 20 and 600 seconds.';
}
};
+export const notificationType = (notification) => {
+ if (!notificationTypes.includes(notification)) {
+ return 'Please select a valid notification type.';
+ }
+};
+
const validators = {
type,
name,
@@ -97,6 +103,7 @@ const validators = {
interval,
retryInterval,
requestTimeout,
+ notificationType,
};
const httpValidators = [
@@ -108,6 +115,7 @@ const httpValidators = [
['interval', 'interval'],
['retryInterval', 'retryInterval'],
['requestTimeout', 'requestTimeout'],
+ ['notificationType', 'notificationType'],
];
const tcpValidators = [
@@ -118,39 +126,37 @@ const tcpValidators = [
['interval', 'interval'],
['retryInterval', 'retryInterval'],
['requestTimeout', 'requestTimeout'],
+ ['notificationType', 'notificationType'],
];
const http = (data) => {
- const errors = httpValidators
- .map((validator) => {
- const error = validators[validator[1]](data[validator[0]]);
- if (error) {
- return error;
- }
- })
- .filter(Boolean);
+ const errors = {};
+
+ httpValidators.forEach((validator) => {
+ const error = validators[validator[1]](data[validator[0]]);
+ if (error) {
+ errors[validator[0]] = error;
+ }
+ });
- if (errors.length) {
- return errors[0];
+ if (Object.keys(errors).length) {
+ return errors;
}
return false;
};
const tcp = (data) => {
- const errors = tcpValidators
- .map((validator) => {
- const error = validators[validator[1]](data[validator[0]]);
- if (error) {
- return error;
- }
- })
- .filter(Boolean);
-
- console.log(errors);
-
- if (errors.length) {
- return errors[0];
+ const errors = {};
+ tcpValidators.forEach((validator) => {
+ const error = validators[validator[1]](data[validator[0]]);
+ if (error) {
+ errors[validator[0]] = error;
+ }
+ });
+
+ if (Object.keys(errors).length) {
+ return errors;
}
return false;
diff --git a/shared/validators/notifications/discord.js b/shared/validators/notifications/discord.js
new file mode 100644
index 0000000..667fe9a
--- /dev/null
+++ b/shared/validators/notifications/discord.js
@@ -0,0 +1,58 @@
+// messageType: type of message for discord webhook (basic, pretty, nerdy)
+// friendlyName: friendly name for discord webhook
+// token: url for discord webhook
+// username: username for discord webhook (optional)
+
+import { NotificationValidatorError } from '../../utils/errors.js';
+
+const friendlyNameRegex = /^[a-zA-Z0-9_-]+$/;
+const messageTypes = ['basic', 'pretty', 'nerdy'];
+const tokenRegex =
+ /^https:\/\/discord.com\/api\/webhooks\/[0-9]+\/[0-9a-zA-Z_.-]+$/;
+const usernameRegex = /^[a-zA-Z0-9_]{1,32}$/;
+
+const Discord = ({
+ messageType,
+ friendlyName,
+ textMessage,
+ token,
+ username,
+}) => {
+ 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 (!tokenRegex.test(token)) {
+ throw new NotificationValidatorError(
+ 'token',
+ 'Invalid Discord Webhook URL'
+ );
+ }
+
+ if (username && !usernameRegex.test(username)) {
+ throw new NotificationValidatorError(
+ 'username',
+ 'Invalid Discord Webhook Username'
+ );
+ }
+
+ return {
+ platform: 'Discord',
+ messageType,
+ token,
+ friendlyName,
+ data: {
+ textMessage,
+ username,
+ },
+ };
+};
+
+export default Discord;
diff --git a/shared/validators/notifications/index.js b/shared/validators/notifications/index.js
new file mode 100644
index 0000000..db20337
--- /dev/null
+++ b/shared/validators/notifications/index.js
@@ -0,0 +1,8 @@
+import Discord from './discord.js';
+import Slack from './slack.js';
+import Telegram from './telegram.js';
+import Webhook from './webhook.js';
+
+const NotificationValidators = { Discord, Telegram, Slack, Webhook };
+
+export default NotificationValidators;
diff --git a/shared/validators/notifications/slack.js b/shared/validators/notifications/slack.js
new file mode 100644
index 0000000..85a34c6
--- /dev/null
+++ b/shared/validators/notifications/slack.js
@@ -0,0 +1,64 @@
+// channel: channel name for slack webhook (e.g. #lunalytics-alerts) (optional)
+// friendlyName: friendly name for slack webhook
+// message: type of message for discord webhook (basic, pretty, nerdy)
+// textMessage: text message for discord webhook (optional)
+// token: url for discord webhook
+// username: username for discord webhook (optional)
+
+import { NotificationValidatorError } from '../../utils/errors.js';
+
+const channelRegex = /^[a-z0-9][a-z0-9_-]{0,79}$/;
+const friendlyNameRegex = /^[a-zA-Z0-9_-]+$/;
+const messageTypes = ['basic', 'pretty', 'nerdy'];
+const tokenRegex =
+ /^https:\/\/hooks.slack.com\/services\/[0-9a-zA-Z]+\/[0-9a-zA-Z]+\/[0-9a-zA-Z]+$/;
+const usernameRegex = /^[a-zA-Z0-9_]{1,32}$/;
+
+const Slack = ({
+ channel,
+ friendlyName,
+ messageType,
+ textMessage,
+ token,
+ username,
+}) => {
+ if (friendlyNameRegex && !friendlyNameRegex.test(friendlyName)) {
+ throw new NotificationValidatorError(
+ 'friendlyName',
+ 'Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only.'
+ );
+ }
+
+ if (channel && !channelRegex.test(channel)) {
+ throw new NotificationValidatorError('channel', 'Invalid Channel Name');
+ }
+
+ if (!messageTypes.includes(messageType)) {
+ throw new NotificationValidatorError('messageType', 'Invalid Message Type');
+ }
+
+ if (!tokenRegex.test(token)) {
+ throw new NotificationValidatorError('token', 'Invalid Slack Webhook URL');
+ }
+
+ if (username && !usernameRegex.test(username)) {
+ throw new NotificationValidatorError(
+ 'username',
+ 'Invalid Slack Webhook Username'
+ );
+ }
+
+ return {
+ platform: 'Slack',
+ messageType,
+ token,
+ friendlyName,
+ data: {
+ channel,
+ textMessage,
+ username,
+ },
+ };
+};
+
+export default Slack;
diff --git a/shared/validators/notifications/telegram.js b/shared/validators/notifications/telegram.js
new file mode 100644
index 0000000..5543087
--- /dev/null
+++ b/shared/validators/notifications/telegram.js
@@ -0,0 +1,69 @@
+// chatId: chat id for telegram webhook
+// disableNotification: disable notification for telegram webhook (boolean)
+// friendlyName: friendly name for telegram webhook
+// message: type of message for telegram webhook (basic, pretty, nerdy)
+// protectContent: protect content for telegram webhook (boolean)
+// token: url for telegram webhook
+
+import { NotificationValidatorError } from '../../utils/errors.js';
+
+const chatIdRegex = /^[0-9]+$/;
+const friendlyNameRegex = /^[a-zA-Z0-9_-]+$/;
+const messageTypes = ['basic', 'pretty', 'nerdy'];
+const tokenRegex = /^[a-zA-Z0-9_]{1,32}$/;
+
+const Telegram = ({
+ chatId,
+ disableNotification = false,
+ friendlyName,
+ messageType,
+ protectContent = false,
+ token,
+}) => {
+ if (friendlyNameRegex && !friendlyNameRegex.test(friendlyName)) {
+ throw new NotificationValidatorError(
+ 'friendlyName',
+ 'Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only.'
+ );
+ }
+
+ if (chatId && !chatIdRegex.test(chatId)) {
+ throw new NotificationValidatorError('chatId', 'Invalid Chat ID');
+ }
+
+ if (typeof disableNotification !== 'boolean') {
+ throw new NotificationValidatorError(
+ 'disableNotification',
+ 'Invalid Disable Notification'
+ );
+ }
+
+ if (!messageTypes.includes(messageType)) {
+ throw new NotificationValidatorError('messageType', 'Invalid Message Type');
+ }
+
+ if (typeof protectContent !== 'boolean') {
+ throw new NotificationValidatorError(
+ 'protectContent',
+ 'Invalid Protect Content'
+ );
+ }
+
+ if (!tokenRegex.test(token)) {
+ throw new NotificationValidatorError('token', 'Invalid Telegram Bot Token');
+ }
+
+ return {
+ platform: 'Telegram',
+ messageType,
+ token,
+ friendlyName,
+ data: {
+ chatId,
+ disableNotification,
+ protectContent,
+ },
+ };
+};
+
+export default Telegram;
diff --git a/shared/validators/notifications/webhook.js b/shared/validators/notifications/webhook.js
new file mode 100644
index 0000000..fd2be95
--- /dev/null
+++ b/shared/validators/notifications/webhook.js
@@ -0,0 +1,71 @@
+// additionalHeaders: additional headers for webhook
+// friendlyName: friendly name for webhook
+// messageType: type of message for webhook (basic, pretty, nerdy)
+// requestType: type of request for webhook (application/json, form-data)
+// showAdditionalHeaders: show additional headers for webhook
+// token: url for webhook (Needs to be a http or https url)
+
+import { NotificationValidatorError } from '../../utils/errors.js';
+
+const friendlyNameRegex = /^[a-zA-Z0-9_-]+$/;
+const messageTypes = ['basic', 'pretty', 'nerdy'];
+const requestTypes = ['application/json', 'form-data'];
+const tokenRegex =
+ /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})+$/;
+
+const isJson = (value) => {
+ try {
+ JSON.parse(value);
+ } catch (e) {
+ return false;
+ }
+ return true;
+};
+
+const Webhook = ({
+ additionalHeaders,
+ friendlyName,
+ messageType,
+ requestType = 'application/json',
+ showAdditionalHeaders = false,
+ token,
+}) => {
+ if (friendlyNameRegex && !friendlyNameRegex.test(friendlyName)) {
+ throw new NotificationValidatorError(
+ 'friendlyName',
+ 'Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only.'
+ );
+ }
+
+ if (showAdditionalHeaders && !isJson(additionalHeaders)) {
+ throw new NotificationValidatorError(
+ 'additionalHeaders',
+ 'Invalid Additional Headers Format'
+ );
+ }
+
+ if (!messageTypes.includes(messageType)) {
+ throw new NotificationValidatorError('messageType', 'Invalid Message Type');
+ }
+
+ if (!tokenRegex.test(token)) {
+ throw new NotificationValidatorError('token', 'Invalid Webhook URL');
+ }
+
+ if (!requestTypes.includes(requestType)) {
+ throw new NotificationValidatorError('requestType', 'Invalid Request Type');
+ }
+
+ return {
+ platform: 'Webhook',
+ messageType,
+ token,
+ friendlyName,
+ data: {
+ additionalHeaders: showAdditionalHeaders ? additionalHeaders : undefined,
+ requestType,
+ },
+ };
+};
+
+export default Webhook;
diff --git a/test/e2e/monitor.test.js b/test/e2e/monitor.test.js
deleted file mode 100644
index adf7989..0000000
--- a/test/e2e/monitor.test.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import loginDetails from './setup/fixtures/login.json';
-import monitorDetails from './setup/fixtures/monitor.json';
-
-describe('Monitor', () => {
- const { name, type, url, method, interval, retryInterval, timeout } =
- monitorDetails;
-
- const { value: monitorName } = name;
-
- context('create a monitor', () => {
- beforeEach(() => {
- const { email, password } = loginDetails.ownerUser;
-
- cy.clearCookies();
- cy.loginUser(email, password);
- });
-
- it('Go to next page without adding Name and Type', () => {
- cy.createMonitor();
-
- cy.get('[id="Next"]').click();
-
- cy.equals(name.error.id, name.error.value);
- cy.equals(type.error.id, type.error.value);
- });
-
- it('Enter a valid name/type and go to next page', () => {
- cy.createMonitor(name, type);
- });
-
- it('Go to next page without adding URL and Method', () => {
- cy.createMonitor(name, type);
-
- cy.get('[id="Next"]').click();
-
- cy.equals(url.error.id, url.error.value);
- cy.equals(method.error.id, method.error.value);
- });
-
- it('Enter a valid URL/Method and go to next page', () => {
- cy.createMonitor(name, type, url, method);
- });
-
- it('Enter invalid time for interval, retry interval and request timeout', () => {
- cy.createMonitor(name, type, url, method);
-
- cy.typeText(interval.id, interval.invalidValue);
- cy.typeText(retryInterval.id, retryInterval.invalidValue);
- cy.typeText(timeout.id, timeout.invalidValue);
-
- cy.get('[id="Submit"]').click();
-
- cy.equals(interval.error.id, interval.error.value);
- cy.equals(retryInterval.error.id, retryInterval.error.value);
- cy.equals(timeout.error.id, timeout.error.value);
- });
-
- it('Enter valid time for interval, retry interval and request timeout and go to next page', () => {
- cy.createMonitor(
- name,
- type,
- url,
- method,
- interval,
- retryInterval,
- timeout
- );
- });
- });
-
- context('edit a monitor', () => {
- before(() => {
- const { email, password } = loginDetails.ownerUser;
-
- cy.clearCookies();
- cy.loginUser(email, password);
- cy.visit('/');
- });
-
- it('Edit monitor name', () => {
- cy.get(`[id="monitor-${monitorName}"]`).click();
-
- cy.get('[id="monitor-edit-button"]').click();
-
- cy.typeText(name.id, '-Edited');
-
- cy.get('[id="Next"]').click();
- cy.get('[id="Next"]').click();
-
- cy.get('[id="Submit"]').click();
-
- cy.equals('[id="monitor-view-menu-name"]', `${name.value}-Edited`);
- });
- });
-
- context('delete a monitor', () => {
- before(() => {
- const { email, password } = loginDetails.ownerUser;
-
- cy.clearCookies();
- cy.loginUser(email, password);
- cy.visit('/');
- });
-
- it('Delete monitor', () => {
- cy.get(`[id="monitor-${monitorName}-Edited"]`).click();
-
- cy.get('[id="monitor-delete-button"]').click();
-
- cy.get('[id="monitor-delete-confirm-button"]').click();
-
- cy.get(`[id="monitor-${monitorName}-Edited"]`).should('not.exist');
- });
- });
-});
diff --git a/test/e2e/monitor/http.test.js b/test/e2e/monitor/http.test.js
new file mode 100644
index 0000000..c3d2620
--- /dev/null
+++ b/test/e2e/monitor/http.test.js
@@ -0,0 +1,65 @@
+import loginDetails from '../setup/fixtures/login.json';
+import monitorDetails from '../setup/fixtures/monitor.json';
+
+describe('Monitor HTTP - Advance', () => {
+ context('create a monitor with basic information', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/');
+ });
+
+ it('should show errors for invalid values and create a monitor with valid values', () => {
+ cy.createMonitor(monitorDetails.http);
+ });
+ });
+
+ context('edit a monitor', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/');
+ });
+
+ it('Edit monitor name', () => {
+ cy.get(`[id="monitor-${monitorDetails.http.name.value}"]`).click();
+
+ cy.get('[id="monitor-edit-button"]').click();
+
+ cy.typeText(monitorDetails.http.name.id, '-Edited');
+
+ cy.get('[id="monitor-create-button"]').click();
+
+ cy.equals(
+ '[id="monitor-view-menu-name"]',
+ `${monitorDetails.http.name.value}-Edited`
+ );
+ });
+ });
+
+ context('delete a monitor', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/');
+ });
+
+ it('Delete monitor', () => {
+ cy.get(`[id="monitor-${monitorDetails.http.name.value}-Edited"]`).click();
+
+ cy.get('[id="monitor-delete-button"]').click();
+
+ cy.get('[id="monitor-delete-confirm-button"]').click();
+
+ cy.get(`[id="monitor-${monitorDetails.http.name.value}-Edited"]`).should(
+ 'not.exist'
+ );
+ });
+ });
+});
diff --git a/test/e2e/monitor/tcp.test.js b/test/e2e/monitor/tcp.test.js
new file mode 100644
index 0000000..3665ba5
--- /dev/null
+++ b/test/e2e/monitor/tcp.test.js
@@ -0,0 +1,65 @@
+import loginDetails from '../setup/fixtures/login.json';
+import monitorDetails from '../setup/fixtures/monitor.json';
+
+describe('Monitor TCP - Advance', () => {
+ context('create a monitor with basic information', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/');
+ });
+
+ it('should show errors for invalid values and create a monitor with valid values', () => {
+ cy.createMonitor(monitorDetails.tcp);
+ });
+ });
+
+ context('edit a monitor', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/');
+ });
+
+ it('Edit monitor name', () => {
+ cy.get(`[id="monitor-${monitorDetails.tcp.name.value}"]`).click();
+
+ cy.get('[id="monitor-edit-button"]').click();
+
+ cy.typeText(monitorDetails.tcp.name.id, '-Edited');
+
+ cy.get('[id="monitor-create-button"]').click();
+
+ cy.equals(
+ '[id="monitor-view-menu-name"]',
+ `${monitorDetails.tcp.name.value}-Edited`
+ );
+ });
+ });
+
+ context('delete a monitor', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/');
+ });
+
+ it('should delete a monitor', () => {
+ cy.get(`[id="monitor-${monitorDetails.tcp.name.value}-Edited"]`).click();
+
+ cy.get('[id="monitor-delete-button"]').click();
+
+ cy.get('[id="monitor-delete-confirm-button"]').click();
+
+ cy.get(`[id="monitor-${monitorDetails.tcp.name.value}-Edited"]`).should(
+ 'not.exist'
+ );
+ });
+ });
+});
diff --git a/test/e2e/notification/discord.test.js b/test/e2e/notification/discord.test.js
new file mode 100644
index 0000000..4fa0846
--- /dev/null
+++ b/test/e2e/notification/discord.test.js
@@ -0,0 +1,99 @@
+import loginDetails from '../setup/fixtures/login.json';
+import discordNotification from '../setup/fixtures/notifications/discord.json';
+
+describe('Notification - Discord', () => {
+ context('create a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show invalid errors and create notification', () => {
+ cy.createNotification(discordNotification);
+ });
+ });
+
+ context('edit a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show error if invalid name is given', () => {
+ cy.get(
+ `[id="notification-configure-${discordNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(discordNotification.friendlyName.id, '{}[]||<>');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.equals(
+ discordNotification.friendlyName.error.id,
+ discordNotification.friendlyName.error.value
+ );
+ });
+
+ it('should change the name and save', () => {
+ cy.get(
+ `[id="notification-configure-${discordNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(discordNotification.friendlyName.id, 'Test');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.get(discordNotification.friendlyName.error.id).should('not.exist');
+ });
+ });
+
+ context('disable a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should disable a notification', () => {
+ const friendlyName = `${discordNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).should(
+ 'have.text',
+ 'Enable'
+ );
+ });
+ });
+
+ context('delete a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should delete a notification', () => {
+ const friendlyName = `${discordNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-delete-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-delete-confirm"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).should(
+ 'not.exist'
+ );
+ });
+ });
+});
diff --git a/test/e2e/notification/slack.test.js b/test/e2e/notification/slack.test.js
new file mode 100644
index 0000000..5d92d28
--- /dev/null
+++ b/test/e2e/notification/slack.test.js
@@ -0,0 +1,103 @@
+import loginDetails from '../setup/fixtures/login.json';
+import slackNotification from '../setup/fixtures/notifications/slack.json';
+
+describe('Notification - Slack', () => {
+ context('create a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show invalid errors and create notification', () => {
+ cy.createNotification(slackNotification);
+
+ cy.get(
+ `[id="notification-configure-${slackNotification.friendlyName.value}"]`
+ ).should('exist');
+ });
+ });
+
+ context('edit a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show error if invalid name is given', () => {
+ cy.get(
+ `[id="notification-configure-${slackNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(slackNotification.friendlyName.id, '{}[]||<>');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.equals(
+ slackNotification.friendlyName.error.id,
+ slackNotification.friendlyName.error.value
+ );
+ });
+
+ it('should change the name and save', () => {
+ cy.get(
+ `[id="notification-configure-${slackNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(slackNotification.friendlyName.id, 'Test');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.get(slackNotification.friendlyName.error.id).should('not.exist');
+ });
+ });
+
+ context('disable a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should disable a notification', () => {
+ const friendlyName = `${slackNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).should(
+ 'have.text',
+ 'Enable'
+ );
+ });
+ });
+
+ context('delete a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should delete a notification', () => {
+ const friendlyName = `${slackNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-delete-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-delete-confirm"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).should(
+ 'not.exist'
+ );
+ });
+ });
+});
diff --git a/test/e2e/notification/telegram.test.js b/test/e2e/notification/telegram.test.js
new file mode 100644
index 0000000..40e5183
--- /dev/null
+++ b/test/e2e/notification/telegram.test.js
@@ -0,0 +1,103 @@
+import loginDetails from '../setup/fixtures/login.json';
+import telegramNotification from '../setup/fixtures/notifications/telegram.json';
+
+describe('Notification - Telegram', () => {
+ context('create a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show invalid errors and create notification', () => {
+ cy.createNotification(telegramNotification);
+
+ cy.get(
+ `[id="notification-configure-${telegramNotification.friendlyName.value}"]`
+ ).should('exist');
+ });
+ });
+
+ context('edit a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show error if invalid name is given', () => {
+ cy.get(
+ `[id="notification-configure-${telegramNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(telegramNotification.friendlyName.id, '{}[]||<>');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.equals(
+ telegramNotification.friendlyName.error.id,
+ telegramNotification.friendlyName.error.value
+ );
+ });
+
+ it('should change the name and save', () => {
+ cy.get(
+ `[id="notification-configure-${telegramNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(telegramNotification.friendlyName.id, 'Test');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.get(telegramNotification.friendlyName.error.id).should('not.exist');
+ });
+ });
+
+ context('disable a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should disable a notification', () => {
+ const friendlyName = `${telegramNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).should(
+ 'have.text',
+ 'Enable'
+ );
+ });
+ });
+
+ context('delete a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should delete a notification', () => {
+ const friendlyName = `${telegramNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-delete-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-delete-confirm"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).should(
+ 'not.exist'
+ );
+ });
+ });
+});
diff --git a/test/e2e/notification/webhooks.test.js b/test/e2e/notification/webhooks.test.js
new file mode 100644
index 0000000..1c25ba5
--- /dev/null
+++ b/test/e2e/notification/webhooks.test.js
@@ -0,0 +1,103 @@
+import loginDetails from '../setup/fixtures/login.json';
+import webhookNotification from '../setup/fixtures/notifications/webhooks.json';
+
+describe('Notification - Telegram', () => {
+ context('create a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show invalid errors and create notification', () => {
+ cy.createNotification(webhookNotification);
+
+ cy.get(
+ `[id="notification-configure-${webhookNotification.friendlyName.value}"]`
+ ).should('exist');
+ });
+ });
+
+ context('edit a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should show error if invalid name is given', () => {
+ cy.get(
+ `[id="notification-configure-${webhookNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(webhookNotification.friendlyName.id, '{}[]||<>');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.equals(
+ webhookNotification.friendlyName.error.id,
+ webhookNotification.friendlyName.error.value
+ );
+ });
+
+ it('should change the name and save', () => {
+ cy.get(
+ `[id="notification-configure-${webhookNotification.friendlyName.value}"]`
+ ).click();
+
+ cy.typeText(webhookNotification.friendlyName.id, 'Test');
+ cy.get('[id="notification-create-button"]').click();
+
+ cy.get(webhookNotification.friendlyName.error.id).should('not.exist');
+ });
+ });
+
+ context('disable a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should disable a notification', () => {
+ const friendlyName = `${webhookNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-toggle-${friendlyName}"]`).should(
+ 'have.text',
+ 'Enable'
+ );
+ });
+ });
+
+ context('delete a notification', () => {
+ beforeEach(() => {
+ const { email, password } = loginDetails.ownerUser;
+
+ cy.clearCookies();
+ cy.loginUser(email, password);
+ cy.visit('/notifications');
+ });
+
+ it('should delete a notification', () => {
+ const friendlyName = `${webhookNotification.friendlyName.value}Test`;
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).click();
+ cy.get(`[id="notification-delete-${friendlyName}"]`).click();
+
+ cy.get(`[id="notification-delete-confirm"]`).click();
+
+ cy.get(`[id="notification-dropdown-${friendlyName}"]`).should(
+ 'not.exist'
+ );
+ });
+ });
+});
diff --git a/test/e2e/setup/fixtures/login.json b/test/e2e/setup/fixtures/login.json
index 46d2728..07adb7e 100644
--- a/test/e2e/setup/fixtures/login.json
+++ b/test/e2e/setup/fixtures/login.json
@@ -35,7 +35,7 @@
},
{
"value": "test123",
- "error": "Password must be between 8 and 64 characters."
+ "error": "Password must be between 8 and 48 characters."
},
{
"value": "1234568900",
diff --git a/test/e2e/setup/fixtures/monitor.json b/test/e2e/setup/fixtures/monitor.json
index 961fa03..a395ac7 100644
--- a/test/e2e/setup/fixtures/monitor.json
+++ b/test/e2e/setup/fixtures/monitor.json
@@ -1,61 +1,139 @@
{
- "name": {
- "id": "[id=\"input-name\"]",
- "value": "test-monitor",
- "error": {
- "id": "[id=\"text-input-error-input-name\"]",
- "value": "Please enter a valid name. Only letters, numbers and - are allowed."
+ "http": {
+ "name": {
+ "id": "[id=\"input-name\"]",
+ "value": "test-monitor",
+ "type": "text",
+ "invalidValue": "@#${}[]||<>",
+ "error": {
+ "id": "[id=\"text-input-error-input-name\"]",
+ "value": "Please enter a valid name. Only letters, numbers and - are allowed."
+ }
+ },
+ "type": {
+ "id": "[id=\"type-dropdown\"]",
+ "value": "[id=\"type-http\"]",
+ "type": "dropdown"
+ },
+ "url": {
+ "id": "[id=\"input-url\"]",
+ "value": "http://lunalytics.xyz/api/status",
+ "type": "text",
+ "invalidValue": "@#${}[]||<>",
+ "error": {
+ "id": "[id=\"text-input-error-input-url\"]",
+ "value": "Please enter a valid URL."
+ }
+ },
+ "advance": {
+ "id": "[id=\"monitor-advanced-settings\"]",
+ "type": "click"
+ },
+ "method": {
+ "id": "[id=\"http-method-dropdown\"]",
+ "value": "[id=\"http-method-GET\"]",
+ "type": "dropdown"
+ },
+ "interval": {
+ "id": "[id=\"input-interval\"]",
+ "value": "{backspace} {backspace} 45",
+ "invalidValue": 10,
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-input-interval\"]",
+ "value": "Please enter a valid interval. Interval should be between 20 and 600 seconds."
+ }
+ },
+ "retryInterval": {
+ "id": "[id=\"input-retry-interval\"]",
+ "value": "{backspace} {backspace} 45",
+ "invalidValue": 10,
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-input-retry-interval\"]",
+ "value": "Please enter a valid retry interval. Retry interval should be between 20 and 600 seconds."
+ }
+ },
+ "timeout": {
+ "id": "[id=\"input-request-timeout\"]",
+ "value": "{backspace} {backspace} 45",
+ "invalidValue": 10,
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-input-request-timeout\"]",
+ "value": "Please enter a valid request timeout. Request timeout should be between 20 and 600 seconds."
+ }
}
},
- "type": {
- "id": "[id=\"type-dropdown\"]",
- "value": "[id=\"type-http\"]",
- "error": {
- "id": "[id=\"text-input-error-input-type\"]",
- "value": "Please select a valid monitor type."
- }
- },
- "url": {
- "id": "[id=\"input-url\"]",
- "value": "http://lunalytics.xyz/api/status",
- "error": {
- "id": "[id=\"text-input-error-input-url\"]",
- "value": "Please enter a valid URL."
- }
- },
- "method": {
- "id": "[id=\"http-method-dropdown\"]",
- "value": "[id=\"http-method-GET\"]",
- "error": {
- "id": "[id=\"text-input-http-method-error\"]",
- "value": "Please select a valid method."
- }
- },
- "interval": {
- "id": "[id=\"input-interval\"]",
- "value": "{backspace} {backspace} 45",
- "invalidValue": 10,
- "error": {
- "id": "[id=\"text-input-error-input-interval\"]",
- "value": "Please enter a valid interval. Interval should be between 20 and 600 seconds."
- }
- },
- "retryInterval": {
- "id": "[id=\"input-retry-interval\"]",
- "value": "{backspace} {backspace} 45",
- "invalidValue": 10,
- "error": {
- "id": "[id=\"text-input-error-input-retry-interval\"]",
- "value": "Please enter a valid retry interval. Retry interval should be between 20 and 600 seconds."
- }
- },
- "timeout": {
- "id": "[id=\"input-request-timeout\"]",
- "value": "{backspace} {backspace} 45",
- "invalidValue": 10,
- "error": {
- "id": "[id=\"text-input-error-input-request-timeout\"]",
- "value": "Please enter a valid request timeout. Request timeout should be between 20 and 600 seconds."
+ "tcp": {
+ "name": {
+ "id": "[id=\"input-name\"]",
+ "value": "test-monitor",
+ "type": "text",
+ "invalidValue": "@#${}[]||<>",
+ "error": {
+ "id": "[id=\"text-input-error-input-name\"]",
+ "value": "Please enter a valid name. Only letters, numbers and - are allowed."
+ }
+ },
+ "type": {
+ "id": "[id=\"type-dropdown\"]",
+ "value": "[id=\"type-tcp\"]",
+ "type": "dropdown"
+ },
+ "host": {
+ "id": "[id=\"input-host\"]",
+ "value": "127.0.0.1",
+ "type": "text",
+ "invalidValue": "@#${}[]||<>",
+ "error": {
+ "id": "[id=\"text-input-error-input-host\"]",
+ "value": "Please enter a valid host (Only IPv4 is valid)."
+ }
+ },
+ "port": {
+ "id": "[id=\"input-port\"]",
+ "value": "2308",
+ "type": "text",
+ "invalidValue": "2308999",
+ "error": {
+ "id": "[id=\"text-input-error-input-port\"]",
+ "value": "Please enter a valid port."
+ }
+ },
+ "advance": {
+ "id": "[id=\"monitor-advanced-settings\"]",
+ "type": "click"
+ },
+ "interval": {
+ "id": "[id=\"input-interval\"]",
+ "value": "{backspace} {backspace} 45",
+ "invalidValue": 10,
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-input-interval\"]",
+ "value": "Please enter a valid interval. Interval should be between 20 and 600 seconds."
+ }
+ },
+ "retryInterval": {
+ "id": "[id=\"input-retry-interval\"]",
+ "value": "{backspace} {backspace} 45",
+ "invalidValue": 10,
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-input-retry-interval\"]",
+ "value": "Please enter a valid retry interval. Retry interval should be between 20 and 600 seconds."
+ }
+ },
+ "timeout": {
+ "id": "[id=\"input-request-timeout\"]",
+ "value": "{backspace} {backspace} 45",
+ "invalidValue": 10,
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-input-request-timeout\"]",
+ "value": "Please enter a valid request timeout. Request timeout should be between 20 and 600 seconds."
+ }
}
}
}
diff --git a/test/e2e/setup/fixtures/notifications/discord.json b/test/e2e/setup/fixtures/notifications/discord.json
new file mode 100644
index 0000000..dcdff5a
--- /dev/null
+++ b/test/e2e/setup/fixtures/notifications/discord.json
@@ -0,0 +1,37 @@
+{
+ "friendlyName": {
+ "id": "[id=\"friendly-name\"]",
+ "value": "Discord",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-friendly-name\"]",
+ "value": "Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only."
+ }
+ },
+ "url": {
+ "id": "[id=\"webhook-url\"]",
+ "value": "https://discord.com/api/webhooks/082399/lunalytics",
+ "invalidValue": "https://discord.com/api/webhook/082399/lunalytics",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-webhook-url\"]",
+ "value": "Invalid Discord Webhook URL"
+ }
+ },
+ "username": {
+ "id": "[id=\"webhook-username\"]",
+ "value": "Lunalytics",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-webhook-username\"]",
+ "value": "Invalid Discord Webhook Username"
+ }
+ },
+ "message": {
+ "id": "[id=\"text-messsage\"]",
+ "value": "@everyone Alert!",
+ "type": "text"
+ }
+}
diff --git a/test/e2e/setup/fixtures/notifications/slack.json b/test/e2e/setup/fixtures/notifications/slack.json
new file mode 100644
index 0000000..7236258
--- /dev/null
+++ b/test/e2e/setup/fixtures/notifications/slack.json
@@ -0,0 +1,53 @@
+{
+ "type": {
+ "id": "[id=\"notification-type-dropdown\"]",
+ "value": "[id=\"notification-type-Slack\"]",
+ "type": "dropdown"
+ },
+ "friendlyName": {
+ "id": "[id=\"friendly-name\"]",
+ "value": "Slack",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-friendly-name\"]",
+ "value": "Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only."
+ }
+ },
+
+ "url": {
+ "id": "[id=\"webhook-url\"]",
+ "value": "https://hooks.slack.com/services/123123/123123/123123",
+ "invalidValue": "https://hooks.slack.com/service/123123/123123/123123",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-webhook-url\"]",
+ "value": "Invalid Slack Webhook URL"
+ }
+ },
+ "username": {
+ "id": "[id=\"webhook-username\"]",
+ "value": "Lunalytics",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-webhook-username\"]",
+ "value": "Invalid Slack Webhook Username"
+ }
+ },
+ "channel": {
+ "id": "[id=\"channel-name\"]",
+ "value": "general",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-channel-name\"]",
+ "value": "Invalid Channel Name"
+ }
+ },
+ "message": {
+ "id": "[id=\"text-messsage\"]",
+ "value": "@everyone Alert!",
+ "type": "text"
+ }
+}
diff --git a/test/e2e/setup/fixtures/notifications/telegram.json b/test/e2e/setup/fixtures/notifications/telegram.json
new file mode 100644
index 0000000..4d43132
--- /dev/null
+++ b/test/e2e/setup/fixtures/notifications/telegram.json
@@ -0,0 +1,37 @@
+{
+ "type": {
+ "id": "[id=\"notification-type-dropdown\"]",
+ "value": "[id=\"notification-type-Telegram\"]",
+ "type": "dropdown"
+ },
+ "friendlyName": {
+ "id": "[id=\"friendly-name\"]",
+ "value": "Slack",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-friendly-name\"]",
+ "value": "Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only."
+ }
+ },
+ "url": {
+ "id": "[id=\"bot-token\"]",
+ "value": "123123123123123123",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-bot-token\"]",
+ "value": "Invalid Telegram Bot Token"
+ }
+ },
+ "chatId": {
+ "id": "[id=\"chat-id\"]",
+ "value": "123123123",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-chat-id\"]",
+ "value": "Invalid Chat ID"
+ }
+ }
+}
diff --git a/test/e2e/setup/fixtures/notifications/webhooks.json b/test/e2e/setup/fixtures/notifications/webhooks.json
new file mode 100644
index 0000000..4b5fad7
--- /dev/null
+++ b/test/e2e/setup/fixtures/notifications/webhooks.json
@@ -0,0 +1,27 @@
+{
+ "type": {
+ "id": "[id=\"notification-type-dropdown\"]",
+ "value": "[id=\"notification-type-Webhook\"]",
+ "type": "dropdown"
+ },
+ "friendlyName": {
+ "id": "[id=\"friendly-name\"]",
+ "value": "Webhook",
+ "invalidValue": "{}[]||<>",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-friendly-name\"]",
+ "value": "Invalid Friendly Name. Must be alphanumeric, dashes, and underscores only."
+ }
+ },
+ "url": {
+ "id": "[id=\"webhook-url\"]",
+ "value": "https://lunalytics.xyz/webhooks/example",
+ "invalidValue": "this is not a webhook url",
+ "type": "text",
+ "error": {
+ "id": "[id=\"text-input-error-webhook-url\"]",
+ "value": "Invalid Webhook URL"
+ }
+ }
+}
diff --git a/test/e2e/setup/support/commands.js b/test/e2e/setup/support/commands.js
index c40dc0c..d600923 100644
--- a/test/e2e/setup/support/commands.js
+++ b/test/e2e/setup/support/commands.js
@@ -10,6 +10,10 @@ Cypress.Commands.add('typeText', (id, value) => {
return cy.get(id).type(value);
});
+Cypress.Commands.add('clearText', (id) => {
+ return cy.get(id).clear();
+});
+
Cypress.Commands.add('registerUser', (email, username, password) => {
cy.visit('/register');
@@ -33,35 +37,76 @@ Cypress.Commands.add('loginUser', (email, password) => {
cy.get('[class="auth-button"]').click();
});
-Cypress.Commands.add(
- 'createMonitor',
- (name, type, url, method, interval, retryInterval, timeout) => {
- cy.visit('/');
- cy.get('[id="home-add-monitor-button"]').click();
+Cypress.Commands.add('createMonitor', (details = {}) => {
+ cy.get('[id="home-add-monitor-button"]').click();
+
+ Object.keys(details).forEach((key) => {
+ const value = details[key];
+ const { id, value: elementValue, error, type, invalidValue } = value;
+
+ if (invalidValue) {
+ if (type === 'text') {
+ cy.typeText(id, invalidValue);
+ }
- if (name && type) {
- cy.typeText(name.id, name.value);
- cy.get(type.id).click();
- cy.get(type.value).click();
+ cy.get('[id="monitor-create-button"]').click();
+ cy.get(error.id).should('be.visible');
+ cy.equals(error.id, error.value);
+ }
+
+ if (type === 'click') {
+ cy.get(id).click();
+ }
- cy.get('[id="Next"]').click();
+ if (elementValue) {
+ if (type === 'text') {
+ cy.clearText(id);
+ cy.typeText(id, elementValue);
+ } else if (type === 'dropdown') {
+ cy.get(id).click();
+ cy.get(elementValue).click();
+ }
}
+ });
- if (url && method) {
- cy.typeText(url.id, url.value);
+ cy.get('[id="monitor-create-button"]').click();
+});
+
+Cypress.Commands.add('createNotification', (details = {}) => {
+ cy.get('[id="home-add-notification-button"]').click();
+
+ Object.keys(details).forEach((key) => {
+ const value = details[key];
+ const { id, type, value: elementValue, error, invalidValue } = value;
+
+ if (invalidValue) {
+ if (type === 'text') {
+ cy.typeText(id, invalidValue);
+ } else if (type === 'dropdown') {
+ cy.get(id).click();
+ cy.get(invalidValue).click();
+ }
- cy.get(method.id).click();
- cy.get(method.value).click();
+ cy.get('[id="notification-create-button"]').click();
- cy.get('[id="Next"]').click();
+ cy.get(error.id).should('be.visible');
+ cy.equals(error.id, error.value);
}
- if (interval && retryInterval && timeout) {
- cy.typeText(interval.id, interval.value);
- cy.typeText(retryInterval.id, retryInterval.value);
- cy.typeText(timeout.id, timeout.value);
+ if (elementValue) {
+ if (type === 'text') {
+ cy.clearText(id);
+ cy.typeText(id, elementValue);
+ } else if (type === 'dropdown') {
+ cy.get(id).click();
+ cy.get(elementValue).click();
+ }
+ }
- cy.get('[id="Submit"]').click();
+ if (type === 'checkbox') {
+ cy.get(id).click();
}
- }
-);
+ });
+
+ cy.get('[id="notification-create-button"]').click();
+});
diff --git a/test/server/middleware/monitor/add.test.js b/test/server/middleware/monitor/add.test.js
index 5bf4a85..b2f7a35 100644
--- a/test/server/middleware/monitor/add.test.js
+++ b/test/server/middleware/monitor/add.test.js
@@ -42,7 +42,7 @@ describe('Add Monitor - Middleware', () => {
certificates: {
get: vi.fn().mockReturnValue({ isValid: false }),
},
- setTimeout: vi.fn(),
+ checkStatus: vi.fn(),
};
userExists = vi.fn().mockReturnValue({ email: 'KSJaay@lunalytics.xyz' });
@@ -66,6 +66,8 @@ describe('Add Monitor - Middleware', () => {
interval: 60,
email: 'KSJaay@lunalytics.xyz',
valid_status_codes: JSON.stringify(['200-299']),
+ notificationType: 'All',
+ notificationId: null,
method: 'GET',
headers: null,
body: null,
@@ -153,7 +155,7 @@ describe('Add Monitor - Middleware', () => {
it('should call setTimeout with monitorId and interval', async () => {
await monitorAdd(fakeRequest, fakeResponse);
- expect(cache.setTimeout).toHaveBeenCalledWith('test', 60);
+ expect(cache.checkStatus).toHaveBeenCalledWith('test');
});
it('should return 200 when data is valid', async () => {
@@ -173,6 +175,8 @@ describe('Add Monitor - Middleware', () => {
interval: 60,
email: 'KSJaay@lunalytics.xyz',
valid_status_codes: null,
+ notificationType: 'All',
+ notificationId: null,
method: null,
headers: null,
body: null,
@@ -252,7 +256,7 @@ describe('Add Monitor - Middleware', () => {
it('should call setTimeout with monitorId and interval', async () => {
await monitorAdd(fakeRequest, fakeResponse);
- expect(cache.setTimeout).toHaveBeenCalledWith('test', 60);
+ expect(cache.checkStatus).toHaveBeenCalledWith('test');
});
it('should return 200 when data is valid', async () => {
diff --git a/test/server/middleware/monitor/edit.test.js b/test/server/middleware/monitor/edit.test.js
index 42b4875..ed56560 100644
--- a/test/server/middleware/monitor/edit.test.js
+++ b/test/server/middleware/monitor/edit.test.js
@@ -42,7 +42,7 @@ describe('Edit Monitor - Middleware', () => {
certificates: {
get: vi.fn().mockReturnValue({ isValid: false }),
},
- setTimeout: vi.fn(),
+ checkStatus: vi.fn(),
};
userExists = vi.fn().mockReturnValue({ email: 'KSJaay@lunalytics.xyz' });
@@ -66,6 +66,8 @@ describe('Edit Monitor - Middleware', () => {
interval: 60,
email: 'KSJaay@lunalytics.xyz',
valid_status_codes: JSON.stringify(['200-299']),
+ notificationType: 'All',
+ notificationId: null,
method: 'GET',
headers: null,
body: null,
@@ -151,10 +153,10 @@ describe('Edit Monitor - Middleware', () => {
);
});
- it('should call setTimeout with monitorId and interval', async () => {
+ it('should call checkStatus with monitorId and interval', async () => {
await monitorEdit(fakeRequest, fakeResponse);
- expect(cache.setTimeout).toHaveBeenCalledWith('test', 60);
+ expect(cache.checkStatus).toHaveBeenCalledWith('test');
});
it('should return 200 when data is valid', async () => {
@@ -174,6 +176,8 @@ describe('Edit Monitor - Middleware', () => {
interval: 60,
email: 'KSJaay@lunalytics.xyz',
valid_status_codes: null,
+ notificationType: 'All',
+ notificationId: null,
method: null,
headers: null,
body: null,
@@ -251,10 +255,10 @@ describe('Edit Monitor - Middleware', () => {
);
});
- it('should call setTimeout with monitorId and interval', async () => {
+ it('should call checkStatus with monitorId and interval', async () => {
await monitorEdit(fakeRequest, fakeResponse);
- expect(cache.setTimeout).toHaveBeenCalledWith('test', 60);
+ expect(cache.checkStatus).toHaveBeenCalledWith('test');
});
it('should return 200 when data is valid', async () => {
diff --git a/test/server/middleware/user/update/password.test.js b/test/server/middleware/user/update/password.test.js
index 3cad730..85ca3c7 100644
--- a/test/server/middleware/user/update/password.test.js
+++ b/test/server/middleware/user/update/password.test.js
@@ -4,10 +4,10 @@ import {
userExists,
} from '../../../../../server/database/queries/user';
import userUpdatePassword from '../../../../../server/middleware/user/update/password';
-import { verifyPassword } from '../../../../../shared/utils/hashPassword';
+import { verifyPassword } from '../../../../../server/utils/hashPassword';
vi.mock('../../../../../server/database/queries/user');
-vi.mock('../../../../../shared/utils/hashPassword');
+vi.mock('../../../../../server/utils/hashPassword');
describe('userUpdatePassword - Middleware', () => {
const user = {
diff --git a/test/shared/setupDatabase.js b/test/shared/setupDatabase.js
index 72b07bb..a5d1e31 100644
--- a/test/shared/setupDatabase.js
+++ b/test/shared/setupDatabase.js
@@ -3,8 +3,8 @@ import fs from 'fs';
// import local files
import { SQLite } from '../../server/database/sqlite/setup.js';
-import { generateHash } from '../../shared/utils/hashPassword.js';
-import logger from '../../shared/utils/logger.js';
+import { generateHash } from '../../server/utils/hashPassword.js';
+import logger from '../../server/utils/logger.js';
import { loadJSON } from '../../shared/parseJson.js';
const loginDetails = loadJSON('../test/e2e/setup/fixtures/login.json');
@@ -12,7 +12,7 @@ const loginDetails = loadJSON('../test/e2e/setup/fixtures/login.json');
const setupDatabase = async () => {
if (fs.existsSync(`${process.cwd()}/server/database/sqlite/e2e-test.db`)) {
fs.unlinkSync(`${process.cwd()}/server/database/sqlite/e2e-test.db`);
- logger.log('SETUP', 'Removed old database', 'INFO', false);
+ logger.info('SETUP', { message: 'Removed old database' });
}
const sqlite = new SQLite();
@@ -31,7 +31,7 @@ const setupDatabase = async () => {
isVerified: true,
});
- logger.log('SETUP', 'Created owner user', 'INFO', false);
+ logger.info('SETUP', { message: 'Created owner user' });
return sqlite.client.destroy();
};
diff --git a/vite.config.js b/vite.config.js
index 456b982..1de0d12 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -3,25 +3,15 @@ import react from '@vitejs/plugin-react-swc';
import { visualizer } from 'rollup-plugin-visualizer';
import viteCompression from 'vite-plugin-compression2';
+const filter = /\.(js|mjs|json|css|svg|html)$/i;
+
export default defineConfig({
plugins: [
react(),
- visualizer({
- filename: './stats/stats.html',
- }),
- viteCompression({
- algorithm: 'gzip',
- filter: /\.(js|mjs|json|css|svg|html)$/i,
- }),
- viteCompression({
- algorithm: 'brotliCompress',
- filter: /\.(js|mjs|json|css|svg|html)$/i,
- }),
+ viteCompression({ algorithm: 'gzip', filter }),
+ viteCompression({ algorithm: 'brotliCompress', filter }),
+ visualizer({ filename: './stats/stats.html' }),
],
- build: {
- commonjsOptions: { transformMixedEsModules: true },
- },
- define: {
- __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
- },
+ define: { __APP_VERSION__: JSON.stringify(process.env.npm_package_version) },
+ css: { preprocessorOptions: { scss: { api: 'modern-compiler' } } },
});