monitor import/export

This commit is contained in:
Alex Holliday committed 2025-11-20 23:27:30 +00:00
1 parent af6fede1b3
commit 6881be7555
12 files changed
+381 -154

No files matched your search

@@ -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 (
<>
<Tooltip title="Settings" placement="top">
@@ -69,6 +74,11 @@ export const SettingsSwitch = () => {
horizontal: "right",
}}
>
{hasMonitorRead && (
<MenuItem onClick={() => handleClick("export")}>
<Typography>Import | Export</Typography>
</MenuItem>
)}
<MenuItem onClick={() => handleClick("profile")}>
<Typography>Profile</Typography>
</MenuItem>
+38
View File
@@ -182,3 +182,41 @@ export const useDelete = <R = any,>() => {
return { deleteFn, loading, error };
};
export const useGetOnDemand = <R = any>() => {
const currentTeamId = useAppSelector((state) => state.auth.selectedTeamId);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError } = useToast();
const getFn = async (
endpoint: string,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await get<ApiResponse<R>>(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 };
};
+153
View File
@@ -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<HTMLInputElement | null>(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 (
<BasePage>
<ConfigBox
title="Export current team's monitors"
subtitle="This will only export the basic monitor settings, not the check history, incidents history, or notifications"
rightContent={
<Stack gap={theme.spacing(4)}>
<Typography>Click here to export your monitors to JSON</Typography>
<Box>
<Button
loading={loading}
variant="contained"
color="accent"
onClick={handleExport}
>
Export to JSON
</Button>
</Box>
</Stack>
}
/>
<ConfigBox
title="Import monitors"
subtitle="Import monitors from a JSON file. Sample:"
leftContent={
<Box
component={"pre"}
sx={{
fontFamily: "monospace",
p: 2,
borderRadius: 1,
overflow: "auto",
}}
>
<code>
{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
)}
</code>
</Box>
}
rightContent={
<Stack gap={theme.spacing(4)}>
<Typography>
Click here to import your monitors from JSON
</Typography>
<Box>
<Button
loading={isPosting}
variant="contained"
color="accent"
onClick={handleImport}
>
Import from JSON
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json"
style={{ display: "none" }}
/>
</Box>
</Stack>
}
/>
</BasePage>
);
};
export default ExportPage;
+24 -20
View File
@@ -291,29 +291,33 @@ export const SettingsForm = ({
</Stack>
)}
/>
<Stack
gap={theme.spacing(4)}
direction="row"
justifyContent="flex-end"
>
<Button
loading={loading}
variant="contained"
color="accent"
onClick={() => {
onTest(formValues);
}}
>
Test transport config
</Button>
<Button
loading={loading}
type="submit"
variant="contained"
color="accent"
>
Save e-mail settings
</Button>
</Stack>
</Stack>
}
/>
<Stack gap={theme.spacing(4)} direction="row" justifyContent="flex-end">
<Button
loading={loading}
variant="contained"
color="accent"
onClick={() => {
onTest(formValues);
}}
>
Test transport config
</Button>
<Button
loading={loading}
type="submit"
variant="contained"
color="accent"
>
{t("settingsSave")}
</Button>
</Stack>
</Stack>
);
};
+3
View File
@@ -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 = () => {
<Route path="diagnostics" element={<DiagnosticPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="export" element={<ExportPage />} />
</Route>
</Routes>
);
-130
View File
@@ -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",
@@ -19,6 +19,8 @@ export interface IMonitorController {
res: Response,
next: NextFunction
): Promise<void>;
export(req: Request, res: Response, next: NextFunction): Promise<void>;
import(req: Request, res: Response, next: NextFunction): Promise<void>;
}
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;
+1 -1
View File
@@ -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";
+17 -1
View File
@@ -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,
@@ -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<IMonitor>
) => Promise<IMonitor>;
delete: (teamId: string, monitorId: string) => Promise<boolean>;
export: (teamId: string) => Promise<Array<Record<string, unknown>>>;
import: (
orgId: string,
teamId: string,
userId: string,
data: { monitors: Array<IImportedMonitor> }
) => 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<IImportedMonitor> }
) => {
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;
@@ -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
+20
View File
@@ -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<typeof monitorImportSchema>;
export const monitorPatchSchema = monitorSchema
.omit({
url: true,