diff --git a/Client/package-lock.json b/Client/package-lock.json index 84bcf1d89..c2790af71 100644 --- a/Client/package-lock.json +++ b/Client/package-lock.json @@ -15,8 +15,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.16", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.21.0", - "@mui/x-date-pickers": "7.21.0", + "@mui/x-data-grid": "7.22.0", + "@mui/x-date-pickers": "7.22.0", "@reduxjs/toolkit": "2.3.0", "axios": "^1.7.4", "chart.js": "^4.4.3", @@ -39,7 +39,7 @@ "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", "prettier": "^3.3.3", "vite": "^5.2.0" @@ -1400,9 +1400,9 @@ } }, "node_modules/@mui/x-charts": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.21.0.tgz", - "integrity": "sha512-Qv7U1Koo7hxinn1ncbn+Yfcwd8h3bSJDVCpjyKKgO0247kGIAK4ecrBlFHwVLol4bNTY36Ir1prEaA0G1MmUrg==", + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.0.tgz", + "integrity": "sha512-B70ix8keyww9CpfdwbsHygQGsgEySCXuHhGrDRiVyFgK+Be4edBWNswbL3ngIp37CHBbWegaYkPp/Q9GDas0AA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1458,9 +1458,9 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.21.0.tgz", - "integrity": "sha512-0JAiwb2yRuVAd4idzfA64Bs1yn6KfC8VPBH7Njba0ySQB0+Ix+hvkzWQySD96hl7tK5heXbvbJ48pYLvhqS4Vw==", + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.0.tgz", + "integrity": "sha512-gXl7+hG0YRNU3YODlPvz6Q/9+EeUsPAWn/u2YMQmYTgwAxeY5QE3lY224VRnwM5v9SfTFheo1kzAKmXPdjb9tQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1495,9 +1495,9 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.21.0.tgz", - "integrity": "sha512-WLpuTu3PvhYwd7IAJSuDWr1Zd8c5C8Cc7rpAYCaV5+tGBoEP0C2UKqClMR4F1wTiU2a7x3dzgQzkcgK72yyqDw==", + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.0.tgz", + "integrity": "sha512-hopYo3ORP7ddYKnyBsqAtO2txEe2Zf6cehdikS5b1cqMTGOSL+18b11jfGVod9oipjb9L2JcT/WWkjoifs9Iww==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -3555,22 +3555,22 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz", + "integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==", "dev": true, "license": "MIT", "engines": { "node": ">=10" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.13.tgz", - "integrity": "sha512-f1EppwrpJRWmqDTyvAyomFVDYRtrS7iTEqv3nokETnMiMzs2SSTmKRTACce4O2p4jYyowiSMvpdwC/RLcMFhuQ==", + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.14.tgz", + "integrity": "sha512-aXvzCTK7ZBv1e7fahFuR3Z/fyQQSIQ711yPgYRj+Oj64tyTgO4iQIDmYXDBqvSWQ/FA4OSCsXOStlF+noU0/NA==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/Client/package.json b/Client/package.json index c9d6e711b..44d796143 100644 --- a/Client/package.json +++ b/Client/package.json @@ -17,8 +17,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.16", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.21.0", - "@mui/x-date-pickers": "7.21.0", + "@mui/x-data-grid": "7.22.0", + "@mui/x-date-pickers": "7.22.0", "@reduxjs/toolkit": "2.3.0", "axios": "^1.7.4", "chart.js": "^4.4.3", @@ -41,7 +41,7 @@ "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", "prettier": "^3.3.3", "vite": "^5.2.0" diff --git a/Client/src/App.jsx b/Client/src/App.jsx index 98772ecca..001758713 100644 --- a/Client/src/App.jsx +++ b/Client/src/App.jsx @@ -36,7 +36,7 @@ import { useSelector } from "react-redux"; import { CssBaseline } from "@mui/material"; import { useEffect } from "react"; import { useDispatch } from "react-redux"; -import { getAppSettings } from "./Features/Settings/settingsSlice"; +import { getAppSettings, updateAppSettings } from "./Features/Settings/settingsSlice"; import { logger } from "./Utils/Logger"; // Import the logger import { networkService } from "./main"; function App() { diff --git a/Client/src/Components/Dialog/genericDialog.jsx b/Client/src/Components/Dialog/genericDialog.jsx index eb1c287d3..0b37d75e8 100644 --- a/Client/src/Components/Dialog/genericDialog.jsx +++ b/Client/src/Components/Dialog/genericDialog.jsx @@ -12,6 +12,7 @@ const GenericDialog = ({ title, description, open, onClose, theme, children }) = aria-describedby={ariaDescribedBy} open={open} onClose={onClose} + onClick={(e)=>e.stopPropagation()} > { @@ -76,6 +77,7 @@ const Select = ({ className="select-component" value={value} onChange={onChange} + onBlur={onBlur} displayEmpty name={name} inputProps={{ id: id }} @@ -140,6 +142,7 @@ Select.propTypes = { }) ).isRequired, onChange: PropTypes.func.isRequired, + onBlur: PropTypes.func, sx: PropTypes.object, }; diff --git a/Client/src/Components/Sidebar/index.jsx b/Client/src/Components/Sidebar/index.jsx index d4e7c9fd2..9135dcefa 100644 --- a/Client/src/Components/Sidebar/index.jsx +++ b/Client/src/Components/Sidebar/index.jsx @@ -181,6 +181,8 @@ function Sidebar() { direction="row" alignItems="center" gap={theme.spacing(4)} + onClick={() => window.open("https://github.com/bluewave-labs/bluewave-uptime", "_blank", "noreferrer")} + sx={{ cursor: "pointer" }} > { const navigate = useNavigate(); @@ -21,7 +23,7 @@ const AdvancedSettings = ({ isAdmin }) => { navigate("/"); } }, [navigate, isAdmin]); - + const [errors, setErrors] = useState({}); const theme = useTheme(); const { authToken } = useSelector((state) => state.auth); const dispatch = useDispatch(); @@ -33,17 +35,30 @@ const AdvancedSettings = ({ isAdmin }) => { systemEmailPort: "", systemEmailAddress: "", systemEmailPassword: "", - jwtTTL: "", + jwtTTLNum: 99, + jwtTTLUnits: "days", + jwtTTL: "99d", dbType: "", redisHost: "", redisPort: "", pagespeedApiKey: "", }); + const parseJWTTTL = (data) => { + if (data.jwtTTL) { + const len = data.jwtTTL.length; + data.jwtTTLNum = data.jwtTTL.substring(0, len - 1); + data.jwtTTLUnits = unitItems.filter( + (itm) => itm._id == data.jwtTTL.substring(len - 1) + )[0].name; + } + }; + useEffect(() => { const getSettings = async () => { const action = await dispatch(getAppSettings({ authToken })); if (action.payload.success) { + parseJWTTTL(action.payload.data); setLocalSettings(action.payload.data); } else { createToast({ body: "Failed to get settings" }); @@ -66,24 +81,56 @@ const AdvancedSettings = ({ isAdmin }) => { warn: 4, }; + const unitItemLookup = { + days: "d", + hours: "h", + }; + const unitItems = Object.keys(unitItemLookup).map((key) => ({ + _id: unitItemLookup[key], + name: key, + })); + const handleLogLevel = (e) => { const id = e.target.value; const newLogLevel = logItems.find((item) => item._id === id).name; setLocalSettings({ ...localSettings, logLevel: newLogLevel }); }; + const handleJWTTTLUnits = (e) => { + const id = e.target.value; + const newUnits = unitItems.find((item) => item._id === id).name; + setLocalSettings({ ...localSettings, jwtTTLUnits: newUnits }); + }; + + const handleBlur = (event) => { + const { value, id } = event.target; + const { error } = advancedSettingsValidation.validate( + { [id]: value }, + { + abortEarly: false, + } + ); + setErrors((prev) => { + return buildErrors(prev, id, error); + }); + }; const handleChange = (event) => { const { value, id } = event.target; setLocalSettings({ ...localSettings, [id]: value }); }; const handleSave = async () => { + localSettings.jwtTTL = + localSettings.jwtTTLNum + unitItemLookup[localSettings.jwtTTLUnits]; + if (hasValidationErrors(localSettings, advancedSettingsValidation, setErrors)) { + return; + } const action = await dispatch( updateAppSettings({ settings: localSettings, authToken }) ); let body = ""; if (action.payload.success) { - console.log(action.payload.data); + parseJWTTTL(action.payload.data); setLocalSettings(action.payload.data); body = "Settings saved successfully"; } else { @@ -118,6 +165,8 @@ const AdvancedSettings = ({ isAdmin }) => { label="API URL Host" value={localSettings.apiBaseUrl} onChange={handleChange} + onBlur={handleBlur} + error={errors.apiBaseUrl} /> + { name="dbType" value={localSettings.dbType} onChange={handleChange} + onBlur={handleBlur} + error={errors.dbType} /> { name="redisHost" value={localSettings.redisHost} onChange={handleChange} + onBlur={handleBlur} + error={errors.redisHost} /> { name="pagespeedApiKey" value={localSettings.pagespeedApiKey} onChange={handleChange} + onBlur={handleBlur} + error={errors.pagespeedApiKey} /> diff --git a/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx b/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx index 4dbf8f747..310587442 100644 --- a/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx +++ b/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx @@ -28,6 +28,7 @@ import { MS_PER_WEEK, } from "../../../Utils/timeUtils"; import { useNavigate, useParams } from "react-router-dom"; +import { buildErrors, hasValidationErrors } from "../../../Validation/error"; const getDurationAndUnit = (durationInMs) => { if (durationInMs % MS_PER_DAY === 0) { @@ -176,16 +177,6 @@ const CreateMaintenance = () => { fetchMonitors(); }, [authToken, user]); - const buildErrors = (prev, id, error) => { - const updatedErrors = { ...prev }; - if (error) { - updatedErrors[id] = error.details[0].message; - } else { - delete updatedErrors[id]; - } - return updatedErrors; - }; - const handleSearch = (value) => { setSearch(value); }; @@ -224,20 +215,8 @@ const CreateMaintenance = () => { }; const handleSubmit = async () => { - const { error } = maintenanceWindowValidation.validate(form, { - abortEarly: false, - }); - - // If errors, return early - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - logger.error(error); - return; - } + if (hasValidationErrors(form, maintenanceWindowValidation, setErrors)) + return; // Build timestamp for maintenance window from startDate and startTime const start = dayjs(form.startDate) .set("hour", form.startTime.hour()) diff --git a/Client/src/Utils/NetworkService.js b/Client/src/Utils/NetworkService.js index 5571d40ff..71cbe9670 100644 --- a/Client/src/Utils/NetworkService.js +++ b/Client/src/Utils/NetworkService.js @@ -87,48 +87,48 @@ class NetworkService { }, }); } - - /** - * - * ************************************ - * Check the endpoint resolution - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.authToken - The authorization token to be used in the request header. - * @param {Object} config.monitorURL - The monitor url to be sent in the request body. - * @returns {Promise} The response from the axios POST request. - */ - async checkEndpointResolution(config) { - const { authToken, monitorURL } = config; - const params = new URLSearchParams(); - - if (monitorURL) params.append("monitorURL", monitorURL); - return this.axiosInstance.get(`/monitors/resolution/url?${params.toString()}`, { - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - } - }) - } + /** + * + * ************************************ + * Check the endpoint resolution + * ************************************ + * + * @async + * @param {Object} config - The configuration object. + * @param {string} config.authToken - The authorization token to be used in the request header. + * @param {Object} config.monitorURL - The monitor url to be sent in the request body. + * @returns {Promise} The response from the axios POST request. + */ + async checkEndpointResolution(config) { + const { authToken, monitorURL } = config; + const params = new URLSearchParams(); - /** - * - * ************************************ - * Gets monitors and summary of stats by TeamID - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.authToken - The authorization token to be used in the request header. - * @param {string} config.teamId - Team ID - * @param {Array} config.types - Array of monitor types - * @returns {Promise} The response from the axios POST request. - */ - async getMonitorsAndSummaryByTeamId(config) { - const params = new URLSearchParams(); + if (monitorURL) params.append("monitorURL", monitorURL); + + return this.axiosInstance.get(`/monitors/resolution/url?${params.toString()}`, { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + }); + } + + /** + * + * ************************************ + * Gets monitors and summary of stats by TeamID + * ************************************ + * + * @async + * @param {Object} config - The configuration object. + * @param {string} config.authToken - The authorization token to be used in the request header. + * @param {string} config.teamId - Team ID + * @param {Array} config.types - Array of monitor types + * @returns {Promise} The response from the axios POST request. + */ + async getMonitorsAndSummaryByTeamId(config) { + const params = new URLSearchParams(); if (config.types) { config.types.forEach((type) => { diff --git a/Client/src/Validation/error.js b/Client/src/Validation/error.js new file mode 100644 index 000000000..a616e13f5 --- /dev/null +++ b/Client/src/Validation/error.js @@ -0,0 +1,37 @@ +const buildErrors = (prev, id, error) => { + const updatedErrors = { ...prev }; + if (error) { + updatedErrors[id] = error.details[0].message?? "Validation error"; + } else { + delete updatedErrors[id]; + } + return updatedErrors; +}; + +const hasValidationErrors = (form, validation, setErrors) => { + const { error } = validation.validate(form, { + abortEarly: false, + }); + if (error) { + const newErrors = {}; + error.details.forEach((err) => { + if ( + ![ + "clientHost", + "refreshTokenSecret", + "dbConnectionString", + "refreshTokenTTL", + "jwtTTL", + ].includes(err.path[0]) + ) { + newErrors[err.path[0]] = err.message ?? "Validation error"; + } + }); + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return true; + } else return false; + } + return false; +}; +export { buildErrors, hasValidationErrors }; \ No newline at end of file diff --git a/Client/src/Validation/validation.js b/Client/src/Validation/validation.js index c620bdf43..08b1632c3 100644 --- a/Client/src/Validation/validation.js +++ b/Client/src/Validation/validation.js @@ -128,10 +128,43 @@ const maintenanceWindowValidation = joi.object({ monitors: joi.array().min(1), }); +const advancedSettingsValidation = joi.object({ + apiBaseUrl: joi.string().uri({ allowRelative: true }).trim().messages({ + "string.empty": "API base url is required.", + "string.uri": "The URL you provided is not valid.", + }), + logLevel: joi.string().valid("debug", "none", "error", "warn").allow(""), + systemEmailHost: joi.string().allow(""), + systemEmailPort: joi.number().allow(null, ""), + systemEmailAddress: joi.string().allow(""), + systemEmailPassword: joi.string().allow(""), + jwtTTLNum: joi.number().messages({ + "number.base": "JWT TTL is required.", + }), + jwtTTLUnits: joi + .string() + .trim() + .custom((value, helpers) => { + if (!["days", "hours"].includes(value)) { + return helpers.message("JWT TTL unit is required."); + } + return value; + }), + dbType: joi.string().trim().messages({ + "string.empty": "DB type is required.", + }), + redisHost: joi.string().trim().messages({ + "string.empty": "Redis host is required.", + }), + redisPort: joi.number().allow(null, ""), + pagespeedApiKey: joi.string().allow(""), +}); + export { credentials, imageValidation, monitorValidation, settingsValidation, maintenanceWindowValidation, + advancedSettingsValidation }; diff --git a/Server/configs/db.js b/Server/configs/db.js deleted file mode 100644 index e42595b4c..000000000 --- a/Server/configs/db.js +++ /dev/null @@ -1,15 +0,0 @@ -const PORT = 5000; - -const connectDbAndRunServer = async (app, db) => { - try { - await db.connect(); - app.listen(PORT, () => { - console.log(`server started on port:${PORT}`); - }); - } catch (error) { - console.log("Failed to connect to DB"); - console.error(error); - } -}; - -export { connectDbAndRunServer }; diff --git a/Server/controllers/authController.js b/Server/controllers/authController.js index b718f3c8e..1fba9c92f 100644 --- a/Server/controllers/authController.js +++ b/Server/controllers/authController.js @@ -79,10 +79,10 @@ const registerUser = async (req, res, next) => { } const newUser = await req.db.insertUser({ ...req.body }, req.file); - - logger.info(successMessages.AUTH_CREATE_USER, { + logger.info({ + message: successMessages.AUTH_CREATE_USER, service: SERVICE_NAME, - userId: newUser._id, + details: newUser._id, }); const userForToken = { ...newUser._doc }; @@ -102,9 +102,11 @@ const registerUser = async (req, res, next) => { "Welcome to Uptime Monitor" ) .catch((error) => { - logger.error("Error sending welcome email", { + logger.error({ + message: error.message, service: SERVICE_NAME, - error: error.message, + method: "registerUser", + stack: error.stack, }); }); diff --git a/Server/controllers/inviteController.js b/Server/controllers/inviteController.js index 72e3d99a3..96b19cc56 100644 --- a/Server/controllers/inviteController.js +++ b/Server/controllers/inviteController.js @@ -53,9 +53,11 @@ const issueInvitation = async (req, res, next) => { "Welcome to Uptime Monitor" ) .catch((error) => { - logger.error("Error sending invite email", { + logger.error({ + message: error.message, service: SERVICE_NAME, - error: error.message, + method: "issueInvitation", + stack: error.stack, }); }); diff --git a/Server/controllers/monitorController.js b/Server/controllers/monitorController.js index 5c1461e70..9bdce12ef 100644 --- a/Server/controllers/monitorController.js +++ b/Server/controllers/monitorController.js @@ -312,14 +312,12 @@ const deleteMonitor = async (req, res, next) => { await req.db.deletePageSpeedChecksByMonitorId(monitor._id); await req.db.deleteNotificationsByMonitorId(monitor._id); } catch (error) { - logger.error( - `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, - { - method: "deleteMonitor", - service: SERVICE_NAME, - error: error.message, - } - ); + logger.error({ + message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, + service: SERVICE_NAME, + method: "deleteMonitor", + stack: error.stack, + }); } return res.status(200).json({ success: true, msg: successMessages.MONITOR_DELETE }); } catch (error) { @@ -352,12 +350,12 @@ const deleteAllMonitors = async (req, res, next) => { await req.db.deletePageSpeedChecksByMonitorId(monitor._id); await req.db.deleteNotificationsByMonitorId(monitor._id); } catch (error) { - logger.error( - `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, - { - method: "deleteAllMonitors", - } - ); + logger.error({ + message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, + service: SERVICE_NAME, + method: "deleteAllMonitors", + stack: error.stack, + }); } }) ); diff --git a/Server/db/models/HardwareCheck.js b/Server/db/models/HardwareCheck.js index b710087bf..5e7f53329 100644 --- a/Server/db/models/HardwareCheck.js +++ b/Server/db/models/HardwareCheck.js @@ -16,7 +16,7 @@ const memorySchema = mongoose.Schema({ usage_percent: { type: Number, default: 0 }, }); -const discSchema = mongoose.Schema({ +const diskSchema = mongoose.Schema({ read_speed_bytes: { type: Number, default: 0 }, write_speed_bytes: { type: Number, default: 0 }, total_bytes: { type: Number, default: 0 }, @@ -37,6 +37,10 @@ const HardwareCheckSchema = mongoose.Schema( ref: "Monitor", immutable: true, }, + status: { + type: Boolean, + index: true, + }, cpu: { type: cpuSchema, default: () => ({}), @@ -46,7 +50,7 @@ const HardwareCheckSchema = mongoose.Schema( default: () => ({}), }, disk: { - type: [discSchema], + type: [diskSchema], default: () => [], }, host: { diff --git a/Server/db/models/Monitor.js b/Server/db/models/Monitor.js index 6d718e9cd..c4dcb649c 100644 --- a/Server/db/models/Monitor.js +++ b/Server/db/models/Monitor.js @@ -1,5 +1,4 @@ import mongoose from "mongoose"; -import Notification from "./Notification.js"; const MonitorSchema = mongoose.Schema( { @@ -48,6 +47,14 @@ const MonitorSchema = mongoose.Schema( type: Number, default: undefined, }, + thresholds: { + type: { + usage_cpu: { type: Number }, + usage_memory: { type: Number }, + usage_disk: { type: Number }, + }, + _id: false, + }, notifications: [ { type: mongoose.Schema.Types.ObjectId, diff --git a/Server/db/models/PageSpeedCheck.js b/Server/db/models/PageSpeedCheck.js index a7c013f93..344cc685d 100644 --- a/Server/db/models/PageSpeedCheck.js +++ b/Server/db/models/PageSpeedCheck.js @@ -1,4 +1,5 @@ import mongoose from "mongoose"; +import logger from "../../utils/logger.js"; const AuditSchema = mongoose.Schema({ id: { type: String, required: true }, title: { type: String, required: true }, @@ -90,19 +91,22 @@ PageSpeedCheck.pre("save", async function (next) { const monitor = await mongoose.model("Monitor").findById(this.monitorId); if (monitor && monitor.status !== this.status) { if (monitor.status === true && this.status === false) { - // TODO issue alert - console.log("Monitor went down"); + logger.info({ message: "Monitor went down", monitorId: monitor._id }); } if (monitor.status === false && this.status === true) { - // TODO issue alert - console.log("Monitor went up"); + logger.info({ message: "Monitor went up", monitorId: monitor._id }); } monitor.status = this.status; await monitor.save(); } } catch (error) { - console.log(error); + logger.error({ + message: error.message, + service: "PageSpeedCheck", + method: "pre-save", + stack: error.stack, + }); } finally { next(); } diff --git a/Server/db/models/User.js b/Server/db/models/User.js index 37ecbcec0..a4d90d44a 100644 --- a/Server/db/models/User.js +++ b/Server/db/models/User.js @@ -1,5 +1,6 @@ import mongoose from "mongoose"; import bcrypt from "bcrypt"; +import logger from "../../utils/logger.js"; const UserSchema = mongoose.Schema( { @@ -81,7 +82,9 @@ UserSchema.methods.comparePassword = async function (submittedPassword) { const User = mongoose.model("User", UserSchema); User.init().then(() => { - console.log("User model initialized"); + logger.info({ + message: "User model initialized", + }); }); export default mongoose.model("User", UserSchema); diff --git a/Server/db/mongo/MongoDB.js b/Server/db/mongo/MongoDB.js index 9031a741f..22460eff4 100644 --- a/Server/db/mongo/MongoDB.js +++ b/Server/db/mongo/MongoDB.js @@ -1,6 +1,8 @@ import mongoose from "mongoose"; import UserModel from "../models/User.js"; import AppSettings from "../models/AppSettings.js"; +import logger from "../../utils/logger.js"; +const SERVICE_NAME = "MongoDB"; //**************************************** // DB Connection @@ -17,14 +19,34 @@ const connect = async () => { appSettings = new AppSettings({}); await appSettings.save(); } - - console.log("Connected to MongoDB"); + logger.info({ message: "Connected to MongoDB" }); } catch (error) { - console.error("Failed to connect to MongoDB"); + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "connect", + stack: error.stack, + }); throw error; } }; +const disconnect = async () => { + try { + logger.info({ message: "Disconnecting from MongoDB" }); + await mongoose.disconnect(); + logger.info({ message: "Disconnected from MongoDB" }); + return; + } catch (error) { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "disconnect", + stack: error.stack, + }); + } +}; + const checkSuperadmin = async (req, res) => { try { const superAdmin = await UserModel.findOne({ role: "superadmin" }); @@ -148,6 +170,7 @@ import { getAppSettings, updateAppSettings } from "./modules/settingsModule.js"; export default { connect, + disconnect, insertUser, getUserByEmail, updateUser, diff --git a/Server/db/mongo/modules/checkModule.js b/Server/db/mongo/modules/checkModule.js index c01dceb07..2457d7131 100644 --- a/Server/db/mongo/modules/checkModule.js +++ b/Server/db/mongo/modules/checkModule.js @@ -30,9 +30,11 @@ const createCheck = async (checkData) => { const monitor = await Monitor.findById(monitorId); if (!monitor) { - logger.error("Monitor not found", { + logger.error({ + message: "Monitor not found", service: SERVICE_NAME, - monitorId, + method: "createCheck", + details: `monitor ID: ${monitorId}`, }); return; } @@ -77,7 +79,11 @@ const getChecksCount = async (req) => { checksQuery.statusCode = 5000; break; default: - console.log("default"); + logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getChecksCount", + }); break; } } @@ -110,7 +116,7 @@ const getChecks = async (req) => { if (dateRange !== undefined) { checksQuery.createdAt = { $gte: dateRangeLookup[dateRange] }; } - // Fitler checks by status + // Filter checks by status if (filter !== undefined) { checksQuery.status = false; switch (filter) { @@ -122,7 +128,11 @@ const getChecks = async (req) => { checksQuery.statusCode = 5000; break; default: - console.log("default"); + logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getChecks", + }); break; } } @@ -170,7 +180,11 @@ const getTeamChecks = async (req) => { checksQuery.statusCode = 5000; break; default: - console.log("default"); + logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getTeamChecks", + }); break; } } @@ -248,9 +262,11 @@ const updateChecksTTL = async (teamId, ttl) => { try { await Check.collection.dropIndex("expiry_1"); } catch (error) { - logger.error("Failed to drop index", { + logger.error({ + message: error.message, service: SERVICE_NAME, method: "updateChecksTTL", + stack: error.stack, }); } diff --git a/Server/db/mongo/modules/maintenanceWindowModule.js b/Server/db/mongo/modules/maintenanceWindowModule.js index 4644d90bf..e2488dc3b 100644 --- a/Server/db/mongo/modules/maintenanceWindowModule.js +++ b/Server/db/mongo/modules/maintenanceWindowModule.js @@ -202,7 +202,6 @@ const deleteMaintenanceWindowByUserId = async (userId) => { }; const editMaintenanceWindowById = async (maintenanceWindowId, maintenanceWindowData) => { - console.log(maintenanceWindowData); try { const editedMaintenanceWindow = MaintenanceWindow.findByIdAndUpdate( maintenanceWindowId, diff --git a/Server/index.js b/Server/index.js index 179f7e9f4..6ee0e0249 100644 --- a/Server/index.js +++ b/Server/index.js @@ -17,9 +17,11 @@ import maintenanceWindowRouter from "./routes/maintenanceWindowRoute.js"; import settingsRouter from "./routes/settingsRoute.js"; import { fileURLToPath } from "url"; -import { connectDbAndRunServer } from "./configs/db.js"; import queueRouter from "./routes/queueRoute.js"; + +//JobQueue service and dependencies import JobQueue from "./service/jobQueue.js"; +import { Queue, Worker } from "bullmq"; //Network service and dependencies import NetworkService from "./service/networkService.js"; @@ -48,31 +50,11 @@ const __dirname = path.dirname(__filename); const openApiSpec = JSON.parse( fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8") ); + +const PORT = 5000; + // Need to wrap server setup in a function to handle async nature of JobQueue const startApp = async () => { - // ************************** - // Here is where we can swap out DBs easily. Spin up a mongoDB instance and try it out. - // Simply comment out the FakeDB and uncomment the MongoDB or vice versa. - // We can easily swap between any type of data source as long as the methods are implemented - // - // FakeDB - // const db = require("./db/FakeDb"); - // - // MongoDB - // const db = require("./db/MongoDB"); - // - // ************************** - // const DB_TYPE = { - // MongoDB: async () => (await import("./db/mongo/MongoDB.js")).default, - // FakedDB: async () => (await import("./db/FakeDb.js")).default, - // }; - - // const db = DB_TYPE[process.env.DB_TYPE] - // ? DB_TYPE[process.env.DB_TYPE]() - // : require("./db/FakeDb"); - - // const db = DB_TYPE.MongoDB(); - const app = express(); // middlewares @@ -82,12 +64,8 @@ const startApp = async () => { ); app.use(express.json()); app.use(helmet()); - // ************************** - // Make DB accessible anywhere we have a Request object - // By adding the DB to the request object, we can access it in any route - // Thus we do not need to import it in every route file, and we can easily swap out DBs as there is only one place to change it - // Same applies for JobQueue and emailService - // ************************** + + // Add db, jobQueue, emailService, and settingsService to request object for easy access app.use((req, res, next) => { req.db = db; req.jobQueue = jobQueue; @@ -111,27 +89,15 @@ const startApp = async () => { //health check app.use("/api/v1/healthy", (req, res) => { try { - logger.info("Checking Health of the server."); + logger.info({ message: "Checking Health of the server." }); return res.status(200).json({ message: "Healthy" }); } catch (error) { - logger.error(error.message); - return res.status(500).json({ message: error.message }); - } - }); - - app.use("/api/v1/mail", async (req, res) => { - try { - const id = await req.emailService.buildAndSendEmail( - "welcomeEmailTemplate", - { - name: "Alex", - }, - "ajhollid@gmail.com", - "Welcome" - ); - res.status(200).json({ success: true, msg: "Email sent", data: id }); - } catch (error) { - logger.error(error.message); + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "healthCheck", + stack: error.stack, + }); return res.status(500).json({ message: error.message }); } }); @@ -143,7 +109,10 @@ const startApp = async () => { app.use(handleErrors); // Create services - await connectDbAndRunServer(app, db); + await db.connect(); + app.listen(PORT, () => { + logger.info({ message: `server started on port:${PORT}` }); + }); const settingsService = new SettingsService(AppSettings); await settingsService.loadSettings(); @@ -157,22 +126,32 @@ const startApp = async () => { logger ); const networkService = new NetworkService(db, emailService, axios, ping, logger, http); - const jobQueue = await JobQueue.createJobQueue(db, networkService, settingsService); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + Queue, + Worker + ); const cleanup = async () => { if (cleaningUp) { - console.log("Already cleaning up"); + logger.warn({ message: "Already cleaning up" }); return; } cleaningUp = true; try { - console.log("Shutting down gracefully"); + logger.info({ message: "shutting down gracefully" }); await jobQueue.obliterate(); - console.log("Finished cleanup"); + await db.disconnect(); + logger.info({ message: "shut down gracefully" }); } catch (error) { - logger.error(errorMessages.JOB_QUEUE_DELETE_JOB, { + logger.error({ + message: error.message, service: SERVICE_NAME, - errorMsg: error.message, + method: "cleanup", + stack: error.stack, }); } process.exit(0); @@ -183,5 +162,11 @@ const startApp = async () => { }; startApp().catch((error) => { - console.log(error); + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "startApp", + stack: error.stack, + }); + process.exit(1); }); diff --git a/Server/middleware/handleErrors.js b/Server/middleware/handleErrors.js index 51b880878..bff2341c3 100644 --- a/Server/middleware/handleErrors.js +++ b/Server/middleware/handleErrors.js @@ -5,9 +5,11 @@ const handleErrors = (error, req, res, next) => { const status = error.status || 500; const message = error.message || errorMessages.FRIENDLY_ERROR; const service = error.service || errorMessages.UNKNOWN_SERVICE; - logger.error(error.message, { + logger.error({ + message: message, service: service, method: error.method, + stack: error.stack, }); res.status(status).json({ success: false, msg: message }); }; diff --git a/Server/middleware/verifyOwnership.js b/Server/middleware/verifyOwnership.js index 37c19b42b..d812dd543 100644 --- a/Server/middleware/verifyOwnership.js +++ b/Server/middleware/verifyOwnership.js @@ -10,8 +10,10 @@ const verifyOwnership = (Model, paramName) => { const doc = await Model.findById(documentId); //If the document is not found, return a 404 error if (!doc) { - logger.error(errorMessages.VERIFY_OWNER_NOT_FOUND, { + logger.error({ + message: errorMessages.VERIFY_OWNER_NOT_FOUND, service: SERVICE_NAME, + method: "verifyOwnership", }); const error = new Error(errorMessages.VERIFY_OWNER_NOT_FOUND); error.status = 404; diff --git a/Server/middleware/verifySuperAdmin.js b/Server/middleware/verifySuperAdmin.js index 073526791..a8d9d1b6e 100644 --- a/Server/middleware/verifySuperAdmin.js +++ b/Server/middleware/verifySuperAdmin.js @@ -37,8 +37,12 @@ const verifySuperAdmin = (req, res, next) => { jwt.verify(parsedToken, jwtSecret, (err, decoded) => { if (err) { - logger.error(errorMessages.INVALID_AUTH_TOKEN, { + logger.error({ + message: err.message, service: SERVICE_NAME, + method: "verifySuperAdmin", + stack: err.stack, + details: errorMessages.INVALID_AUTH_TOKEN, }); return res .status(401) @@ -46,8 +50,11 @@ const verifySuperAdmin = (req, res, next) => { } if (decoded.role.includes("superadmin") === false) { - logger.error(errorMessages.INVALID_AUTH_TOKEN, { + logger.error({ + message: errorMessages.INVALID_AUTH_TOKEN, service: SERVICE_NAME, + method: "verifySuperAdmin", + stack: err.stack, }); return res.status(401).json({ success: false, msg: errorMessages.UNAUTHORIZED }); } diff --git a/Server/package-lock.json b/Server/package-lock.json index 46faaa3e0..73f2316c8 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -4369,9 +4369,9 @@ } }, "node_modules/mongoose": { - "version": "8.7.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.7.2.tgz", - "integrity": "sha512-Ok4VzMds9p5G3ZSUhmvBm1GdxanbzhS29jpSn02SPj+IXEVFnIdfwAlHHXWkyNscZKlcn8GuMi68FH++jo0flg==", + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.7.3.tgz", + "integrity": "sha512-Xl6+dzU5ZpEcDoJ8/AyrIdAwTY099QwpolvV73PIytpK13XqwllLq/9XeVzzLEQgmyvwBVGVgjmMrKbuezxrIA==", "license": "MIT", "dependencies": { "bson": "^6.7.0", diff --git a/Server/service/emailService.js b/Server/service/emailService.js index 519162ba0..5977048ef 100644 --- a/Server/service/emailService.js +++ b/Server/service/emailService.js @@ -44,9 +44,11 @@ class EmailService { const templateContent = this.fs.readFileSync(templatePath, "utf8"); return this.compile(templateContent); } catch (error) { - this.logger.error("Error loading Email templates", { - error, - service: this.SERVICE_NAME, + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "loadTemplate", + stack: error.stack, }); } }; @@ -63,6 +65,7 @@ class EmailService { serverIsDownTemplate: this.loadTemplate("serverIsDown"), serverIsUpTemplate: this.loadTemplate("serverIsUp"), passwordResetTemplate: this.loadTemplate("passwordReset"), + thresholdViolatedTemplate: this.loadTemplate("thresholdViolated"), }; /** @@ -102,9 +105,11 @@ class EmailService { const html = await this.mjml2html(mjml); return html.html; } catch (error) { - this.logger.error("Error building Email HTML", { - error, + this.logger.error({ + message: error.message, service: SERVICE_NAME, + method: "buildAndSendEmail", + stack: error.stack, }); } }; @@ -118,9 +123,11 @@ class EmailService { }); return info; } catch (error) { - this.logger.error("Error sending Email", { - error, + this.logger.error({ + message: error.message, service: SERVICE_NAME, + method: "sendEmail", + stack: error.stack, }); } }; diff --git a/Server/service/jobQueue.js b/Server/service/jobQueue.js index f8493911e..51e27fdb2 100644 --- a/Server/service/jobQueue.js +++ b/Server/service/jobQueue.js @@ -1,8 +1,5 @@ -import { Queue, Worker, Job } from "bullmq"; const QUEUE_NAME = "monitors"; - const JOBS_PER_WORKER = 5; -import logger from "../utils/logger.js"; import { errorMessages, successMessages } from "../utils/messages.js"; const SERVICE_NAME = "JobQueue"; /** @@ -19,11 +16,13 @@ class JobQueue { * @param {SettingsService} settingsService - The settings service * @throws {Error} */ - constructor(settingsService) { - const { redisHost, redisPort } = settingsService.getSettings(); + constructor(settingsService, logger, Queue, Worker) { + const settings = settingsService.getSettings() || {}; + + const { redisHost = "127.0.0.1", redisPort = 6379 } = settings; const connection = { - host: redisHost || "127.0.0.1", - port: redisPort || 6379, + host: redisHost, + port: redisPort, }; this.connection = connection; this.queue = new Queue(QUEUE_NAME, { @@ -33,6 +32,8 @@ class JobQueue { this.db = null; this.networkService = null; this.settingsService = settingsService; + this.logger = logger; + this.Worker = Worker; } /** @@ -42,8 +43,15 @@ class JobQueue { * @returns {Promise} - Returns a new JobQueue * */ - static async createJobQueue(db, networkService, settingsService) { - const queue = new JobQueue(settingsService); + static async createJobQueue( + db, + networkService, + settingsService, + logger, + Queue, + Worker + ) { + const queue = new JobQueue(settingsService, logger, Queue, Worker); try { queue.db = db; queue.networkService = networkService; @@ -69,7 +77,7 @@ class JobQueue { * @returns {Worker} The newly created worker */ createWorker() { - const worker = new Worker( + const worker = new this.Worker( QUEUE_NAME, async (job) => { try { @@ -96,20 +104,22 @@ class JobQueue { } return acc; }, false); - if (!maintenanceWindowActive) { await this.networkService.getStatus(job); } else { - logger.info(`Monitor ${monitorId} is in maintenance window`, { + this.logger.info({ + message: `Monitor ${monitorId} is in maintenance window`, service: SERVICE_NAME, - monitorId, + method: "createWorker", }); } } catch (error) { - logger.error(`Error processing job ${job.id}: ${error.message}`, { + this.logger.error({ + message: error.message, service: SERVICE_NAME, - jobId: job.id, - error: error, + method: "createWorker", + details: `Error processing job ${job.id}: ${error.message}`, + stack: error.stack, }); } }, @@ -169,11 +179,9 @@ class JobQueue { } return true; } - if (workerStats.load > JOBS_PER_WORKER) { // Find out how many more jobs we have than current workers can handle const excessJobs = workerStats.jobs.length - this.workers.length * JOBS_PER_WORKER; - // Divide by jobs/worker to find out how many workers to add const workersToAdd = Math.ceil(excessJobs / JOBS_PER_WORKER); for (let i = 0; i < workersToAdd; i++) { @@ -188,19 +196,19 @@ class JobQueue { const workerCapacity = this.workers.length * JOBS_PER_WORKER; const excessCapacity = workerCapacity - workerStats.jobs.length; // Calculate how many workers to remove - const workersToRemove = Math.floor(excessCapacity / JOBS_PER_WORKER); - if (this.workers.length > 5) { - for (let i = 0; i < workersToRemove; i++) { - const worker = this.workers.pop(); - try { - await worker.close(); - } catch (error) { - // Catch the error instead of throwing it - logger.error(errorMessages.JOB_QUEUE_WORKER_CLOSE, { - service: SERVICE_NAME, - }); - } - } + let workersToRemove = Math.floor(excessCapacity / JOBS_PER_WORKER); // Make sure there are always at least 5 + while (workersToRemove > 0 && this.workers.length > 5) { + const worker = this.workers.pop(); + workersToRemove--; + await worker.close().catch((error) => { + // Catch the error instead of throwing it + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "scaleWorkers", + stack: error.stack, + }); + }); } return true; } @@ -252,7 +260,7 @@ class JobQueue { */ async addJob(jobName, payload) { try { - console.log("Adding job", payload?.url ?? "No URL"); + this.logger.info({ message: `Adding job ${payload?.url ?? "No URL"}` }); // Execute job immediately await this.queue.add(jobName, payload); await this.queue.add(jobName, payload, { @@ -278,20 +286,24 @@ class JobQueue { */ async deleteJob(monitor) { try { - const deleted = await this.queue.removeRepeatable(monitor._id, { + const wasDeleted = await this.queue.removeRepeatable(monitor._id, { every: monitor.interval, }); - if (deleted) { - logger.info(successMessages.JOB_QUEUE_DELETE_JOB, { + if (wasDeleted === true) { + this.logger.info({ + message: successMessages.JOB_QUEUE_DELETE_JOB, service: SERVICE_NAME, - jobId: monitor.id, + method: "deleteJob", + details: `Deleted job ${monitor._id}`, }); const workerStats = await this.getWorkerStats(); await this.scaleWorkers(workerStats); } else { - logger.error(errorMessages.JOB_QUEUE_DELETE_JOB, { + this.logger.error({ + message: errorMessages.JOB_QUEUE_DELETE_JOB, service: SERVICE_NAME, - jobId: monitor.id, + method: "deleteJob", + details: `Failed to delete job ${monitor._id}`, }); } } catch (error) { @@ -311,11 +323,16 @@ class JobQueue { delayed: await this.queue.getDelayedCount(), repeatableJobs: (await this.queue.getRepeatableJobs()).length, }; + this.logger.info({ + message: metrics, + }); return metrics; } catch (error) { - logger.error("Failed to retrieve job queue metrics", { + this.logger.error({ + message: error.message, service: SERVICE_NAME, - errorMsg: error.message, + method: "getMetrics", + stack: error.stack, }); } } @@ -327,7 +344,7 @@ class JobQueue { async obliterate() { try { let metrics = await this.getMetrics(); - console.log(metrics); + this.logger.info({ message: metrics }); await this.queue.pause(); const jobs = await this.getJobs(); @@ -343,10 +360,8 @@ class JobQueue { await this.queue.obliterate(); metrics = await this.getMetrics(); - console.log(metrics); - logger.info(successMessages.JOB_QUEUE_OBLITERATE, { - service: SERVICE_NAME, - }); + this.logger.info({ message: metrics }); + this.logger.info({ message: successMessages.JOB_QUEUE_OBLITERATE }); return true; } catch (error) { error.service === undefined ? (error.service = SERVICE_NAME) : null; diff --git a/Server/service/networkService.js b/Server/service/networkService.js index e90180835..157a29006 100644 --- a/Server/service/networkService.js +++ b/Server/service/networkService.js @@ -34,32 +34,34 @@ class NetworkService { } /** - * Handles the notification process for a monitor. + * Handles notifications for a given monitor configuration. * - * @param {Object} monitor - The monitor object containing monitor details. - * @param {boolean} isAlive - The status of the monitor (true if up, false if down). - * @returns {Promise} - */ async handleNotification(monitor, isAlive) { + * @param {Object} config - The configuration object for the notification. + * @param {Object} config.monitor - The monitor object containing the monitor ID. + * @param {string} config.template - The email template to be used. + * @param {Object} config.context - The context for the email template. + * @param {string} config.subject - The subject of the email. + */ + async handleNotification(config) { try { - let template = isAlive === true ? "serverIsUpTemplate" : "serverIsDownTemplate"; - let status = isAlive === true ? "up" : "down"; - - const notifications = await this.db.getNotificationsByMonitorId(monitor._id); + const notifications = await this.db.getNotificationsByMonitorId(config.monitor._id); for (const notification of notifications) { if (notification.type === "email") { await this.emailService.buildAndSendEmail( - template, - { monitorName: monitor.name, monitorUrl: monitor.url }, + config.template, + config.context, notification.address, - `Monitor ${monitor.name} is ${status}` + config.subject ); } } } catch (error) { - this.logger.error(error.message, { - method: "handleNotification", + this.logger.error({ + message: error.message, service: this.SERVICE_NAME, - monitorId: monitor._id, + method: "handleNotification", + details: `notification error for monitor: ${monitor._id}`, + stack: error.stack, }); } } @@ -69,9 +71,10 @@ class NetworkService { * * @param {Object} job - The job object containing job details. * @param {boolean} isAlive - The status of the monitor (true if up, false if down). + * @param {Object} [hardwareData] - The hardware data for the monitor. * @returns {Promise} */ - async handleStatusUpdate(job, isAlive) { + async handleStatusUpdate(job, isAlive, hardwareData) { let monitor; const { _id } = job.data; @@ -79,37 +82,96 @@ class NetworkService { try { monitor = await this.db.getMonitorById(_id); } catch (error) { + this.logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "handleStatusUpdate", + stack: error.stack, + details: `monitor lookup error for monitor: ${_id}`, + }); return; } // Otherwise, try to update monitor status try { - if (monitor === null || monitor === undefined) { - this.logger.error(`Null Monitor: ${_id}`, { - method: "handleStatusUpdate", - service: this.SERVICE_NAME, - jobId: job.id, - }); - return; - } if (monitor.status === undefined || monitor.status !== isAlive) { const oldStatus = monitor.status; monitor.status = isAlive; await monitor.save(); if (oldStatus !== undefined && oldStatus !== isAlive) { - this.handleNotification(monitor, isAlive); + const config = { + monitor: monitor, + template: isAlive === true ? "serverIsUpTemplate" : "serverIsDownTemplate", + context: { monitorName: monitor.name, monitorUrl: monitor.url }, + subject: `Monitor ${monitor.name} is ${isAlive === true ? "up" : "down"}`, + }; + this.handleNotification(config); } } + if (monitor.type === this.TYPE_HARDWARE) { + this.handleHardwareUpdate(monitor, hardwareData); + } } catch (error) { - this.logger.error(error.message, { - method: "handleStatusUpdate", + this.logger.error({ + message: error.message, service: this.SERVICE_NAME, - jobId: job.id, + method: "handleStatusUpdate", + stack: error.stack, + details: `status update error for monitor: ${_id}`, }); } } + async handleHardwareUpdate(monitor, hardwareData) { + //Get Thresholds + const thresholds = monitor?.thresholds; + if (thresholds === undefined) { + return; + } + //Get Values + const cpuUsage = hardwareData?.cpu?.usage_percent; + const memoryUsage = hardwareData?.memory?.usage_percent; + const diskUsage = hardwareData?.disk[0]?.usage_percent; + + // Return early if no values + if (cpuUsage === undefined && memoryUsage === undefined && diskUsage === undefined) { + return; + } + + // Return early if all values are below thresholds + if ( + cpuUsage < thresholds.usage_cpu && + memoryUsage < thresholds.usage_memory && + diskUsage < thresholds.usage_disk + ) { + return; + } + + let context = { + message: "Threshold(s) exceeded:", + }; + if (cpuUsage > thresholds.usage_cpu) { + context.cpu = `CPU USAGE: ${cpuUsage * 100}% > ${thresholds.usage_cpu * 100}%`; + } + + if (memoryUsage > thresholds.usage_memory) { + context.memory = `MEMORY USAGE: ${memoryUsage * 100}% > ${thresholds.usage_memory * 100}%`; + } + + if (diskUsage > thresholds.usage_disk) { + context.disk = `DISK USAGE: ${diskUsage * 100}% > ${thresholds.usage_disk * 100}%`; + } + + const config = { + monitor: monitor, + template: "thresholdViolatedTemplate", + context: context, + subject: `Threshold Violated for ${monitor.name}`, + }; + this.handleNotification(config); + } + /** * Measures the response time of an asynchronous operation. * @param {Function} operation - An asynchronous operation to measure. @@ -308,13 +370,13 @@ class NetworkService { frequency: 266, temperature: null, free_percent: null, - usage_percent: null, + usage_percent: 1, }, memory: { total_bytes: 4, available_bytes: 4, used_bytes: 2, - usage_percent: 0.5, + usage_percent: 1, }, disk: [ { @@ -322,7 +384,7 @@ class NetworkService { write_speed_bytes: 3, total_bytes: 10, free_bytes: 2, - usage_percent: 0.8, + usage_percent: 1, }, ], host: { @@ -369,7 +431,7 @@ class NetworkService { }; this.logAndStoreCheck(nullData, this.db.createHardwareCheck); } finally { - this.handleStatusUpdate(job, isAlive); + this.handleStatusUpdate(job, isAlive, hardwareData); } } @@ -391,11 +453,12 @@ class NetworkService { case this.TYPE_HARDWARE: return await this.handleHardware(job); default: - this.logger.error(`Unsupported type: ${job.data.type}`, { + this.logger.error({ + message: `Unsupported type: ${job.data.type}`, service: this.SERVICE_NAME, method: "getStatus", - jobId: job.id, }); + return false; } } @@ -417,11 +480,12 @@ class NetworkService { } throw new Error(); } catch (error) { - this.logger.error(`Error writing check for ${data.monitorId}`, { + this.logger.error({ + message: error.message, service: this.SERVICE_NAME, method: "logAndStoreCheck", - monitorId: data.monitorId, - error: error, + details: `Error writing check for ${data.monitorId}`, + stack: error.stack, }); } } diff --git a/Server/templates/thresholdViolated.mjml b/Server/templates/thresholdViolated.mjml new file mode 100644 index 000000000..a3d4344e4 --- /dev/null +++ b/Server/templates/thresholdViolated.mjml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + Message from BlueWave Uptime Service + + + + + {{message}} + + + {{#if cpu}} + {{cpu}} + {{/if}} + + + {{#if disk}} + {{disk}} + {{/if}} + + + {{#if memory}} + {{memory}} + {{/if}} + + + + + + \ No newline at end of file diff --git a/Server/tests/controllers/authController.test.js b/Server/tests/controllers/authController.test.js index cff7554cd..cde2ddbc1 100644 --- a/Server/tests/controllers/authController.test.js +++ b/Server/tests/controllers/authController.test.js @@ -182,7 +182,7 @@ describe("Auth Controller - registerUser", () => { req.emailService.buildAndSendEmail.rejects(new Error("emailService error")); await registerUser(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[1].error).to.equal("emailService error"); + expect(logger.error.firstCall.args[0].message).to.equal("emailService error"); }); it("should return a success message and data if all operations are successful", async () => { const user = { _id: "123" }; diff --git a/Server/tests/controllers/monitorController.test.js b/Server/tests/controllers/monitorController.test.js index 01ed666e7..8c36653ae 100644 --- a/Server/tests/controllers/monitorController.test.js +++ b/Server/tests/controllers/monitorController.test.js @@ -534,14 +534,9 @@ describe("Monitor Controller - deleteMonitor", () => { req.jobQueue.deleteJob.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.equal( + expect(logger.error.firstCall.args[0].message).to.equal( `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` ); - expect(logger.error.firstCall.args[1]).to.deep.equal({ - method: "deleteMonitor", - service: SERVICE_NAME, - error: error.message, - }); }); it("should log an error if deleteChecks throws an error", async () => { const error = new Error("Checks error"); @@ -550,14 +545,9 @@ describe("Monitor Controller - deleteMonitor", () => { req.db.deleteChecks.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.equal( + expect(logger.error.firstCall.args[0].message).to.equal( `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` ); - expect(logger.error.firstCall.args[1]).to.deep.equal({ - method: "deleteMonitor", - service: SERVICE_NAME, - error: error.message, - }); }); it("should log an error if deletePageSpeedChecksByMonitorId throws an error", async () => { const error = new Error("PageSpeed error"); @@ -566,14 +556,9 @@ describe("Monitor Controller - deleteMonitor", () => { req.db.deletePageSpeedChecksByMonitorId.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.equal( + expect(logger.error.firstCall.args[0].message).to.equal( `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` ); - expect(logger.error.firstCall.args[1]).to.deep.equal({ - method: "deleteMonitor", - service: SERVICE_NAME, - error: error.message, - }); }); it("should log an error if deleteNotificationsByMonitorId throws an error", async () => { const error = new Error("Notifications error"); @@ -582,14 +567,9 @@ describe("Monitor Controller - deleteMonitor", () => { req.db.deleteNotificationsByMonitorId.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.equal( + expect(logger.error.firstCall.args[0].message).to.equal( `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` ); - expect(logger.error.firstCall.args[1]).to.deep.equal({ - method: "deleteMonitor", - service: SERVICE_NAME, - error: error.message, - }); }); it("should return success message if all operations succeed", async () => { const monitor = { name: "test_monitor", _id: "123" }; @@ -664,18 +644,7 @@ describe("Monitor Controller - deleteAllMonitors", () => { expect(next.firstCall.args[0]).to.be.an("error"); expect(next.firstCall.args[0].message).to.equal("DB error"); }); - it("should log an error if deleteJob throws an error", async () => { - const monitors = [{ name: "test_monitor", _id: "123" }]; - req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); - req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); - const error = new Error("Job error"); - req.jobQueue.deleteJob.rejects(error); - await deleteAllMonitors(req, res, next); - expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.deep.equal( - `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` - ); - }); + it("should log an error if deleteChecks throws an error", async () => { const monitors = [{ name: "test_monitor", _id: "123" }]; req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); @@ -684,7 +653,7 @@ describe("Monitor Controller - deleteAllMonitors", () => { req.db.deleteChecks.rejects(error); await deleteAllMonitors(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.deep.equal( + expect(logger.error.firstCall.args[0].message).to.equal( `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` ); }); @@ -696,7 +665,7 @@ describe("Monitor Controller - deleteAllMonitors", () => { req.db.deletePageSpeedChecksByMonitorId.rejects(error); await deleteAllMonitors(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.deep.equal( + expect(logger.error.firstCall.args[0].message).to.equal( `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` ); }); @@ -708,7 +677,7 @@ describe("Monitor Controller - deleteAllMonitors", () => { req.db.deleteNotificationsByMonitorId.rejects(error); await deleteAllMonitors(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0]).to.deep.equal( + expect(logger.error.firstCall.args[0].message).to.equal( `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` ); }); diff --git a/Server/tests/services/emailService.test.js b/Server/tests/services/emailService.test.js index 1be064af7..6720751e9 100644 --- a/Server/tests/services/emailService.test.js +++ b/Server/tests/services/emailService.test.js @@ -93,14 +93,7 @@ describe("EmailService - Constructor", () => { loggerMock ); expect(loggerMock.error.called).to.be.true; - const errorCalls = loggerMock.error.getCalls(); - const errorCall = errorCalls.find( - (call) => call.args[0] === "Error loading Email templates" - ); - expect(errorCall).to.not.be.undefined; - expect(emailService.settingsService).to.equal(settingsServiceMock); - expect(emailService.templateLookup.welcomeEmailTemplate).to.be.undefined; - expect(emailService.templateLookup.employeeActivationTemplate).to.be.undefined; + expect(loggerMock.error.firstCall.args[0].message).to.equal("File read error"); }); }); @@ -182,7 +175,7 @@ describe("EmailService - buildAndSendEmail", () => { "Welcome" ); expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.getCall(0).args[0]).to.equal("Error building Email HTML"); + expect(loggerMock.error.getCall(0).args[0].message).to.equal("MJML error"); }); it("should log error if sending email fails", async () => { @@ -194,7 +187,7 @@ describe("EmailService - buildAndSendEmail", () => { "Welcome" ); expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.getCall(0).args[0]).to.equal("Error sending Email"); + expect(loggerMock.error.getCall(0).args[0].message).to.equal("SMTP error"); }); it("should log error if both building HTML and sending email fail", async () => { @@ -210,8 +203,8 @@ describe("EmailService - buildAndSendEmail", () => { expect(messageId).to.be.undefined; expect(loggerMock.error.calledTwice).to.be.true; - expect(loggerMock.error.getCall(0).args[0]).to.equal("Error building Email HTML"); - expect(loggerMock.error.getCall(1).args[0]).to.equal("Error sending Email"); + expect(loggerMock.error.getCall(0).args[0].message).to.equal("MJML error"); + expect(loggerMock.error.getCall(1).args[0].message).to.equal("SMTP error"); }); it("should log an error if buildHtml fails", async () => {}); diff --git a/Server/tests/services/jobQueue.test.js b/Server/tests/services/jobQueue.test.js new file mode 100644 index 000000000..4a22821ff --- /dev/null +++ b/Server/tests/services/jobQueue.test.js @@ -0,0 +1,741 @@ +import sinon from "sinon"; +import JobQueue from "../../service/jobQueue.js"; +import { log } from "console"; + +class QueueStub { + constructor(queueName, options) { + this.queueName = queueName; + this.options = options; + this.workers = []; + this.jobs = []; + } + + // Add any methods that are expected to be called on the Queue instance + add(job) { + this.jobs.push(job); + } + + removeRepeatable(id) { + const removedJob = this.jobs.find((job) => job.data._id === id); + this.jobs = this.jobs.filter((job) => job.data._id !== id); + if (removedJob) { + return true; + } + return false; + } + + getRepeatableJobs() { + return this.jobs; + } + async getJobs() { + return this.jobs; + } +} + +class WorkerStub { + constructor(QUEUE_NAME, workerTask) { + this.queueName = QUEUE_NAME; + this.workerTask = async () => workerTask({ data: { _id: 1 } }); + } + + async close() { + return true; + } +} + +describe("JobQueue", () => { + let settingsService, logger, db, networkService; + + beforeEach(() => { + settingsService = { getSettings: sinon.stub() }; + logger = { error: sinon.stub(), info: sinon.stub() }; + db = { + getAllMonitors: sinon.stub().returns([]), + getMaintenanceWindowsByMonitorId: sinon.stub().returns([]), + }; + networkService = { getStatus: sinon.stub() }; + }); + + afterEach(() => { + sinon.restore(); + }); + describe("createJobQueue", () => { + it("should create a new JobQueue and add jobs for active monitors", async () => { + db.getAllMonitors.returns([ + { id: 1, isActive: true }, + { id: 2, isActive: true }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + // There should be double the jobs, as one is meant to be instantly executed + // And one is meant to be enqueued + expect(jobQueue.queue.jobs.length).to.equal(4); + }); + + it("should reject with an error if an error occurs", async () => { + db.getAllMonitors.throws("Error"); + try { + await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("createJobQueue"); + } + }); + it("should reject with an error if an error occurs, should not overwrite error data", async () => { + const error = new Error("Error"); + error.service = "otherService"; + error.method = "otherMethod"; + db.getAllMonitors.throws(error); + + try { + await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + describe("Constructor", () => { + it("should construct a new JobQueue with default port and host if not provided", () => { + settingsService.getSettings.returns({}); + const jobQueue = new JobQueue(settingsService, logger, QueueStub, WorkerStub); + expect(jobQueue.connection.host).to.equal("127.0.0.1"); + expect(jobQueue.connection.port).to.equal(6379); + }); + it("should construct a new JobQueue with provided port and host", () => { + settingsService.getSettings.returns({ redisHost: "localhost", redisPort: 1234 }); + const jobQueue = new JobQueue(settingsService, logger, QueueStub, WorkerStub); + expect(jobQueue.connection.host).to.equal("localhost"); + expect(jobQueue.connection.port).to.equal(1234); + }); + }); + + describe("createWorker", () => { + it("should create a new worker", async () => { + const jobQueue = new JobQueue(settingsService, logger, QueueStub, WorkerStub); + const worker = jobQueue.createWorker(); + expect(worker).to.be.instanceOf(WorkerStub); + }); + it("worker should handle a maintenanceWindow error", async () => { + db.getMaintenanceWindowsByMonitorId.throws("Error"); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const worker = jobQueue.createWorker(); + await worker.workerTask(); + expect(logger.error.calledOnce).to.be.true; + }); + it("worker should handle a maintenanceWindow that is not active", async () => { + db.getMaintenanceWindowsByMonitorId.returns([ + { start: 123, end: 123, repeat: 123456 }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const worker = jobQueue.createWorker(); + await worker.workerTask(); + expect(networkService.getStatus.calledOnce).to.be.true; + }); + it("worker should handle a maintenanceWindow that is active", async () => { + db.getMaintenanceWindowsByMonitorId.returns([ + { + active: true, + start: new Date(Date.now() - 1000).toISOString(), + end: new Date(Date.now() + 1000).toISOString(), + repeat: 0, + }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const worker = jobQueue.createWorker(); + await worker.workerTask(); + expect(networkService.getStatus.calledOnce).to.be.false; + }); + it("worker should handle a maintenanceWindow that is active, has a repeat, but is not in maintenance zone", async () => { + db.getMaintenanceWindowsByMonitorId.returns([ + { + active: true, + start: new Date(Date.now() - 10000).toISOString(), + end: new Date(Date.now() + 5000).toISOString(), + repeat: 10000, + }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const worker = jobQueue.createWorker(); + await worker.workerTask(); + expect(networkService.getStatus.calledOnce).to.be.true; + }); + }); + describe("getWorkerStats", () => { + it("should throw an error if getRepeatable Jobs fails", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getRepeatableJobs = async () => { + throw new Error("Error"); + }; + try { + const stats = await jobQueue.getWorkerStats(); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("getWorkerStats"); + } + }); + it("should throw an error if getRepeatable Jobs fails but respect existing error data", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getRepeatableJobs = async () => { + const error = new Error("Existing Error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + try { + await jobQueue.getWorkerStats(); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + describe("scaleWorkers", () => { + it("should scale workers to 5 if no workers", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + expect(jobQueue.workers.length).to.equal(5); + }); + it("should scale workers up", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.scaleWorkers({ + load: 100, + jobs: Array.from({ length: 100 }, (_, i) => i + 1), + }); + expect(jobQueue.workers.length).to.equal(20); + }); + it("should scale workers down, even with error of worker.close fails", async () => { + WorkerStub.prototype.close = async () => { + throw new Error("Error"); + }; + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + await jobQueue.scaleWorkers({ + load: 100, + jobs: Array.from({ length: 100 }, (_, i) => i + 1), + }); + + const res = await jobQueue.scaleWorkers({ + load: 0, + jobs: [], + }); + expect(jobQueue.workers.length).to.equal(5); + }); + it("should scale workers down", async () => { + WorkerStub.prototype.close = async () => { + return true; + }; + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + await jobQueue.scaleWorkers({ + load: 40, + jobs: Array.from({ length: 40 }, (_, i) => i + 1), + }); + + const res = await jobQueue.scaleWorkers({ + load: 0, + jobs: [], + }); + expect(jobQueue.workers.length).to.equal(5); + }); + it("should return false if scaling doesn't happen", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const res = await jobQueue.scaleWorkers({ load: 5 }); + expect(jobQueue.workers.length).to.equal(5); + expect(res).to.be.false; + }); + }); + + describe("getJobs", async () => { + it("should return jobs", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const jobs = await jobQueue.getJobs(); + expect(jobs.length).to.equal(0); + }); + it("should throw an error if getRepeatableJobs fails", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + try { + jobQueue.queue.getRepeatableJobs = async () => { + throw new Error("error"); + }; + + await jobQueue.getJobs(true); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("getJobs"); + } + }); + it("should throw an error if getRepeatableJobs fails but respect existing error data", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + try { + jobQueue.queue.getRepeatableJobs = async () => { + const error = new Error("Existing error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + + await jobQueue.getJobs(true); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("getJobStats", async () => { + it("should return job stats for no jobs", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const jobStats = await jobQueue.getJobStats(); + expect(jobStats).to.deep.equal({ jobs: [], workers: 5 }); + }); + it("should return job stats for jobs", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getJobs = async () => { + return [{ data: { url: "test" }, getState: async () => "completed" }]; + }; + const jobStats = await jobQueue.getJobStats(); + expect(jobStats).to.deep.equal({ + jobs: [{ url: "test", state: "completed" }], + workers: 5, + }); + }); + it("should reject with an error if mapping jobs fails", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getJobs = async () => { + return [ + { + data: { url: "test" }, + getState: async () => { + throw new Error("Mapping Error"); + }, + }, + ]; + }; + try { + await jobQueue.getJobStats(); + } catch (error) { + expect(error.message).to.equal("Mapping Error"); + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("getJobStats"); + } + }); + it("should reject with an error if mapping jobs fails but respect existing error data", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getJobs = async () => { + return [ + { + data: { url: "test" }, + getState: async () => { + const error = new Error("Mapping Error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }, + }, + ]; + }; + try { + await jobQueue.getJobStats(); + } catch (error) { + expect(error.message).to.equal("Mapping Error"); + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("addJob", async () => { + it("should add a job to the queue", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.addJob("test", { url: "test" }); + expect(jobQueue.queue.jobs.length).to.equal(1); + }); + it("should reject with an error if adding fails", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.add = async () => { + throw new Error("Error adding job"); + }; + try { + await jobQueue.addJob("test", { url: "test" }); + } catch (error) { + expect(error.message).to.equal("Error adding job"); + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("addJob"); + } + }); + it("should reject with an error if adding fails but respect existing error data", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.add = async () => { + const error = new Error("Error adding job"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + try { + await jobQueue.addJob("test", { url: "test" }); + } catch (error) { + expect(error.message).to.equal("Error adding job"); + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + describe("deleteJob", async () => { + it("should delete a job from the queue", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.getWorkerStats = sinon.stub().returns({ load: 1, jobs: [{}] }); + jobQueue.scaleWorkers = sinon.stub(); + const monitor = { _id: 1 }; + const job = { data: monitor }; + jobQueue.queue.jobs = [job]; + await jobQueue.deleteJob(monitor); + // expect(jobQueue.queue.jobs.length).to.equal(0); + // expect(logger.info.calledOnce).to.be.true; + // expect(jobQueue.getWorkerStats.calledOnce).to.be.true; + // expect(jobQueue.scaleWorkers.calledOnce).to.be.true; + }); + it("should log an error if job is not found", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.getWorkerStats = sinon.stub().returns({ load: 1, jobs: [{}] }); + jobQueue.scaleWorkers = sinon.stub(); + const monitor = { _id: 1 }; + const job = { data: monitor }; + jobQueue.queue.jobs = [job]; + await jobQueue.deleteJob({ id_: 2 }); + expect(logger.error.calledOnce).to.be.true; + }); + it("should reject with an error if removeRepeatable fails", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.removeRepeatable = async () => { + const error = new Error("removeRepeatable error"); + throw error; + }; + + try { + await jobQueue.deleteJob({ _id: 1 }); + } catch (error) { + expect(error.message).to.equal("removeRepeatable error"); + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("deleteJob"); + } + }); + it("should reject with an error if removeRepeatable fails but respect existing error data", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.removeRepeatable = async () => { + const error = new Error("removeRepeatable error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + + try { + await jobQueue.deleteJob({ _id: 1 }); + } catch (error) { + expect(error.message).to.equal("removeRepeatable error"); + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + describe("getMetrics", () => { + it("should return metrics for the job queue", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getWaitingCount = async () => 1; + jobQueue.queue.getActiveCount = async () => 2; + jobQueue.queue.getCompletedCount = async () => 3; + jobQueue.queue.getFailedCount = async () => 4; + jobQueue.queue.getDelayedCount = async () => 5; + jobQueue.queue.getRepeatableJobs = async () => [1, 2, 3]; + const metrics = await jobQueue.getMetrics(); + expect(metrics).to.deep.equal({ + waiting: 1, + active: 2, + completed: 3, + failed: 4, + delayed: 5, + repeatableJobs: 3, + }); + }); + it("should log an error if metrics operations fail", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getWaitingCount = async () => { + throw new Error("Error"); + }; + await jobQueue.getMetrics(); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal("Error"); + }); + }); + + describe("obliterate", () => { + it("should return true if obliteration is successful", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.pause = async () => true; + jobQueue.getJobs = async () => [{ key: 1, id: 1 }]; + jobQueue.queue.removeRepeatableByKey = async () => true; + jobQueue.queue.remove = async () => true; + jobQueue.queue.obliterate = async () => true; + const obliteration = await jobQueue.obliterate(); + expect(obliteration).to.be.true; + }); + it("should throw an error if obliteration fails", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + + jobQueue.getMetrics = async () => { + throw new Error("Error"); + }; + + try { + await jobQueue.obliterate(); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("obliterate"); + } + }); + it("should throw an error if obliteration fails but respect existing error data", async () => { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + + jobQueue.getMetrics = async () => { + const error = new Error("Error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + + try { + await jobQueue.obliterate(); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); +}); diff --git a/Server/tests/services/networkService.test.js b/Server/tests/services/networkService.test.js index 5d3e81a22..7e5ed7861 100644 --- a/Server/tests/services/networkService.test.js +++ b/Server/tests/services/networkService.test.js @@ -105,13 +105,7 @@ describe("NetworkService - handleNotification", () => { await networkService.handleNotification(monitor, true); expect(loggerMock.error.calledOnce).to.be.true; - expect( - loggerMock.error.calledWith("Database error", { - method: "handleNotification", - service: networkService.SERVICE_NAME, - monitorId: monitor._id, - }) - ).to.be.true; + expect(loggerMock.error.firstCall.args[0].message).to.equal("Database error"); }); }); @@ -160,13 +154,13 @@ describe("NetworkService - handleStatusUpdate", function () { expect(res).to.be.undefined; }); - it("should log an error if monitor is null", async function () { - dbMock.getMonitorById.resolves(null); + it("should log an error if finding monitor fails", async function () { + dbMock.getMonitorById.throws(new Error("Database error")); await networkService.handleStatusUpdate({ data: { _id: "monitor1" } }, true); expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.calledWith("Null Monitor: monitor1")).to.be.true; + expect(loggerMock.error.firstCall.args[0].message).to.equal("Database error"); }); it("should update monitor status if different", async function () { @@ -196,7 +190,7 @@ describe("NetworkService - handleStatusUpdate", function () { await networkService.handleStatusUpdate({ data: { _id: "monitor1" } }, true); expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.calledWith("Save error")).to.be.true; + expect(loggerMock.error.firstCall.args[0].message).to.equal("Save error"); }); }); @@ -308,43 +302,36 @@ describe("networkService - handlePing", () => { sinon.restore(); }); - it("should handle a successful ping response", async function () { + it("should handle a successful ping response", async () => { const response = { alive: true }; const responseTime = 0; - pingMock.promise.probe.resolves(response); + pingMock.promise.probe.returns(response); logAndStoreCheckStub.resolves(); await networkService.handlePing(job); expect( - logAndStoreCheckStub.calledOnceWith( - { - monitorId: job.data._id, - status: true, - responseTime, - message: successMessages.PING_SUCCESS, - }, - networkService.db.createCheck - ) + logAndStoreCheckStub.calledOnceWith({ + monitorId: job.data._id, + status: response.alive, + responseTime, + message: successMessages.PING_SUCCESS, + }) ).to.be.true; expect(handleStatusUpdateStub.calledOnceWith(job, true)).to.be.true; }); - it("should handle a successful ping response and isAlive === false", async function () { + it("should handle a successful response and isAlive === false", async function () { const response = { alive: false }; const responseTime = 0; pingMock.promise.probe.resolves(response); logAndStoreCheckStub.resolves(); await networkService.handlePing(job); - console.log(logAndStoreCheckStub.getCall(0).args[0]); expect( - logAndStoreCheckStub.calledOnceWith( - { - monitorId: job.data._id, - status: false, - responseTime, - message: errorMessages.PING_CANNOT_RESOLVE, - }, - networkService.db.createCheck - ) + logAndStoreCheckStub.calledOnceWith({ + monitorId: job.data._id, + status: false, + responseTime, + message: errorMessages.PING_CANNOT_RESOLVE, + }) ).to.be.true; expect(handleStatusUpdateStub.calledOnceWith(job, false)).to.be.true; }); @@ -801,7 +788,9 @@ describe("NetworkService - getStatus", () => { const result = await networkService.getStatus(job); expect(result).to.be.false; expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.calledWith(`Unsupported type: unknown`)).to.be.true; + expect(loggerMock.error.firstCall.args[0].message).to.equal( + "Unsupported type: unknown" + ); }); }); @@ -830,12 +819,14 @@ describe("NetworkService - logAndStoreCheck", async () => { sinon.restore(); }); - it("should log an error if `writeToDb` throws an error", async () => { + it("should log an error if writeToDb throws an error", async () => { writeToDB.rejects(new Error("Database error")); await networkService.logAndStoreCheck(data, writeToDB); + expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.calledWith(`Error writing check for ${data.monitorId}`)).to.be - .true; + expect(loggerMock.error.firstCall.args[0].details).to.equal( + `Error writing check for ${data.monitorId}` + ); }); it("should return the status of the inserted check if successful", async () => { @@ -848,14 +839,17 @@ describe("NetworkService - logAndStoreCheck", async () => { writeToDB.resolves(null); await networkService.logAndStoreCheck(data, writeToDB); expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.calledWith(`Error writing check for ${data.monitorId}`)).to.be - .true; + expect(loggerMock.error.firstCall.args[0].details).to.equal( + `Error writing check for ${data.monitorId}` + ); }); it("should thrown an error if the check is not inserted (undefined)", async () => { writeToDB.resolves(undefined); await networkService.logAndStoreCheck(data, writeToDB); + expect(loggerMock.error.calledOnce).to.be.true; - expect(loggerMock.error.calledWith(`Error writing check for ${data.monitorId}`)).to.be - .true; + expect(loggerMock.error.firstCall.args[0].details).to.equal( + `Error writing check for ${data.monitorId}` + ); }); }); diff --git a/Server/tests/services/settingsService.test.js b/Server/tests/services/settingsService.test.js index 3060758f7..0835bcf00 100644 --- a/Server/tests/services/settingsService.test.js +++ b/Server/tests/services/settingsService.test.js @@ -84,7 +84,6 @@ describe("SettingsService", () => { try { await settingsService.loadSettings(); } catch (error) { - console.log(error); expect(error.message).to.equal("Test error"); expect(error.service).to.equal("OTHER_SERVICE"); expect(error.method).to.equal("otherMethod"); diff --git a/Server/tests/utils/logger.test.js b/Server/tests/utils/logger.test.js new file mode 100644 index 000000000..154e2e462 --- /dev/null +++ b/Server/tests/utils/logger.test.js @@ -0,0 +1,118 @@ +import sinon from "sinon"; +import logger from "../../utils/logger.js"; +import { Logger } from "../../utils/logger.js"; +import winston from "winston"; + +describe("Logger", () => { + let infoStub, warnStub, errorStub; + + beforeEach(() => { + infoStub = sinon.stub(logger.logger, "info"); + warnStub = sinon.stub(logger.logger, "warn"); + errorStub = sinon.stub(logger.logger, "error"); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("constructor", () => { + let createLoggerStub; + + beforeEach(function () { + createLoggerStub = sinon.stub(winston, "createLogger"); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should convert message to JSON string if it is an object", function () { + const logMessage = { key: "value" }; + const expectedMessage = JSON.stringify(logMessage, null, 2); + + createLoggerStub.callsFake((config) => { + const consoleTransport = config.transports[0]; + const logEntry = { + level: "info", + message: logMessage, + timestamp: new Date().toISOString(), + }; + const formattedMessage = consoleTransport.format.transform(logEntry); + expect(formattedMessage).to.include(expectedMessage); + return { log: sinon.spy() }; + }); + + const logger = new Logger(); + logger.logger.info(logMessage); + }); + }); + + describe("info", () => { + it("should log an informational message", () => { + const config = { + message: "Info message", + service: "TestService", + method: "TestMethod", + details: { key: "value" }, + }; + + logger.info(config); + + expect(infoStub.calledOnce).to.be.true; + expect( + infoStub.calledWith(config.message, { + service: config.service, + method: config.method, + details: config.details, + }) + ).to.be.true; + }); + }); + + describe("warn", () => { + it("should log a warning message", () => { + const config = { + message: "Warning message", + service: "TestService", + method: "TestMethod", + details: { key: "value" }, + }; + + logger.warn(config); + + expect(warnStub.calledOnce).to.be.true; + expect( + warnStub.calledWith(config.message, { + service: config.service, + method: config.method, + details: config.details, + }) + ).to.be.true; + }); + }); + + describe("error", () => { + it("should log an error message", () => { + const config = { + message: "Error message", + service: "TestService", + method: "TestMethod", + details: { key: "value" }, + stack: "Error stack trace", + }; + + logger.error(config); + + expect(errorStub.calledOnce).to.be.true; + expect( + errorStub.calledWith(config.message, { + service: config.service, + method: config.method, + details: config.details, + stack: config.stack, + }) + ).to.be.true; + }); + }); +}); diff --git a/Server/utils/logger.js b/Server/utils/logger.js index 6c0bddcd8..60d509a6a 100644 --- a/Server/utils/logger.js +++ b/Server/utils/logger.js @@ -1,23 +1,91 @@ -import winston from "winston"; -/** - * @module - * @example - * logger.info("Registered a new user!") - * logger.warn("User not found!") - * logger.error("Cannot save") - * @example - * "Specify service and ID in the log if applicable." - * logger.error("Descriptive Message",{"service":"monitor","monitorId":"123456"}) - * logger.error("Incorrect Credentials",{"service":"Auth","userId":"654321"}) - * logger.error("User not found!",{"service":"Auth"}) - */ -const logger = winston.createLogger({ - level: "info", - format: winston.format.combine(winston.format.timestamp(), winston.format.json()), - transports: [ - new winston.transports.Console(), - new winston.transports.File({ filename: "app.log" }), - ], -}); +import { createLogger, format, transports } from "winston"; + +class Logger { + constructor() { + const consoleFormat = format.printf( + ({ level, message, service, method, details, timestamp }) => { + if (message instanceof Object) { + message = JSON.stringify(message, null, 2); + } + let msg = `${timestamp} ${level}:`; + service && (msg += ` [${service}]`); + method && (msg += `(${method})`); + message && (msg += ` ${message}`); + details && (msg += ` (details: ${details})`); + return msg; + } + ); + + this.logger = createLogger({ + level: "info", + format: format.combine(format.timestamp()), + transports: [ + new transports.Console({ + format: format.combine( + format.colorize(), + format.prettyPrint(), + format.json(), + consoleFormat + ), + }), + new transports.File({ + format: format.combine(format.json()), + filename: "app.log", + }), + ], + }); + } + /** + * Logs an informational message. + * @param {Object} config - The configuration object. + * @param {string} config.message - The message to log. + * @param {string} config.service - The service name. + * @param {string} config.method - The method name. + * @param {Object} config.details - Additional details. + */ + info(config) { + this.logger.info(config.message, { + service: config.service, + method: config.method, + details: config.details, + }); + } + + /** + * Logs a warning message. + * @param {Object} config - The configuration object. + * @param {string} config.message - The message to log. + * @param {string} config.service - The service name. + * @param {string} config.method - The method name. + * @param {Object} config.details - Additional details. + */ + warn(config) { + this.logger.warn(config.message, { + service: config.service, + method: config.method, + details: config.details, + }); + } + + /** + * Logs an error message. + * @param {Object} config - The configuration object. + * @param {string} config.message - The message to log. + * @param {string} config.service - The service name. + * @param {string} config.method - The method name. + * @param {Object} config.details - Additional details. + */ + error(config) { + this.logger.error(config.message, { + service: config.service, + method: config.method, + details: config.details, + stack: config.stack, + }); + } +} + +const logger = new Logger(); +export { Logger }; export default logger; diff --git a/Server/validation/joi.js b/Server/validation/joi.js index c67967270..48edc6f9f 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -222,6 +222,11 @@ const createMonitorBodyValidation = joi.object({ url: joi.string().required(), isActive: joi.boolean(), interval: joi.number(), + thresholds: joi.object().keys({ + usage_cpu: joi.number(), + usage_memory: joi.number(), + usage_disk: joi.number(), + }), notifications: joi.array().items(joi.object()).min(1), }); diff --git a/guides/users-guide/server-settings.md b/guides/users-guide/server-settings.md index 6276f3c42..ae1380dff 100644 --- a/guides/users-guide/server-settings.md +++ b/guides/users-guide/server-settings.md @@ -26,10 +26,12 @@ Here you can setup the following: **Email settings:** Set your host email settings here. These settings are used for sending system emails. -Server settings: Several server settings can be done here. Alternatively, you can add a pagespeed API key to bypass Google's limitations (albeit they are generous about it) +**Server settings:** Several server settings can be done here. Alternatively, you can add a pagespeed API key to bypass Google's limitations (albeit they are generous about it) + +Select a number for jwt TTL and a unit for it (days or hours), the combined result +would be in vercel/ms time format, e.g "99d", this will be sent to server to persist -\