From e9e509ab0458d01637c91b423c3345a296ba21cb Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 17 Jun 2025 00:11:33 +0530 Subject: [PATCH 1/8] Addition of export. --- .../Components/MonitorCreateHeader/index.jsx | 33 ++++++++++++------ client/src/Hooks/monitorHooks.js | 34 +++++++++++++++++++ client/src/Utils/NetworkService.js | 7 ++++ client/src/locales/en.json | 7 +++- server/controllers/monitorController.js | 27 +++++++++++++++ server/routes/monitorRoute.js | 5 +++ 6 files changed, 102 insertions(+), 11 deletions(-) diff --git a/client/src/Components/MonitorCreateHeader/index.jsx b/client/src/Components/MonitorCreateHeader/index.jsx index b630cb259..7a4d75226 100644 --- a/client/src/Components/MonitorCreateHeader/index.jsx +++ b/client/src/Components/MonitorCreateHeader/index.jsx @@ -3,11 +3,13 @@ import { useNavigate } from "react-router-dom"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; import { useTheme } from "@emotion/react"; +import { useExportMonitors } from "../../Hooks/monitorHooks"; const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => { const navigate = useNavigate(); const { t } = useTranslation(); const theme = useTheme(); + const [exportMonitors, isExporting] = useExportMonitors() // Use the provided label or fall back to the translated default @@ -29,16 +31,27 @@ const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath {label || t("createNew")} {bulkPath && ( - + <> + + + + )} ); diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index 7e46438e0..b3e4a9478 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -453,6 +453,39 @@ const useCreateBulkMonitors = () => { return [createBulkMonitors, isLoading]; }; +const useExportMonitors = () => { + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); + + const exportMonitors = async () => { + setIsLoading(true); + try { + const blob = await networkService.exportMonitors(); + + // Create a download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'monitors.csv'); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + createToast({ body: t('export.success') }); + return [true, null]; + } catch (err) { + const errorMessage = err?.response?.data?.msg || err.message; + createToast({ body: errorMessage || t('export.failed') }); + return [false, errorMessage]; + } finally { + setIsLoading(false); + } + }; + + return [exportMonitors, isLoading]; +}; + export { useFetchMonitorsWithSummary, useFetchMonitorsWithChecks, @@ -469,4 +502,5 @@ export { useDeleteAllMonitors, useDeleteMonitorStats, useCreateBulkMonitors, + useExportMonitors, }; diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index eee795268..6451f93e8 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -1022,6 +1022,13 @@ class NetworkService { const { id, notification } = config; return this.axiosInstance.put(`/notifications/${id}`, notification); } + + async exportMonitors() { + const response = await this.axiosInstance.get("/monitors/export", { + responseType: "blob", + }); + return response.data; + } } export default NetworkService; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 783cdb179..d71cf3212 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -739,5 +739,10 @@ "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "docker": "Enter the Docker ID of your container. Docker IDs must be the full 64 char Docker ID. You can run docker inspect to get the full container ID.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." - } + }, + "export": { + "title": "Export Monitors", + "success": "Monitors exported successfully!", + "failed": "Failed to export monitors" + } } diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 6c6136fb5..5d71f9cb6 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -702,6 +702,33 @@ class MonitorController { next(handleError(error, SERVICE_NAME, "seedDb")); } }; + + exportMonitorsToCSV = async (req, res, next) => { + try { + const { teamId } = req.user; + + const monitors = await this.db.getMonitorsByTeamId({ teamId }); + const csvData = monitors?.filteredMonitors?.map((monitor) => ({ + name: monitor.name, + description: monitor.description, + type: monitor.type, + url: monitor.url, + interval: monitor.interval, + port: monitor.port, + ignoreTlsErrors: monitor.ignoreTlsErrors, + isActive: monitor.isActive, + })); + + const csv = pkg.unparse(csvData); + + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", "attachment; filename=monitors.csv"); + + res.send(csv); + } catch (error) { + next(handleError(error, SERVICE_NAME, "exportMonitorsToCSV")); + } + }; } export default MonitorController; diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 0dabab407..2c6ae870e 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -17,6 +17,11 @@ class MonitorRoutes { initRoutes() { this.router.get("/", this.monitorController.getAllMonitors); this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); + this.router.get( + "/export", + isAllowed(["admin", "superadmin"]), + this.monitorController.exportMonitorsToCSV + ); this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById); this.router.get( "/hardware/details/:monitorId", From 11212c73717e431c62c8138a7d9d9932a270dba9 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 17 Jun 2025 00:17:46 +0530 Subject: [PATCH 2/8] Formatting was done. --- .../Components/MonitorCreateHeader/index.jsx | 2 +- client/src/Hooks/monitorHooks.js | 56 +++++++++---------- client/src/locales/en.json | 8 +-- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/client/src/Components/MonitorCreateHeader/index.jsx b/client/src/Components/MonitorCreateHeader/index.jsx index 7a4d75226..a62123531 100644 --- a/client/src/Components/MonitorCreateHeader/index.jsx +++ b/client/src/Components/MonitorCreateHeader/index.jsx @@ -9,7 +9,7 @@ const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath const navigate = useNavigate(); const { t } = useTranslation(); const theme = useTheme(); - const [exportMonitors, isExporting] = useExportMonitors() + const [exportMonitors, isExporting] = useExportMonitors(); // Use the provided label or fall back to the translated default diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index b3e4a9478..d7c8e4287 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -454,36 +454,36 @@ const useCreateBulkMonitors = () => { }; const useExportMonitors = () => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); - const exportMonitors = async () => { - setIsLoading(true); - try { - const blob = await networkService.exportMonitors(); - - // Create a download link - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', 'monitors.csv'); - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - - createToast({ body: t('export.success') }); - return [true, null]; - } catch (err) { - const errorMessage = err?.response?.data?.msg || err.message; - createToast({ body: errorMessage || t('export.failed') }); - return [false, errorMessage]; - } finally { - setIsLoading(false); - } - }; + const exportMonitors = async () => { + setIsLoading(true); + try { + const blob = await networkService.exportMonitors(); - return [exportMonitors, isLoading]; + // Create a download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", "monitors.csv"); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + createToast({ body: t("export.success") }); + return [true, null]; + } catch (err) { + const errorMessage = err?.response?.data?.msg || err.message; + createToast({ body: errorMessage || t("export.failed") }); + return [false, errorMessage]; + } finally { + setIsLoading(false); + } + }; + + return [exportMonitors, isLoading]; }; export { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d71cf3212..d13d44f7f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -741,8 +741,8 @@ "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." }, "export": { - "title": "Export Monitors", - "success": "Monitors exported successfully!", - "failed": "Failed to export monitors" - } + "title": "Export Monitors", + "success": "Monitors exported successfully!", + "failed": "Failed to export monitors" + } } From e71e2ef42cebeee6c8b5962f4e1023b5d5355d94 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Wed, 18 Jun 2025 01:07:22 +0530 Subject: [PATCH 3/8] Addressed all the comments in pr. --- .../src/Components/MonitorActions/index.jsx | 116 ++++++++++++++++++ .../Components/MonitorCreateHeader/index.jsx | 27 +--- client/src/Hooks/monitorHooks.js | 4 +- client/src/Utils/NetworkService.js | 2 +- client/src/locales/en.json | 5 + server/controllers/monitorController.js | 20 ++- server/middleware/responseHandler.js | 7 +- 7 files changed, 147 insertions(+), 34 deletions(-) create mode 100644 client/src/Components/MonitorActions/index.jsx diff --git a/client/src/Components/MonitorActions/index.jsx b/client/src/Components/MonitorActions/index.jsx new file mode 100644 index 000000000..7086cb307 --- /dev/null +++ b/client/src/Components/MonitorActions/index.jsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import Button from "@mui/material/Button"; +import ButtonGroup from "@mui/material/ButtonGroup"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import ClickAwayListener from "@mui/material/ClickAwayListener"; +import Grow from "@mui/material/Grow"; +import Paper from "@mui/material/Paper"; +import Popper from "@mui/material/Popper"; +import MenuItem from "@mui/material/MenuItem"; +import MenuList from "@mui/material/MenuList"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { createToast } from "../../Utils/toastUtils"; +import { useExportMonitors } from "../../Hooks/monitorHooks"; + +const options = ["Import Monitors", "Export Monitors"]; + +const MonitorActions = ({ isLoading }) => { + const [open, setOpen] = React.useState(false); + const anchorRef = React.useRef(null); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const navigate = useNavigate(); + const { t } = useTranslation(); + const [exportMonitors, isExporting] = useExportMonitors(); + + const handleClick = async () => { + if (selectedIndex === 0) { + // Import + navigate("/uptime/bulk-import"); + } else { + // Export + const [success, error] = await exportMonitors(); + if (!success) { + createToast({ body: error || t("export.failed") }); + } + } + }; + + const handleMenuItemClick = (event, index) => { + setSelectedIndex(index); + setOpen(false); + }; + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return; + } + setOpen(false); + }; + + return ( + + + + + + + {({ TransitionProps, placement }) => ( + + + + + {options.map((option, index) => ( + handleMenuItemClick(event, index)} + > + {option} + + ))} + + + + + )} + + + ); +}; + +export default MonitorActions; diff --git a/client/src/Components/MonitorCreateHeader/index.jsx b/client/src/Components/MonitorCreateHeader/index.jsx index a62123531..f2b6bbce4 100644 --- a/client/src/Components/MonitorCreateHeader/index.jsx +++ b/client/src/Components/MonitorCreateHeader/index.jsx @@ -3,13 +3,12 @@ import { useNavigate } from "react-router-dom"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; import { useTheme } from "@emotion/react"; -import { useExportMonitors } from "../../Hooks/monitorHooks"; +import MonitorActions from "../MonitorActions"; const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => { const navigate = useNavigate(); const { t } = useTranslation(); const theme = useTheme(); - const [exportMonitors, isExporting] = useExportMonitors(); // Use the provided label or fall back to the translated default @@ -30,29 +29,7 @@ const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath > {label || t("createNew")} - {bulkPath && ( - <> - - - - - )} + {bulkPath && } ); }; diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index d7c8e4287..5f303d26a 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -460,10 +460,10 @@ const useExportMonitors = () => { const exportMonitors = async () => { setIsLoading(true); try { - const blob = await networkService.exportMonitors(); + const response = await networkService.exportMonitors(); // Create a download link - const url = window.URL.createObjectURL(blob); + const url = window.URL.createObjectURL(response); const link = document.createElement("a"); link.href = url; link.setAttribute("download", "monitors.csv"); diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 6451f93e8..1a6b2a516 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -1027,7 +1027,7 @@ class NetworkService { const response = await this.axiosInstance.get("/monitors/export", { responseType: "blob", }); - return response.data; + return response; } } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d13d44f7f..9fde3e9dd 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -744,5 +744,10 @@ "title": "Export Monitors", "success": "Monitors exported successfully!", "failed": "Failed to export monitors" + }, + "monitorActions": { + "title": "Export/Import", + "import": "Import Monitors", + "export": "Export Monitors" } } diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 5d71f9cb6..612fc328f 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -685,7 +685,7 @@ class MonitorController { explain, }); return res.success({ - msg: "OK", // TODO + msg: "OK", data: result, }); } catch (error) { @@ -708,6 +708,12 @@ class MonitorController { const { teamId } = req.user; const monitors = await this.db.getMonitorsByTeamId({ teamId }); + if (!monitors || monitors.length === 0) { + return res.success({ + msg: this.stringService.noMonitorsFound, + data: null, + }); + } const csvData = monitors?.filteredMonitors?.map((monitor) => ({ name: monitor.name, description: monitor.description, @@ -721,10 +727,14 @@ class MonitorController { const csv = pkg.unparse(csvData); - res.setHeader("Content-Type", "text/csv"); - res.setHeader("Content-Disposition", "attachment; filename=monitors.csv"); - - res.send(csv); + return res.success({ + msg: this.stringService.monitorsExported, + data: csv, + headers: { + "Content-Type": "text/csv", + "Content-Disposition": "attachment; filename=monitors.csv", + }, + }); } catch (error) { next(handleError(error, SERVICE_NAME, "exportMonitorsToCSV")); } diff --git a/server/middleware/responseHandler.js b/server/middleware/responseHandler.js index a58e2e845..e051e3fa5 100755 --- a/server/middleware/responseHandler.js +++ b/server/middleware/responseHandler.js @@ -16,7 +16,12 @@ const responseHandler = (req, res, next) => { * @param {*} [options.data=null] - Response data payload * @returns {Object} Express response object */ - res.success = ({ status = 200, msg = "OK", data = null }) => { + res.success = ({ status = 200, msg = "OK", data = null, headers = {} }) => { + // Set custom headers if provided + Object.entries(headers).forEach(([key, value]) => { + res.set(key, value); + }); + return res.status(status).json({ success: true, msg: msg, From 81d830d99e30de8deb18390e1b29de8e078b7e88 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Wed, 18 Jun 2025 22:24:43 +0530 Subject: [PATCH 4/8] Miss with respect to blob type. --- client/src/Hooks/monitorHooks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index 5f303d26a..013e7fc25 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -463,7 +463,7 @@ const useExportMonitors = () => { const response = await networkService.exportMonitors(); // Create a download link - const url = window.URL.createObjectURL(response); + const url = window.URL.createObjectURL(response.data); const link = document.createElement("a"); link.href = url; link.setAttribute("download", "monitors.csv"); From e6b9b5df3c85fcf407556417b98502798f004e70 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Wed, 18 Jun 2025 22:31:48 +0530 Subject: [PATCH 5/8] Translation and localisation. --- client/src/Components/MonitorActions/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/Components/MonitorActions/index.jsx b/client/src/Components/MonitorActions/index.jsx index 7086cb307..d28822227 100644 --- a/client/src/Components/MonitorActions/index.jsx +++ b/client/src/Components/MonitorActions/index.jsx @@ -13,8 +13,6 @@ import { useTranslation } from "react-i18next"; import { createToast } from "../../Utils/toastUtils"; import { useExportMonitors } from "../../Hooks/monitorHooks"; -const options = ["Import Monitors", "Export Monitors"]; - const MonitorActions = ({ isLoading }) => { const [open, setOpen] = React.useState(false); const anchorRef = React.useRef(null); @@ -23,6 +21,8 @@ const MonitorActions = ({ isLoading }) => { const { t } = useTranslation(); const [exportMonitors, isExporting] = useExportMonitors(); + const options = [t('monitorActions.import'), t('monitorActions.export')]; + const handleClick = async () => { if (selectedIndex === 0) { // Import From a1c11fbb3cccaf8dc91ffde0e81a2555fdb71374 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Thu, 19 Jun 2025 22:23:10 +0530 Subject: [PATCH 6/8] Added a new method for file download and correct csv is getting added. --- server/controllers/monitorController.js | 3 +-- server/middleware/responseHandler.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 612fc328f..024a18a27 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -727,8 +727,7 @@ class MonitorController { const csv = pkg.unparse(csvData); - return res.success({ - msg: this.stringService.monitorsExported, + return res.file({ data: csv, headers: { "Content-Type": "text/csv", diff --git a/server/middleware/responseHandler.js b/server/middleware/responseHandler.js index e051e3fa5..5169e7160 100755 --- a/server/middleware/responseHandler.js +++ b/server/middleware/responseHandler.js @@ -45,6 +45,21 @@ const responseHandler = (req, res, next) => { data, }); }; + + /** + * Sends a raw file response (for CSV, PDF, etc.) + * @param {Object} options + * @param {Buffer|string} options.data - The file content + * @param {Object} options.headers - Headers to set (e.g. Content-Type, Content-Disposition) + * @param {number} [options.status=200] - HTTP status code + */ + res.file = ({ data, headers = {}, status = 200 }) => { + Object.entries(headers).forEach(([key, value]) => { + res.setHeader(key, value); + }); + return res.status(status).send(data); + }; + next(); }; From 9f88fbbcf20fa7f09941515fb5b186c74adad601 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Thu, 19 Jun 2025 22:32:53 +0530 Subject: [PATCH 7/8] Formatting issues. --- client/src/Components/MonitorActions/index.jsx | 2 +- client/src/locales/en.json | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/Components/MonitorActions/index.jsx b/client/src/Components/MonitorActions/index.jsx index d28822227..ac5d89a47 100644 --- a/client/src/Components/MonitorActions/index.jsx +++ b/client/src/Components/MonitorActions/index.jsx @@ -21,7 +21,7 @@ const MonitorActions = ({ isLoading }) => { const { t } = useTranslation(); const [exportMonitors, isExporting] = useExportMonitors(); - const options = [t('monitorActions.import'), t('monitorActions.export')]; + const options = [t("monitorActions.import"), t("monitorActions.export")]; const handleClick = async () => { if (selectedIndex === 0) { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2fc2f1bb3..a22e8123d 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -745,9 +745,7 @@ "settingsEmailSecure": "Secure - Use SSL", "settingsEmailPool": "Pool - Enable connection pooling", "sendTestNotifications": "Send test notifications", - "selectAll": "Select all" -} - }, + "selectAll": "Select all", "export": { "title": "Export Monitors", "success": "Monitors exported successfully!", From 452d1a2ad5168a8b0022cd3e2e184ec35ba95bfa Mon Sep 17 00:00:00 2001 From: mohadeseh safari Date: Fri, 20 Jun 2025 19:58:11 -0400 Subject: [PATCH 8/8] Fix missing password error translation for auth.common.fields.password.errors.incorrect --- client/src/locales/en.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a7d9fe069..c374b48e8 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -507,6 +507,13 @@ }, "auth": { "common": { + "fields": { + "password": { + "errors": { + "incorrect": "The password you provided does not match our records" + } + } + }, "navigation": { "continue": "Continue", "back": "Back"