From b708e83ccef253e4888331efc669cedaa23c5654 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Feb 2026 15:11:37 -0800 Subject: [PATCH 1/6] add TODOs --- server/src/controllers/monitorController.ts | 6 +++++- server/src/repositories/monitors/IMonitorsRepository.ts | 1 + server/src/repositories/monitors/MongoMonitorsRepository.ts | 1 + server/src/routes/monitorRoute.ts | 5 +++++ server/src/service/business/monitorService.ts | 2 ++ 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index df1945fcc..847c73731 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -172,9 +172,10 @@ class MonitorController { } }; + // TODO rename to Import, change to accept JSON createBulkMonitors = async (req: Request, res: Response, next: NextFunction) => { try { - const userId = req.user?.id; + const userId = req.user?.id; // TODO use the correct helper function if (!userId) { throw new AppError({ message: "User ID is required", status: 400 }); } @@ -182,6 +183,9 @@ class MonitorController { const teamId = requireTeamId(req?.user?.teamId); const data = req.body; + + // TODO extract JSON, validate? + const monitors = await this.monitorService.createBulkMonitors(data, userId, teamId); return res.status(200).json({ diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 63940f591..1cbb1d3fc 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -17,6 +17,7 @@ export interface SummaryConfig { export interface IMonitorsRepository { // create create(monitor: Monitor, teamId: string, userId: string): Promise; + // TODO rename createMonitors createBulkMonitors(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..a8d7d202d 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -12,6 +12,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(saved); }; + // TODO rename to createMonitors, must accept JSON createBulkMonitors = async (monitors: Monitor[]): Promise => { if (!monitors.length) { return []; diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index e958f3664..5458c3c31 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,11 @@ 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); + + // TODO this needs to be modified to accept JSON, change to /import this.router.post("/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors); + // END TODO + 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..8c2f9e111 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -29,6 +29,7 @@ export interface IMonitorService { // create createMonitor(teamId: string, userId: string, body: Monitor): Promise; + // TODO rename createMonitors createBulkMonitors(monitors: Array, userId: string, teamId: string): Promise; addDemoMonitors(args: { userId: string; teamId: string }): Promise; @@ -158,6 +159,7 @@ export class MonitorService implements IMonitorService { }; createBulkMonitors = async (monitors: Array, userId: string, teamId: string): Promise => { + // TDO rename createMonitors, change to accept JSON const createdMonitors = await this.monitorsRepository.createBulkMonitors(monitors); if (!monitors || monitors.length === 0) { throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createBulkMonitors" }); From 673f47983a76530867b1bd136cb648d9601e15f5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 21 Feb 2026 03:14:11 +0000 Subject: [PATCH 2/6] FE implementation --- client/src/Pages/Settings/index.tsx | 55 ++++++++++++++++++++++++++--- client/src/locales/en.json | 11 ++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index b9d3e1f3c..55e08234d 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -202,6 +202,37 @@ 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") { + alert("Please select a valid JSON file"); + event.target.value = ""; // Reset input + return; + } + + // Log the file contents + try { + const text = await file.text(); + const monitors = JSON.parse(text); + + if (!Array.isArray(monitors)) { + alert("Invalid file format: expected an array of monitors"); + event.target.value = ""; + return; + } + console.log(monitors); + event.target.value = ""; // Reset input + } catch (error) { + console.error("Import error:", error); + alert("Error importing monitors. 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 +897,33 @@ export const SettingsPage = () => { {/* Export Monitors - Admin Only */} {isAdmin && ( + + + - + } /> )} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e47ab64cc..8c20121f1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -44,7 +44,9 @@ "addDemo": "Add demo monitors", "removeMonitors": "Remove monitors", "sendTestEmail": "Send test email", - "exportToJSON": "Export to JSON" + "exportToJSON": "Export to JSON", + "importFromJSON": "Import from JSON", + "selectFile": "Select file" }, "charts": { "labels": { @@ -1086,8 +1088,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", From f94afb63c6f7a73945c1d062ed1343daa4d12ebd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 21 Feb 2026 03:43:11 +0000 Subject: [PATCH 3/6] backend --- server/src/controllers/monitorController.ts | 26 ++++++------ .../monitors/IMonitorsRepository.ts | 1 - .../monitors/MongoMonitorsRepository.ts | 1 - server/src/routes/monitorRoute.ts | 5 +-- server/src/service/business/monitorService.ts | 42 ++++++++++++++++++- 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 847c73731..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,26 +173,23 @@ class MonitorController { } }; - // TODO rename to Import, change to accept JSON - createBulkMonitors = async (req: Request, res: Response, next: NextFunction) => { + importMonitorsFromJSON = async (req: Request, res: Response, next: NextFunction) => { try { - const userId = req.user?.id; // TODO use the correct helper function - 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; - - // TODO extract JSON, validate? - - 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 1cbb1d3fc..63940f591 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -17,7 +17,6 @@ export interface SummaryConfig { export interface IMonitorsRepository { // create create(monitor: Monitor, teamId: string, userId: string): Promise; - // TODO rename createMonitors createBulkMonitors(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 a8d7d202d..728d2081f 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -12,7 +12,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(saved); }; - // TODO rename to createMonitors, must accept JSON createBulkMonitors = async (monitors: Monitor[]): Promise => { if (!monitors.length) { return []; diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index 5458c3c31..afe8c7819 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -45,10 +45,7 @@ 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); - - // TODO this needs to be modified to accept JSON, change to /import - this.router.post("/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors); - // END TODO + 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 8c2f9e111..2d633ab46 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -29,8 +29,6 @@ export interface IMonitorService { // create createMonitor(teamId: string, userId: string, body: Monitor): Promise; - // TODO rename createMonitors - createBulkMonitors(monitors: Array, userId: string, teamId: string): Promise; addDemoMonitors(args: { userId: string; teamId: string }): Promise; // read @@ -73,6 +71,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 { @@ -488,4 +487,43 @@ 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[] = []; + + // Clean the data: remove id, _id, createdAt, updatedAt, recentChecks fields + const cleanedMonitors = monitors.map((monitor) => { + const cleanData = { ...monitor }; + delete cleanData.id; + delete cleanData._id; + delete cleanData.createdAt; + delete cleanData.updatedAt; + delete cleanData.recentChecks; + // Force the monitor to belong to the current team + cleanData.teamId = teamId; + return cleanData; + }); + + // Use createBulkMonitors for efficient bulk insert + const createdMonitors = await this.createBulkMonitors(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 }; + }; } From 83d56f1ff6f53b3054c636901cef9ac4c8abbfb9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 21 Feb 2026 16:03:45 +0000 Subject: [PATCH 4/6] fe->be --- client/src/Pages/Settings/index.tsx | 22 +++++++++++-------- server/src/service/business/monitorService.ts | 4 +--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 55e08234d..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"); @@ -209,26 +213,25 @@ export const SettingsPage = () => { } if (file.type !== "application/json") { - alert("Please select a valid JSON file"); - event.target.value = ""; // Reset input - return; + toastError("Please select a valid JSON file"); + event.target.value = ""; } - // Log the file contents try { const text = await file.text(); const monitors = JSON.parse(text); if (!Array.isArray(monitors)) { - alert("Invalid file format: expected an array of monitors"); + toastError("Invalid file format: expected an array of monitors"); event.target.value = ""; return; } - console.log(monitors); - event.target.value = ""; // Reset input + + await importMonitors("/monitors/import/json", { monitors }); + + event.target.value = ""; } catch (error) { - console.error("Import error:", error); - alert("Error importing monitors. Please check the file format."); + toastError("Error parsing JSON file. Please check the file format."); event.target.value = ""; } }; @@ -914,6 +917,7 @@ export const SettingsPage = () => { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 2d633ab46..87bb7eeda 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -499,7 +499,6 @@ export class MonitorService implements IMonitorService { }): Promise<{ imported: number; errors: string[] }> => { const errors: string[] = []; - // Clean the data: remove id, _id, createdAt, updatedAt, recentChecks fields const cleanedMonitors = monitors.map((monitor) => { const cleanData = { ...monitor }; delete cleanData.id; @@ -507,12 +506,11 @@ export class MonitorService implements IMonitorService { delete cleanData.createdAt; delete cleanData.updatedAt; delete cleanData.recentChecks; - // Force the monitor to belong to the current team + // Monitors must belong to current team cleanData.teamId = teamId; return cleanData; }); - // Use createBulkMonitors for efficient bulk insert const createdMonitors = await this.createBulkMonitors(cleanedMonitors, userId, teamId); if (!createdMonitors || createdMonitors.length === 0) { From da38f2ee76592e563384ab3a29e662bb76e284e4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 21 Feb 2026 16:17:23 +0000 Subject: [PATCH 5/6] createBulkMonitors -> CreateMonitors --- .../repositories/monitors/IMonitorsRepository.ts | 2 +- .../monitors/MongoMonitorsRepository.ts | 16 +++++++++++++--- server/src/service/business/monitorService.ts | 12 ++++++------ 3 files changed, 20 insertions(+), 10 deletions(-) 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/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 87bb7eeda..a0591a74f 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -29,6 +29,7 @@ export interface IMonitorService { // create createMonitor(teamId: string, userId: string, body: Monitor): Promise; + createMonitors(monitors: Array, userId: string, teamId: string): Promise; addDemoMonitors(args: { userId: string; teamId: string }): Promise; // read @@ -157,11 +158,10 @@ export class MonitorService implements IMonitorService { this.jobQueue.addJob(monitor.id, monitor); }; - createBulkMonitors = async (monitors: Array, userId: string, teamId: string): Promise => { - // TDO rename createMonitors, change to accept JSON - 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))); @@ -185,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; @@ -511,7 +511,7 @@ export class MonitorService implements IMonitorService { return cleanData; }); - const createdMonitors = await this.createBulkMonitors(cleanedMonitors, userId, teamId); + const createdMonitors = await this.createMonitors(cleanedMonitors, userId, teamId); if (!createdMonitors || createdMonitors.length === 0) { throw new AppError({ From ea54736a395e8afa3b7d4b8eb9cc8e86f4f94ac2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 21 Feb 2026 16:19:43 +0000 Subject: [PATCH 6/6] remove string --- client/src/locales/en.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8c20121f1..bb740cd13 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -45,8 +45,7 @@ "removeMonitors": "Remove monitors", "sendTestEmail": "Send test email", "exportToJSON": "Export to JSON", - "importFromJSON": "Import from JSON", - "selectFile": "Select file" + "importFromJSON": "Import from JSON" }, "charts": { "labels": {