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/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/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/Hooks/useBulkMonitors.js b/client/src/Hooks/useBulkMonitors.js new file mode 100644 index 000000000..0caa58b41 --- /dev/null +++ b/client/src/Hooks/useBulkMonitors.js @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { networkService } from "../main"; + +export const useBulkMonitors = () => { + const [isLoading, setIsLoading] = useState(false); + + const createBulkMonitors = async (file, user) => { + setIsLoading(true); + + 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 [true, response.data, null]; // [success, data, error] + } catch (err) { + const errorMessage = err?.response?.data?.msg || err.message; + return [false, null, errorMessage]; + } finally { + setIsLoading(false); + } + }; + + return [createBulkMonitors, isLoading]; +}; diff --git a/client/src/Pages/Uptime/BulkImport/Upload.jsx b/client/src/Pages/Uptime/BulkImport/Upload.jsx new file mode 100644 index 000000000..9f49b54bc --- /dev/null +++ b/client/src/Pages/Uptime/BulkImport/Upload.jsx @@ -0,0 +1,73 @@ +import { useTheme } from "@emotion/react"; +import { useState, useRef } from "react"; +import { Button, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; + +const UploadFile = ({ onFileSelect }) => { // Changed prop to onFileSelect + 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(""); + 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 + }; + + return ( +
+ + + {file?.name || t("bulkImport.noFileSelected")} + + + {error} + + +
+ ); +} + + +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 new file mode 100644 index 000000000..4d2e716f3 --- /dev/null +++ b/client/src/Pages/Uptime/BulkImport/index.jsx @@ -0,0 +1,112 @@ +// React, Redux, Router +import { useTheme } from "@emotion/react"; +import { useState } from "react"; +// MUI +import { Box, Stack, Typography, Button, Link } from "@mui/material"; + +//Components +import { createToast } from "../../../Utils/toastUtils"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +import ConfigBox from "../../../Components/ConfigBox"; +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 { 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 [createBulkMonitors, hookLoading] = useBulkMonitors(); + + const handleSubmit = async () => { + if (!selectedFile) { + createToast({ body: t("bulkImport.noFileSelected") }); + return; + } + + 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 || t("bulkImport.uploadFailed") }); + } + }; + + return ( + + + + + {t("bulkImport.title")} + + + + {t("bulkImport.selectFileTips")} + + + ), + sample: ( + + ), + }} + /> + + + + + setSelectedFile(file)} /> + + + + + + + + + ); +}; + +export default BulkImport; 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..6d7dbbaca 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -1086,6 +1086,19 @@ class NetworkService { return this.axiosInstance.delete(`/status-page/${encodedUrl}`, {}); } + // ************************************ + // Create bulk monitors + // ************************************ + + async createBulkMonitors(formData) { + const response = await this.axiosInstance.post(`/monitors/bulk`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return response.data; + } + // ************************************ // Fetch monitors with summary by TeamID // ************************************ diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 0b9088e1c..9bc04ec8d 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -359,5 +359,5 @@ export { advancedSettingsValidation, infrastructureMonitorValidation, statusPageValidation, - logoImageValidation, + logoImageValidation }; diff --git a/client/src/locales/gb.json b/client/src/locales/gb.json index 12653725b..5e6613c54 100644 --- a/client/src/locales/gb.json +++ b/client/src/locales/gb.json @@ -399,5 +399,15 @@ "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", + "noFileSelected": "No file selected" + } } diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 2cd2d9678..06c2d583f 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,119 @@ 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); + // validate the file + if (!req.file) { + throw new Error("No file uploaded"); + } - // create notifications for each monitor - await Promise.all( - monitors.map(async (monitor, index) => { - const notifications = req.body[index].notifications; + // Check if the file is a CSV + if (!req.file.mimetype.includes("csv")) { + throw new Error("File is not a CSV"); + } - 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(); + // 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) { + 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"); + + // 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)) { + throw new Error(`${header} should be a valid number, got: ${value}`); + } + return num; } - // Add monitor to job queue - this.jobQueue.addJob(monitor._id, monitor); - }) - ); + return value; + }, + complete: async ({ data, errors }) => { + try { + if (errors.length > 0) { + throw new Error("Error parsing CSV"); + } - return res.success({ - msg: this.stringService.bulkMonitorsCreate, - data: monitors, + 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) { + 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 );