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 template or sample",
+ "selectFile": "Select File",
+ "parsingFailed": "Parsing failed",
+ "uploadSuccess": "Monitors created successfully!",
+ "validationFailed": "Validation failed"
+ }
}