Merge pull request #946 from om-3004/enhancement/check-if-url-resolves

check if the url resolves before adding the monitor
This commit is contained in:
Alexander Holliday
2024-10-19 16:48:42 +08:00
committed by GitHub
10 changed files with 830 additions and 362 deletions

View File

@@ -31,6 +31,30 @@ export const createPageSpeed = createAsyncThunk(
}
);
export const checkEndpointResolution = createAsyncThunk(
"monitors/checkEndpoint",
async (data, thunkApi) => {
try {
const { authToken, monitorURL } = data;
const res = await networkService.checkEndpointResolution({
authToken: authToken,
monitorURL: monitorURL,
})
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
)
export const getPagespeedMonitorById = createAsyncThunk(
"monitors/getMonitorById",
async (data, thunkApi) => {
@@ -222,7 +246,24 @@ const pageSpeedMonitorSlice = createSlice({
? action.payload.msg
: "Failed to create page speed monitor";
})
// *****************************************************
// Resolve Endpoint
// *****************************************************
.addCase(checkEndpointResolution.pending, (state) => {
state.isLoading = true;
})
.addCase(checkEndpointResolution.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(checkEndpointResolution.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to check endpoint resolution";
})
// *****************************************************
// Update Monitor
// *****************************************************

View File

@@ -31,6 +31,30 @@ export const createUptimeMonitor = createAsyncThunk(
}
);
export const checkEndpointResolution = createAsyncThunk(
"monitors/checkEndpoint",
async (data, thunkApi) => {
try {
const { authToken, monitorURL } = data;
const res = await networkService.checkEndpointResolution({
authToken: authToken,
monitorURL: monitorURL,
})
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
)
export const getUptimeMonitorById = createAsyncThunk(
"monitors/getMonitorById",
async (data, thunkApi) => {
@@ -271,6 +295,24 @@ const uptimeMonitorsSlice = createSlice({
: "Failed to create uptime monitor";
})
// *****************************************************
// Resolve Endpoint
// *****************************************************
.addCase(checkEndpointResolution.pending, (state) => {
state.isLoading = true;
})
.addCase(checkEndpointResolution.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(checkEndpointResolution.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to check endpoint resolution";
})
// *****************************************************
// Get Monitor By Id
// *****************************************************
.addCase(getUptimeMonitorById.pending, (state) => {

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from "react";
import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material";
import LoadingButton from '@mui/lab/LoadingButton';
import { useSelector, useDispatch } from "react-redux";
import { monitorValidation } from "../../../Validation/validation";
import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { checkEndpointResolution } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"
import { useNavigate, useParams } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { createToast } from "../../../Utils/toastUtils";
@@ -17,12 +19,12 @@ import { getUptimeMonitorById } from "../../../Features/UptimeMonitors/uptimeMon
import "./index.css";
const CreateMonitor = () => {
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { monitors } = useSelector((state) => state.uptimeMonitors);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { monitors, isLoading } = useSelector((state) => state.uptimeMonitors);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"monitor-url": "url",
@@ -146,6 +148,17 @@ const CreateMonitor = () => {
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
if (monitor.type === "http") {
const checkEndpointAction = await dispatch(
checkEndpointResolution({ authToken, monitorURL: form.url })
)
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({ body: "The endpoint you entered doesn't resolve. Check the URL again." });
setErrors({ url: "The entered URL is not reachable." });
return;
}
}
form = {
...form,
description: form.name,
@@ -373,18 +386,19 @@ const CreateMonitor = () => {
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
>
Create monitor
</Button>
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
loading={isLoading}
>
Create monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
export default CreateMonitor;
export default CreateMonitor;

View File

@@ -1,10 +1,11 @@
import { useState } from "react";
import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material";
import LoadingButton from '@mui/lab/LoadingButton';
import { useSelector, useDispatch } from "react-redux";
import { monitorValidation } from "../../../Validation/validation";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { createPageSpeed } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { createPageSpeed, checkEndpointResolution } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../../Monitors/styled";
@@ -16,11 +17,12 @@ import Breadcrumbs from "../../../Components/Breadcrumbs";
import "./index.css";
const CreatePageSpeed = () => {
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"monitor-url": "url",
@@ -103,248 +105,252 @@ const CreatePageSpeed = () => {
abortEarly: false,
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(createPageSpeed({ authToken, monitor: form }));
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/pagespeed");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
const checkEndpointAction = await dispatch(
checkEndpointResolution({ authToken, monitorURL: form.url })
)
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({ body: "The endpoint you entered doesn't resolve. Check the URL again." });
setErrors({ url: "The entered URL is not reachable." });
return;
}
//select values
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
return (
<Box
className="create-monitor"
sx={{
"& h1": {
color: theme.palette.text.primary,
},
}}
>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "create", path: `/pagespeed/create` },
]}
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography
component="h1"
variant="h1"
>
<Typography
component="span"
fontSize="inherit"
>
Create your{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
color={theme.palette.text.secondary}
>
pagespeed monitor
</Typography>
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
type={"url"}
id="monitor-url"
label="URL to monitor"
https={https}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "pagespeed"}
onChange={(event) => handleChange(event)}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
</Stack>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={monitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 3}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
>
Create monitor
</Button>
</Stack>
</Stack>
</Box>
);
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(
createPageSpeed({ authToken, monitor: form })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/pagespeed");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
return (
<Box
className="create-monitor"
sx={{
"& h1": {
color: theme.palette.text.primary,
},
}}
>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "create", path: `/pagespeed/create` },
]}
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography component="h1" variant="h1">
<Typography component="span" fontSize="inherit">
Create your{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
color={theme.palette.text.secondary}
>
pagespeed monitor
</Typography>
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of
monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
type={"url"}
id="monitor-url"
label="URL to monitor"
https={https}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "pagespeed"}
onChange={(event) => handleChange(event)}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
</Stack>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={monitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 3}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
loading={isLoading}
>
Create monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
export default CreatePageSpeed;

View File

@@ -3,7 +3,6 @@ const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
const FALLBACK_BASE_URL = "http://localhost:5000/api/v1";
import { clearAuthState } from "../Features/Auth/authSlice";
import { clearUptimeMonitorState } from "../Features/UptimeMonitors/uptimeMonitorsSlice";
import { logger } from "./Logger";
class NetworkService {
constructor(store, dispatch, navigate) {
this.store = store;
@@ -88,22 +87,48 @@ class NetworkService {
},
});
}
/**
*
* ************************************
* Check the endpoint resolution
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {Object} config.monitorURL - The monitor url to be sent in the request body.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*/
async checkEndpointResolution(config) {
const { authToken, monitorURL } = config;
const params = new URLSearchParams();
if (monitorURL) params.append("monitorURL", monitorURL);
/**
*
* ************************************
* Gets monitors and summary of stats by TeamID
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {string} config.teamId - Team ID
* @param {Array<string>} config.types - Array of monitor types
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*/
async getMonitorsAndSummaryByTeamId(config) {
const params = new URLSearchParams();
return this.axiosInstance.get(`/monitors/resolution/url?${params.toString()}`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
}
})
}
/**
*
* ************************************
* Gets monitors and summary of stats by TeamID
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {string} config.teamId - Team ID
* @param {Array<string>} config.types - Array of monitor types
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*/
async getMonitorsAndSummaryByTeamId(config) {
const params = new URLSearchParams();
if (config.types) {
config.types.forEach((type) => {

View File

@@ -3,6 +3,7 @@ import {
getMonitorByIdQueryValidation,
getMonitorsByTeamIdValidation,
createMonitorBodyValidation,
getMonitorURLByQueryValidation,
editMonitorBodyValidation,
getMonitorsAndSummaryByTeamIdParamValidation,
getMonitorsAndSummaryByTeamIdQueryValidation,
@@ -13,13 +14,14 @@ import {
getCertificateParamValidation,
} from "../validation/joi.js";
import sslChecker from "ssl-checker";
const SERVICE_NAME = "monitorController";
import { errorMessages, successMessages } from "../utils/messages.js";
import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
import logger from "../utils/logger.js";
import { handleError, handleValidationError } from "./controllerUtils.js";
import dns from "dns";
const SERVICE_NAME = "monitorController";
/**
* Returns all monitors
@@ -257,6 +259,44 @@ const createMonitor = async (req, res, next) => {
}
};
/**
* Checks if the endpoint can be resolved
* @async
* @param {Object} req - The Express request object.
* @property {Object} req.query - The query parameters of the request.
* @param {Object} res - The Express response object.
* @param {function} next - The next middleware function.
* @returns {Object} The response object with a success status, a message, and the resolution result.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
const checkEndpointResolution = async (req, res, next) => {
try {
await getMonitorURLByQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
let { monitorURL } = req.query;
monitorURL = new URL(monitorURL);
await new Promise((resolve, reject) => {
dns.resolve(monitorURL.hostname, (error) => {
if (error) {
reject(error);
}
resolve();
});
});
return res.status(200).json({
success: true,
msg: `URL resolved successfully`,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "checkEndpointResolution"));
}
}
/**
* Deletes a monitor by its ID and also deletes associated checks, alerts, and notifications.
* @async
@@ -477,6 +517,7 @@ export {
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
checkEndpointResolution,
deleteMonitor,
deleteAllMonitors,
editMonitor,

View File

@@ -22,12 +22,16 @@
"variables": {
"PORT": {
"description": "API Port",
"enum": ["5000"],
"enum": [
"5000"
],
"default": "5000"
},
"API_PATH": {
"description": "API Base Path",
"enum": ["api/v1"],
"enum": [
"api/v1"
],
"default": "api/v1"
}
}
@@ -38,7 +42,9 @@
"variables": {
"API_PATH": {
"description": "API Base Path",
"enum": ["api/v1"],
"enum": [
"api/v1"
],
"default": "api/v1"
}
}
@@ -49,12 +55,16 @@
"variables": {
"PORT": {
"description": "API Port",
"enum": ["5000"],
"enum": [
"5000"
],
"default": "5000"
},
"API_PATH": {
"description": "API Base Path",
"enum": ["api/v1"],
"enum": [
"api/v1"
],
"default": "api/v1"
}
}
@@ -89,7 +99,9 @@
"paths": {
"/auth/register": {
"post": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Register a new user",
"requestBody": {
"content": {
@@ -125,8 +137,23 @@
},
"role": {
"type": "array",
"enum": [["user"], ["admin"], ["superadmin"], ["Demo"]],
"default": ["superadmin"]
"enum": [
[
"user"
],
[
"admin"
],
[
"superadmin"
],
[
"Demo"
]
],
"default": [
"superadmin"
]
},
"teamId": {
"type": "string",
@@ -173,14 +200,19 @@
},
"/auth/login": {
"post": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Login with credentials",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "password"],
"required": [
"email",
"password"
],
"properties": {
"email": {
"type": "string",
@@ -231,7 +263,9 @@
},
"/auth/user/{userId}": {
"put": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Change user information",
"parameters": [
{
@@ -292,7 +326,9 @@
]
},
"delete": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Delete user",
"parameters": [
{
@@ -345,7 +381,9 @@
},
"/auth/users/superadmin": {
"get": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Checks to see if an admin account exists",
"responses": {
"200": {
@@ -388,7 +426,9 @@
},
"/auth/users": {
"get": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Get all users",
"responses": {
"200": {
@@ -431,14 +471,18 @@
},
"/auth/recovery/request": {
"post": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Request a recovery token",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email"],
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
@@ -485,14 +529,18 @@
},
"/auth/recovery/validate": {
"post": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Validate recovery token",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["recoveryToken"],
"required": [
"recoveryToken"
],
"properties": {
"recoveryToken": {
"type": "string"
@@ -538,14 +586,19 @@
},
"/auth/recovery/reset": {
"post": {
"tags": ["auth"],
"tags": [
"auth"
],
"description": "Password reset",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["recoveryToken", "password"],
"required": [
"recoveryToken",
"password"
],
"properties": {
"recoveryToken": {
"type": "string"
@@ -594,14 +647,19 @@
},
"/invite": {
"post": {
"tags": ["invite"],
"tags": [
"invite"
],
"description": "Request an invitation",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "role"],
"required": [
"email",
"role"
],
"properties": {
"email": {
"type": "string"
@@ -655,14 +713,18 @@
},
"/invite/verify": {
"post": {
"tags": ["invite"],
"tags": [
"invite"
],
"description": "Request an invitation",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["token"],
"required": [
"token"
],
"properties": {
"token": {
"type": "string"
@@ -713,7 +775,9 @@
},
"/monitors": {
"get": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Get all monitors",
"responses": {
"200": {
@@ -754,7 +818,9 @@
]
},
"post": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Create a new monitor",
"requestBody": {
"content": {
@@ -804,7 +870,9 @@
]
},
"delete": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Delete all monitors",
"responses": {
"200": {
@@ -845,9 +913,78 @@
]
}
},
"/monitors/resolution/url": {
"get": {
"tags": [
"monitors"
],
"description": "Check DNS resolution for a given URL",
"parameters": [
{
"name": "monitorURL",
"in": "query",
"required": true,
"schema": {
"type": "string",
"example": "https://example.com"
},
"description": "The URL to check DNS resolution for"
}
],
"responses": {
"200": {
"description": "URL resolved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SuccessResponse"
}
}
}
},
"400": {
"description": "DNS resolution failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/monitors/{monitorId}": {
"get": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Get monitor by id",
"parameters": [
{
@@ -898,7 +1035,9 @@
]
},
"put": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Update monitor by id",
"parameters": [
{
@@ -958,7 +1097,9 @@
]
},
"delete": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Delete monitor by id",
"parameters": [
{
@@ -1011,7 +1152,9 @@
},
"/monitors/stats/{monitorId}": {
"get": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Get monitor stats",
"parameters": [
{
@@ -1064,7 +1207,9 @@
},
"/monitors/certificate/{monitorId}": {
"get": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Get monitor certificate",
"parameters": [
{
@@ -1117,7 +1262,9 @@
},
"/monitors/team/summary/{teamId}": {
"get": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Get monitors and summary by teamId",
"parameters": [
{
@@ -1134,7 +1281,11 @@
"required": false,
"schema": {
"type": "array",
"enum": ["http", "ping", "pagespeed"]
"enum": [
"http",
"ping",
"pagespeed"
]
}
}
],
@@ -1179,7 +1330,9 @@
},
"/monitors/team/{teamId}": {
"get": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Get monitors by teamId",
"parameters": [
{
@@ -1206,7 +1359,10 @@
"required": false,
"schema": {
"type": "string",
"enum": ["asc", "desc"]
"enum": [
"asc",
"desc"
]
}
},
{
@@ -1225,7 +1381,11 @@
"required": false,
"schema": {
"type": "string",
"enum": ["http", "ping", "pagespeed"]
"enum": [
"http",
"ping",
"pagespeed"
]
}
},
{
@@ -1269,7 +1429,11 @@
"required": false,
"schema": {
"type": "string",
"enum": ["http", "ping", "pagespeed"]
"enum": [
"http",
"ping",
"pagespeed"
]
}
}
],
@@ -1314,7 +1478,9 @@
},
"/monitors/pause/{monitorId}": {
"post": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Pause monitor",
"parameters": [
{
@@ -1367,7 +1533,9 @@
},
"/monitors/demo": {
"post": {
"tags": ["monitors"],
"tags": [
"monitors"
],
"description": "Create a demo monitor",
"requestBody": {
"content": {
@@ -1419,7 +1587,9 @@
},
"/checks/{monitorId}": {
"get": {
"tags": ["checks"],
"tags": [
"checks"
],
"description": "Get all checks for a monitor",
"parameters": [
{
@@ -1470,7 +1640,9 @@
]
},
"post": {
"tags": ["checks"],
"tags": [
"checks"
],
"description": "Create a new check",
"parameters": [
{
@@ -1530,7 +1702,9 @@
]
},
"delete": {
"tags": ["checks"],
"tags": [
"checks"
],
"description": "Delete all checks for a monitor",
"parameters": [
{
@@ -1583,7 +1757,9 @@
},
"/checks/team/{teamId}": {
"get": {
"tags": ["checks"],
"tags": [
"checks"
],
"description": "Get all checks for a team",
"parameters": [
{
@@ -1634,7 +1810,9 @@
]
},
"delete": {
"tags": ["checks"],
"tags": [
"checks"
],
"description": "Delete all checks for a team",
"parameters": [
{
@@ -1687,7 +1865,9 @@
},
"/checks/team/ttl": {
"put": {
"tags": ["checks"],
"tags": [
"checks"
],
"description": "Update check TTL",
"requestBody": {
"content": {
@@ -1739,7 +1919,9 @@
},
"/maintenance-window/monitor/{monitorId}": {
"get": {
"tags": ["maintenance-window"],
"tags": [
"maintenance-window"
],
"description": "Get maintenance window for monitor",
"parameters": [
{
@@ -1790,7 +1972,9 @@
]
},
"post": {
"tags": ["maintenance-window"],
"tags": [
"maintenance-window"
],
"description": "Create maintenance window for monitor",
"parameters": [
{
@@ -1852,7 +2036,9 @@
},
"/maintenance-window/user/{userId}": {
"get": {
"tags": ["maintenance-window"],
"tags": [
"maintenance-window"
],
"description": "Get maintenance window for user",
"parameters": [
{
@@ -1905,7 +2091,9 @@
},
"/queue/jobs": {
"get": {
"tags": ["queue"],
"tags": [
"queue"
],
"description": "Get all jobs in queue",
"responses": {
"200": {
@@ -1946,7 +2134,9 @@
]
},
"post": {
"tags": ["queue"],
"tags": [
"queue"
],
"description": "Create a new job. Useful for testing scaling workers",
"responses": {
"200": {
@@ -1989,7 +2179,9 @@
},
"/queue/metrics": {
"get": {
"tags": ["queue"],
"tags": [
"queue"
],
"description": "Get queue metrics",
"responses": {
"200": {
@@ -2032,7 +2224,9 @@
},
"/queue/obliterate": {
"post": {
"tags": ["queue"],
"tags": [
"queue"
],
"description": "Obliterate job queue",
"responses": {
"200": {
@@ -2074,7 +2268,6 @@
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
@@ -2113,7 +2306,14 @@
},
"UserUpdateRequest": {
"type": "object",
"required": ["firstName", "lastName", "email", "password", "role", "teamId"],
"required": [
"firstName",
"lastName",
"email",
"password",
"role",
"teamId"
],
"properties": {
"firstName": {
"type": "string"
@@ -2135,8 +2335,23 @@
},
"role": {
"type": "array",
"enum": [["user"], ["admin"], ["superadmin"], ["Demo"]],
"default": ["superadmin"]
"enum": [
[
"user"
],
[
"admin"
],
[
"superadmin"
],
[
"Demo"
]
],
"default": [
"superadmin"
]
},
"deleteProfileImage": {
"type": "boolean"
@@ -2145,7 +2360,14 @@
},
"CreateMonitorBody": {
"type": "object",
"required": ["userId", "teamId", "name", "description", "type", "url"],
"required": [
"userId",
"teamId",
"name",
"description",
"type",
"url"
],
"properties": {
"_id": {
"type": "string"
@@ -2164,7 +2386,11 @@
},
"type": {
"type": "string",
"enum": ["http", "ping", "pagespeed"]
"enum": [
"http",
"ping",
"pagespeed"
]
},
"url": {
"type": "string"
@@ -2205,7 +2431,13 @@
},
"CreateCheckBody": {
"type": "object",
"required": ["monitorId", "status", "responseTime", "statusCode", "message"],
"required": [
"monitorId",
"status",
"responseTime",
"statusCode",
"message"
],
"properties": {
"monitorId": {
"type": "string"
@@ -2226,7 +2458,9 @@
},
"UpdateCheckTTLBody": {
"type": "object",
"required": ["ttl"],
"required": [
"ttl"
],
"properties": {
"ttl": {
"type": "integer"
@@ -2235,7 +2469,13 @@
},
"CreateMaintenanceWindowBody": {
"type": "object",
"required": ["userId", "active", "oneTime", "start", "end"],
"required": [
"userId",
"active",
"oneTime",
"start",
"end"
],
"properties": {
"userId": {
"type": "string"
@@ -2262,4 +2502,4 @@
}
}
}
}
}

View File

@@ -1,17 +1,18 @@
import { Router } from "express";
import {
getAllMonitors,
getMonitorStatsById,
getMonitorCertificate,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
deleteAllMonitors,
editMonitor,
pauseMonitor,
addDemoMonitors,
getAllMonitors,
getMonitorStatsById,
getMonitorCertificate,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
checkEndpointResolution,
deleteMonitor,
deleteAllMonitors,
editMonitor,
pauseMonitor,
addDemoMonitors,
} from "../controllers/monitorController.js";
import { isAllowed } from "../middleware/isAllowed.js";
import { fetchMonitorCertificate } from "../controllers/controllerUtils.js";
@@ -21,15 +22,25 @@ const router = Router();
router.get("/", getAllMonitors);
router.get("/stats/:monitorId", getMonitorStatsById);
router.get("/certificate/:monitorId", (req, res, next) => {
getMonitorCertificate(req, res, next, fetchMonitorCertificate);
getMonitorCertificate(req, res, next, fetchMonitorCertificate);
});
router.get("/:monitorId", getMonitorById);
router.get("/team/summary/:teamId", getMonitorsAndSummaryByTeamId);
router.get("/team/:teamId", getMonitorsByTeamId);
router.post("/", isAllowed(["admin", "superadmin"]), createMonitor);
router.get(
"/resolution/url",
isAllowed(["admin", "superadmin"]),
checkEndpointResolution
)
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), deleteMonitor);
router.delete(
"/:monitorId",
isAllowed(["admin", "superadmin"]),
deleteMonitor
);
router.post("/", isAllowed(["admin", "superadmin"]), createMonitor);
router.put("/:monitorId", isAllowed(["admin", "superadmin"]), editMonitor);

View File

@@ -6,6 +6,7 @@ import {
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
checkEndpointResolution,
deleteMonitor,
deleteAllMonitors,
editMonitor,
@@ -16,9 +17,7 @@ import jwt from "jsonwebtoken";
import sinon from "sinon";
import { successMessages } from "../../utils/messages.js";
import logger from "../../utils/logger.js";
import * as monitorController from "../../controllers/monitorController.js";
import { fetchMonitorCertificate } from "../../controllers/controllerUtils.js";
import dns from "dns";
const SERVICE_NAME = "monitorController";
describe("Monitor Controller - getAllMonitors", () => {
@@ -460,6 +459,50 @@ describe("Monitor Controller - createMonitor", () => {
});
});
describe("Monitor Controllor - checkEndpointResolution", () => {
let req, res, next, dnsResolveStub;
beforeEach(() => {
req = { query: { monitorURL: 'https://example.com' } };
res = { status: sinon.stub().returnsThis(), json: sinon.stub() };
next = sinon.stub();
dnsResolveStub = sinon.stub(dns, 'resolve');
});
afterEach(() => {
dnsResolveStub.restore();
});
it('should resolve the URL successfully', async () => {
dnsResolveStub.callsFake((hostname, callback) => callback(null));
await checkEndpointResolution(req, res, next);
expect(res.status.calledWith(200)).to.be.true;
expect(res.json.calledWith({
success: true,
msg: 'URL resolved successfully',
})).to.be.true;
expect(next.called).to.be.false;
});
it("should return an error if DNS resolution fails", async () => {
const dnsError = new Error("DNS resolution failed");
dnsError.code = 'ENOTFOUND';
dnsResolveStub.callsFake((hostname, callback) => callback(dnsError));
await checkEndpointResolution(req, res, next);
expect(next.calledOnce).to.be.true;
const errorPassedToNext = next.getCall(0).args[0];
expect(errorPassedToNext).to.be.an.instanceOf(Error);
expect(errorPassedToNext.message).to.include('DNS resolution failed');
expect(errorPassedToNext.code).to.equal('ENOTFOUND');
expect(errorPassedToNext.status).to.equal(500);
});
it('should reject with an error if query validation fails', async () => {
req.query.monitorURL = 'invalid-url';
await checkEndpointResolution(req, res, next);
expect(next.calledOnce).to.be.true;
const error = next.getCall(0).args[0];
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].status).to.equal(422);
expect(error.message).to.equal('"monitorURL" must be a valid uri');
});
});
describe("Monitor Controller - deleteMonitor", () => {
let req, res, next;
beforeEach(() => {

View File

@@ -236,6 +236,10 @@ const pauseMonitorParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorURLByQueryValidation = joi.object({
monitorURL: joi.string().uri().required(),
});
//****************************************
// Alerts
//****************************************
@@ -446,6 +450,7 @@ export {
getCertificateParamValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
getMonitorURLByQueryValidation,
editUserParamValidation,
editUserBodyValidation,
createAlertParamValidation,