Merge pull request #2154 from bluewave-labs/Feature-Bulk-Upload-Of-Monitors
Feature release for bulk upload of uptime monitors
This commit is contained in:
15 files changed
+376
-36
No files matched your search
@@ -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,,,
|
||||
|
@@ -0,0 +1 @@
|
||||
type,name,url,interval,port,expectedValue,jsonPath,matchMethod
|
||||
|
@@ -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 <SkeletonLayout />;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
@@ -25,6 +33,17 @@ const CreateMonitorHeader = ({
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{bulkPath && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={() => {
|
||||
navigate(`${bulkPath}`);
|
||||
}}
|
||||
>
|
||||
{t("bulkImport.title")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -36,4 +55,5 @@ CreateMonitorHeader.propTypes = {
|
||||
shouldRender: PropTypes.bool,
|
||||
path: PropTypes.string.isRequired,
|
||||
label: PropTypes.string,
|
||||
bulkPath: PropTypes.string,
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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 (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Typography
|
||||
component="h2"
|
||||
mb={theme.spacing(1.5)}
|
||||
sx={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{file?.name || t("bulkImport.noFileSelected")}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="div"
|
||||
mb={theme.spacing(1.5)}
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={handleSelectFile}
|
||||
>
|
||||
{t("bulkImport.selectFile")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
UploadFile.prototype = {
|
||||
onFileSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default UploadFile;
|
||||
@@ -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 (
|
||||
<Box className="bulk-import-monitor">
|
||||
<Breadcrumbs list={crumbs} />
|
||||
<Stack
|
||||
component="form"
|
||||
gap={theme.spacing(12)}
|
||||
mt={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
{t("bulkImport.title")}
|
||||
</Typography>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">{t("bulkImport.selectFileTips")}</Typography>
|
||||
<Typography component="p">
|
||||
<Trans
|
||||
i18nKey="bulkImport.selectFileDescription"
|
||||
components={{
|
||||
template: (
|
||||
<Link
|
||||
color="info"
|
||||
download
|
||||
href="/bulk_import_monitors_template.csv"
|
||||
/>
|
||||
),
|
||||
sample: (
|
||||
<Link
|
||||
color="info"
|
||||
download
|
||||
href="/bulk_import_monitors_sample.csv"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(12)}>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<UploadFile onFileSelect={(file) => setSelectedFile(file)} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={handleSubmit}
|
||||
disabled={hookLoading}
|
||||
loading={hookLoading}
|
||||
>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkImport;
|
||||
@@ -191,6 +191,7 @@ const UptimeMonitors = () => {
|
||||
isAdmin={isAdmin}
|
||||
shouldRender={!isLoading}
|
||||
path="/uptime/create"
|
||||
bulkPath="/uptime/bulk-import"
|
||||
/>
|
||||
<Greeting type="uptime" />
|
||||
<StatusBoxes
|
||||
|
||||
@@ -56,6 +56,7 @@ import ProtectedRoute from "../Components/ProtectedRoute";
|
||||
import ProtectedDistributedUptimeRoute from "../Components/ProtectedDistributedUptimeRoute";
|
||||
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance";
|
||||
import withAdminCheck from "../Components/HOC/withAdminCheck";
|
||||
import BulkImport from "../Pages/Uptime/BulkImport";
|
||||
|
||||
const Routes = () => {
|
||||
const AdminCheckedRegister = withAdminCheck(AuthRegister);
|
||||
@@ -78,6 +79,11 @@ const Routes = () => {
|
||||
element={<Uptime />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/uptime/bulk-import"
|
||||
element={<BulkImport />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/uptime/create/:monitorId?"
|
||||
element={<UptimeCreate />}
|
||||
|
||||
@@ -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
|
||||
// ************************************
|
||||
|
||||
@@ -359,5 +359,5 @@ export {
|
||||
advancedSettingsValidation,
|
||||
infrastructureMonitorValidation,
|
||||
statusPageValidation,
|
||||
logoImageValidation,
|
||||
logoImageValidation
|
||||
};
|
||||
@@ -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 <template>template</template> or <sample>sample</sample>",
|
||||
"selectFile": "Select File",
|
||||
"parsingFailed": "Parsing failed",
|
||||
"uploadSuccess": "Monitors created successfully!",
|
||||
"validationFailed": "Validation failed",
|
||||
"noFileSelected": "No file selected"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user