Merge pull request #2648 from bluewave-labs/develop

develop -> demo
This commit is contained in:
Alexander Holliday authored and GitHub committed 2025-07-20 19:50:19 -07:00
commit 82ad0bee9d
54 files changed
+1838 -1079

No files matched your search

+9 -3
View File
@@ -72,14 +72,20 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
## 💚 Questions & Ideas
If you have any questions, suggestions or comments, please use our [Discord channel](https://discord.gg/NAb6H3UTjK). We've also launched our [Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) page! Feel free to ask questions or share your ideas—we'd love to hear from you!
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/)
Feel free to ask questions or share your ideas - we'd love to hear from you!
## 🧩 Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
- Website monitoring
- Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance etc) - requires [Capture](https://github.com/bluewave-labs/capture)
- Infrastructure monitoring (memory, disk usage, CPU performance etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Docker monitoring
- Ping monitoring
- SSL monitoring
@@ -134,7 +140,7 @@ If you have any questions, suggestions or comments, please use our [Discord chan
We are [Alex](http://github.com/ajhollid) (team lead), [Vishnu](http://github.com/vishnusn77), [Mohadeseh](http://github.com/mohicody), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 6000+ stars and attracted 80+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+1 -1
View File
@@ -14,6 +14,6 @@ metadata:
name: checkmate-secrets
type: Opaque
stringData:
{{- range $key, $value := := $secrets }}
{{- range $key, $value := $secrets }}
{{ $key }}: {{ $value | quote }}
{{- end }}
@@ -1,8 +1,10 @@
import { useTheme } from "@emotion/react";
import { useEffect, useState, useMemo } from "react";
import Stack from "@mui/material/Stack";
import { Box, Typography } from "@mui/material";
import PropTypes from "prop-types";
import "./index.css";
import CircularProgress from "@mui/material/CircularProgress";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
@@ -16,6 +18,8 @@ const MAXIMUM_VALUE = 100;
* @param {number} [props.radius=60] - Radius of the gauge circle
* @param {number} [props.strokeWidth=15] - Width of the gauge stroke
* @param {number} [props.threshold=50] - Threshold for color change
* @param {number} [props.precision=1] - Precision of the progress percentage
* @param {string} [props.unit="%"] - Unit of progress
*
* @example
* <CustomGauge
@@ -27,7 +31,15 @@ const MAXIMUM_VALUE = 100;
*
* @returns {React.ReactElement} Rendered CustomGauge component
*/
const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold = 50 }) => {
const CustomGauge = ({
isLoading = false,
progress = 0,
radius = 70,
strokeWidth = 15,
threshold = 50,
precision = 1,
unit = "%",
}) => {
const theme = useTheme();
// Calculate the length of the stroke for the circle
const { circumference, totalSize, strokeLength } = useMemo(
@@ -54,8 +66,22 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
const fillColor =
progressWithinRange > threshold
? theme.palette.error.lowContrast // CAIO_REVIEW
: theme.palette.accent.main; // CAIO_REVIEW
? theme.palette.error.lowContrast
: theme.palette.accent.main;
if (isLoading) {
return (
<Stack
className="radial-chart"
width={radius}
height={radius}
alignItems="center"
justifyContent="center"
>
<CircularProgress color="accent" />
</Stack>
);
}
return (
<Box
@@ -71,7 +97,7 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
>
<circle
className="radial-chart-base"
stroke={theme.palette.secondary.light} // CAIO_REVIEW
stroke={theme.palette.secondary.light}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2} // Center the circle
@@ -103,7 +129,7 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
fill: theme.typography.h2.color,
}}
>
{`${progressWithinRange.toFixed(1)}%`}
{`${progressWithinRange.toFixed(precision)}${unit}`}
</Typography>
</Box>
);
@@ -112,8 +138,11 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
export default CustomGauge;
CustomGauge.propTypes = {
isLoading: PropTypes.bool,
progress: PropTypes.number,
radius: PropTypes.number,
strokeWidth: PropTypes.number,
threshold: PropTypes.number,
precision: PropTypes.number,
unit: PropTypes.string,
};
+3 -4
View File
@@ -4,15 +4,14 @@ const ConfigBox = styled(Stack)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing(20),
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.spacing(2),
"& > *": {
paddingTop: theme.spacing(12),
paddingBottom: theme.spacing(18),
paddingTop: theme.spacing(15),
paddingBottom: theme.spacing(15),
},
"& > div:first-of-type": {
flex: 0.7,
@@ -25,7 +24,7 @@ const ConfigBox = styled(Stack)(({ theme }) => ({
"& > div:last-of-type": {
flex: 1,
paddingRight: theme.spacing(20),
paddingLeft: theme.spacing(18),
paddingLeft: theme.spacing(20),
},
"& h1, & h2": {
color: theme.palette.primary.contrastTextSecondary,
@@ -0,0 +1,51 @@
import { Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
const DEFAULT_GAP = 6;
const FieldWrapper = ({
label,
children,
gap,
labelMb,
labelFontWeight = 500,
labelVariant = "h3",
labelSx = {},
sx = {},
}) => {
const theme = useTheme();
return (
<Stack
gap={gap ?? theme.spacing(DEFAULT_GAP)}
sx={sx}
>
{label && (
<Typography
component={labelVariant}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={labelFontWeight}
sx={{
...(labelMb !== undefined && { mb: theme.spacing(labelMb) }),
...labelSx,
}}
>
{label}
</Typography>
)}
{children}
</Stack>
);
};
FieldWrapper.propTypes = {
label: PropTypes.node,
children: PropTypes.node.isRequired,
gap: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.object]),
labelMb: PropTypes.number,
labelFontWeight: PropTypes.number,
labelVariant: PropTypes.string,
labelSx: PropTypes.object,
sx: PropTypes.object,
};
export default FieldWrapper;
+19 -2
View File
@@ -24,7 +24,17 @@ import "./index.css";
* @returns {JSX.Element} - The rendered Radio component.
*/
const Radio = ({ name, checked, value, id, size, title, desc, onChange }) => {
const Radio = ({
name,
checked,
value,
id,
size,
title,
desc,
onChange,
labelSpacing,
}) => {
const theme = useTheme();
return (
@@ -53,7 +63,14 @@ const Radio = ({ name, checked, value, id, size, title, desc, onChange }) => {
onChange={onChange}
label={
<>
<Typography component="p">{title}</Typography>
<Typography
component="p"
mb={
labelSpacing !== undefined ? theme.spacing(labelSpacing) : theme.spacing(2)
}
>
{title}
</Typography>
<Typography
component="h6"
mt={theme.spacing(1)}
+30 -13
View File
@@ -12,6 +12,7 @@ import { useTheme } from "@emotion/react";
import SearchIcon from "../../../assets/icons/search.svg?react";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import FieldWrapper from "../FieldWrapper";
/**
* Search component using Material UI's Autocomplete.
@@ -24,6 +25,7 @@ import { useTranslation } from "react-i18next";
* @param {Function} props.handleChange - Function to call when the input changes
* @param {Function} Prop.onBlur - Function to call when the input is blured
* @param {Object} props.sx - Additional styles to apply to the component
* @param {string} props.unit - Label to identify type of options
* @returns {JSX.Element} The rendered Search component
*/
@@ -49,7 +51,7 @@ const SearchAdornment = () => {
);
};
//TODO keep search state inside of component
//TODO keep search state inside of component.
const Search = ({
label,
id,
@@ -68,6 +70,13 @@ const Search = ({
startAdornment,
endAdornment,
onBlur,
//FieldWrapper's props
gap,
labelMb,
labelFontWeight,
labelVariant,
labelSx = {},
unit = "option",
}) => {
const theme = useTheme();
const { t } = useTranslation();
@@ -139,15 +148,17 @@ const Search = ({
getOptionLabel={(option) => option[filteredBy]}
isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier
renderInput={(params) => (
<Stack>
<Typography
component="h3"
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
>
{label}
</Typography>
<FieldWrapper
label={label}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
...sx,
}}
>
<TextField
{...params}
error={Boolean(error)}
@@ -175,7 +186,7 @@ const Search = ({
{error}
</Typography>
)}
</Stack>
</FieldWrapper>
)}
filterOptions={(options, { inputValue }) => {
if (inputValue.trim() === "" && multiple && isAdorned) {
@@ -186,7 +197,12 @@ const Search = ({
);
if (filtered.length === 0) {
return [{ [filteredBy]: "No monitors found", noOptions: true }];
return [
{
[filteredBy]: t("general.noOptionsFound", { unit: unit }),
noOptions: true,
},
];
}
return filtered;
}}
@@ -281,7 +297,7 @@ Search.propTypes = {
options: PropTypes.array.isRequired,
filteredBy: PropTypes.string.isRequired,
secondaryLabel: PropTypes.string,
value: PropTypes.array,
value: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
inputValue: PropTypes.string.isRequired,
handleInputChange: PropTypes.func.isRequired,
handleChange: PropTypes.func,
@@ -292,6 +308,7 @@ Search.propTypes = {
startAdornment: PropTypes.object,
endAdornment: PropTypes.object,
onBlur: PropTypes.func,
unit: PropTypes.string,
};
export default Search;
+26 -15
View File
@@ -2,6 +2,7 @@ import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { MenuItem, Select as MuiSelect, Stack, Typography } from "@mui/material";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import FieldWrapper from "../FieldWrapper";
import "./index.css";
@@ -50,8 +51,14 @@ const Select = ({
onBlur,
sx,
name = "",
labelControlSpacing = 2,
labelControlSpacing = 6,
maxWidth,
//FieldWrapper's props
labelMb,
labelFontWeight,
labelVariant,
labelSx = {},
fieldWrapperSx = {},
}) => {
const theme = useTheme();
const itemStyles = {
@@ -69,20 +76,17 @@ const Select = ({
};
return (
<Stack
gap={theme.spacing(labelControlSpacing)}
className="select-wrapper"
<FieldWrapper
label={label}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={labelControlSpacing}
sx={{
...fieldWrapperSx,
}}
>
{label && (
<Typography
component="h3"
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
fontSize={13}
>
{label}
</Typography>
)}
<MuiSelect
className="select-component"
value={value}
@@ -107,6 +111,13 @@ const Select = ({
"& svg path": {
fill: theme.palette.primary.contrastTextTertiary,
},
"& .MuiSelect-select": {
padding: "0 13px",
minHeight: "34px",
display: "flex",
alignItems: "center",
lineHeight: 1,
},
...sx,
}}
renderValue={(selected) => {
@@ -151,7 +162,7 @@ const Select = ({
</MenuItem>
))}
</MuiSelect>
</Stack>
</FieldWrapper>
);
};
@@ -2,6 +2,7 @@ import { Stack, TextField, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { forwardRef, useState, cloneElement } from "react";
import PropTypes from "prop-types";
import FieldWrapper from "../FieldWrapper";
const getSx = (theme, type, maxWidth) => {
const sx = {
@@ -85,31 +86,43 @@ const TextInput = forwardRef(
marginLeft,
disabled = false,
hidden = false,
//FieldWrapper's props
gap,
labelMb,
labelFontWeight,
labelVariant,
labelSx = {},
sx = {},
},
ref
) => {
const [fieldType, setFieldType] = useState(type);
const theme = useTheme();
const labelContent = label && (
<>
{label}
{isRequired && <Required />}
{isOptional && <Optional optionalLabel={optionalLabel} />}
</>
);
return (
<Stack
flex={flex}
display={hidden ? "none" : ""}
marginTop={marginTop}
marginRight={marginRight}
marginBottom={marginBottom}
marginLeft={marginLeft}
<FieldWrapper
label={labelContent}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
flex,
display: hidden ? "none" : "",
mt: marginTop,
mr: marginRight,
mb: marginBottom,
ml: marginLeft,
...sx,
}}
>
<Typography
component="h3"
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
mb={theme.spacing(2)}
>
{label}
{isRequired && <Required />}
{isOptional && <Optional optionalLabel={optionalLabel} />}
</Typography>
<TextField
id={id}
name={name}
@@ -132,7 +145,7 @@ const TextInput = forwardRef(
}}
disabled={disabled}
/>
</Stack>
</FieldWrapper>
);
}
);
@@ -10,7 +10,18 @@ import { useState, useEffect } from "react";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
const NotificationConfig = ({ notifications, setMonitor, setNotifications }) => {
const NotificationConfig = ({
notifications,
setMonitor,
setNotifications,
//FieldWrapper's props
gap,
labelMb,
labelFontWeight,
labelVariant,
labelSx = {},
sx = {},
}) => {
// Local state
const [notificationsSearch, setNotificationsSearch] = useState("");
const [selectedNotifications, setSelectedNotifications] = useState([]);
@@ -66,6 +77,14 @@ const NotificationConfig = ({ notifications, setMonitor, setNotifications }) =>
handleChange={(value) => {
handleSearch(value);
}}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
...sx,
}}
/>
<Stack
flex={1}
+34
View File
@@ -0,0 +1,34 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const ToastBody = ({ body }) => {
const theme = useTheme();
if (Array.isArray(body)) {
return (
<Stack gap={theme.spacing(2)}>
{body.map((item, idx) => (
<Typography
key={`item-${idx}`}
color={theme.palette.secondary.contrastText}
>
{item}
</Typography>
))}
</Stack>
);
} else if (typeof body === "string") {
return <Typography color={theme.palette.secondary.contrastText}>{body}</Typography>;
}
return null;
};
ToastBody.propTypes = {
body: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
};
export default ToastBody;
+96
View File
@@ -0,0 +1,96 @@
import Stack from "@mui/material/Stack";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import ToastBody from "./body";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined";
import CloseIcon from "@mui/icons-material/Close";
// Utils
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const icons = {
info: <InfoOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
warning: <WarningAmberOutlinedIcon />,
};
const Toast = ({ variant, title, body, onClick, hasDismiss, hasIcon }) => {
const theme = useTheme();
const icon = icons[variant];
return (
<Stack
gap={theme.spacing(2)}
paddingTop={theme.spacing(4)}
paddingRight={theme.spacing(8)}
paddingBottom={theme.spacing(4)}
paddingLeft={theme.spacing(8)}
backgroundColor={theme.palette.alert.main}
border={`solid 1px ${theme.palette.alert.contrastText}`}
borderRadius={theme.shape.borderRadius}
>
<Stack
direction="row"
gap={theme.spacing(8)}
justifyContent="space-between"
alignItems="center"
>
{hasIcon && icon}
{title && (
<Typography
fontWeight="700"
color={theme.palette.secondary.contrastText}
>
{title}
</Typography>
)}
{title && (
<IconButton onClick={onClick}>
<CloseIcon />
</IconButton>
)}
</Stack>
<Stack
direction="row"
gap={theme.spacing(2)}
alignItems="center"
>
<ToastBody body={body} />
{!title && (
<IconButton onClick={onClick}>
<CloseIcon />
</IconButton>
)}
</Stack>
{hasDismiss && (
<Button
variant="text"
color="info"
onClick={onClick}
sx={{
fontWeight: "600",
width: "fit-content",
}}
>
Dismiss
</Button>
)}
</Stack>
);
};
export default Toast;
Toast.propTypes = {
variant: PropTypes.string.isRequired,
title: PropTypes.string,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
hasDismiss: PropTypes.bool,
hasIcon: PropTypes.bool,
onClick: PropTypes.func,
};
+80 -1
View File
@@ -32,4 +32,83 @@ const useFetchLogs = () => {
return [logs, isLoading, error];
};
export { useFetchLogs };
const useFetchQueueData = (trigger) => {
const [jobs, setJobs] = useState(undefined);
const [metrics, setMetrics] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
useEffect(() => {
const fetchJobs = async () => {
try {
setIsLoading(true);
const response = await networkService.getQueueData();
if (response.status === 200) {
setJobs(response.data.data.jobs);
setMetrics(response.data.data.metrics);
}
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
};
fetchJobs();
}, [trigger]);
return [jobs, metrics, isLoading, error];
};
const useFlushQueue = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const flushQueue = async (trigger, setTrigger) => {
try {
setIsLoading(true);
await networkService.flushQueue();
createToast({
body: "Queue flushed",
});
} catch (error) {
setError(error);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
setTrigger(!trigger);
}
};
return [flushQueue, isLoading, error];
};
const useFetchDiagnostics = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const [diagnostics, setDiagnostics] = useState(undefined);
const fetchDiagnostics = async () => {
try {
setIsLoading(true);
const response = await networkService.getDiagnostics();
setDiagnostics(response.data.data);
} catch (error) {
createToast({
body: error.message,
});
setError(error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchDiagnostics();
}, []);
return [diagnostics, fetchDiagnostics, isLoading, error];
};
export { useFetchLogs, useFetchQueueData, useFlushQueue, useFetchDiagnostics };
-57
View File
@@ -1,57 +0,0 @@
import { useState, useEffect } from "react";
import { networkService } from "../main";
import { createToast } from "../Utils/toastUtils";
const useFetchQueueData = (trigger) => {
const [jobs, setJobs] = useState(undefined);
const [metrics, setMetrics] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
useEffect(() => {
const fetchJobs = async () => {
try {
setIsLoading(true);
const response = await networkService.getQueueData();
if (response.status === 200) {
setJobs(response.data.data.jobs);
setMetrics(response.data.data.metrics);
}
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
};
fetchJobs();
}, [trigger]);
return [jobs, metrics, isLoading, error];
};
const useFlushQueue = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const flushQueue = async (trigger, setTrigger) => {
try {
setIsLoading(true);
await networkService.flushQueue();
createToast({
body: "Queue flushed",
});
} catch (error) {
setError(error);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
setTrigger(!trigger);
}
};
return [flushQueue, isLoading, error];
};
export { useFetchQueueData, useFlushQueue };
@@ -62,14 +62,15 @@ export const CustomThreshold = ({
const theme = useTheme();
return (
<Stack
direction={"row"}
sx={{
width: "50%",
justifyContent: "space-between",
flexWrap: "wrap",
}}
direction={{ sm: "column", md: "row" }}
spacing={theme.spacing(2)}
>
<Box>
<Box
sx={{
width: { md: "45%", lg: "25%", xl: "20%" },
}}
justifyContent="flex-start"
>
<Checkbox
id={checkboxId}
name={checkboxName}
@@ -81,8 +82,10 @@ export const CustomThreshold = ({
<Stack
direction={"row"}
sx={{
justifyContent: "flex-end",
justifyContent: "flex-start",
}}
alignItems="center"
spacing={theme.spacing(4)}
>
<TextInput
maxWidth="var(--env-var-width-4)"
@@ -25,6 +25,7 @@ import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
import { createToast } from "../../../Utils/toastUtils";
import Select from "../../../Components/Inputs/Select";
import { CustomThreshold } from "./Components/CustomThreshold";
import FieldWrapper from "../../../Components/Inputs/FieldWrapper";
const SELECT_VALUES = [
{ _id: 0.25, name: "15 seconds" },
@@ -283,7 +284,7 @@ const CreateInfrastructureMonitor = () => {
/>
</Typography>
</Stack>
<Stack gap={theme.spacing(15)}>
<Stack gap={theme.spacing(8)}>
<TextInput
type="url"
id="url"
@@ -299,8 +300,10 @@ const CreateInfrastructureMonitor = () => {
disabled={!isCreate}
/>
{isCreate && (
<Box>
<Typography component="p">{t("infrastructureProtocol")}</Typography>
<FieldWrapper
label={t("infrastructureProtocol")}
labelVariant="p"
>
<ButtonGroup>
<Button
variant="group"
@@ -317,7 +320,7 @@ const CreateInfrastructureMonitor = () => {
{t("http")}
</Button>
</ButtonGroup>
</Box>
</FieldWrapper>
)}
<TextInput
type="text"
@@ -11,7 +11,7 @@ import Filter from "./Components/Filters";
import SearchComponent from "../../Uptime/Monitors/Components/SearchComponent";
// Utils
import { useTheme } from "@emotion/react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
import { useTranslation } from "react-i18next";
import { useFetchMonitorsByTeamId } from "../../../Hooks/monitorHooks";
@@ -58,6 +58,12 @@ const InfrastructureMonitors = () => {
setPage(0);
};
useEffect(() => {
if (isSearching) {
setPage(0);
}
}, [isSearching]);
const handleReset = () => {
setSelectedStatus(undefined);
setToFilterStatus(undefined);
@@ -0,0 +1,109 @@
import Stack from "@mui/material/Stack";
import Gauge from "../../../../../Components/Charts/CustomGauge";
import Typography from "@mui/material/Typography";
// Utils
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { getPercentage } from "../../utils/utils";
import { useTranslation } from "react-i18next";
const GaugeBox = ({ title, subtitle, children }) => {
const theme = useTheme();
return (
<Stack
alignItems="center"
p={theme.spacing(2)}
maxWidth={150}
width={150}
>
{children}
<Typography variant="h2">{title}</Typography>
<Typography variant="body2">{subtitle}</Typography>
</Stack>
);
};
GaugeBox.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
const Gauges = ({ diagnostics, isLoading }) => {
const heapTotalSize = getPercentage(
diagnostics?.v8HeapStats?.totalHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const heapUsedSize = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const actualHeapUsed = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.totalHeapSizeBytes
);
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
spacing={theme.spacing(4)}
flexWrap="wrap"
>
<GaugeBox
title={t("diagnosticsPage.gauges.heapAllocationTitle")}
subtitle={t("diagnosticsPage.gauges.heapAllocationSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={heapTotalSize}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.heapUsageTitle")}
subtitle={t("diagnosticsPage.gauges.heapUsageSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={heapUsedSize}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.heapUtilizationTitle")}
subtitle={t("diagnosticsPage.gauges.heapUtilizationSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={actualHeapUsed}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.instantCpuUsageTitle")}
subtitle={t("diagnosticsPage.gauges.instantCpuUsageSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={diagnostics?.cpuUsage?.usagePercentage}
precision={2}
/>
</GaugeBox>
</Stack>
);
};
Gauges.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
export default Gauges;
@@ -0,0 +1,97 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CircularProgress from "@mui/material/CircularProgress";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { getHumanReadableDuration } from "../../../../../Utils/timeUtils";
import { formatBytes } from "../../utils/utils";
import { useTranslation } from "react-i18next";
const StatsCard = ({ title, value, unit = "", isLoading }) => {
const theme = useTheme();
return (
<Card sx={{ width: 150, maxWidth: 150, height: 80, maxHeight: 80 }}>
{isLoading ? (
<Stack
alignItems="center"
justifyContent="center"
height={80}
maxHeight={80}
>
<CircularProgress color="accent" />
</Stack>
) : (
<CardContent>
<Typography
variant="body1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Typography variant="body1">
{value} {unit}
</Typography>
</CardContent>
)}
</Card>
);
};
StatsCard.propTypes = {
title: PropTypes.string,
value: PropTypes.string,
unit: PropTypes.string,
isLoading: PropTypes.bool,
};
const Stats = ({ diagnostics, isLoading }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
flexWrap="wrap"
>
<StatsCard
title={t("diagnosticsPage.stats.eventLoopDelayTitle")}
value={getHumanReadableDuration(diagnostics?.eventLoopDelayMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.uptimeTitle")}
value={getHumanReadableDuration(diagnostics?.uptimeMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.usedHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.totalHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.osMemoryLimitTitle")}
value={formatBytes(diagnostics?.osStats?.totalMemoryBytes)}
isLoading={isLoading}
/>
</Stack>
);
};
Stats.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
export default Stats;
@@ -0,0 +1,54 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Gauges from "./components/gauges";
import Stats from "./components/stats";
import Divider from "@mui/material/Divider";
import Button from "@mui/material/Button";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useFetchDiagnostics } from "../../../Hooks/logHooks";
const Diagnostics = () => {
// Local state
// Hooks
const theme = useTheme();
const { t } = useTranslation();
const [diagnostics, fetchDiagnostics, isLoading, error] = useFetchDiagnostics();
// Setup
return (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="h2">{t("diagnosticsPage.diagnosticDescription")}</Typography>
</Box>
<Divider color={theme.palette.accent.main} />
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(10)}
>
<Gauges
diagnostics={diagnostics}
isLoading={isLoading}
/>
<Stats
diagnostics={diagnostics}
isLoading={isLoading}
/>
<Box>
<Button
variant="contained"
color="accent"
onClick={fetchDiagnostics}
loading={isLoading}
>
Fetch Diagnostics
</Button>
</Box>
</Stack>
</Stack>
);
};
export default Diagnostics;
@@ -0,0 +1,16 @@
export const getPercentage = (value, total) => {
if (!value || !total) return 0;
return (value / total) * 100;
};
export const formatBytes = (bytes) => {
if (!bytes) return "N/A";
if (bytes === 0) return "0 Bytes";
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
+5 -1
View File
@@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Select from "../../../Components/Inputs/Select";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import { useFetchLogs } from "../../../Hooks/logHooks";
import { useTheme } from "@emotion/react";
@@ -51,12 +52,15 @@ const Logs = () => {
return (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="body">{t("logsPage.description")}</Typography>
<Typography variant="h2">{t("logsPage.description")}</Typography>
</Box>
<Divider color={theme.palette.accent.main} />
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
mt={theme.spacing(10)}
>
<Typography>{t("logsPage.logLevelSelect.title")}</Typography>
<Select
@@ -1,8 +1,6 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import StatBox from "../../../../../Components/StatBox";
import StatusBoxes from "../../../../../Components/StatusBoxes";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
@@ -24,20 +22,17 @@ const Metrics = ({ metrics = {} }) => {
});
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
</Stack>
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
);
};
export default Metrics;
+40 -31
View File
@@ -5,10 +5,12 @@ import Metrics from "./components/Metrics";
import FailedJobTable from "./components/FailedJobTable";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
// Utils
import { useState } from "react";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/queueHooks";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/logHooks";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
@@ -26,39 +28,46 @@ const QueueDetails = () => {
if (error || flushError) return <div>Error: {error.message}</div>;
return (
<Stack gap={theme.spacing(20)}>
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<FailedJobTable metrics={metrics} />
<ButtonGroup
variant="contained"
color="accent"
sx={{
position: "sticky",
bottom: 0,
zIndex: 1000,
backgroundColor: theme.palette.primary.main,
p: theme.spacing(4),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.spacing(2),
}}
<Stack gap={theme.spacing(4)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<Divider color={theme.palette.accent.main} />
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(10)}
>
<Button
onClick={() => {
setTrigger(!trigger);
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<FailedJobTable metrics={metrics} />
<ButtonGroup
variant="contained"
color="accent"
sx={{
position: "sticky",
bottom: 0,
zIndex: 1000,
backgroundColor: theme.palette.primary.main,
p: theme.spacing(4),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.spacing(2),
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
<Button
onClick={() => {
setTrigger(!trigger);
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
+4 -1
View File
@@ -4,6 +4,7 @@ import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Queue from "./Queue";
import LogsComponent from "./Logs";
import Diagnostics from "./Diagnostics";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
@@ -14,7 +15,7 @@ const Logs = () => {
const theme = useTheme();
// Local state
const [value, setValue] = useState(0);
const [value, setValue] = useState(2);
// Handlers
const handleChange = (event, newValue) => {
@@ -31,9 +32,11 @@ const Logs = () => {
>
<Tab label={t("logsPage.tabs.logs")} />
<Tab label={t("logsPage.tabs.queue")} />
<Tab label={t("logsPage.tabs.diagnostics")} />
</Tabs>
{value === 0 && <LogsComponent />}
{value === 1 && <Queue />}
{value === 2 && <Diagnostics />}
</Stack>
);
};
@@ -0,0 +1,69 @@
// Components
import { Stack, Typography } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const MonitorListItem = ({ monitor, onDelete }) => {
const theme = useTheme();
return (
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(4)}
width="100%"
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<DeleteIcon
sx={{ cursor: "pointer" }}
onClick={() => onDelete(monitor)}
/>
</Stack>
);
};
MonitorListItem.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
onDelete: PropTypes.func.isRequired,
};
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
const onDelete = (monitorToDelete) => {
const newMonitors = selectedMonitors.filter(
(monitor) => monitor._id !== monitorToDelete._id
);
setSelectedMonitors(newMonitors);
};
const theme = useTheme();
return (
<Stack
gap={theme.spacing(6)}
width="100%"
>
{selectedMonitors?.map((monitor) => (
<MonitorListItem
key={monitor._id}
monitor={monitor}
onDelete={onDelete}
/>
))}
</Stack>
);
};
MonitorList.propTypes = {
selectedMonitors: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
setSelectedMonitors: PropTypes.func.isRequired,
};
export default MonitorList;
@@ -9,6 +9,8 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
import { maintenanceWindowValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import MonitorList from "./Components/MonitorList";
import Checkbox from "../../../Components/Inputs/Checkbox";
import dayjs from "dayjs";
import Select from "../../../Components/Inputs/Select";
@@ -212,6 +214,17 @@ const CreateMaintenance = () => {
});
};
const handleMonitorsChange = (selected) => {
setForm((prev) => ({ ...prev, monitors: selected }));
const { error } = maintenanceWindowValidation.validate(
{ monitors: selected },
{ abortEarly: false }
);
setErrors((prev) => {
return buildErrors(prev, "monitors", error);
});
};
const handleSubmit = async () => {
if (hasValidationErrors(form, maintenanceWindowValidation, setErrors)) return;
// Build timestamp for maintenance window from startDate and startTime
@@ -525,19 +538,25 @@ const CreateMaintenance = () => {
id={"monitors"}
label={t("addMonitors")}
multiple={true}
isAdorned={false}
options={monitors ? monitors : []}
isAdorned={true}
options={monitors}
filteredBy="name"
secondaryLabel={"type"}
inputValue={search}
value={form.monitors}
handleInputChange={handleSearch}
handleChange={handleSelectMonitors}
handleInputChange={setSearch}
handleChange={handleMonitorsChange}
error={errors["monitors"]}
disabled={maintenanceWindowId !== undefined}
/>
<MonitorList
selectedMonitors={form.monitors}
setSelectedMonitors={(monitors) =>
setForm((prev) => ({ ...prev, monitors }))
}
/>
</Stack>
</ConfigBox>
<Box
ml="auto"
display="inline-block"
+29 -12
View File
@@ -65,12 +65,19 @@ const CreateNotifications = () => {
const [isOpen, setIsOpen] = useState(false);
const getNotificationTypeValue = (typeId) => {
return NOTIFICATION_TYPES.find((type) => type._id === typeId)?.value || "email";
};
const extractError = (error, field) =>
error?.details.find((d) => d.path.includes(field))?.message;
// handlers
const onSubmit = (e) => {
e.preventDefault();
const form = {
...notification,
type: NOTIFICATION_TYPES.find((type) => type._id === notification.type).value,
type: getNotificationTypeValue(notification.type),
};
let error = null;
@@ -84,7 +91,7 @@ const CreateNotifications = () => {
});
console.log(JSON.stringify(newErrors));
console.log(JSON.stringify(form, null, 2));
createToast({ body: "Please check the form for errors." });
createToast({ body: Object.values(newErrors)[0] });
setErrors(newErrors);
return;
}
@@ -98,22 +105,32 @@ const CreateNotifications = () => {
const onChange = (e) => {
const { name, value } = e.target;
let rawNotification = { ...notification, [name]: value };
let newNotification = {
...rawNotification,
type: getNotificationTypeValue(rawNotification.type),
};
const newNotification = { ...notification, [name]: value };
const { error } = notificationValidation.validate(newNotification, {
abortEarly: false,
});
let validationError = { ...errors };
const { error } = notificationValidation.extract(name).validate(value);
setErrors((prev) => ({
...prev,
[name]: error?.message,
}));
if (name === "type") {
validationError["type"] = extractError(error, "type");
validationError["address"] = extractError(error, "address");
} else {
validationError[name] = extractError(error, name);
}
setNotification(newNotification);
setNotification(rawNotification);
setErrors(validationError);
};
const onTestNotification = () => {
const form = {
...notification,
type: NOTIFICATION_TYPES.find((type) => type._id === notification.type).value,
type: getNotificationTypeValue(notification.type),
};
let error = null;
@@ -125,7 +142,7 @@ const CreateNotifications = () => {
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
createToast({ body: "Please check the form for errors." });
createToast({ body: Object.values(newErrors)[0] });
setErrors(newErrors);
return;
}
@@ -139,7 +156,7 @@ const CreateNotifications = () => {
}
};
const type = NOTIFICATION_TYPES.find((type) => type._id === notification.type).value;
const type = getNotificationTypeValue(notification.type);
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
+7 -7
View File
@@ -117,8 +117,8 @@ const SettingsEmail = ({
<Box>
<Stack gap={theme.spacing(10)}>
<Box>
<Typography>{t("settingsPage.emailSettings.labelHost")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelHost")}
name="systemEmailHost"
placeholder="smtp.gmail.com"
value={systemEmailHost}
@@ -126,8 +126,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelPort")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelPort")}
name="systemEmailPort"
placeholder="425"
type="number"
@@ -136,8 +136,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelUser")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelUser")}
name="systemEmailUser"
placeholder={t("settingsPage.emailSettings.placeholderUser")}
value={systemEmailUser}
@@ -145,8 +145,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelAddress")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelAddress")}
name="systemEmailAddress"
placeholder="uptime@bluewavelabs.ca"
value={systemEmailAddress}
@@ -155,8 +155,8 @@ const SettingsEmail = ({
</Box>
{(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && (
<Box>
<Typography>{t("settingsPage.emailSettings.labelPassword")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelPassword")}
name="systemEmailPassword"
type="password"
placeholder="123 456 789 101112"
@@ -188,8 +188,8 @@ const SettingsEmail = ({
</Box>
)}
<Box>
<Typography>{t("settingsPage.emailSettings.labelTLSServername")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelTLSServername")}
name="systemEmailTLSServername"
placeholder="bluewavelabs.ca"
value={systemEmailTLSServername}
@@ -197,8 +197,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelConnectionHost")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelConnectionHost")}
name="systemEmailConnectionHost"
placeholder="bluewavelabs.ca"
value={systemEmailConnectionHost}
+33 -7
View File
@@ -2,16 +2,37 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import ConfigBox from "../../Components/ConfigBox";
import Select from "../../Components/Inputs/Select";
import Search from "../../Components/Inputs/Search";
import timezones from "../../Utils/timezones.json";
// Utils
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
const theme = useTheme();
const { t } = useTranslation();
const [rawInput, setRawInput] = useState("");
const selectedTimezone = useMemo(
() => timezones.find((tz) => tz._id === timezone) ?? null,
[timezone]
);
const handleTimezoneChange = useCallback(
(newValue) => {
setRawInput("");
handleChange({
target: {
name: "timezone",
value: newValue?._id ?? "",
},
});
},
[handleChange]
);
return (
<ConfigBox>
<Box>
@@ -28,12 +49,17 @@ const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
label={t("settingsPage.timezoneSettings.label")}
name="timezone"
value={timezone}
onChange={handleChange}
items={timezones}
<Search
id="timezone"
label={t("settingsDisplayTimezone")}
options={timezones}
filteredBy="name"
value={selectedTimezone}
inputValue={rawInput}
handleInputChange={(val) => setRawInput(val)}
handleChange={handleTimezoneChange}
isAdorned={false}
unit="timezone"
/>
</Stack>
</ConfigBox>
@@ -4,7 +4,7 @@ import { TabPanel } from "@mui/lab";
import ConfigBox from "../../../../../Components/ConfigBox";
import Checkbox from "../../../../../Components/Inputs/Checkbox";
import TextInput from "../../../../../Components/Inputs/TextInput";
import Select from "../../../../../Components/Inputs/Select";
import Search from "../../../../../Components/Inputs/Search";
import ImageUpload from "../../../../../Components/Inputs/ImageUpload";
import ColorPicker from "../../../../../Components/Inputs/ColorPicker";
import Progress from "../Progress";
@@ -14,6 +14,7 @@ import { useTheme } from "@emotion/react";
import timezones from "../../../../../Utils/timezones.json";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useMemo, useState, useCallback } from "react";
const TabSettings = ({
isCreate,
@@ -28,6 +29,25 @@ const TabSettings = ({
// Utils
const theme = useTheme();
const { t } = useTranslation();
const [rawInput, setRawInput] = useState("");
const selectedTimezone = useMemo(
() => timezones.find((tz) => tz._id === form.timezone) ?? null,
[form.timezone]
);
const handleTimezoneChange = useCallback(
(newValue) => {
setRawInput("");
handleFormChange({
target: {
name: "timezone",
value: newValue?._id ?? "",
},
});
},
[handleFormChange]
);
return (
<TabPanel value={tabValue}>
@@ -101,13 +121,17 @@ const TabSettings = ({
</Typography>
</Stack>
<Stack gap={theme.spacing(6)}>
<Select
<Search
id="timezone"
name="timezone"
label={t("settingsDisplayTimezone")}
items={timezones}
value={form.timezone}
onChange={handleFormChange}
options={timezones}
filteredBy="name"
value={selectedTimezone}
inputValue={rawInput}
handleInputChange={(newVal) => setRawInput(newVal)}
handleChange={handleTimezoneChange}
isAdorned={false}
unit="timezone"
/>
</Stack>
</ConfigBox>
+8
View File
@@ -1091,6 +1091,14 @@ class NetworkService {
},
});
}
async getDiagnostics() {
return this.axiosInstance.get(`/diagnostic/system`, {
headers: {
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;
+5
View File
@@ -553,6 +553,11 @@ const baseTheme = (palette) => ({
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
padding: "0 13px",
minHeight: "34px",
display: "flex",
alignItems: "center",
lineHeight: 1,
}),
},
},
+4
View File
@@ -85,6 +85,7 @@ export const getHumanReadableDuration = (ms) => {
hours: durationObj.hours(),
minutes: durationObj.minutes(),
seconds: durationObj.seconds(),
milliseconds: durationObj.milliseconds(),
};
const result = [];
@@ -101,6 +102,9 @@ export const getHumanReadableDuration = (ms) => {
if (result.length < 2 && parts.seconds > 0) {
result.push(`${parts.seconds}s`);
}
if (result.length < 2 && parts.milliseconds > 0 && parts.seconds < 1) {
result.push(`${parts.milliseconds.toFixed(2)}ms`);
}
if (result.length === 0) {
// fallback for durations < 1s
+6 -4
View File
@@ -1,7 +1,6 @@
import PropTypes from "prop-types";
import { toast, Slide } from "react-toastify";
import Alert from "../Components/Alert";
import Toast from "../Components/Toast";
/**
* @param {object} props
* @param {'info' | 'error' | 'warning'} - The variant of the alert (e.g., "info", "error").
@@ -15,6 +14,7 @@ export const createToast = ({
variant = "info",
title,
body,
hasDismiss = false,
hasIcon = false,
config = {},
}) => {
@@ -29,13 +29,14 @@ export const createToast = ({
toast(
({ closeToast }) => (
<Alert
<Toast
variant={variant}
title={title}
body={body}
isToast={true}
hasIcon={hasIcon}
onClick={closeToast}
hasDismiss={hasDismiss}
hasIcon={hasIcon}
/>
),
toastConfig
@@ -47,5 +48,6 @@ createToast.propTypes = {
title: PropTypes.string,
body: PropTypes.string.isRequired,
hasIcon: PropTypes.bool,
hasDismiss: PropTypes.bool,
config: PropTypes.object,
};
+27 -8
View File
@@ -410,14 +410,33 @@ const notificationValidation = joi.object({
"string.empty": "Notification name is required",
"any.required": "Notification name is required",
}),
address: joi.string().required().messages({
"string.empty": "This field cannot be empty",
"string.base": "This field must be a string",
"any.required": "This field is required",
}),
type: joi.string().required().messages({
"string.empty": "This field is required",
"any.required": "This field is required",
type: joi
.string()
.valid("email", "webhook", "slack", "discord", "pager_duty")
.required()
.messages({
"string.empty": "Notification type is required",
"any.required": "Notification type is required",
"any.only": "Notification type must be email, webhook, or pager_duty",
}),
address: joi.when("type", {
is: "email",
then: joi
.string()
.email({ tlds: { allow: false } })
.required()
.messages({
"string.empty": "E-mail address cannot be empty",
"any.required": "E-mail address is required",
"string.email": "Please enter a valid e-mail address",
}),
otherwise: joi.string().uri().required().messages({
"string.empty": "Webhook URL cannot be empty",
"any.required": "Webhook URL is required",
"string.uri": "Please enter a valid Webhook URL",
}),
}),
});
+27 -2
View File
@@ -229,6 +229,9 @@
"unknownError": "Unknown error"
}
},
"general": {
"noOptionsFound": "No {{unit}} found"
},
"commonSave": "Save",
"commonSaving": "Saving...",
"companyName": "Company name",
@@ -443,7 +446,7 @@
"inviteNoTokenFound": "No invite token found",
"loginHere": "Login here",
"logsPage": {
"description": "This page shows the latest 1000 lines of logs from the Checkmate server",
"description": "System logs - last 1000 lines",
"logLevelSelect": {
"title": "Log level",
"values": {
@@ -456,13 +459,35 @@
},
"tabs": {
"logs": "Server logs",
"queue": "Job queue"
"queue": "Job queue",
"diagnostics": "Diagnostics"
},
"title": "Logs",
"toast": {
"fetchLogsSuccess": "Logs fetched successfully"
}
},
"diagnosticsPage": {
"diagnosticDescription": "System diagnostics",
"statsDescription": "System statistics",
"gauges": {
"heapAllocationTitle": "Heap allocation",
"heapAllocationSubtitle": "% of available memory",
"heapUsageTitle": "Heap usage",
"heapUsageSubtitle": "% of available memory",
"heapUtilizationTitle": "Heap utilization",
"heapUtilizationSubtitle": "% of allocated",
"instantCpuUsageTitle": "Instant CPU usage",
"instantCpuUsageSubtitle": "% of 1s used by CPU"
},
"stats": {
"eventLoopDelayTitle": "Event loop delay",
"uptimeTitle": "Uptime",
"usedHeapSizeTitle": "Used heap size",
"totalHeapSizeTitle": "Total heap size",
"osMemoryLimitTitle": "OS Memory Limit"
}
},
"low": "low",
"maintenance": "maintenance",
"maintenanceRepeat": "Maintenance Repeat",
+14 -20
View File
@@ -1,5 +1,5 @@
import { createAnnouncementValidation } from "../validation/joi.js";
import { handleError } from "./controllerUtils.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "announcementController";
@@ -28,16 +28,10 @@ class AnnouncementController {
*
* @returns {Promise<void>} A promise that resolves once the response is sent.
*/
createAnnouncement = async (req, res, next) => {
try {
createAnnouncement = asyncHandler(
async (req, res, next) => {
await createAnnouncementValidation.validateAsync(req.body);
} catch (error) {
return next(handleError(error, SERVICE_NAME)); // Handle Joi validation errors
}
const { title, message } = req.body;
try {
const { title, message } = req.body;
const announcementData = {
title: title.trim(),
message: message.trim(),
@@ -49,10 +43,10 @@ class AnnouncementController {
msg: this.stringService.createAnnouncement,
data: newAnnouncement,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createAnnouncement"));
}
};
},
SERVICE_NAME,
"createAnnouncement"
);
/**
* Handles retrieving announcements with pagination.
@@ -63,17 +57,17 @@ class AnnouncementController {
* - `msg`: A message about the success of the request.
* @param {Function} next - The next middleware function in the stack for error handling.
*/
getAnnouncement = async (req, res, next) => {
try {
getAnnouncement = asyncHandler(
async (req, res, next) => {
const allAnnouncements = await this.db.getAnnouncements();
return res.success({
msg: this.stringService.getAnnouncement,
data: allAnnouncements,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAnnouncement"));
}
};
},
SERVICE_NAME,
"getAnnouncement"
);
}
export default AnnouncementController;
+69 -124
View File
@@ -10,7 +10,8 @@ import {
import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
import crypto from "crypto";
import { handleValidationError, handleError } from "./controllerUtils.js";
import { asyncHandler, createAuthError, createError } from "../utils/errorUtils.js";
const SERVICE_NAME = "authController";
class AuthController {
@@ -32,15 +33,10 @@ class AuthController {
* @throws {Error}
*/
issueToken = (payload, appSettings) => {
try {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
} catch (error) {
throw handleError(error, SERVICE_NAME, "issueToken");
}
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
};
/**
@@ -55,19 +51,14 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the creation of the user, the created user data, and a JWT token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
registerUser = async (req, res, next) => {
try {
registerUser = asyncHandler(
async (req, res, next) => {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await registrationBodyValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
// Create a new user
try {
// Create a new user
const user = req.body;
// If superAdmin exists, a token should be attached to all further register requests
const superAdminExists = await this.db.checkSuperadmin(req, res);
@@ -123,10 +114,10 @@ class AuthController {
msg: this.stringService.authCreateUser,
data: { user: newUser, token: token },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "registerController"));
}
};
},
SERVICE_NAME,
"registerUser"
);
/**
* Logs in a user by validating the user's credentials and issuing a JWT token.
@@ -140,18 +131,13 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the login of the user, the user data (without password and avatar image), and a JWT token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422) or the password is incorrect.
*/
loginUser = async (req, res, next) => {
try {
loginUser = asyncHandler(
async (req, res, next) => {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const { email, password } = req.body;
// Check if user exists
@@ -160,10 +146,7 @@ class AuthController {
// Compare password
const match = await user.comparePassword(password);
if (match !== true) {
const error = new Error(this.stringService.authIncorrectPassword);
error.status = 401;
next(error);
return;
throw createAuthError(this.stringService.authIncorrectPassword);
}
// Remove password from user object. Should this be abstracted to DB layer?
@@ -184,11 +167,10 @@ class AuthController {
token: token,
},
});
} catch (error) {
error.status = 401;
next(handleError(error, SERVICE_NAME, "loginUser"));
}
};
},
SERVICE_NAME,
"loginUser"
);
/**
* Edits a user's information. If the user wants to change their password, the current password is checked before updating to the new password.
@@ -204,26 +186,16 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the update of the user, and the updated user data.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422), the user is unauthorized (401), or the password is incorrect (403).
*/
editUser = async (req, res, next) => {
try {
editUser = asyncHandler(
async (req, res, next) => {
await editUserParamValidation.validateAsync(req.params);
await editUserBodyValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
// TODO is this neccessary any longer? Verify ownership middleware should handle this
if (req.params.userId !== req.user._id.toString()) {
const error = new Error(this.stringService.unauthorized);
error.status = 401;
error.service = SERVICE_NAME;
next(error);
return;
}
// TODO is this neccessary any longer? Verify ownership middleware should handle this
if (req.params.userId !== req.user._id.toString()) {
throw createAuthError(this.stringService.unauthorized);
}
try {
// Change Password check
if (req.body.password && req.body.newPassword) {
// Get token from headers
@@ -240,10 +212,7 @@ class AuthController {
// If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor
if (!match) {
const error = new Error(this.stringService.authIncorrectPassword);
error.status = 403;
next(error);
return;
throw createError(this.stringService.authIncorrectPassword, 403);
}
// If a match, update the password
req.body.password = req.body.newPassword;
@@ -254,10 +223,10 @@ class AuthController {
msg: this.stringService.authUpdateUser,
data: updatedUser,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "userEditController"));
}
};
},
SERVICE_NAME,
"editUser"
);
/**
* Checks if a superadmin account exists in the database.
@@ -268,18 +237,17 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the existence of a superadmin, and a boolean indicating the existence of a superadmin.
* @throws {Error} If there is an error during the process.
*/
checkSuperadminExists = async (req, res, next) => {
try {
checkSuperadminExists = asyncHandler(
async (req, res, next) => {
const superAdminExists = await this.db.checkSuperadmin(req, res);
return res.success({
msg: this.stringService.authAdminExists,
data: superAdminExists,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "checkSuperadminController"));
}
};
},
SERVICE_NAME,
"checkSuperadminExists"
);
/**
* Requests a recovery token for a user. The user's email is validated and a recovery token is created and sent via email.
* @async
@@ -291,16 +259,9 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the creation of the recovery token, and the message ID of the sent email.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
requestRecovery = async (req, res, next) => {
try {
requestRecovery = asyncHandler(
async (req, res, next) => {
await recoveryValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const { email } = req.body;
const user = await this.db.getUserByEmail(email);
const recoveryToken = await this.db.requestRecoveryToken(req, res);
@@ -323,10 +284,10 @@ class AuthController {
msg: this.stringService.authCreateRecoveryToken,
data: msgId,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "recoveryRequestController"));
}
};
},
SERVICE_NAME,
"requestRecovery"
);
/**
* Validates a recovery token. The recovery token is validated and if valid, a success message is returned.
* @async
@@ -338,25 +299,17 @@ class AuthController {
* @returns {Object} The response object with a success status and a message indicating the validation of the recovery token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
validateRecovery = async (req, res, next) => {
try {
validateRecovery = asyncHandler(
async (req, res, next) => {
await recoveryTokenValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
await this.db.validateRecoveryToken(req, res);
return res.success({
msg: this.stringService.authVerifyRecoveryToken,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "validateRecoveryTokenController"));
}
};
},
SERVICE_NAME,
"validateRecovery"
);
/**
* Resets a user's password. The new password is validated and if valid, the user's password is updated in the database and a new JWT token is issued.
@@ -370,27 +323,20 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the reset of the password, the updated user data (without password and avatar image), and a new JWT token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
resetPassword = async (req, res, next) => {
try {
resetPassword = asyncHandler(
async (req, res, next) => {
await newPasswordValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const user = await this.db.resetPassword(req, res);
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(user._doc, appSettings);
return res.success({
msg: this.stringService.authResetPassword,
data: { user, token },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "resetPasswordController"));
}
};
},
SERVICE_NAME,
"resetPassword"
);
/**
* Deletes a user and all associated monitors, checks, and alerts.
@@ -401,8 +347,8 @@ class AuthController {
* @returns {Object} The response object with success status and message.
* @throws {Error} If user validation fails or user is not found in the database.
*/
deleteUser = async (req, res, next) => {
try {
deleteUser = asyncHandler(
async (req, res, next) => {
const token = getTokenFromHeaders(req.headers);
const decodedToken = jwt.decode(token);
const { email } = decodedToken;
@@ -430,23 +376,22 @@ class AuthController {
return res.success({
msg: this.stringService.authDeleteUser,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteUserController"));
}
};
},
SERVICE_NAME,
"deleteUser"
);
getAllUsers = async (req, res, next) => {
try {
getAllUsers = asyncHandler(
async (req, res, next) => {
const allUsers = await this.db.getAllUsers(req, res);
return res.success({
msg: this.stringService.authGetAllUsers,
data: allUsers,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAllUsersController"));
}
};
},
SERVICE_NAME,
"getAllUsers"
);
}
export default AuthController;
+57 -99
View File
@@ -12,9 +12,7 @@ import {
ackAllChecksParamValidation,
ackAllChecksBodyValidation,
} from "../validation/joi.js";
import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
import { handleValidationError, handleError } from "./controllerUtils.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "checkController";
@@ -25,16 +23,11 @@ class CheckController {
this.stringService = stringService;
}
createCheck = async (req, res, next) => {
try {
createCheck = asyncHandler(
async (req, res, next) => {
await createCheckParamValidation.validateAsync(req.params);
await createCheckBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const checkData = { ...req.body };
const check = await this.db.createCheck(checkData);
@@ -42,21 +35,16 @@ class CheckController {
msg: this.stringService.checkCreate,
data: check,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createCheck"));
}
};
},
SERVICE_NAME,
"createCheck"
);
getChecksByMonitor = async (req, res, next) => {
try {
getChecksByMonitor = asyncHandler(
async (req, res, next) => {
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorId } = req.params;
let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } =
req.query;
@@ -76,20 +64,16 @@ class CheckController {
msg: this.stringService.checkGet,
data: result,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getChecks"));
}
};
},
SERVICE_NAME,
"getChecksByMonitor"
);
getChecksByTeam = async (req, res, next) => {
try {
getChecksByTeam = asyncHandler(
async (req, res, next) => {
await getTeamChecksParamValidation.validateAsync(req.params);
await getTeamChecksQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
let { sortOrder, dateRange, filter, ack, page, rowsPerPage } = req.query;
const { teamId } = req.user;
@@ -106,33 +90,28 @@ class CheckController {
msg: this.stringService.checkGet,
data: checkData,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getTeamChecks"));
}
};
},
SERVICE_NAME,
"getChecksByTeam"
);
getChecksSummaryByTeamId = async (req, res, next) => {
try {
getChecksSummaryByTeamId = asyncHandler(
async (req, res, next) => {
const { teamId } = req.user;
const summary = await this.db.getChecksSummaryByTeamId({ teamId });
return res.success({
msg: this.stringService.checkGetSummary,
data: summary,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getChecksSummaryByTeamId"));
}
};
},
SERVICE_NAME,
"getChecksSummaryByTeamId"
);
ackCheck = async (req, res, next) => {
try {
ackCheck = asyncHandler(
async (req, res, next) => {
await ackCheckBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { checkId } = req.params;
const { ack } = req.body;
const { teamId } = req.user;
@@ -143,21 +122,16 @@ class CheckController {
msg: this.stringService.checkUpdateStatus,
data: updatedCheck,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "ackCheck"));
}
};
},
SERVICE_NAME,
"ackCheck"
);
ackAllChecks = async (req, res, next) => {
try {
ackAllChecks = asyncHandler(
async (req, res, next) => {
await ackAllChecksParamValidation.validateAsync(req.params);
await ackAllChecksBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorId, path } = req.params;
const { ack } = req.body;
const { teamId } = req.user;
@@ -168,40 +142,30 @@ class CheckController {
msg: this.stringService.checkUpdateStatus,
data: updatedChecks,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "ackAllChecks"));
}
};
},
SERVICE_NAME,
"ackAllChecks"
);
deleteChecks = async (req, res, next) => {
try {
deleteChecks = asyncHandler(
async (req, res, next) => {
await deleteChecksParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const deletedCount = await this.db.deleteChecks(req.params.monitorId);
return res.success({
msg: this.stringService.checkDelete,
data: { deletedCount },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteChecks"));
}
};
},
SERVICE_NAME,
"deleteChecks"
);
deleteChecksByTeamId = async (req, res, next) => {
try {
deleteChecksByTeamId = asyncHandler(
async (req, res, next) => {
await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { teamId } = req.user;
const deletedCount = await this.db.deleteChecksByTeamId(teamId);
@@ -209,23 +173,17 @@ class CheckController {
msg: this.stringService.checkDelete,
data: { deletedCount },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteChecksByTeamId"));
}
};
},
SERVICE_NAME,
"deleteChecksByTeamId"
);
updateChecksTTL = async (req, res, next) => {
const SECONDS_PER_DAY = 86400;
updateChecksTTL = asyncHandler(
async (req, res, next) => {
const SECONDS_PER_DAY = 86400;
try {
await updateChecksTTLBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
// Get user's teamId
const { teamId } = req.user;
const ttl = parseInt(req.body.ttl, 10) * SECONDS_PER_DAY;
await this.db.updateChecksTTL(teamId, ttl);
@@ -233,9 +191,9 @@ class CheckController {
return res.success({
msg: this.stringService.checkUpdateTTL,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "updateTTL"));
}
};
},
SERVICE_NAME,
"updateChecksTtl"
);
}
export default CheckController;
+3 -15
View File
@@ -1,16 +1,4 @@
const handleValidationError = (error, serviceName) => {
error.status = 422;
error.service = serviceName;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
return error;
};
const handleError = (error, serviceName, method, status = 500) => {
error.status === undefined ? (error.status = status) : null;
error.service === undefined ? (error.service = serviceName) : null;
error.method === undefined ? (error.method = method) : null;
return error;
};
import { createServerError } from "../utils/errorUtils.js";
const fetchMonitorCertificate = async (sslChecker, monitor) => {
const monitorUrl = new URL(monitor.url);
@@ -18,9 +6,9 @@ const fetchMonitorCertificate = async (sslChecker, monitor) => {
const cert = await sslChecker(hostname);
// Throw an error if no cert or if cert.validTo is not present
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
throw createServerError("Certificate not found");
}
return cert;
};
export { handleValidationError, handleError, fetchMonitorCertificate };
export { fetchMonitorCertificate };
+99 -13
View File
@@ -1,6 +1,14 @@
import { handleError } from "./controllerUtils.js";
import v8 from "v8";
import os from "os";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "diagnosticController";
const obs = new PerformanceObserver((items) => {
const entry = items.getEntries()[0];
performance.clearMarks();
});
obs.observe({ entryTypes: ["measure"] });
class DiagnosticController {
constructor(db) {
this.db = db;
@@ -9,20 +17,20 @@ class DiagnosticController {
this.getDbStats = this.getDbStats.bind(this);
}
async getMonitorsByTeamIdExecutionStats(req, res, next) {
try {
getMonitorsByTeamIdExecutionStats = asyncHandler(
async (req, res, next) => {
const data = await this.db.getMonitorsByTeamIdExecutionStats(req);
return res.success({
msg: "OK",
data,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMonitorsByTeamIdExecutionStats"));
}
}
},
SERVICE_NAME,
"getMonitorsByTeamIdExecutionStats"
);
async getDbStats(req, res, next) {
try {
getDbStats = asyncHandler(
async (req, res, next) => {
const { methodName, args = [] } = req.body;
if (!methodName || !this.db[methodName]) {
return res.error({
@@ -40,9 +48,87 @@ class DiagnosticController {
msg: "OK",
data: stats,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getDbStats"));
}
}
},
SERVICE_NAME,
"getDbStats"
);
getCPUUsage = asyncHandler(
async (req, res, next) => {
const startUsage = process.cpuUsage();
const timingPeriod = 1000; // measured in ms
await new Promise((resolve) => setTimeout(resolve, timingPeriod));
const endUsage = process.cpuUsage(startUsage);
const cpuUsage = {
userUsageMs: endUsage.user / 1000,
systemUsageMs: endUsage.system / 1000,
usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100,
};
return cpuUsage;
},
SERVICE_NAME,
"getCPUUsage"
);
getSystemStats = asyncHandler(
async (req, res, next) => {
// Memory Usage
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const osStats = {
freeMemoryBytes: freeMemory, // bytes
totalMemoryBytes: totalMemory, // bytes
};
const used = process.memoryUsage();
const memoryUsage = {};
for (let key in used) {
memoryUsage[`${key}Mb`] = Math.round((used[key] / 1024 / 1024) * 100) / 100; // MB
}
// CPU Usage
const cpuMetrics = await this.getCPUUsage();
// V8 Heap Statistics
const heapStats = v8.getHeapStatistics();
const v8Metrics = {
totalHeapSizeBytes: heapStats.total_heap_size, // bytes
usedHeapSizeBytes: heapStats.used_heap_size, // bytes
heapSizeLimitBytes: heapStats.heap_size_limit, // bytes
};
// Event Loop Delay
let eventLoopDelay = 0;
performance.mark("start");
await new Promise((resolve) => setTimeout(resolve, 0));
performance.mark("end");
performance.measure("eventLoopDelay", "start", "end");
const entries = performance.getEntriesByName("eventLoopDelay");
if (entries.length > 0) {
eventLoopDelay = entries[0].duration;
}
// Uptime
const uptimeMs = process.uptime() * 1000; // ms
// Combine Metrics
const diagnostics = {
osStats,
memoryUsage,
cpuUsage: cpuMetrics,
v8HeapStats: v8Metrics,
eventLoopDelayMs: eventLoopDelay,
uptimeMs,
};
return res.success({
msg: "OK",
data: diagnostics,
});
},
SERVICE_NAME,
"getSystemStats"
);
}
export default DiagnosticController;
+35 -62
View File
@@ -3,10 +3,9 @@ import {
inviteBodyValidation,
inviteVerificationBodyValidation,
} from "../validation/joi.js";
import logger from "../utils/logger.js";
import jwt from "jsonwebtoken";
import { handleError, handleValidationError } from "./controllerUtils.js";
import { getTokenFromHeaders } from "../utils/utils.js";
import { asyncHandler, createServerError } from "../utils/errorUtils.js";
const SERVICE_NAME = "inviteController";
@@ -31,99 +30,73 @@ class InviteController {
* @returns {Object} The response object with a success status, a message indicating the sending of the invitation, and the invitation token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
getInviteToken = async (req, res, next) => {
try {
getInviteToken = asyncHandler(
async (req, res, next) => {
// Only admins can invite
const token = getTokenFromHeaders(req.headers);
const { role, teamId } = jwt.decode(token);
req.body.teamId = teamId;
try {
await inviteRoleValidation.validateAsync({ roles: role });
await inviteBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
await inviteRoleValidation.validateAsync({ roles: role });
await inviteBodyValidation.validateAsync(req.body);
const inviteToken = await this.db.requestInviteToken({ ...req.body });
return res.success({
msg: this.stringService.inviteIssued,
data: inviteToken,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "inviteController"));
}
};
},
SERVICE_NAME,
"getInviteToken"
);
sendInviteEmail = async (req, res, next) => {
try {
sendInviteEmail = asyncHandler(
async (req, res, next) => {
// Only admins can invite
const token = getTokenFromHeaders(req.headers);
const { role, firstname, teamId } = jwt.decode(token);
req.body.teamId = teamId;
try {
await inviteRoleValidation.validateAsync({ roles: role });
await inviteBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
await inviteRoleValidation.validateAsync({ roles: role });
await inviteBodyValidation.validateAsync(req.body);
const inviteToken = await this.db.requestInviteToken({ ...req.body });
const { clientHost } = this.settingsService.getSettings();
try {
const html = await this.emailService.buildEmail("employeeActivationTemplate", {
name: firstname,
link: `${clientHost}/register/${inviteToken.token}`,
});
const result = await this.emailService.sendEmail(
req.body.email,
"Welcome to Uptime Monitor",
html
const html = await this.emailService.buildEmail("employeeActivationTemplate", {
name: firstname,
link: `${clientHost}/register/${inviteToken.token}`,
});
const result = await this.emailService.sendEmail(
req.body.email,
"Welcome to Uptime Monitor",
html
);
if (!result) {
throw createServerError(
"Failed to send invite e-mail... Please verify your settings."
);
if (!result) {
return res.error({
msg: "Failed to send invite e-mail... Please verify your settings.",
});
}
} catch (error) {
logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "sendInviteEmail",
stack: error.stack,
});
}
return res.success({
msg: this.stringService.inviteIssued,
data: inviteToken,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "inviteController"));
}
};
},
SERVICE_NAME,
"sendInviteEmail"
);
inviteVerifyController = async (req, res, next) => {
try {
inviteVerifyController = asyncHandler(
async (req, res, next) => {
await inviteVerificationBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const invite = await this.db.getInviteToken(req.body.token);
return res.success({
msg: this.stringService.inviteVerified,
data: invite,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "inviteVerifyController"));
}
};
},
SERVICE_NAME,
"inviteVerifyController"
);
}
export default InviteController;
+8 -9
View File
@@ -1,23 +1,22 @@
import { handleError } from "./controllerUtils.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "JobQueueController";
const SERVICE_NAME = "LogController";
class LogController {
constructor(logger) {
this.logger = logger;
}
getLogs = async (req, res, next) => {
try {
getLogs = asyncHandler(
async (req, res, next) => {
const logs = await this.logger.getLogs();
res.success({
msg: "Logs fetched successfully",
data: logs,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getLogs"));
return;
}
};
},
SERVICE_NAME,
"getLogs"
);
}
export default LogController;
@@ -7,7 +7,7 @@ import {
getMaintenanceWindowsByTeamIdQueryValidation,
deleteMaintenanceWindowByIdParamValidation,
} from "../validation/joi.js";
import { handleValidationError, handleError } from "./controllerUtils.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "maintenanceWindowController";
@@ -18,14 +18,10 @@ class MaintenanceWindowController {
this.stringService = stringService;
}
createMaintenanceWindows = async (req, res, next) => {
try {
createMaintenanceWindows = asyncHandler(
async (req, res, next) => {
await createMaintenanceWindowBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { teamId } = req.user;
const monitorIds = req.body.monitors;
const dbTransactions = monitorIds.map((monitorId) => {
@@ -44,39 +40,28 @@ class MaintenanceWindowController {
return res.success({
msg: this.stringService.maintenanceWindowCreate,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createMaintenanceWindow"));
}
};
},
SERVICE_NAME,
"createMaintenanceWindows"
);
getMaintenanceWindowById = async (req, res, next) => {
try {
getMaintenanceWindowById = asyncHandler(
async (req, res, next) => {
await getMaintenanceWindowByIdParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const maintenanceWindow = await this.db.getMaintenanceWindowById(req.params.id);
return res.success({
msg: this.stringService.maintenanceWindowGetById,
data: maintenanceWindow,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMaintenanceWindowById"));
}
};
},
SERVICE_NAME,
"getMaintenanceWindowById"
);
getMaintenanceWindowsByTeamId = async (req, res, next) => {
try {
getMaintenanceWindowsByTeamId = asyncHandler(
async (req, res, next) => {
await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { teamId } = req.user;
const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId(
teamId,
@@ -87,20 +72,15 @@ class MaintenanceWindowController {
msg: this.stringService.maintenanceWindowGetByTeam,
data: maintenanceWindows,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMaintenanceWindowsByUserId"));
}
};
},
SERVICE_NAME,
"getMaintenanceWindowsByTeamId"
);
getMaintenanceWindowsByMonitorId = async (req, res, next) => {
try {
getMaintenanceWindowsByMonitorId = asyncHandler(
async (req, res, next) => {
await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(
req.params.monitorId
);
@@ -109,37 +89,27 @@ class MaintenanceWindowController {
msg: this.stringService.maintenanceWindowGetByUser,
data: maintenanceWindows,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMaintenanceWindowsByMonitorId"));
}
};
},
SERVICE_NAME,
"getMaintenanceWindowsByMonitorId"
);
deleteMaintenanceWindow = async (req, res, next) => {
try {
deleteMaintenanceWindow = asyncHandler(
async (req, res, next) => {
await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
await this.db.deleteMaintenanceWindowById(req.params.id);
return res.success({
msg: this.stringService.maintenanceWindowDelete,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteMaintenanceWindow"));
}
};
},
SERVICE_NAME,
"deleteMaintenanceWindow"
);
editMaintenanceWindow = async (req, res, next) => {
try {
editMaintenanceWindow = asyncHandler(
async (req, res, next) => {
await editMaintenanceWindowByIdParamValidation.validateAsync(req.params);
await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const editedMaintenanceWindow = await this.db.editMaintenanceWindowById(
req.params.id,
req.body
@@ -148,10 +118,10 @@ class MaintenanceWindowController {
msg: this.stringService.maintenanceWindowEdit,
data: editedMaintenanceWindow,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "editMaintenanceWindow"));
}
};
},
SERVICE_NAME,
"editMaintenanceWindow"
);
}
export default MaintenanceWindowController;
+161 -228
View File
@@ -16,12 +16,12 @@ import {
} from "../validation/joi.js";
import sslChecker from "ssl-checker";
import logger from "../utils/logger.js";
import { handleError, handleValidationError } from "./controllerUtils.js";
import axios from "axios";
import seedDb from "../db/mongo/utils/seedDb.js";
const SERVICE_NAME = "monitorController";
import pkg from "papaparse";
import { asyncHandler, createServerError } from "../utils/errorUtils.js";
const SERVICE_NAME = "monitorController";
class MonitorController {
constructor(db, settingsService, jobQueue, stringService, emailService) {
this.db = db;
@@ -40,17 +40,17 @@ class MonitorController {
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
getAllMonitors = async (req, res, next) => {
try {
getAllMonitors = asyncHandler(
async (req, res, next) => {
const monitors = await this.db.getAllMonitors();
return res.success({
msg: this.stringService.monitorGetAll,
data: monitors,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAllMonitors"));
}
};
},
SERVICE_NAME,
"getAllMonitors"
);
/**
* Returns all monitors with uptime stats for 1,7,30, and 90 days
@@ -61,20 +61,20 @@ class MonitorController {
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
getAllMonitorsWithUptimeStats = async (req, res, next) => {
try {
getAllMonitorsWithUptimeStats = asyncHandler(
async (req, res, next) => {
const monitors = await this.db.getAllMonitorsWithUptimeStats();
return res.success({
msg: this.stringService.monitorGetAll,
data: monitors,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAllMonitorsWithUptimeStats"));
}
};
},
SERVICE_NAME,
"getAllMonitorsWithUptimeStats"
);
getUptimeDetailsById = async (req, res, next) => {
try {
getUptimeDetailsById = asyncHandler(
async (req, res, next) => {
const { monitorId } = req.params;
const { dateRange, normalize } = req.query;
@@ -87,10 +87,10 @@ class MonitorController {
msg: this.stringService.monitorGetByIdSuccess,
data,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMonitorDetailsById"));
}
};
},
SERVICE_NAME,
"getUptimeDetailsById"
);
/**
* Returns monitor stats for monitor with matching ID
@@ -101,16 +101,11 @@ class MonitorController {
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
getMonitorStatsById = async (req, res, next) => {
try {
getMonitorStatsById = asyncHandler(
async (req, res, next) => {
await getMonitorStatsByIdParamValidation.validateAsync(req.params);
await getMonitorStatsByIdQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query;
const { monitorId } = req.params;
@@ -126,10 +121,10 @@ class MonitorController {
msg: this.stringService.monitorStatsById,
data: monitorStats,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMonitorStatsById"));
}
};
},
SERVICE_NAME,
"getMonitorStatsById"
);
/**
* Get hardware details for a specific monitor by ID
@@ -140,15 +135,11 @@ class MonitorController {
* @returns {Promise<Express.Response>}
* @throws {Error} - Throws error if monitor not found or other database errors
*/
getHardwareDetailsById = async (req, res, next) => {
try {
getHardwareDetailsById = asyncHandler(
async (req, res, next) => {
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorId } = req.params;
const { dateRange } = req.query;
const monitor = await this.db.getHardwareDetailsById({ monitorId, dateRange });
@@ -156,19 +147,15 @@ class MonitorController {
msg: this.stringService.monitorGetByIdSuccess,
data: monitor,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getHardwareDetailsById"));
}
};
},
SERVICE_NAME,
"getHardwareDetailsById"
);
getMonitorCertificate = async (req, res, next, fetchMonitorCertificate) => {
try {
getMonitorCertificate = asyncHandler(
async (req, res, next, fetchMonitorCertificate) => {
await getCertificateParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
}
try {
const { monitorId } = req.params;
const monitor = await this.db.getMonitorById(monitorId);
const certificate = await fetchMonitorCertificate(sslChecker, monitor);
@@ -179,10 +166,10 @@ class MonitorController {
certificateDate: new Date(certificate.validTo),
},
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMonitorCertificate"));
}
};
},
SERVICE_NAME,
"getMonitorCertificate"
);
/**
* Retrieves a monitor by its ID.
@@ -195,25 +182,20 @@ class MonitorController {
* @returns {Object} The response object with a success status, a message, and the retrieved monitor data.
* @throws {Error} If there is an error during the process, especially if the monitor is not found (404) or if there is a validation error (422).
*/
getMonitorById = async (req, res, next) => {
try {
getMonitorById = asyncHandler(
async (req, res, next) => {
await getMonitorByIdParamValidation.validateAsync(req.params);
await getMonitorByIdQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const monitor = await this.db.getMonitorById(req.params.monitorId);
return res.success({
msg: this.stringService.monitorGetByIdSuccess,
data: monitor,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMonitorById"));
}
};
},
SERVICE_NAME,
"getMonitorById"
);
/**
* Creates a new monitor and adds it to the job queue.
@@ -226,15 +208,10 @@ class MonitorController {
* @returns {Object} The response object with a success status, a message indicating the creation of the monitor, and the created monitor data.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
createMonitor = async (req, res, next) => {
try {
createMonitor = asyncHandler(
async (req, res, next) => {
await createMonitorBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { _id, teamId } = req.user;
const monitor = await this.db.createMonitor({
body: req.body,
@@ -248,10 +225,10 @@ class MonitorController {
msg: this.stringService.monitorCreate,
data: monitor,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createMonitor"));
}
};
},
SERVICE_NAME,
"createMonitor"
);
/**
* Creates bulk monitors and adds them to the job queue after parsing CSV.
@@ -263,8 +240,8 @@ class MonitorController {
* @returns {Object} The response object with a success status and message.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
createBulkMonitors = async (req, res, next) => {
try {
createBulkMonitors = asyncHandler(
async (req, res, next) => {
const { parse } = pkg;
// validate the file
@@ -310,51 +287,43 @@ class MonitorController {
return value;
},
complete: async ({ data, errors }) => {
try {
if (errors.length > 0) {
throw new Error("Error parsing CSV");
}
if (!data || data.length === 0) {
throw new Error("CSV file contains no data rows");
}
const enrichedData = data.map((monitor) => ({
userId: _id,
teamId,
...monitor,
description: monitor.description || monitor.name || monitor.url,
name: monitor.name || monitor.url,
type: monitor.type || "http",
}));
await createMonitorsBodyValidation.validateAsync(enrichedData);
try {
const monitors = await this.db.createBulkMonitors(enrichedData);
await Promise.all(
monitors.map(async (monitor, index) => {
this.jobQueue.addJob(monitor._id, monitor);
})
);
return res.success({
msg: this.stringService.bulkMonitorsCreate,
data: monitors,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createBulkMonitors"));
}
} catch (error) {
next(handleError(error, SERVICE_NAME, "createBulkMonitors"));
if (errors.length > 0) {
throw createServerError("Error parsing CSV");
}
if (!data || data.length === 0) {
throw createServerError("CSV file contains no data rows");
}
const enrichedData = data.map((monitor) => ({
userId: _id,
teamId,
...monitor,
description: monitor.description || monitor.name || monitor.url,
name: monitor.name || monitor.url,
type: monitor.type || "http",
}));
await createMonitorsBodyValidation.validateAsync(enrichedData);
const monitors = await this.db.createBulkMonitors(enrichedData);
await Promise.all(
monitors.map(async (monitor, index) => {
this.jobQueue.addJob(monitor._id, monitor);
})
);
return res.success({
msg: this.stringService.bulkMonitorsCreate,
data: monitors,
});
},
});
} catch (error) {
return next(handleError(error, SERVICE_NAME, "createBulkMonitors"));
}
};
},
SERVICE_NAME,
"createBulkMonitors"
);
/**
* Checks if the endpoint can be resolved
* @async
@@ -365,15 +334,9 @@ class MonitorController {
* @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).
*/
checkEndpointResolution = async (req, res, next) => {
try {
checkEndpointResolution = asyncHandler(
async (req, res, next) => {
await getMonitorURLByQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorURL } = req.query;
const parsedUrl = new URL(monitorURL);
const response = await axios.get(parsedUrl, {
@@ -384,10 +347,10 @@ class MonitorController {
status: response.status,
msg: response.statusText,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "checkEndpointResolution"));
}
};
},
SERVICE_NAME,
"checkEndpointResolution"
);
/**
* Deletes a monitor by its ID and also deletes associated checks, alerts, and notifications.
@@ -400,24 +363,18 @@ class MonitorController {
* @returns {Object} The response object with a success status and a message indicating the deletion of the monitor.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422) or an error in deleting associated records.
*/
deleteMonitor = async (req, res, next) => {
try {
deleteMonitor = asyncHandler(
async (req, res, next) => {
await getMonitorByIdParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const monitorId = req.params.monitorId;
const monitor = await this.db.deleteMonitor({ monitorId });
await this.jobQueue.deleteJob(monitor);
await this.db.deleteStatusPagesByMonitorId(monitor._id);
return res.success({ msg: this.stringService.monitorDelete });
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteMonitor"));
}
};
},
SERVICE_NAME,
"deleteMonitor"
);
/**
* Deletes all monitors associated with a team.
@@ -430,8 +387,8 @@ class MonitorController {
* @returns {Object} The response object with a success status and a message indicating the number of deleted monitors.
* @throws {Error} If there is an error during the deletion process.
*/
deleteAllMonitors = async (req, res, next) => {
try {
deleteAllMonitors = asyncHandler(
async (req, res, next) => {
const { teamId } = req.user;
const { monitors, deletedCount } = await this.db.deleteAllMonitors(teamId);
await Promise.all(
@@ -442,7 +399,7 @@ class MonitorController {
await this.db.deletePageSpeedChecksByMonitorId(monitor._id);
await this.db.deleteNotificationsByMonitorId(monitor._id);
} catch (error) {
logger.error({
logger.warn({
message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
@@ -452,10 +409,10 @@ class MonitorController {
})
);
return res.success({ msg: `Deleted ${deletedCount} monitors` });
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteAllMonitors"));
}
};
},
SERVICE_NAME,
"deleteAllMonitors"
);
/**
* Edits a monitor by its ID, updates its notifications, and updates its job in the job queue.
@@ -470,16 +427,10 @@ class MonitorController {
* @returns {Object} The response object with a success status, a message indicating the editing of the monitor, and the edited monitor data.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
editMonitor = async (req, res, next) => {
try {
editMonitor = asyncHandler(
async (req, res, next) => {
await getMonitorByIdParamValidation.validateAsync(req.params);
await editMonitorBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorId } = req.params;
const editedMonitor = await this.db.editMonitor(monitorId, req.body);
@@ -490,10 +441,10 @@ class MonitorController {
msg: this.stringService.monitorEdit,
data: editedMonitor,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "editMonitor"));
}
};
},
SERVICE_NAME,
"editMonitor"
);
/**
* Pauses or resumes a monitor based on its current state.
@@ -506,14 +457,10 @@ class MonitorController {
* @returns {Object} The response object with a success status, a message indicating the new state of the monitor, and the updated monitor data.
* @throws {Error} If there is an error during the process.
*/
pauseMonitor = async (req, res, next) => {
try {
pauseMonitor = asyncHandler(
async (req, res, next) => {
await pauseMonitorParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
}
try {
const monitorId = req.params.monitorId;
const monitor = await this.db.pauseMonitor({ monitorId });
monitor.isActive === true
@@ -526,10 +473,10 @@ class MonitorController {
: this.stringService.monitorPause,
data: monitor,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "pauseMonitor"));
}
};
},
SERVICE_NAME,
"pauseMonitor"
);
/**
* Adds demo monitors for a team.
@@ -542,8 +489,8 @@ class MonitorController {
* @returns {Object} The response object with a success status, a message indicating the addition of demo monitors, and the number of demo monitors added.
* @throws {Error} If there is an error during the process.
*/
addDemoMonitors = async (req, res, next) => {
try {
addDemoMonitors = asyncHandler(
async (req, res, next) => {
const { _id, teamId } = req.user;
const demoMonitors = await this.db.addDemoMonitors(_id, teamId);
await Promise.all(
@@ -554,10 +501,10 @@ class MonitorController {
msg: this.stringService.monitorDemoAdded,
data: demoMonitors.length,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "addDemoMonitors"));
}
};
},
SERVICE_NAME,
"addDemoMonitors"
);
/**
* Sends a test email to verify email delivery functionality.
@@ -570,8 +517,8 @@ class MonitorController {
* @returns {Object} The response object with a success status and the email delivery message ID.
* @throws {Error} If there is an error while sending the test email.
*/
sendTestEmail = async (req, res, next) => {
try {
sendTestEmail = asyncHandler(
async (req, res, next) => {
const { to } = req.body;
if (!to || typeof to !== "string") {
throw new Error(this.stringService.errorForValidEmailAddress);
@@ -584,29 +531,23 @@ class MonitorController {
const messageId = await this.emailService.sendEmail(to, subject, html);
if (!messageId) {
return res.error({
msg: "Failed to send test email.",
});
throw createServerError("Failed to send test email.");
}
return res.success({
msg: this.stringService.sendTestEmail,
data: { messageId },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "sendTestEmail"));
}
};
},
SERVICE_NAME,
"sendTestEmail"
);
getMonitorsByTeamId = async (req, res, next) => {
try {
getMonitorsByTeamId = asyncHandler(
async (req, res, next) => {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
}
try {
let { limit, type, page, rowsPerPage, filter, field, order } = req.query;
const teamId = req.user.teamId;
@@ -624,20 +565,16 @@ class MonitorController {
msg: this.stringService.monitorGetByTeamId,
data: monitors,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMonitorsByTeamId"));
}
};
},
SERVICE_NAME,
"getMonitorsByTeamId"
);
getMonitorsAndSummaryByTeamId = async (req, res, next) => {
try {
getMonitorsAndSummaryByTeamId = asyncHandler(
async (req, res, next) => {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
} catch (error) {
return next(handleValidationError(error, SERVICE_NAME));
}
try {
const { explain } = req;
const { type } = req.query;
const { teamId } = req.user;
@@ -651,20 +588,16 @@ class MonitorController {
msg: "OK", // TODO
data: result,
});
} catch (error) {
return next(handleError(error, SERVICE_NAME, "getMonitorsAndSummaryByTeamId"));
}
};
},
SERVICE_NAME,
"getMonitorsAndSummaryByTeamId"
);
getMonitorsWithChecksByTeamId = async (req, res, next) => {
try {
getMonitorsWithChecksByTeamId = asyncHandler(
async (req, res, next) => {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
} catch (error) {
return next(handleValidationError(error, SERVICE_NAME));
}
try {
const { explain } = req;
let { limit, type, page, rowsPerPage, filter, field, order } = req.query;
const { teamId } = req.user;
@@ -684,23 +617,23 @@ class MonitorController {
msg: "OK",
data: result,
});
} catch (error) {
return next(handleError(error, SERVICE_NAME, "getMonitorsWithChecksByTeamId"));
}
};
},
SERVICE_NAME,
"getMonitorsWithChecksByTeamId"
);
seedDb = async (req, res, next) => {
try {
seedDb = asyncHandler(
async (req, res, next) => {
const { _id, teamId } = req.user;
await seedDb(_id, teamId);
res.success({ msg: "Database seeded" });
} catch (error) {
next(handleError(error, SERVICE_NAME, "seedDb"));
}
};
},
SERVICE_NAME,
"seedDb"
);
exportMonitorsToCSV = async (req, res, next) => {
try {
exportMonitorsToCSV = asyncHandler(
async (req, res, next) => {
const { teamId } = req.user;
const monitors = await this.db.getMonitorsByTeamId({ teamId });
@@ -730,10 +663,10 @@ class MonitorController {
"Content-Disposition": "attachment; filename=monitors.csv",
},
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "exportMonitorsToCSV"));
}
};
},
SERVICE_NAME,
"exportMonitorsToCSV"
);
}
export default MonitorController;
+44 -68
View File
@@ -1,22 +1,8 @@
import {
triggerNotificationBodyValidation,
createNotificationBodyValidation,
} from "../validation/joi.js";
import { handleError, handleValidationError } from "./controllerUtils.js";
import { createNotificationBodyValidation } from "../validation/joi.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "NotificationController";
const NOTIFICATION_TYPES = {
WEBHOOK: "webhook",
TELEGRAM: "telegram",
};
const PLATFORMS = {
SLACK: "slack",
DISCORD: "discord",
TELEGRAM: "telegram",
};
class NotificationController {
constructor({ notificationService, stringService, statusService, db }) {
this.notificationService = notificationService;
@@ -25,8 +11,8 @@ class NotificationController {
this.db = db;
}
testNotification = async (req, res, next) => {
try {
testNotification = asyncHandler(
async (req, res, next) => {
const notification = req.body;
const success = await this.notificationService.sendTestNotification(notification);
@@ -41,22 +27,17 @@ class NotificationController {
return res.success({
msg: "Notification sent successfully",
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "testWebhook"));
}
};
},
SERVICE_NAME,
"testNotification"
);
createNotification = async (req, res, next) => {
try {
createNotification = asyncHandler(
async (req, res, next) => {
await createNotificationBodyValidation.validateAsync(req.body, {
abortEarly: false,
});
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const body = req.body;
const { _id, teamId } = req.user;
body.userId = _id;
@@ -66,69 +47,64 @@ class NotificationController {
msg: "Notification created successfully",
data: notification,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createNotification"));
}
};
},
SERVICE_NAME,
"createNotification"
);
getNotificationsByTeamId = async (req, res, next) => {
try {
getNotificationsByTeamId = asyncHandler(
async (req, res, next) => {
const notifications = await this.db.getNotificationsByTeamId(req.user.teamId);
return res.success({
msg: "Notifications fetched successfully",
data: notifications,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getNotificationsByTeamId"));
}
};
},
SERVICE_NAME,
"getNotificationsByTeamId"
);
deleteNotification = async (req, res, next) => {
try {
deleteNotification = asyncHandler(
async (req, res, next) => {
await this.db.deleteNotificationById(req.params.id);
return res.success({
msg: "Notification deleted successfully",
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteNotification"));
}
};
},
SERVICE_NAME,
"deleteNotification"
);
getNotificationById = async (req, res, next) => {
try {
getNotificationById = asyncHandler(
async (req, res, next) => {
const notification = await this.db.getNotificationById(req.params.id);
return res.success({
msg: "Notification fetched successfully",
data: notification,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getNotificationById"));
}
};
},
SERVICE_NAME,
"getNotificationById"
);
editNotification = async (req, res, next) => {
try {
editNotification = asyncHandler(
async (req, res, next) => {
await createNotificationBodyValidation.validateAsync(req.body, {
abortEarly: false,
});
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const notification = await this.db.editNotification(req.params.id, req.body);
return res.success({
msg: "Notification updated successfully",
data: notification,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "editNotification"));
}
};
},
SERVICE_NAME,
"editNotification"
);
testAllNotifications = async (req, res, next) => {
try {
testAllNotifications = asyncHandler(
async (req, res, next) => {
const { monitorId } = req.body;
const monitor = await this.db.getMonitorById(monitorId);
const notifications = monitor.notifications;
@@ -138,10 +114,10 @@ class NotificationController {
return res.success({
msg: "All notifications sent successfully",
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "testAllNotifications"));
}
};
},
SERVICE_NAME,
"testAllNotifications"
);
}
export default NotificationController;
+37 -43
View File
@@ -1,4 +1,4 @@
import { handleError } from "./controllerUtils.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "JobQueueController";
@@ -8,82 +8,76 @@ class JobQueueController {
this.stringService = stringService;
}
getMetrics = async (req, res, next) => {
try {
getMetrics = asyncHandler(
async (req, res, next) => {
const metrics = await this.jobQueue.getMetrics();
res.success({
msg: this.stringService.queueGetMetrics,
data: metrics,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMetrics"));
return;
}
};
},
SERVICE_NAME,
"getMetrics"
);
getJobs = async (req, res, next) => {
try {
getJobs = asyncHandler(
async (req, res, next) => {
const jobs = await this.jobQueue.getJobs();
return res.success({
msg: this.stringService.queueGetJobs,
data: jobs,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getJobs"));
return;
}
};
},
SERVICE_NAME,
"getJobs"
);
getAllMetrics = async (req, res, next) => {
try {
getAllMetrics = asyncHandler(
async (req, res, next) => {
const jobs = await this.jobQueue.getJobs();
const metrics = await this.jobQueue.getMetrics();
return res.success({
msg: this.stringService.queueGetAllMetrics,
data: { jobs, metrics },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAllMetrics"));
return;
}
};
},
SERVICE_NAME,
"getAllMetrics"
);
addJob = async (req, res, next) => {
try {
addJob = asyncHandler(
async (req, res, next) => {
await this.jobQueue.addJob(Math.random().toString(36).substring(7));
return res.success({
msg: this.stringService.queueAddJob,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "addJob"));
return;
}
};
},
SERVICE_NAME,
"addJob"
);
flushQueue = async (req, res, next) => {
try {
flushQueue = asyncHandler(
async (req, res, next) => {
const result = await this.jobQueue.flushQueues();
return res.success({
msg: this.stringService.jobQueueFlush,
data: result,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "flushQueue"));
return;
}
};
},
SERVICE_NAME,
"flushQueue"
);
checkQueueHealth = async (req, res, next) => {
try {
checkQueueHealth = asyncHandler(
async (req, res, next) => {
const stuckQueues = await this.jobQueue.checkQueueHealth();
return res.success({
msg: this.stringService.queueHealthCheck,
data: stuckQueues,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "checkQueueHealth"));
return;
}
};
},
SERVICE_NAME,
"checkQueueHealth"
);
}
export default JobQueueController;
+33 -36
View File
@@ -1,6 +1,7 @@
import { updateAppSettingsBodyValidation } from "../validation/joi.js";
import { handleValidationError, handleError } from "./controllerUtils.js";
import { sendTestEmailBodyValidation } from "../validation/joi.js";
import { asyncHandler, createServerError } from "../utils/errorUtils.js";
const SERVICE_NAME = "SettingsController";
class SettingsController {
@@ -32,45 +33,44 @@ class SettingsController {
return returnSettings;
};
getAppSettings = async (req, res, next) => {
const dbSettings = await this.settingsService.getDBSettings();
getAppSettings = asyncHandler(
async (req, res, next) => {
const dbSettings = await this.settingsService.getDBSettings();
const returnSettings = this.buildAppSettings(dbSettings);
return res.success({
msg: this.stringService.getAppSettings,
data: returnSettings,
});
};
const returnSettings = this.buildAppSettings(dbSettings);
return res.success({
msg: this.stringService.getAppSettings,
data: returnSettings,
});
},
SERVICE_NAME,
"getAppSettings"
);
updateAppSettings = async (req, res, next) => {
try {
updateAppSettings = asyncHandler(
async (req, res, next) => {
await updateAppSettingsBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const updatedSettings = await this.db.updateAppSettings(req.body);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.success({
msg: this.stringService.updateAppSettings,
data: returnSettings,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "updateAppSettings"));
}
};
},
SERVICE_NAME,
"updateAppSettings"
);
sendTestEmail = async (req, res, next) => {
try {
await sendTestEmailBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
sendTestEmail = asyncHandler(
async (req, res, next) => {
try {
await sendTestEmailBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const {
to,
systemEmailHost,
@@ -107,20 +107,17 @@ class SettingsController {
});
if (!messageId) {
return res.error({
msg: "Failed to send test email.",
});
throw createServerError("Failed to send test email.");
}
return res.success({
msg: this.stringService.sendTestEmail,
data: { messageId },
});
} catch (error) {
next(handleError(error, SERVICE_NAME));
return;
}
};
},
SERVICE_NAME,
"sendTestEmail"
);
}
export default SettingsController;
+37 -52
View File
@@ -1,10 +1,10 @@
import { handleError, handleValidationError } from "./controllerUtils.js";
import {
createStatusPageBodyValidation,
getStatusPageParamValidation,
getStatusPageQueryValidation,
imageValidation,
} from "../validation/joi.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "statusPageController";
@@ -14,16 +14,11 @@ class StatusPageController {
this.stringService = stringService;
}
createStatusPage = async (req, res, next) => {
try {
createStatusPage = asyncHandler(
async (req, res, next) => {
await createStatusPageBodyValidation.validateAsync(req.body);
await imageValidation.validateAsync(req.file);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { _id, teamId } = req.user;
const statusPage = await this.db.createStatusPage({
statusPageData: req.body,
@@ -35,21 +30,16 @@ class StatusPageController {
msg: this.stringService.statusPageCreate,
data: statusPage,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createStatusPage"));
}
};
},
SERVICE_NAME,
"createStatusPage"
);
updateStatusPage = async (req, res, next) => {
try {
updateStatusPage = asyncHandler(
async (req, res, next) => {
await createStatusPageBodyValidation.validateAsync(req.body);
await imageValidation.validateAsync(req.file);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const statusPage = await this.db.updateStatusPage(req.body, req.file);
if (statusPage === null) {
const error = new Error(this.stringService.statusPageNotFound);
@@ -60,45 +50,40 @@ class StatusPageController {
msg: this.stringService.statusPageUpdate,
data: statusPage,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "updateStatusPage"));
}
};
},
SERVICE_NAME,
"updateStatusPage"
);
getStatusPage = async (req, res, next) => {
try {
getStatusPage = asyncHandler(
async (req, res, next) => {
const statusPage = await this.db.getStatusPage();
return res.success({
msg: this.stringService.statusPageByUrl,
data: statusPage,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getStatusPage"));
}
};
},
SERVICE_NAME,
"getStatusPage"
);
getStatusPageByUrl = async (req, res, next) => {
try {
getStatusPageByUrl = asyncHandler(
async (req, res, next) => {
await getStatusPageParamValidation.validateAsync(req.params);
await getStatusPageQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const statusPage = await this.db.getStatusPageByUrl(req.params.url, req.query.type);
return res.success({
msg: this.stringService.statusPageByUrl,
data: statusPage,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getStatusPageByUrl"));
}
};
},
SERVICE_NAME,
"getStatusPageByUrl"
);
getStatusPagesByTeamId = async (req, res, next) => {
try {
getStatusPagesByTeamId = asyncHandler(
async (req, res, next) => {
const teamId = req.user.teamId;
const statusPages = await this.db.getStatusPagesByTeamId(teamId);
@@ -106,21 +91,21 @@ class StatusPageController {
msg: this.stringService.statusPageByTeamId,
data: statusPages,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getStatusPageByTeamId"));
}
};
},
SERVICE_NAME,
"getStatusPagesByTeamId"
);
deleteStatusPage = async (req, res, next) => {
try {
deleteStatusPage = asyncHandler(
async (req, res, next) => {
await this.db.deleteStatusPage(req.params.url);
return res.success({
msg: this.stringService.statusPageDelete,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteStatusPage"));
}
};
},
SERVICE_NAME,
"deleteStatusPage"
);
}
export default StatusPageController;
+1
View File
@@ -3,6 +3,7 @@ import ServiceRegistry from "../service/serviceRegistry.js";
import StringService from "../service/stringService.js";
const handleErrors = (error, req, res, next) => {
console.log("ERROR", error);
const status = error.status || 500;
const stringService = ServiceRegistry.get(StringService.SERVICE_NAME);
const message = error.message || stringService.authIncorrectPassword;
+1
View File
@@ -13,6 +13,7 @@ class DiagnosticRoutes {
);
this.router.post("/db/stats", this.diagnosticController.getDbStats);
this.router.get("/system", this.diagnosticController.getSystemStats);
}
getRouter() {
+121
View File
@@ -0,0 +1,121 @@
class AppError extends Error {
constructor(message, status = 500, service = null, method = null, details = null) {
super(message);
this.status = status;
this.service = service;
this.method = method;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
export const createError = (
message,
status = 500,
service = null,
method = null,
details = null
) => {
return new AppError(message, status, service, method, details);
};
export const createValidationError = (
message,
details = null,
service = null,
method = null
) => {
return createError(message, 422, service, method, details);
};
export const createAuthError = (
message,
details = null,
service = null,
method = null
) => {
return createError(message, 401, service, method, details);
};
export const createForbiddenError = (
message,
details = null,
service = null,
method = null
) => {
return createError(message, 403, service, method, details);
};
export const createNotFoundError = (
message,
details = null,
service = null,
method = null
) => {
return createError(message, 404, service, method, details);
};
export const createConflictError = (
message,
details = null,
service = null,
method = null
) => {
return createError(message, 409, service, method, details);
};
export const createServerError = (
message,
details = null,
service = null,
method = null
) => {
return createError(message, 500, service, method, details);
};
export const asyncHandler = (fn, serviceName, methodName) => {
return async (req, res, next) => {
try {
await fn(req, res, next);
} catch (error) {
// Handle validation errors
if (error.isJoi || error.name === "ValidationError") {
const validationError = createValidationError(
error.message,
error.details,
serviceName,
methodName
);
return next(validationError);
}
if (error instanceof AppError) {
error.service = error.service || serviceName;
error.method = error.method || methodName;
return next(error);
}
if (error.code === "23505") {
const appError = createConflictError("Resource already exists", {
originalError: error.message,
code: error.code,
});
appError.service = serviceName;
appError.method = methodName;
return next(appError);
}
// For unknown errors, create a server error
const appError = createServerError(
error.message || "An unexpected error occurred",
{ originalError: error.message, stack: error.stack }
);
appError.service = serviceName;
appError.method = methodName;
appError.stack = error.stack; // Preserve original stack
return next(appError);
}
};
};
+15 -8
View File
@@ -577,14 +577,7 @@ const createNotificationBodyValidation = joi.object({
"string.empty": "Notification name is required",
"any.required": "Notification name is required",
}),
address: joi.when("type", {
is: "email",
then: joi.string().email().required().messages({
"string.empty": "E-mail address cannot be empty",
"any.required": "E-mail address is required",
"string.email": "Please enter a valid e-mail address",
}),
}),
type: joi
.string()
.valid("email", "webhook", "slack", "discord", "pager_duty")
@@ -594,6 +587,20 @@ const createNotificationBodyValidation = joi.object({
"any.required": "Notification type is required",
"any.only": "Notification type must be email, webhook, or pager_duty",
}),
address: joi.when("type", {
is: "email",
then: joi.string().email().required().messages({
"string.empty": "E-mail address cannot be empty",
"any.required": "E-mail address is required",
"string.email": "Please enter a valid e-mail address",
}),
otherwise: joi.string().uri().required().messages({
"string.empty": "Webhook URL cannot be empty",
"any.required": "Webhook URL is required",
"string.uri": "Please enter a valid Webhook URL",
}),
}),
});
//****************************************