From 6881be7555d62cb57c0b23d4bdf69ae685dffe1d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Nov 2025 23:27:30 +0000 Subject: [PATCH] monitor import/export --- .../src/components/inputs/SwitchSettings.tsx | 10 ++ client/src/hooks/UseApi.tsx | 38 +++++ client/src/pages/export/Export.tsx | 153 ++++++++++++++++++ client/src/pages/settings/SettingsForm.tsx | 44 ++--- client/src/routes/Router.tsx | 3 + server/package-lock.json | 130 --------------- server/src/controllers/MonitorController.ts | 58 +++++++ server/src/db/models/index.ts | 2 +- server/src/routes/monitors.ts | 18 ++- .../src/services/business/MonitorService.ts | 52 ++++++ .../services/infrastructure/StatusService.ts | 7 +- server/src/validation/index.ts | 20 +++ 12 files changed, 381 insertions(+), 154 deletions(-) create mode 100644 client/src/pages/export/Export.tsx diff --git a/client/src/components/inputs/SwitchSettings.tsx b/client/src/components/inputs/SwitchSettings.tsx index ee5e3a6dd..25861e807 100644 --- a/client/src/components/inputs/SwitchSettings.tsx +++ b/client/src/components/inputs/SwitchSettings.tsx @@ -42,6 +42,11 @@ export const SettingsSwitch = () => { orgPermissions.includes("invite.write") || orgPermissions.includes("*"); + const hasMonitorRead = + orgPermissions.includes("monitors.*") || + orgPermissions.includes("monitors.read") || + orgPermissions.includes("*"); + return ( <> @@ -69,6 +74,11 @@ export const SettingsSwitch = () => { horizontal: "right", }} > + {hasMonitorRead && ( + handleClick("export")}> + Import | Export + + )} handleClick("profile")}> Profile diff --git a/client/src/hooks/UseApi.tsx b/client/src/hooks/UseApi.tsx index d62386cb7..271bbbcef 100644 --- a/client/src/hooks/UseApi.tsx +++ b/client/src/hooks/UseApi.tsx @@ -182,3 +182,41 @@ export const useDelete = () => { return { deleteFn, loading, error }; }; + +export const useGetOnDemand = () => { + const currentTeamId = useAppSelector((state) => state.auth.selectedTeamId); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { toastError } = useToast(); + + const getFn = async ( + endpoint: string, + config?: AxiosRequestConfig + ): Promise | null> => { + setLoading(true); + setError(null); + + try { + const res = await get>(endpoint, { + ...config, + headers: { + ...config?.headers, + "x-team-id": currentTeamId, + }, + }); + + return res.data; + } catch (err: any) { + console.error(err); + const errMsg = + err?.response?.data?.message || err.message || "An error occurred"; + toastError(errMsg); + setError(errMsg); + return null; + } finally { + setLoading(false); + } + }; + + return { get: getFn, loading, error }; +}; diff --git a/client/src/pages/export/Export.tsx b/client/src/pages/export/Export.tsx new file mode 100644 index 000000000..6ed7742f3 --- /dev/null +++ b/client/src/pages/export/Export.tsx @@ -0,0 +1,153 @@ +import { BasePage, ConfigBox } from "@/components/design-elements/index.js"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import { Button } from "@/components/inputs"; +import { Typography } from "@mui/material"; +import { useGetOnDemand, usePost } from "@/hooks/UseApi"; +import { useTheme } from "@mui/material/styles"; +import { useRef } from "react"; + +const ExportPage = () => { + const fileInputRef = useRef(null); + const theme = useTheme(); + + const { get, loading } = useGetOnDemand(); + const { post, loading: isPosting } = usePost(); + + const handleExport = async () => { + const res = await get("/monitors/export"); + if (!res?.data) { + return; + } + + const blob = new Blob([JSON.stringify(res.data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = "monitors.json"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleImport = async () => { + if (!fileInputRef.current) { + return; + } + + const input = fileInputRef.current; + + const handleFileChange = async () => { + if (!input.files || input.files.length === 0) { + return; + } + + const file = input.files[0]; + try { + const text = await file.text(); + const json = JSON.parse(text); + const res = await post("/monitors/import", { monitors: json }); + console.log(res); + } catch (error) { + console.error("Failed to parse JSON", error); + } finally { + input.value = ""; + input.removeEventListener("change", handleFileChange); + } + }; + + input.addEventListener("change", handleFileChange); + input.click(); + }; + + return ( + + + Click here to export your monitors to JSON + + + + + } + /> + + + {JSON.stringify( + [ + { + name: "Metrics", + url: "http://localhost:59232/api/v1/metrics", + type: "infrastructure", + interval: 60000, + n: 3, + }, + { + name: "Yahoo", + url: "https://www.yahoo.com", + type: "pagespeed", + interval: 180000, + n: 3, + }, + ], + null, + 2 + )} + + + } + rightContent={ + + + Click here to import your monitors from JSON + + + + + + + } + /> + + ); +}; + +export default ExportPage; diff --git a/client/src/pages/settings/SettingsForm.tsx b/client/src/pages/settings/SettingsForm.tsx index 1cec295e8..5fd8d8dee 100644 --- a/client/src/pages/settings/SettingsForm.tsx +++ b/client/src/pages/settings/SettingsForm.tsx @@ -291,29 +291,33 @@ export const SettingsForm = ({ )} /> + + + + } /> - - - - ); }; diff --git a/client/src/routes/Router.tsx b/client/src/routes/Router.tsx index 196ce6c1c..c0ed05a64 100644 --- a/client/src/routes/Router.tsx +++ b/client/src/routes/Router.tsx @@ -45,6 +45,8 @@ import ResetPage from "@/pages/recovery/Reset"; import ProfilePage from "@/pages/profile/Profile"; +import ExportPage from "@/pages/export/Export"; + import RootLayout from "@/components/layouts/RootLayout"; import { ProtectedRoute } from "@/components/protected-route"; @@ -132,6 +134,7 @@ const Router = () => { } /> } /> + } /> ); diff --git a/server/package-lock.json b/server/package-lock.json index 0d6503819..ac6a22648 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -4046,14 +4046,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4182,27 +4174,6 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -7104,32 +7075,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, "node_modules/jest-leak-detector": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", @@ -8549,52 +8494,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jest/node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -10135,20 +10034,6 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "optional": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -10886,21 +10771,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/server/src/controllers/MonitorController.ts b/server/src/controllers/MonitorController.ts index e19d434fa..625435f7e 100644 --- a/server/src/controllers/MonitorController.ts +++ b/server/src/controllers/MonitorController.ts @@ -19,6 +19,8 @@ export interface IMonitorController { res: Response, next: NextFunction ): Promise; + export(req: Request, res: Response, next: NextFunction): Promise; + import(req: Request, res: Response, next: NextFunction): Promise; } class MonitorController { private monitorService: MonitorService; @@ -315,6 +317,62 @@ class MonitorController { next(error); } }; + + export = async (req: Request, res: Response, next: NextFunction) => { + try { + const userContext = req.user; + if (!userContext) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const teamId = userContext.currentTeamId; + if (!teamId) { + throw new ApiError("No team ID", 400); + } + + const monitors = await this.monitorService.export(teamId); + res.status(200).json({ + message: "Monitors exported successfully", + data: monitors, + }); + } catch (error) { + next(error); + } + }; + + import = async (req: Request, res: Response, next: NextFunction) => { + const userContext = req.user; + if (!userContext) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const teamId = userContext.currentTeamId; + if (!teamId) { + throw new ApiError("No team ID", 400); + } + + const orgId = userContext.orgId; + if (!orgId) { + throw new ApiError("No organization ID", 400); + } + + const result = await this.monitorService.import( + orgId, + teamId, + userContext.sub, + req.body + ); + + res.status(200).json({ + message: `Import request received. ${result.imported} successfully inserted, ${result.errors} failed`, + data: result, + }); + + try { + } catch (error) { + next(error); + } + }; } export default MonitorController; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index 332b384c9..17c4a33bf 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -6,7 +6,7 @@ export type { ILighthouseResult, } from "./checks/Check.js"; -export { Monitor, MonitorStatuses } from "./monitors/Monitor.js"; +export { Monitor, MonitorStatuses, MonitorTypes } from "./monitors/Monitor.js"; export type { IMonitor } from "./monitors/Monitor.js"; export { MonitorStats } from "./monitors/MonitorStats.js"; diff --git a/server/src/routes/monitors.ts b/server/src/routes/monitors.ts index ef94bee89..9e4421604 100644 --- a/server/src/routes/monitors.ts +++ b/server/src/routes/monitors.ts @@ -10,8 +10,8 @@ import { monitorIdChecksQuerySchema, monitorPatchSchema, monitorAllEmbedChecksQuerySchema, + monitorImportSchema, } from "@/validation/index.js"; -import { verify } from "node:crypto"; class MonitorRoutes { private router; @@ -41,6 +41,22 @@ class MonitorRoutes { this.controller.getAll ); + this.router.get( + "/export", + verifyToken, + addUserContext, + verifyTeamPermission([PERMISSIONS.monitors.read]), + this.controller.export + ); + this.router.post( + "/import", + verifyToken, + addUserContext, + validateBody(monitorImportSchema), + verifyTeamPermission([PERMISSIONS.monitors.write]), + this.controller.import + ); + this.router.get( "/:id/checks", verifyToken, diff --git a/server/src/services/business/MonitorService.ts b/server/src/services/business/MonitorService.ts index f8688a658..72fc33a04 100644 --- a/server/src/services/business/MonitorService.ts +++ b/server/src/services/business/MonitorService.ts @@ -6,6 +6,14 @@ import { IJobQueue } from "@/services/infrastructure/JobQueue.js"; import { MonitorWithChecksResponse } from "@/types/index.js"; import { MonitorStatus, MonitorType } from "@/db/models/monitors/Monitor.js"; +export interface IImportedMonitor { + name: string; + type: MonitorType; + interval: number; + url: string; + n: number; +} + const SERVICE_NAME = "MonitorService"; export interface IMonitorService { @@ -50,6 +58,13 @@ export interface IMonitorService { updateData: Partial ) => Promise; delete: (teamId: string, monitorId: string) => Promise; + export: (teamId: string) => Promise>>; + import: ( + orgId: string, + teamId: string, + userId: string, + data: { monitors: Array } + ) => Promise<{ imported: number; errors: number }>; } class MonitorService implements IMonitorService { @@ -639,6 +654,43 @@ class MonitorService implements IMonitorService { await monitor.deleteOne(); return true; } + + export = async (teamId: string) => { + return Monitor.find({ teamId }) + .select("name url type interval n secret -_id") + .lean(); + }; + + import = async ( + orgId: string, + teamId: string, + userId: string, + data: { monitors: Array } + ) => { + let result = []; + try { + result = await Monitor.insertMany( + data?.monitors.map((monitor) => ({ + ...monitor, + orgId: new mongoose.Types.ObjectId(orgId), + teamId: new mongoose.Types.ObjectId(teamId), + createdBy: new mongoose.Types.ObjectId(userId), + updatedBy: new mongoose.Types.ObjectId(userId), + })) + ); + + for (const monitor of result) { + await this.jobQueue.addJob(monitor); + } + } catch (error) { + console.error("Import error:", error); + } + + return { + imported: result.length, + errors: data?.monitors?.length - result.length, + }; + }; } export default MonitorService; diff --git a/server/src/services/infrastructure/StatusService.ts b/server/src/services/infrastructure/StatusService.ts index 8fecb9614..5ff3cb4e4 100644 --- a/server/src/services/infrastructure/StatusService.ts +++ b/server/src/services/infrastructure/StatusService.ts @@ -96,9 +96,12 @@ class StatusService implements IStatusService { statusResponse: StatusResponse, statusChanged: boolean ) => { - const stats = await MonitorStats.findOne({ monitorId: monitor._id }); + let stats = await MonitorStats.findOne({ monitorId: monitor._id }); if (!stats) { - throw new ApiError("MonitorStats not found", 500); + stats = await MonitorStats.create({ + monitorId: monitor._id, + currentStreakStartedAt: Date.now(), + }); } // Update check counts diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index e6ec9182c..95ce1e428 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -1,6 +1,7 @@ import { ChannelTypes, MonitorStatuses, + MonitorTypes, ResolutionTypes, } from "@/db/models/index.js"; import { z } from "zod"; @@ -82,6 +83,25 @@ export const monitorSchema = z } }); +export const monitorImportSchema = z.object({ + monitors: z + .array( + z + .object({ + name: z.string().trim().min(1, "Monitor name is required"), + url: z.string().trim().regex(urlRegex, "Invalid URL"), + secret: z.string().trim().optional(), + type: z.enum(MonitorTypes), + interval: z.number().min(1, "Interval must be greater than 0"), + n: z.number().min(1, "n must be at least 1"), + }) + .strict() + ) + .min(1, { message: "Provide at least one monitor" }), +}); + +export type MonitorImportPayload = z.infer; + export const monitorPatchSchema = monitorSchema .omit({ url: true,