diff --git a/client/src/Components/MonitorActions/index.jsx b/client/src/Components/MonitorActions/index.jsx new file mode 100644 index 000000000..ac5d89a47 --- /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 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 options = [t("monitorActions.import"), t("monitorActions.export")]; + + 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 b630cb259..f2b6bbce4 100644 --- a/client/src/Components/MonitorCreateHeader/index.jsx +++ b/client/src/Components/MonitorCreateHeader/index.jsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; import { useTheme } from "@emotion/react"; +import MonitorActions from "../MonitorActions"; const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => { const navigate = useNavigate(); @@ -28,18 +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 7e46438e0..013e7fc25 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 response = await networkService.exportMonitors(); + + // Create a download link + const url = window.URL.createObjectURL(response.data); + 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 85feafbe6..d9a3853e1 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -1033,6 +1033,13 @@ class NetworkService { async flushQueue() { return this.axiosInstance.post(`/queue/flush`); + + + async exportMonitors() { + const response = await this.axiosInstance.get("/monitors/export", { + responseType: "blob", + }); + return response; } } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e55acb530..d3a274a32 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -510,6 +510,13 @@ }, "auth": { "common": { + "fields": { + "password": { + "errors": { + "incorrect": "The password you provided does not match our records" + } + } + }, "navigation": { "continue": "Continue", "back": "Back" @@ -779,5 +786,14 @@ "failedAtHeader": "Last failed at", "failReasonHeader": "Fail reason" } + "export": { + "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 e1d087c74..d32ef463e 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -681,7 +681,7 @@ class MonitorController { explain, }); return res.success({ - msg: "OK", // TODO + msg: "OK", data: result, }); } catch (error) { @@ -698,6 +698,42 @@ 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 }); + 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, + type: monitor.type, + url: monitor.url, + interval: monitor.interval, + port: monitor.port, + ignoreTlsErrors: monitor.ignoreTlsErrors, + isActive: monitor.isActive, + })); + + const csv = pkg.unparse(csvData); + + return res.file({ + data: csv, + headers: { + "Content-Type": "text/csv", + "Content-Disposition": "attachment; filename=monitors.csv", + }, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "exportMonitorsToCSV")); + } + }; } export default MonitorController; diff --git a/server/middleware/responseHandler.js b/server/middleware/responseHandler.js index a58e2e845..5169e7160 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, @@ -40,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(); }; 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",