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