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" + } }