diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index b9d3e1f3c..484ab3690 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; import DummyChart from "@/Pages/Settings/DummyChart"; import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi"; +import { useToast } from "@/Hooks/UseToast"; import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData } from "@/Validation/settings"; @@ -44,10 +45,13 @@ export const SettingsPage = () => { const { t, i18n } = useTranslation(); const dispatch = useDispatch(); const isAdmin = useIsAdmin(); + const { toastError } = useToast(); // Local state for demo monitors dialog const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false); const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost(); const { deleteFn: deleteAllMonitors, loading: isDeletingAllMonitors } = useDelete(); + // Import monitors functionality + const { post: importMonitors, loading: isImportingMonitors } = usePost(); // Fetch settings data from API const { data: fetchedSettings } = useGet("/settings"); @@ -202,6 +206,36 @@ export const SettingsPage = () => { URL.revokeObjectURL(url); }; + const handleFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + if (file.type !== "application/json") { + toastError("Please select a valid JSON file"); + event.target.value = ""; + } + + try { + const text = await file.text(); + const monitors = JSON.parse(text); + + if (!Array.isArray(monitors)) { + toastError("Invalid file format: expected an array of monitors"); + event.target.value = ""; + return; + } + + await importMonitors("/monitors/import/json", { monitors }); + + event.target.value = ""; + } catch (error) { + toastError("Error parsing JSON file. Please check the file format."); + event.target.value = ""; + } + }; + const onSubmit = async (data: SettingsFormData) => { // Don't send pagespeedApiKey if it's already set and user hasn't clicked reset const dataToSend = { ...data }; @@ -866,17 +900,34 @@ export const SettingsPage = () => { {/* Export Monitors - Admin Only */} {isAdmin && ( + + + - + } /> )} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e47ab64cc..bb740cd13 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -44,7 +44,8 @@ "addDemo": "Add demo monitors", "removeMonitors": "Remove monitors", "sendTestEmail": "Send test email", - "exportToJSON": "Export to JSON" + "exportToJSON": "Export to JSON", + "importFromJSON": "Import from JSON" }, "charts": { "labels": { @@ -1086,8 +1087,11 @@ } }, "exportMonitors": { - "title": "Export monitors", - "description": "Export your monitors data as a JSON file for backup or transfer." + "title": "Export monitors" + }, + "importExportMonitors": { + "title": "Import / Export monitors", + "description": "Import or export your monitors data as a JSON file for backup or transfer." }, "about": { "title": "About", diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index df1945fcc..8bd131dfc 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -23,6 +23,7 @@ import { parseMonitorTypeFilter, parseSortOrder, requireTeamId, + requireUserId, } from "./controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorService } from "@/service/index.js"; @@ -172,22 +173,23 @@ class MonitorController { } }; - createBulkMonitors = async (req: Request, res: Response, next: NextFunction) => { + importMonitorsFromJSON = async (req: Request, res: Response, next: NextFunction) => { try { - const userId = req.user?.id; - if (!userId) { - throw new AppError({ message: "User ID is required", status: 400 }); + const teamId = requireTeamId(req?.user?.teamId); + const userId = requireUserId(req?.user?.id); + + const { monitors } = req.body; + + if (!monitors || !Array.isArray(monitors)) { + throw new AppError({ message: "Invalid request: monitors array is required", status: 400 }); } - const teamId = requireTeamId(req?.user?.teamId); - - const data = req.body; - const monitors = await this.monitorService.createBulkMonitors(data, userId, teamId); + const result = await this.monitorService.importMonitorsFromJSON({ teamId, userId, monitors }); return res.status(200).json({ success: true, - msg: "Bulk monitors created successfully", - data: monitors, + msg: `Successfully imported ${result.imported} monitor(s)`, + data: result, }); } catch (error) { next(error); diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 63940f591..9d82d9987 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -17,7 +17,7 @@ export interface SummaryConfig { export interface IMonitorsRepository { // create create(monitor: Monitor, teamId: string, userId: string): Promise; - createBulkMonitors(monitors: Monitor[]): Promise; + createMonitors(monitors: Monitor[]): Promise; // single fetch findById(monitorId: string, teamId: string): Promise; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 728d2081f..4ae4394d4 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -12,13 +12,23 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(saved); }; - createBulkMonitors = async (monitors: Monitor[]): Promise => { + createMonitors = async (monitors: Monitor[]): Promise => { if (!monitors.length) { return []; } const payload = monitors.map((monitor) => ({ ...monitor, notifications: undefined })); - const inserted = await MonitorModel.insertMany(payload, { ordered: false }); - return this.mapDocuments(inserted); + try { + const inserted = await MonitorModel.insertMany(payload, { ordered: false }); + return this.mapDocuments(inserted); + } catch (error: any) { + if (error.name === "MongoBulkWriteError" || error.name === "BulkWriteError") { + const insertedDocs = error.insertedDocs || []; + if (insertedDocs.length > 0) { + return this.mapDocuments(insertedDocs); + } + } + throw error; + } }; findById = async (monitorId: string, teamId: string): Promise => { diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index e958f3664..afe8c7819 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -26,6 +26,7 @@ class MonitorRoutes { // Hardware routes this.router.get("/hardware/details/:monitorId", this.monitorController.getHardwareDetailsById); + // PageSpeed routes this.router.get("/pagespeed/details/:monitorId", this.monitorController.getPageSpeedDetailsById); @@ -44,7 +45,8 @@ class MonitorRoutes { // Other static routes this.router.post("/demo", isAllowed(["admin", "superadmin"]), this.monitorController.addDemoMonitors); this.router.get("/export/json", isAllowed(["admin", "superadmin"]), this.monitorController.exportMonitorsToJSON); - this.router.post("/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors); + this.router.post("/import/json", isAllowed(["admin", "superadmin"]), this.monitorController.importMonitorsFromJSON); + this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.monitorController.sendTestEmail); this.router.get("/games", this.monitorController.getAllGames); diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 8ea83c509..a0591a74f 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -29,7 +29,7 @@ export interface IMonitorService { // create createMonitor(teamId: string, userId: string, body: Monitor): Promise; - createBulkMonitors(monitors: Array, userId: string, teamId: string): Promise; + createMonitors(monitors: Array, userId: string, teamId: string): Promise; addDemoMonitors(args: { userId: string; teamId: string }): Promise; // read @@ -72,6 +72,7 @@ export interface IMonitorService { // other sendTestEmail(args: { to: string }): Promise; exportMonitorsToJSON(args: { teamId: string }): Promise; + importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: any[] }): Promise<{ imported: number; errors: string[] }>; } export class MonitorService implements IMonitorService { @@ -157,10 +158,10 @@ export class MonitorService implements IMonitorService { this.jobQueue.addJob(monitor.id, monitor); }; - createBulkMonitors = async (monitors: Array, userId: string, teamId: string): Promise => { - const createdMonitors = await this.monitorsRepository.createBulkMonitors(monitors); + createMonitors = async (monitors: Array, userId: string, teamId: string): Promise => { + const createdMonitors = await this.monitorsRepository.createMonitors(monitors); if (!monitors || monitors.length === 0) { - throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createBulkMonitors" }); + throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" }); } await Promise.all(createdMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor))); @@ -184,7 +185,7 @@ export class MonitorService implements IMonitorService { interval: 60000, }; }); - const demoMonitors = await this.monitorsRepository.createBulkMonitors(monitors); + const demoMonitors = await this.monitorsRepository.createMonitors(monitors); await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor))); return demoMonitors; @@ -486,4 +487,41 @@ export class MonitorService implements IMonitorService { return monitors; }; + + importMonitorsFromJSON = async ({ + teamId, + userId, + monitors, + }: { + teamId: string; + userId: string; + monitors: any[]; + }): Promise<{ imported: number; errors: string[] }> => { + const errors: string[] = []; + + const cleanedMonitors = monitors.map((monitor) => { + const cleanData = { ...monitor }; + delete cleanData.id; + delete cleanData._id; + delete cleanData.createdAt; + delete cleanData.updatedAt; + delete cleanData.recentChecks; + // Monitors must belong to current team + cleanData.teamId = teamId; + return cleanData; + }); + + const createdMonitors = await this.createMonitors(cleanedMonitors, userId, teamId); + + if (!createdMonitors || createdMonitors.length === 0) { + throw new AppError({ + message: "Failed to import any monitors. Please check the file format and try again.", + service: SERVICE_NAME, + method: "importMonitorsFromJSON", + status: 400, + }); + } + + return { imported: createdMonitors.length, errors }; + }; }