From b2fd9b1e89f8afaaacdc52d8715874a027c93342 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Sun, 27 Apr 2025 03:05:14 +0530 Subject: [PATCH 1/8] Feature release for uptime monitors. --- client/package.json | 1 + client/public/bulk_import_monitors_sample.csv | 6 + .../Components/MonitorCreateHeader/index.jsx | 20 +++ client/src/Pages/Uptime/BulkImport/Upload.jsx | 89 ++++++++++++++ client/src/Pages/Uptime/BulkImport/index.jsx | 116 ++++++++++++++++++ client/src/Pages/Uptime/Monitors/index.jsx | 1 + client/src/Routes/index.jsx | 6 + client/src/Utils/NetworkService.js | 8 ++ client/src/Validation/validation.js | 3 + client/src/locales/gb.json | 11 +- 10 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 client/public/bulk_import_monitors_sample.csv create mode 100644 client/src/Pages/Uptime/BulkImport/Upload.jsx create mode 100644 client/src/Pages/Uptime/BulkImport/index.jsx diff --git a/client/package.json b/client/package.json index 15e83dc87..7bad58680 100644 --- a/client/package.json +++ b/client/package.json @@ -38,6 +38,7 @@ "joi": "17.13.3", "maplibre-gl": "5.3.1", "mui-color-input": "^6.0.0", + "papaparse": "^5.5.2", "react": "18.3.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/client/public/bulk_import_monitors_sample.csv b/client/public/bulk_import_monitors_sample.csv new file mode 100644 index 000000000..2a01ea483 --- /dev/null +++ b/client/public/bulk_import_monitors_sample.csv @@ -0,0 +1,6 @@ +type,name,url,interval,port,expectedValue,jsonPath,matchMethod +http,HTTP Monitor,https://www.google.com,60000,,,, +http,API Monitor,http://reqres.in/api/users/2,120000,,Janet,data.first_name,equal +ping,Ping monitor,127.0.0.1,60000,,,, +docker,Docker monitor,bbdd20b4e3757d63e070bc16e04d3f770c4a8f6e62e813de380f23b141e2ca01,60000,,,, +port,Port monitor,127.0.0.1,60000,5000,,, \ No newline at end of file diff --git a/client/src/Components/MonitorCreateHeader/index.jsx b/client/src/Components/MonitorCreateHeader/index.jsx index 565dca032..382345379 100644 --- a/client/src/Components/MonitorCreateHeader/index.jsx +++ b/client/src/Components/MonitorCreateHeader/index.jsx @@ -2,21 +2,29 @@ import { Stack, Button } from "@mui/material"; import { useNavigate } from "react-router-dom"; import PropTypes from "prop-types"; import SkeletonLayout from "./skeleton"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@emotion/react"; const CreateMonitorHeader = ({ isAdmin, label = "Create new", shouldRender = true, path, + bulkPath, }) => { const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + if (!isAdmin) return null; if (!shouldRender) return ; + return ( + {bulkPath && ( + + )} ); }; @@ -36,4 +55,5 @@ CreateMonitorHeader.propTypes = { shouldRender: PropTypes.bool, path: PropTypes.string.isRequired, label: PropTypes.string, + bulkPath: PropTypes.string, }; diff --git a/client/src/Pages/Uptime/BulkImport/Upload.jsx b/client/src/Pages/Uptime/BulkImport/Upload.jsx new file mode 100644 index 000000000..1229320d0 --- /dev/null +++ b/client/src/Pages/Uptime/BulkImport/Upload.jsx @@ -0,0 +1,89 @@ +import { useTheme } from "@emotion/react"; +import { useState, useRef, useEffect } from "react"; +import { Button, Typography } from "@mui/material"; +import { parse } from "papaparse"; +import { bulkMonitorsValidation } from "../../../Validation/validation"; +import { useTranslation } from "react-i18next"; + +export function Upload({ onComplete }) { + const theme = useTheme(); + const [file, setFile] = useState(); + const [error, setError] = useState(""); + const inputRef = useRef(); + const { t } = useTranslation(); + + const handleSelectFile = () => { + inputRef.current.click(); + }; + + const handleFileChange = (e) => { + setError(""); + setFile(e.target.files[0]); + }; + + useEffect(() => { + if (!file) return; + parse(file, { + header: true, + skipEmptyLines: true, + transform: (value, header) => { + if (!value) { + return undefined; + } + if (header === "port" || header === "interval") { + return parseInt(value); + } + return value; + }, + complete: ({ data, errors }) => { + if (errors.length > 0) { + setError(t("bulkImport.parsingFailed")); + return; + } + const { error } = bulkMonitorsValidation.validate(data); + if (error) { + setError( + error.details?.[0]?.message || + error.message || + t("bulkImport.validationFailed") + ); + return; + } + onComplete(data); + }, + }); + }, [file]); + + return ( +
+ + + {file?.name} + + + {error} + + +
+ ); +} \ No newline at end of file diff --git a/client/src/Pages/Uptime/BulkImport/index.jsx b/client/src/Pages/Uptime/BulkImport/index.jsx new file mode 100644 index 000000000..09b7651dd --- /dev/null +++ b/client/src/Pages/Uptime/BulkImport/index.jsx @@ -0,0 +1,116 @@ +// React, Redux, Router +import { useTheme } from "@emotion/react"; +import { useState } from "react"; +// MUI +import { Box, Stack, Typography, Button, Link } from "@mui/material"; + +//Components +import { networkService } from "../../../main"; +import { createToast } from "../../../Utils/toastUtils"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +import ConfigBox from "../../../Components/ConfigBox"; +import { Upload } from "./Upload"; +import { useSelector } from "react-redux"; +import { useNavigate } from "react-router"; +import { Trans, useTranslation } from "react-i18next"; + +const BulkImport = () => { + const theme = useTheme(); + + const [monitors, setMonitors] = useState([]); + const { user } = useSelector((state) => state.auth); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const crumbs = [ + { name: t("uptime"), path: "/uptime" }, + { name: t("bulkImport.title"), path: `/uptime/bulk-import` }, + ]; + + const [isLoading, setIsLoading] = useState(false); + const handleSubmit = async () => { + setIsLoading(true); + try { + const monitorsWithUser = monitors.map((monitor) => ({ + ...monitor, + description: monitor.name || monitor.url, + teamId: user.teamId, + userId: user._id, + })); + await networkService.createBulkMonitors({ monitors: monitorsWithUser }); + createToast({ body: t("bulkImport.uploadSuccess") }); + navigate("/uptime"); + } catch (error) { + createToast({ body: error?.response?.data?.msg ?? error.message }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + {t("bulkImport.title")} + + + + + {t("bulkImport.selectFileTips")} + + + + ), + sample: ( + + ), + }} + /> + + + + + + + + + + + + + + ); +}; + +export default BulkImport; \ No newline at end of file diff --git a/client/src/Pages/Uptime/Monitors/index.jsx b/client/src/Pages/Uptime/Monitors/index.jsx index 19b39c64a..74883c909 100644 --- a/client/src/Pages/Uptime/Monitors/index.jsx +++ b/client/src/Pages/Uptime/Monitors/index.jsx @@ -191,6 +191,7 @@ const UptimeMonitors = () => { isAdmin={isAdmin} shouldRender={!isLoading} path="/uptime/create" + bulkPath="/uptime/bulk-import" /> { const AdminCheckedRegister = withAdminCheck(AuthRegister); @@ -78,6 +79,11 @@ const Routes = () => { element={} /> + } + /> + } diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 7c4ffba9f..de096f2c2 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -1086,6 +1086,14 @@ class NetworkService { return this.axiosInstance.delete(`/status-page/${encodedUrl}`, {}); } + // ************************************ + // Create bulk monitors + // ************************************ + + async createBulkMonitors({ monitors }) { + return this.axiosInstance.post(`/monitors/bulk`, monitors); + } + // ************************************ // Fetch monitors with summary by TeamID // ************************************ diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 6e8ff76cb..dc6c155f2 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -173,6 +173,8 @@ const monitorValidation = joi.object({ matchMethod: joi.string(), }); +const bulkMonitorsValidation = joi.array().items(monitorValidation); + const imageValidation = joi.object({ type: joi.string().valid("image/jpeg", "image/png").messages({ "any.only": "Invalid file format.", @@ -367,4 +369,5 @@ export { infrastructureMonitorValidation, statusPageValidation, logoImageValidation, + bulkMonitorsValidation }; diff --git a/client/src/locales/gb.json b/client/src/locales/gb.json index 95ef6d32e..50a412f5d 100644 --- a/client/src/locales/gb.json +++ b/client/src/locales/gb.json @@ -395,5 +395,14 @@ "DeleteAccount": "Delete account", "DeleteDescriptionText": "Note that deleting your account will remove all data from the server. This is permanent and non-recoverable.", "DeleteAccountWarning": "If you delete your account, you will no longer be able to sign in, and all of your data will be deleted. Deleting your account is permanent and non-recoverable action.", - "DeleteWarningTitle": "Really delete this account?" + "DeleteWarningTitle": "Really delete this account?", + "bulkImport": { + "title": "Bulk Import", + "selectFileTips": "Select CSV file to upload", + "selectFileDescription": "You can download our or sample", + "selectFile": "Select File", + "parsingFailed": "Parsing failed", + "uploadSuccess": "Monitors created successfully!", + "validationFailed": "Validation failed" + } } From bd8554707d8026e13551bc2234e42863b2735c25 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 29 Apr 2025 12:47:24 +0530 Subject: [PATCH 2/8] Moved the parsing logic to backend as suggested. --- client/package.json | 1 - client/src/Hooks/useBulkMonitors.js | 29 +++++ client/src/Pages/Uptime/BulkImport/Upload.jsx | 64 ++++------- client/src/Pages/Uptime/BulkImport/index.jsx | 44 ++++--- client/src/Utils/NetworkService.js | 9 +- client/src/locales/gb.json | 3 +- server/controllers/monitorController.js | 107 ++++++++++++------ server/db/mongo/modules/monitorModule.js | 2 +- server/package.json | 3 +- server/routes/monitorRoute.js | 6 + 10 files changed, 165 insertions(+), 103 deletions(-) create mode 100644 client/src/Hooks/useBulkMonitors.js diff --git a/client/package.json b/client/package.json index 7bad58680..15e83dc87 100644 --- a/client/package.json +++ b/client/package.json @@ -38,7 +38,6 @@ "joi": "17.13.3", "maplibre-gl": "5.3.1", "mui-color-input": "^6.0.0", - "papaparse": "^5.5.2", "react": "18.3.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/client/src/Hooks/useBulkMonitors.js b/client/src/Hooks/useBulkMonitors.js new file mode 100644 index 000000000..614ea47b2 --- /dev/null +++ b/client/src/Hooks/useBulkMonitors.js @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { networkService } from "../main"; // Your network service + +export const useBulkMonitors = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const createBulkMonitors = async (file, user) => { + setIsLoading(true); + setError(null); + + const formData = new FormData(); + formData.append("csvFile", file); + formData.append("userId", user._id); + formData.append("teamId", user.teamId); + + try { + const response = await networkService.createBulkMonitors(formData); + return response.data; + } catch (err) { + setError(err?.response?.data?.msg ?? err.message); + return null; + } finally { + setIsLoading(false); + } + }; + + return { createBulkMonitors, isLoading, error }; +}; diff --git a/client/src/Pages/Uptime/BulkImport/Upload.jsx b/client/src/Pages/Uptime/BulkImport/Upload.jsx index 1229320d0..9f49b54bc 100644 --- a/client/src/Pages/Uptime/BulkImport/Upload.jsx +++ b/client/src/Pages/Uptime/BulkImport/Upload.jsx @@ -1,11 +1,10 @@ import { useTheme } from "@emotion/react"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef } from "react"; import { Button, Typography } from "@mui/material"; -import { parse } from "papaparse"; -import { bulkMonitorsValidation } from "../../../Validation/validation"; import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; -export function Upload({ onComplete }) { +const UploadFile = ({ onFileSelect }) => { // Changed prop to onFileSelect const theme = useTheme(); const [file, setFile] = useState(); const [error, setError] = useState(""); @@ -18,42 +17,20 @@ export function Upload({ onComplete }) { const handleFileChange = (e) => { setError(""); - setFile(e.target.files[0]); + const selectedFile = e.target.files[0]; + + // Basic file validation + if (!selectedFile) return; + + if (!selectedFile.name.endsWith('.csv')) { + setError(t("bulkImport.invalidFileType")); + return; + } + + setFile(selectedFile); + onFileSelect(selectedFile); // Pass the file directly to parent }; - useEffect(() => { - if (!file) return; - parse(file, { - header: true, - skipEmptyLines: true, - transform: (value, header) => { - if (!value) { - return undefined; - } - if (header === "port" || header === "interval") { - return parseInt(value); - } - return value; - }, - complete: ({ data, errors }) => { - if (errors.length > 0) { - setError(t("bulkImport.parsingFailed")); - return; - } - const { error } = bulkMonitorsValidation.validate(data); - if (error) { - setError( - error.details?.[0]?.message || - error.message || - t("bulkImport.validationFailed") - ); - return; - } - onComplete(data); - }, - }); - }, [file]); - return (
- {file?.name} + {file?.name || t("bulkImport.noFileSelected")}
); -} \ No newline at end of file +} + + +UploadFile.prototype = { + onFileSelect: PropTypes.func.isRequired, +}; + +export default UploadFile; \ No newline at end of file diff --git a/client/src/Pages/Uptime/BulkImport/index.jsx b/client/src/Pages/Uptime/BulkImport/index.jsx index 09b7651dd..bb09b9f4d 100644 --- a/client/src/Pages/Uptime/BulkImport/index.jsx +++ b/client/src/Pages/Uptime/BulkImport/index.jsx @@ -5,45 +5,41 @@ import { useState } from "react"; import { Box, Stack, Typography, Button, Link } from "@mui/material"; //Components -import { networkService } from "../../../main"; import { createToast } from "../../../Utils/toastUtils"; import Breadcrumbs from "../../../Components/Breadcrumbs"; import ConfigBox from "../../../Components/ConfigBox"; -import { Upload } from "./Upload"; +import { UploadFile } from "./Upload"; import { useSelector } from "react-redux"; import { useNavigate } from "react-router"; import { Trans, useTranslation } from "react-i18next"; +import { useBulkMonitors } from "../../../Hooks/useBulkMonitors"; const BulkImport = () => { const theme = useTheme(); - - const [monitors, setMonitors] = useState([]); const { user } = useSelector((state) => state.auth); const navigate = useNavigate(); const { t } = useTranslation(); + const [selectedFile, setSelectedFile] = useState(null); const crumbs = [ { name: t("uptime"), path: "/uptime" }, { name: t("bulkImport.title"), path: `/uptime/bulk-import` }, ]; - const [isLoading, setIsLoading] = useState(false); + const { createBulkMonitors, isLoading: hookLoading, error } = useBulkMonitors(); + const handleSubmit = async () => { - setIsLoading(true); - try { - const monitorsWithUser = monitors.map((monitor) => ({ - ...monitor, - description: monitor.name || monitor.url, - teamId: user.teamId, - userId: user._id, - })); - await networkService.createBulkMonitors({ monitors: monitorsWithUser }); + if (!selectedFile) { + createToast({ body: t("bulkImport.noFileSelected") }); + return; + } + const success = await createBulkMonitors(selectedFile, user); + + if (success) { createToast({ body: t("bulkImport.uploadSuccess") }); navigate("/uptime"); - } catch (error) { - createToast({ body: error?.response?.data?.msg ?? error.message }); - } finally { - setIsLoading(false); + } else { + createToast({ body: error }); } }; @@ -63,9 +59,7 @@ const BulkImport = () => { - - {t("bulkImport.selectFileTips")} - + {t("bulkImport.selectFileTips")} { - + setSelectedFile(file)} /> @@ -102,8 +96,8 @@ const BulkImport = () => { variant="contained" color="accent" onClick={handleSubmit} - disabled={!monitors?.length} - loading={isLoading} + disabled={hookLoading} + loading={hookLoading} > {t("submit")} @@ -113,4 +107,4 @@ const BulkImport = () => { ); }; -export default BulkImport; \ No newline at end of file +export default BulkImport; diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index de096f2c2..6d7dbbaca 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -1090,8 +1090,13 @@ class NetworkService { // Create bulk monitors // ************************************ - async createBulkMonitors({ monitors }) { - return this.axiosInstance.post(`/monitors/bulk`, monitors); + async createBulkMonitors(formData) { + const response = await this.axiosInstance.post(`/monitors/bulk`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return response.data; } // ************************************ diff --git a/client/src/locales/gb.json b/client/src/locales/gb.json index 50a412f5d..a3b260657 100644 --- a/client/src/locales/gb.json +++ b/client/src/locales/gb.json @@ -403,6 +403,7 @@ "selectFile": "Select File", "parsingFailed": "Parsing failed", "uploadSuccess": "Monitors created successfully!", - "validationFailed": "Validation failed" + "validationFailed": "Validation failed", + "noFileSelected": "No file selected" } } diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 2cd2d9678..45ae8e05b 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -24,6 +24,7 @@ import axios from "axios"; import seedDb from "../db/mongo/utils/seedDb.js"; import { seedDistributedTest } from "../db/mongo/utils/seedDb.js"; const SERVICE_NAME = "monitorController"; +import pkg from "papaparse"; class MonitorController { constructor(db, settingsService, jobQueue, stringService) { @@ -243,56 +244,98 @@ class MonitorController { }; /** - * Creates bulk monitors and adds them to the job queue. + * Creates bulk monitors and adds them to the job queue after parsing CSV. * @async * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. + * @property {Object} req.file - The uploaded CSV file. * @param {Object} res - The Express response object. * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the creation of the monitor, and the created monitor data. + * @returns {Object} The response object with a success status and message. * @throws {Error} If there is an error during the process, especially if there is a validation error (422). */ createBulkMonitors = async (req, res, next) => { try { - await createMonitorsBodyValidation.validateAsync(req.body); - } catch (error) { - next(handleValidationError(error, SERVICE_NAME)); - return; - } + const { parse } = pkg; - try { - // create monitors - const monitors = await this.db.createBulkMonitors(req); + if (!req.file) { + return res.status(400).json({ msg: "No file uploaded" }); + } - // create notifications for each monitor - await Promise.all( - monitors.map(async (monitor, index) => { - const notifications = req.body[index].notifications; + const { userId, teamId } = req.body; - if (notifications?.length) { - monitor.notifications = await Promise.all( - notifications.map(async (notification) => { - notification.monitorId = monitor._id; - return await this.db.createNotification(notification); - }) - ); - await monitor.save(); + if (!userId || !teamId) { + return res.status(400).json({ msg: "Missing userId or teamId in form data" }); + } + + // Get file buffer from memory and convert to string + const fileData = req.file.buffer.toString("utf-8"); + + parse(fileData, { + header: true, + skipEmptyLines: true, + transform: (value, header) => { + if (value === "") return undefined; + if (["port", "interval"].includes(header)) { + const num = parseInt(value, 10); + return isNaN(num) ? undefined : num; + } + + return value; + }, + complete: async ({ data, errors }) => { + if (errors.length > 0) { + return res.status(400).json({ msg: "Error parsing CSV", errors }); } - // Add monitor to job queue - this.jobQueue.addJob(monitor._id, monitor); - }) - ); + const enrichedData = data.map((monitor) => ({ + userId, + teamId, + ...monitor, + description: monitor.description || monitor.name || monitor.url, + name: monitor.name || monitor.url, + type: monitor.type || "http", + })); - return res.success({ - msg: this.stringService.bulkMonitorsCreate, - data: monitors, + try { + await createMonitorsBodyValidation.validateAsync(enrichedData); + } catch (error) { + return next(handleValidationError(error, SERVICE_NAME)); + } + + try { + const monitors = await this.db.createBulkMonitors(enrichedData); + + await Promise.all( + monitors.map(async (monitor, index) => { + const notifications = enrichedData[index].notifications; + + if (notifications?.length) { + monitor.notifications = await Promise.all( + notifications.map(async (notification) => { + notification.monitorId = monitor._id; + return await this.db.createNotification(notification); + }) + ); + await monitor.save(); + } + + this.jobQueue.addJob(monitor._id, monitor); + }) + ); + + return res.success({ + msg: this.stringService.bulkMonitorsCreate, + data: monitors, + }); + } catch (error) { + return next(handleError(error, SERVICE_NAME, "createBulkMonitors")); + } + }, }); } catch (error) { - next(handleError(error, SERVICE_NAME, "createBulkMonitors")); + return next(handleError(error, SERVICE_NAME, "createBulkMonitors")); } }; - /** * Checks if the endpoint can be resolved * @async diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index 1cea930ac..fe8e3c2fc 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -751,7 +751,7 @@ const createMonitor = async (req, res) => { */ const createBulkMonitors = async (req) => { try { - const monitors = req.body.map( + const monitors = req.map( (item) => new Monitor({ ...item, notifications: undefined }) ); await Monitor.bulkSave(monitors); diff --git a/server/package.json b/server/package.json index c95fb6f62..097cf4443 100755 --- a/server/package.json +++ b/server/package.json @@ -33,8 +33,9 @@ "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", - "multer": "1.4.5-lts.1", + "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.14", + "papaparse": "^5.5.2", "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 36eed3911..4b4331d5b 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -1,7 +1,12 @@ import { Router } from "express"; import { isAllowed } from "../middleware/isAllowed.js"; +import multer from "multer"; import { fetchMonitorCertificate } from "../controllers/controllerUtils.js"; +const upload = multer({ + storage: multer.memoryStorage() // Store file in memory as Buffer + }); + class MonitorRoutes { constructor(monitorController) { this.router = Router(); @@ -89,6 +94,7 @@ class MonitorRoutes { this.router.post( "/bulk", isAllowed(["admin", "superadmin"]), + upload.single("csvFile"), this.monitorController.createBulkMonitors ); From 2a045f93e0087595cc28c51b4227f1427fa5fef8 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 29 Apr 2025 12:49:33 +0530 Subject: [PATCH 3/8] Removed the validation from client. --- client/src/Validation/validation.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index dc6c155f2..2ea14e732 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -173,8 +173,6 @@ const monitorValidation = joi.object({ matchMethod: joi.string(), }); -const bulkMonitorsValidation = joi.array().items(monitorValidation); - const imageValidation = joi.object({ type: joi.string().valid("image/jpeg", "image/png").messages({ "any.only": "Invalid file format.", @@ -368,6 +366,5 @@ export { advancedSettingsValidation, infrastructureMonitorValidation, statusPageValidation, - logoImageValidation, - bulkMonitorsValidation + logoImageValidation }; From 59027cf33c0c57bd63a7066fc134d1b2ff61335a Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 29 Apr 2025 15:07:46 +0530 Subject: [PATCH 4/8] Fixed the issue with respect to imports. --- client/src/Pages/Uptime/BulkImport/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/Uptime/BulkImport/index.jsx b/client/src/Pages/Uptime/BulkImport/index.jsx index bb09b9f4d..1494e692a 100644 --- a/client/src/Pages/Uptime/BulkImport/index.jsx +++ b/client/src/Pages/Uptime/BulkImport/index.jsx @@ -8,7 +8,7 @@ import { Box, Stack, Typography, Button, Link } from "@mui/material"; import { createToast } from "../../../Utils/toastUtils"; import Breadcrumbs from "../../../Components/Breadcrumbs"; import ConfigBox from "../../../Components/ConfigBox"; -import { UploadFile } from "./Upload"; +import UploadFile from "./Upload"; import { useSelector } from "react-redux"; import { useNavigate } from "react-router"; import { Trans, useTranslation } from "react-i18next"; From 8a53ddcf15452ffe540ee505b1acc644a398f736 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Wed, 30 Apr 2025 23:52:17 +0530 Subject: [PATCH 5/8] Replaced the object destructing with array as it is a standard pratice. --- client/src/Hooks/useBulkMonitors.js | 4 ++-- client/src/Pages/Uptime/BulkImport/index.jsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/Hooks/useBulkMonitors.js b/client/src/Hooks/useBulkMonitors.js index 614ea47b2..e5ee2eb25 100644 --- a/client/src/Hooks/useBulkMonitors.js +++ b/client/src/Hooks/useBulkMonitors.js @@ -1,5 +1,5 @@ import { useState } from "react"; -import { networkService } from "../main"; // Your network service +import { networkService } from "../main"; export const useBulkMonitors = () => { const [isLoading, setIsLoading] = useState(false); @@ -25,5 +25,5 @@ export const useBulkMonitors = () => { } }; - return { createBulkMonitors, isLoading, error }; + return [createBulkMonitors, isLoading, error]; }; diff --git a/client/src/Pages/Uptime/BulkImport/index.jsx b/client/src/Pages/Uptime/BulkImport/index.jsx index 1494e692a..15bc5efcf 100644 --- a/client/src/Pages/Uptime/BulkImport/index.jsx +++ b/client/src/Pages/Uptime/BulkImport/index.jsx @@ -26,7 +26,7 @@ const BulkImport = () => { { name: t("bulkImport.title"), path: `/uptime/bulk-import` }, ]; - const { createBulkMonitors, isLoading: hookLoading, error } = useBulkMonitors(); + const [createBulkMonitors, hookLoading, error] = useBulkMonitors(); const handleSubmit = async () => { if (!selectedFile) { From ecce31ce34e919d128bcfc180b60129c3f885687 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Wed, 30 Apr 2025 23:59:25 +0530 Subject: [PATCH 6/8] Added proper csv file which can be downloaded. --- client/public/bulk_import_monitors_template.csv | 1 + client/src/Pages/Uptime/BulkImport/index.jsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 client/public/bulk_import_monitors_template.csv diff --git a/client/public/bulk_import_monitors_template.csv b/client/public/bulk_import_monitors_template.csv new file mode 100644 index 000000000..2902010fc --- /dev/null +++ b/client/public/bulk_import_monitors_template.csv @@ -0,0 +1 @@ +type,name,url,interval,port,expectedValue,jsonPath,matchMethod \ No newline at end of file diff --git a/client/src/Pages/Uptime/BulkImport/index.jsx b/client/src/Pages/Uptime/BulkImport/index.jsx index 15bc5efcf..a1c38c0b6 100644 --- a/client/src/Pages/Uptime/BulkImport/index.jsx +++ b/client/src/Pages/Uptime/BulkImport/index.jsx @@ -68,14 +68,14 @@ const BulkImport = () => { ), sample: ( ), }} From 075559ffc37093abdd6047e75e3c99096e89cd3a Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Thu, 1 May 2025 12:05:28 +0530 Subject: [PATCH 7/8] Fixed the errors coming out in csv. --- client/src/Hooks/useBulkMonitors.js | 10 ++-- client/src/Pages/Uptime/BulkImport/index.jsx | 8 ++- server/controllers/monitorController.js | 62 ++++++++++++++------ 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/client/src/Hooks/useBulkMonitors.js b/client/src/Hooks/useBulkMonitors.js index e5ee2eb25..3c4f3c00e 100644 --- a/client/src/Hooks/useBulkMonitors.js +++ b/client/src/Hooks/useBulkMonitors.js @@ -3,11 +3,9 @@ import { networkService } from "../main"; export const useBulkMonitors = () => { const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); const createBulkMonitors = async (file, user) => { setIsLoading(true); - setError(null); const formData = new FormData(); formData.append("csvFile", file); @@ -16,14 +14,14 @@ export const useBulkMonitors = () => { try { const response = await networkService.createBulkMonitors(formData); - return response.data; + return [true, response.data, null]; // [success, data, error] } catch (err) { - setError(err?.response?.data?.msg ?? err.message); - return null; + const errorMessage = err?.response?.data?.errors?.[0]?.message || err.message; + return [false, null, errorMessage]; } finally { setIsLoading(false); } }; - return [createBulkMonitors, isLoading, error]; + return [createBulkMonitors, isLoading]; }; diff --git a/client/src/Pages/Uptime/BulkImport/index.jsx b/client/src/Pages/Uptime/BulkImport/index.jsx index a1c38c0b6..4d2e716f3 100644 --- a/client/src/Pages/Uptime/BulkImport/index.jsx +++ b/client/src/Pages/Uptime/BulkImport/index.jsx @@ -26,20 +26,22 @@ const BulkImport = () => { { name: t("bulkImport.title"), path: `/uptime/bulk-import` }, ]; - const [createBulkMonitors, hookLoading, error] = useBulkMonitors(); + const [createBulkMonitors, hookLoading] = useBulkMonitors(); const handleSubmit = async () => { if (!selectedFile) { createToast({ body: t("bulkImport.noFileSelected") }); return; } - const success = await createBulkMonitors(selectedFile, user); + + const [success, data, error] = await createBulkMonitors(selectedFile, user); if (success) { + // You can use `data` here if needed createToast({ body: t("bulkImport.uploadSuccess") }); navigate("/uptime"); } else { - createToast({ body: error }); + createToast({ body: error || t("bulkImport.uploadFailed") }); } }; diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 45ae8e05b..fc9482e59 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -256,37 +256,60 @@ class MonitorController { createBulkMonitors = async (req, res, next) => { try { const { parse } = pkg; - + if (!req.file) { - return res.status(400).json({ msg: "No file uploaded" }); + return res.status(400).json({ + errors: [ + { message: "No file uploaded" } + ] + }); } - + const { userId, teamId } = req.body; - + if (!userId || !teamId) { - return res.status(400).json({ msg: "Missing userId or teamId in form data" }); + return res.status(400).json({ + errors: [ + { message: "Missing userId or teamId in form data" } + ] + }); } - + // Get file buffer from memory and convert to string const fileData = req.file.buffer.toString("utf-8"); - + console.log("File data:", fileData); + + // Parse the CSV data parse(fileData, { header: true, skipEmptyLines: true, transform: (value, header) => { - if (value === "") return undefined; + if (value === "") return undefined; // Empty fields become undefined + + // Handle 'port' and 'interval' fields, check if they're valid numbers if (["port", "interval"].includes(header)) { const num = parseInt(value, 10); - return isNaN(num) ? undefined : num; + if (isNaN(num)) { + return res.status(400).json({ + errors: [ + { message: `${header} should be a valid number, got: ${value}` } + ] + }); // Return error immediately with this structure + } + return num; } - + return value; }, complete: async ({ data, errors }) => { if (errors.length > 0) { - return res.status(400).json({ msg: "Error parsing CSV", errors }); + return res.status(400).json({ + errors: [ + { message: "Error parsing CSV" } + ] + }); } - + const enrichedData = data.map((monitor) => ({ userId, teamId, @@ -295,20 +318,21 @@ class MonitorController { name: monitor.name || monitor.url, type: monitor.type || "http", })); - + try { + // Run Joi validation await createMonitorsBodyValidation.validateAsync(enrichedData); } catch (error) { return next(handleValidationError(error, SERVICE_NAME)); } - + try { const monitors = await this.db.createBulkMonitors(enrichedData); - + await Promise.all( monitors.map(async (monitor, index) => { const notifications = enrichedData[index].notifications; - + if (notifications?.length) { monitor.notifications = await Promise.all( notifications.map(async (notification) => { @@ -318,11 +342,11 @@ class MonitorController { ); await monitor.save(); } - + this.jobQueue.addJob(monitor._id, monitor); }) ); - + return res.success({ msg: this.stringService.bulkMonitorsCreate, data: monitors, @@ -335,7 +359,7 @@ class MonitorController { } catch (error) { return next(handleError(error, SERVICE_NAME, "createBulkMonitors")); } - }; + }; /** * Checks if the endpoint can be resolved * @async From 30ef28170c70da662a37769e727271e97f13c7be Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Fri, 2 May 2025 02:54:39 +0530 Subject: [PATCH 8/8] Removed all return error ro throw new err where this will be send to middleware. --- client/src/Hooks/useBulkMonitors.js | 2 +- server/controllers/monitorController.js | 141 ++++++++++++------------ 2 files changed, 70 insertions(+), 73 deletions(-) diff --git a/client/src/Hooks/useBulkMonitors.js b/client/src/Hooks/useBulkMonitors.js index 3c4f3c00e..0caa58b41 100644 --- a/client/src/Hooks/useBulkMonitors.js +++ b/client/src/Hooks/useBulkMonitors.js @@ -16,7 +16,7 @@ export const useBulkMonitors = () => { const response = await networkService.createBulkMonitors(formData); return [true, response.data, null]; // [success, data, error] } catch (err) { - const errorMessage = err?.response?.data?.errors?.[0]?.message || err.message; + const errorMessage = err?.response?.data?.msg || err.message; return [false, null, errorMessage]; } finally { setIsLoading(false); diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index fc9482e59..06c2d583f 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -256,110 +256,107 @@ class MonitorController { createBulkMonitors = async (req, res, next) => { try { const { parse } = pkg; - + + // validate the file if (!req.file) { - return res.status(400).json({ - errors: [ - { message: "No file uploaded" } - ] - }); + throw new Error("No file uploaded"); } - + + // Check if the file is a CSV + if (!req.file.mimetype.includes("csv")) { + throw new Error("File is not a CSV"); + } + + // Validate if the file is empty + if (req.file.size === 0) { + throw new Error("File is empty"); + } + const { userId, teamId } = req.body; - + if (!userId || !teamId) { - return res.status(400).json({ - errors: [ - { message: "Missing userId or teamId in form data" } - ] - }); + throw new Error("Missing userId or teamId in form data"); } - + // Get file buffer from memory and convert to string const fileData = req.file.buffer.toString("utf-8"); - console.log("File data:", fileData); - + // Parse the CSV data parse(fileData, { header: true, skipEmptyLines: true, transform: (value, header) => { if (value === "") return undefined; // Empty fields become undefined - + // Handle 'port' and 'interval' fields, check if they're valid numbers if (["port", "interval"].includes(header)) { const num = parseInt(value, 10); if (isNaN(num)) { - return res.status(400).json({ - errors: [ - { message: `${header} should be a valid number, got: ${value}` } - ] - }); // Return error immediately with this structure + throw new Error(`${header} should be a valid number, got: ${value}`); } return num; } - + return value; }, complete: async ({ data, errors }) => { - if (errors.length > 0) { - return res.status(400).json({ - errors: [ - { message: "Error parsing CSV" } - ] - }); - } - - const enrichedData = data.map((monitor) => ({ - userId, - teamId, - ...monitor, - description: monitor.description || monitor.name || monitor.url, - name: monitor.name || monitor.url, - type: monitor.type || "http", - })); - try { - // Run Joi validation + if (errors.length > 0) { + throw new Error("Error parsing CSV"); + } + + if (!data || data.length === 0) { + throw new Error("CSV file contains no data rows"); + } + + const enrichedData = data.map((monitor) => ({ + userId, + teamId, + ...monitor, + description: monitor.description || monitor.name || monitor.url, + name: monitor.name || monitor.url, + type: monitor.type || "http", + })); + await createMonitorsBodyValidation.validateAsync(enrichedData); + + try { + const monitors = await this.db.createBulkMonitors(enrichedData); + + await Promise.all( + monitors.map(async (monitor, index) => { + const notifications = enrichedData[index].notifications; + + if (notifications?.length) { + monitor.notifications = await Promise.all( + notifications.map(async (notification) => { + notification.monitorId = monitor._id; + return await this.db.createNotification(notification); + }) + ); + await monitor.save(); + } + + this.jobQueue.addJob(monitor._id, monitor); + }) + ); + + return res.success({ + msg: this.stringService.bulkMonitorsCreate, + data: monitors, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createBulkMonitors")); + } } catch (error) { - return next(handleValidationError(error, SERVICE_NAME)); - } - - try { - const monitors = await this.db.createBulkMonitors(enrichedData); - - await Promise.all( - monitors.map(async (monitor, index) => { - const notifications = enrichedData[index].notifications; - - if (notifications?.length) { - monitor.notifications = await Promise.all( - notifications.map(async (notification) => { - notification.monitorId = monitor._id; - return await this.db.createNotification(notification); - }) - ); - await monitor.save(); - } - - this.jobQueue.addJob(monitor._id, monitor); - }) - ); - - return res.success({ - msg: this.stringService.bulkMonitorsCreate, - data: monitors, - }); - } catch (error) { - return next(handleError(error, SERVICE_NAME, "createBulkMonitors")); + next(handleError(error, SERVICE_NAME, "createBulkMonitors")); } }, }); } catch (error) { return next(handleError(error, SERVICE_NAME, "createBulkMonitors")); } - }; + }; /** * Checks if the endpoint can be resolved * @async