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 );