Merge pull request #925 from bluewave-labs/develop

Develop -> Master
This commit is contained in:
Alexander Holliday
2024-10-11 09:39:48 +08:00
committed by GitHub
81 changed files with 5126 additions and 2026 deletions

View File

@@ -20,13 +20,13 @@ import NewPasswordConfirmed from "./Pages/Auth/NewPasswordConfirmed";
import ProtectedRoute from "./Components/ProtectedRoute";
import Details from "./Pages/Monitors/Details";
import AdvancedSettings from "./Pages/AdvancedSettings";
// import Maintenance from "./Pages/Maintenance";
import Maintenance from "./Pages/Maintenance";
import withAdminCheck from "./HOC/withAdminCheck";
import withAdminProp from "./HOC/withAdminProp";
import Configure from "./Pages/Monitors/Configure";
import PageSpeed from "./Pages/PageSpeed";
import CreatePageSpeed from "./Pages/PageSpeed/CreatePageSpeed";
// import CreateNewMaintenanceWindow from "./Pages/Maintenance/CreateMaintenanceWindow";
import CreateNewMaintenanceWindow from "./Pages/Maintenance/CreateMaintenance";
import PageSpeedDetails from "./Pages/PageSpeed/Details";
import PageSpeedConfigure from "./Pages/PageSpeed/Configure";
import { ThemeProvider } from "@emotion/react";
@@ -45,7 +45,7 @@ function App() {
const MonitorDetailsWithAdminProp = withAdminProp(Details);
const PageSpeedWithAdminProp = withAdminProp(PageSpeed);
const PageSpeedDetailsWithAdminProp = withAdminProp(PageSpeedDetails);
// const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const SettingsWithAdminProp = withAdminProp(Settings);
const AdvancedSettingsWithAdminProp = withAdminProp(AdvancedSettings);
const mode = useSelector((state) => state.ui.mode);
@@ -105,14 +105,14 @@ function App() {
path="integrations"
element={<ProtectedRoute Component={Integrations} />}
/>
{/* <Route
<Route
path="maintenance"
element={<ProtectedRoute Component={MaintenanceWithAdminProp} />}
/> */}
{/* <Route
path="/maintenance/create"
/>
<Route
path="/maintenance/create/:maintenanceWindowId?"
element={<CreateNewMaintenanceWindow />}
/> */}
/>
<Route
path="settings"
element={<ProtectedRoute Component={SettingsWithAdminProp} />}

View File

@@ -1,5 +1,12 @@
import PropTypes from "prop-types";
import { Box, ListItem, Autocomplete, TextField } from "@mui/material";
import {
Box,
ListItem,
Autocomplete,
TextField,
Stack,
Typography,
} from "@mui/material";
import { useTheme } from "@emotion/react";
import SearchIcon from "../../../assets/icons/search.svg?react";
@@ -15,21 +22,52 @@ import SearchIcon from "../../../assets/icons/search.svg?react";
* @param {Object} props.sx - Additional styles to apply to the component
* @returns {JSX.Element} The rendered Search component
*/
const SearchAdornment = () => {
const theme = useTheme();
return (
<Box
mr={theme.spacing(4)}
height={16}
sx={{
"& svg": {
width: 16,
height: 16,
"& path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.2,
},
},
}}
>
<SearchIcon />
</Box>
);
};
const Search = ({
id,
options,
filteredBy,
secondaryLabel,
value,
inputValue,
handleInputChange,
handleChange,
sx,
multiple = false,
isAdorned = true,
error,
disabled,
}) => {
const theme = useTheme();
return (
<Autocomplete
multiple={multiple}
id={id}
inputValue={value}
value={value}
inputValue={inputValue}
onInputChange={(_, newValue) => {
handleInputChange(newValue);
}}
@@ -38,45 +76,45 @@ const Search = ({
}}
fullWidth
freeSolo
disabled={disabled}
disableClearable
options={options}
getOptionLabel={(option) => option[filteredBy]}
renderInput={(params) => (
<TextField
{...params}
placeholder="Type to search"
InputProps={{
...params.InputProps,
startAdornment: (
<Box
mr={theme.spacing(4)}
height={16}
sx={{
"& svg": {
width: 16,
height: 16,
"& path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.2,
},
},
}}
>
<SearchIcon />
</Box>
),
}}
sx={{
"& fieldset": {
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
"& .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
<Stack>
<TextField
{...params}
error={Boolean(error)}
placeholder="Type to search"
InputProps={{
...params.InputProps,
...(isAdorned && { startAdornment: <SearchAdornment /> }),
}}
sx={{
"& fieldset": {
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
"& .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
borderColor: theme.palette.border.light,
},
}}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.text}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
)}
filterOptions={(options, { inputValue }) => {
const filtered = options.filter((option) =>
@@ -103,7 +141,8 @@ const Search = ({
: {}
}
>
{option[filteredBy]}
{option[filteredBy] +
(secondaryLabel ? ` (${option[secondaryLabel]})` : "")}
</ListItem>
);
}}
@@ -139,12 +178,18 @@ const Search = ({
Search.propTypes = {
id: PropTypes.string,
multiple: PropTypes.bool,
options: PropTypes.array.isRequired,
filteredBy: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
secondaryLabel: PropTypes.string,
value: PropTypes.array,
inputValue: PropTypes.string.isRequired,
handleInputChange: PropTypes.func.isRequired,
handleChange: PropTypes.func,
isAdorned: PropTypes.bool,
sx: PropTypes.object,
error: PropTypes.string,
disabled: PropTypes.bool,
};
export default Search;

View File

@@ -34,6 +34,7 @@ import "./index.css";
*
* <Select
* id="frequency-id"
* name="my-name"
* label="Check frequency"
* placeholder="Select frequency"
* value={value}
@@ -51,6 +52,7 @@ const Select = ({
items,
onChange,
sx,
name = "",
}) => {
const theme = useTheme();
const itemStyles = {
@@ -77,6 +79,7 @@ const Select = ({
value={value}
onChange={onChange}
displayEmpty
name={name}
inputProps={{ id: id }}
IconComponent={KeyboardArrowDownIcon}
MenuProps={{ disableScrollLock: true }}
@@ -127,6 +130,7 @@ const Select = ({
Select.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
isHidden: PropTypes.bool,

View File

@@ -55,7 +55,7 @@ const menu = [
},
{ name: "Incidents", path: "incidents", icon: <Incidents /> },
// { name: "Status pages", path: "status", icon: <StatusPages /> },
// { name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
{ name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
// { name: "Integrations", path: "integrations", icon: <Integrations /> },
{
name: "Account",

View File

@@ -9,6 +9,9 @@ const initialState = {
team: {
rowsPerPage: 5,
},
maintenance: {
rowsPerPage: 5,
},
sidebar: {
collapsed: false,
},

View File

@@ -0,0 +1,3 @@
.create-maintenance button {
height: 35px;
}

View File

@@ -0,0 +1,570 @@
import { Box, Button, duration, Stack, Typography } from "@mui/material";
import { useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import { useEffect, useState } from "react";
import { ConfigBox } from "./styled";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
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 LoadingButton from "@mui/lab/LoadingButton";
import dayjs from "dayjs";
import Select from "../../../Components/Inputs/Select";
import Field from "../../../Components/Inputs/Field";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import CalendarIcon from "../../../assets/icons/calendar.svg?react";
import "./index.css";
import Search from "../../../Components/Inputs/Search";
import { networkService } from "../../../main";
import { logger } from "../../../Utils/Logger";
import {
MS_PER_SECOND,
MS_PER_MINUTE,
MS_PER_HOUR,
MS_PER_DAY,
MS_PER_WEEK,
} from "../../../Utils/timeUtils";
import { useNavigate, useParams } from "react-router-dom";
const getDurationAndUnit = (durationInMs) => {
if (durationInMs % MS_PER_DAY === 0) {
return {
duration: (durationInMs / MS_PER_DAY).toString(),
durationUnit: "days",
};
} else if (durationInMs % MS_PER_HOUR === 0) {
return {
duration: (durationInMs / MS_PER_HOUR).toString(),
durationUnit: "hours",
};
} else if (durationInMs % MS_PER_MINUTE === 0) {
return {
duration: (durationInMs / MS_PER_MINUTE).toString(),
durationUnit: "minutes",
};
} else {
return {
duration: (durationInMs / MS_PER_SECOND).toString(),
durationUnit: "seconds",
};
}
};
const MS_LOOKUP = {
seconds: MS_PER_SECOND,
minutes: MS_PER_MINUTE,
hours: MS_PER_HOUR,
days: MS_PER_DAY,
weeks: MS_PER_WEEK,
};
const REPEAT_LOOKUP = {
none: 0,
daily: MS_PER_DAY,
weekly: MS_PER_DAY * 7,
};
const REVERSE_REPEAT_LOOKUP = {
0: "none",
[MS_PER_DAY]: "daily",
[MS_PER_WEEK]: "weekly",
};
const repeatConfig = [
{ _id: 0, name: "Don't repeat", value: "none" },
{
_id: 1,
name: "Repeat daily",
value: "daily",
},
{ _id: 2, name: "Repeat weekly", value: "weekly" },
];
const durationConfig = [
{ _id: 0, name: "seconds" },
{ _id: 1, name: "minutes" },
{ _id: 2, name: "hours" },
{
_id: 3,
name: "days",
},
];
const getValueById = (config, id) => {
const item = config.find((config) => config._id === id);
return item ? (item.value ? item.value : item.name) : null;
};
const getIdByValue = (config, name) => {
const item = config.find((config) => {
if (config.value) {
return config.value === name;
} else {
return config.name === name;
}
});
return item ? item._id : null;
};
const CreateMaintenance = () => {
const { maintenanceWindowId } = useParams();
const navigate = useNavigate();
const theme = useTheme();
const { user, authToken } = useSelector((state) => state.auth);
const [monitors, setMonitors] = useState([]);
const [search, setSearch] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
repeat: "none",
startDate: dayjs(),
startTime: dayjs(),
duration: "",
durationUnit: "seconds",
name: "",
monitors: [],
});
const [errors, setErrors] = useState({});
useEffect(() => {
const fetchMonitors = async () => {
setIsLoading(true);
try {
const response = await networkService.getMonitorsByTeamId({
authToken: authToken,
teamId: user.teamId,
limit: -1,
types: ["http", "ping", "pagespeed"],
});
const monitors = response.data.data.monitors;
setMonitors(monitors);
if (maintenanceWindowId === undefined) {
return;
}
const res = await networkService.getMaintenanceWindowById({
authToken: authToken,
maintenanceWindowId: maintenanceWindowId,
});
const maintenanceWindow = res.data.data;
const { name, start, end, repeat, monitorId } = maintenanceWindow;
const startTime = dayjs(start);
const endTime = dayjs(end);
const durationInMs = endTime.diff(startTime, "milliseconds").toString();
const { duration, durationUnit } = getDurationAndUnit(durationInMs);
const monitor = monitors.find((monitor) => monitor._id === monitorId);
setForm({
...form,
name,
repeat: REVERSE_REPEAT_LOOKUP[repeat],
startDate: startTime,
startTime,
duration,
durationUnit,
monitors: monitor ? [monitor] : [],
});
} catch (error) {
createToast({ body: "Failed to fetch data" });
logger.error("Failed to fetch monitors", error);
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [authToken, user]);
const buildErrors = (prev, id, error) => {
const updatedErrors = { ...prev };
if (error) {
updatedErrors[id] = error.details[0].message;
} else {
delete updatedErrors[id];
}
return updatedErrors;
};
const handleSearch = (value) => {
setSearch(value);
};
const handleSelectMonitors = (monitors) => {
setForm({ ...form, monitors });
const { error } = maintenanceWindowValidation.validate(
{ monitors },
{ abortEarly: false }
);
setErrors((prev) => {
return buildErrors(prev, "monitors", error);
});
};
const handleFormChange = (key, value) => {
setForm({ ...form, [key]: value });
const { error } = maintenanceWindowValidation.validate(
{ [key]: value },
{ abortEarly: false }
);
setErrors((prev) => {
return buildErrors(prev, key, error);
});
};
const handleTimeChange = (key, newTime) => {
setForm({ ...form, [key]: newTime });
const { error } = maintenanceWindowValidation.validate(
{ [key]: newTime },
{ abortEarly: false }
);
setErrors((prev) => {
return buildErrors(prev, key, error);
});
};
const handleSubmit = async () => {
const { error } = maintenanceWindowValidation.validate(form, {
abortEarly: false,
});
// If errors, return early
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
logger.error(error);
return;
}
// Build timestamp for maintenance window from startDate and startTime
const start = dayjs(form.startDate)
.set("hour", form.startTime.hour())
.set("minute", form.startTime.minute());
// Build end timestamp for maintenance window
const MS_MULTIPLIER = MS_LOOKUP[form.durationUnit];
const durationInMs = form.duration * MS_MULTIPLIER;
const end = start.add(durationInMs);
// Get repeat value in milliseconds
const repeat = REPEAT_LOOKUP[form.repeat];
const submit = {
monitors: form.monitors.map((monitor) => monitor._id),
name: form.name,
start: start.toISOString(),
end: end.toISOString(),
repeat,
};
if (repeat === 0) {
submit.expiry = end;
}
const requestConfig = { authToken: authToken, maintenanceWindow: submit };
if (maintenanceWindowId !== undefined) {
requestConfig.maintenanceWindowId = maintenanceWindowId;
}
const request =
maintenanceWindowId === undefined
? networkService.createMaintenanceWindow(requestConfig)
: networkService.editMaintenanceWindow(requestConfig);
try {
setIsLoading(true);
await request;
createToast({
body: "Successfully created maintenance window",
});
navigate("/maintenance");
} catch (error) {
createToast({
body: `Failed to ${
maintenanceWindowId === undefined ? "create" : "edit"
} maintenance window`,
});
logger.error(error);
} finally {
setIsLoading(false);
}
};
return (
<Box className="create-maintenance">
<Breadcrumbs
list={[
{ name: "maintenance", path: "/maintenance" },
{
name: maintenanceWindowId === undefined ? "create" : "edit",
path: `/maintenance/create`,
},
]}
/>
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Box>
<Typography component="h1" variant="h1">
<Typography component="span" fontSize="inherit">
{`${maintenanceWindowId === undefined ? "Create a" : "Edit"}`}{" "}
</Typography>
<Typography
component="span"
variant="h2"
fontSize="inherit"
fontWeight="inherit"
>
maintenance{" "}
</Typography>
<Typography component="span" fontSize="inherit">
window
</Typography>
</Typography>
<Typography component="h2" variant="body2" fontSize={14}>
Your pings won&apos;t be sent during this time frame
</Typography>
</Box>
<ConfigBox direction="row">
<Typography component="h2" variant="h2">
General Settings
</Typography>
<Box
px={theme.spacing(14)}
borderLeft={1}
borderLeftColor={theme.palette.border.light}
>
<Select
id="repeat"
name="maintenance-repeat"
label="Maintenance Repeat"
value={getIdByValue(repeatConfig, form.repeat)}
onChange={(event) => {
handleFormChange(
"repeat",
getValueById(repeatConfig, event.target.value)
);
}}
items={repeatConfig}
/>
<Stack gap={theme.spacing(2)} mt={theme.spacing(16)}>
<Typography component="h3">Date</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
id="startDate"
disablePast
disableHighlightToday
value={form.startDate}
slots={{ openPickerIcon: CalendarIcon }}
slotProps={{
switchViewButton: { sx: { display: "none" } },
nextIconButton: { sx: { ml: theme.spacing(2) } },
field: {
sx: {
width: "fit-content",
"& > .MuiOutlinedInput-root": {
flexDirection: "row-reverse",
},
"& input": {
height: 34,
p: 0,
pr: theme.spacing(5),
},
"& fieldset": {
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: theme.palette.border.dark,
},
},
},
inputAdornment: { sx: { ml: 0, px: 3 } },
openPickerButton: {
sx: {
py: 0,
mr: 0,
"& path": {
stroke: theme.palette.other.icon,
strokeWidth: 1.1,
},
"&:hover": { backgroundColor: "transparent" },
},
},
}}
sx={{}}
onChange={(newDate) => {
handleTimeChange("startDate", newDate);
}}
error={errors["startDate"]}
/>
</LocalizationProvider>
</Stack>
</Box>
</ConfigBox>
<ConfigBox>
<Stack direction="row">
<Box>
<Typography component="h2" variant="h2">
Start time
</Typography>
<Typography>
All dates and times are in GMT+0 time zone.
</Typography>
</Box>
<Stack direction="row">
<LocalizationProvider dateAdapter={AdapterDayjs}>
<MobileTimePicker
id="startTime"
value={form.startTime}
onChange={(newTime) => {
handleTimeChange("startTime", newTime);
}}
slotProps={{
nextIconButton: { sx: { ml: theme.spacing(2) } },
field: {
sx: {
width: "fit-content",
"& > .MuiOutlinedInput-root": {
flexDirection: "row-reverse",
},
"& input": {
height: 34,
p: 0,
pl: theme.spacing(5),
},
"& fieldset": {
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: theme.palette.border.dark,
},
},
},
}}
error={errors["startTime"]}
/>
</LocalizationProvider>
</Stack>
</Stack>
<Stack direction="row">
<Box>
<Typography component="h2" variant="h2">
Duration
</Typography>
</Box>
<Stack direction="row" spacing={theme.spacing(8)}>
<Field
type="number"
id="duration"
value={form.duration}
onChange={(event) => {
handleFormChange("duration", event.target.value);
}}
error={errors["duration"]}
/>
<Select
id="durationUnit"
value={getIdByValue(durationConfig, form.durationUnit)}
items={durationConfig}
onChange={(event) => {
handleFormChange(
"durationUnit",
getValueById(durationConfig, event.target.value)
);
}}
error={errors["durationUnit"]}
/>
</Stack>
</Stack>
</ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
fontSize={16}
my={theme.spacing(6)}
>
Monitor related settings
</Typography>
<ConfigBox>
<Stack direction="row">
<Box>
<Typography component="h2" variant="h2">
Friendly name
</Typography>
</Box>
<Box>
<Field
id="name"
placeholder="Maintenance at __ : __ for ___ minutes"
value={form.name}
onChange={(event) => {
handleFormChange("name", event.target.value);
}}
error={errors["name"]}
/>
</Box>
</Stack>
<Stack direction="row">
<Box>
<Typography component="h2" variant="h2">
Add monitors
</Typography>
</Box>
<Box>
<Search
id={"monitors"}
multiple={true}
isAdorned={false}
options={monitors ? monitors : []}
filteredBy="name"
secondaryLabel={"type"}
inputValue={search}
value={form.monitors}
handleInputChange={handleSearch}
handleChange={handleSelectMonitors}
error={errors["monitors"]}
disabled={maintenanceWindowId !== undefined}
/>
</Box>
</Stack>
</ConfigBox>
</Box>
<Box ml="auto" display="inline-block">
<Button
variant="contained"
color="secondary"
onClick={() => navigate("/maintenance")}
sx={{ mr: theme.spacing(6) }}
>
Cancel
</Button>
<LoadingButton
loading={isLoading}
variant="contained"
color="primary"
onClick={handleSubmit}
disabled={false}
>
{`${
maintenanceWindowId === undefined
? "Create maintenance"
: "Edit maintenance"
}`}
</LoadingButton>
</Box>
</Stack>
</Box>
);
};
export default CreateMaintenance;

View File

@@ -0,0 +1,25 @@
import { Stack, styled } from "@mui/material";
export const ConfigBox = styled(Stack)(({ theme }) => ({
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.background.main,
"& > *": { padding: theme.spacing(14) },
"& > :first-of-type, & > .MuiStack-root > div:first-of-type": {
flex: 0.6,
},
"& > div:last-of-type, & > .MuiStack-root > div:last-of-type": {
flex: 1,
},
"& > .MuiStack-root > div:first-of-type": { paddingRight: theme.spacing(14) },
"& > .MuiStack-root > div:last-of-type": {
paddingLeft: theme.spacing(14),
},
"& h2": { fontSize: 13.5, fontWeight: 500 },
"& h3, & p": {
color: theme.palette.text.tertiary,
},
"& h3": { fontWeight: 500 },
}));

View File

@@ -1,71 +0,0 @@
.maintenance-options .MuiStack-root .MuiStack-root.select-wrapper,
.maintenance-options .MuiStack-root .field {
width: 60%;
max-width: 380px;
}
.date-picker .MuiOutlinedInput-root,
.time-picker .MuiOutlinedInput-root {
height: 34px;
font-size: var(--env-var-font-size-medium);
}
.date-picker .MuiOutlinedInput-root {
width: 140px;
}
.time-picker .MuiOutlinedInput-root {
width: 90px;
}
.maintenance-options .MuiInputAdornment-outlined .MuiIconButton-root svg {
width: 20px;
}
.duration-config .field-text .MuiTextField-root,
.maintenance-options .duration-config .field {
width: 70px;
min-width: 70px;
max-width: 70px;
}
.duration-config > .MuiStack-root.select-wrapper {
width: 100px;
}
.duration-config
> .MuiStack-root.select-wrapper
> .MuiInputBase-root:has(> .MuiSelect-select) {
width: 100px;
min-width: 100px;
}
.duration-config > .select-wrapper .select-component > .MuiSelect-select {
text-transform: none;
}
.maintenance-options .add-monitors-fields .field.field-text {
width: 100%;
}
.MuiPopover-paper.MuiMenu-paper.select-dropdown
.MuiMenu-list
.MuiButtonBase-root {
text-transform: none;
}
.date-picker
.MuiOutlinedInput-root
.MuiInputAdornment-outlined
.MuiIconButton-root {
outline: none;
&:hover {
background-color: transparent;
}
}
.create-maintenance-window .MuiStack-root .MuiStack-root > button {
width: 110px;
height: 34px;
}

View File

@@ -1,298 +0,0 @@
import { Box, Button, Stack, Typography } from "@mui/material";
import "./index.css";
import { useState } from "react";
import Back from "../../../assets/icons/left-arrow-long.svg?react";
import Select from "../../../Components/Inputs/Select";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs from "dayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
import Field from "../../../Components/Inputs/Field";
import { maintenanceWindowValidation } from "../../../Validation/validation";
import { logger } from "../../../Utils/Logger";
import { createToast } from "../../../Utils/toastUtils";
import { useTheme } from "@emotion/react";
const directory = {
title: "Create a maintenance window",
description: "Your pings wont be sent in this time frame.",
};
const repeatOptions = [
{ _id: 1, name: "Don't repeat" },
{ _id: 2, name: "Repeat daily" },
{ _id: 3, name: "Repeat weekly" },
];
const configOptionTitle = (title, description) => {
return (
<Stack width="40%" gap={1}>
<Typography
style={{
fontWeight: 600,
fontSize: "var(--env-var-font-size-medium)",
}}
>
{title}
</Typography>
{description && (
<Typography
style={{
fontSize: "var(--env-var-font-size-small)",
}}
>
{description}
</Typography>
)}
</Stack>
);
};
const durationOptions = [
{ _id: "minutes", name: "minutes" },
{ _id: "hours", name: "hours" },
{ _id: "days", name: "days" },
];
const CreateNewMaintenanceWindow = () => {
const theme = useTheme();
const [values, setValues] = useState({
repeat: 1,
date: dayjs(),
startTime: dayjs(),
duration: "60",
unit: "minutes",
displayName: "",
AddMonitors: "",
});
const [errors, setErrors] = useState({});
const handleChange = (event, name) => {
const { value } = event.target;
setValues((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async () => {
const data = {
repeat: values.repeat,
date: values.date.format("YYYY-MM-DD"),
startTime: values.startTime.format("HH:mm"),
duration: values.duration,
unit: values.unit,
displayName: values.displayName,
addMonitors: values.AddMonitors,
};
const { error } = maintenanceWindowValidation.validate(data, {
abortEarly: false,
});
logger.log("error: ", error);
if (!error || error.details.length === 0) {
setErrors({});
} else {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data",
});
logger.error("Validation errors:", error.details);
}
logger.log("Submitting data: ", data);
};
const configOptions = [
{
title: "Repeat",
component: (
<Select
onChange={(e) => handleChange(e, "repeat")}
id="repeat-mode"
items={repeatOptions}
value={values.repeat}
/>
),
},
{
title: "Date",
component: (
<LocalizationProvider
className="date-localization-provider"
dateAdapter={AdapterDayjs}
>
<DatePicker
className="date-picker"
defaultValue={values.date}
onChange={(date) =>
handleChange({ target: { value: date } }, "date")
}
/>
</LocalizationProvider>
),
},
{
title: "Start time",
component: (
<LocalizationProvider
className="time-localization-provider"
dateAdapter={AdapterDayjs}
>
<MobileTimePicker
className="time-picker"
defaultValue={values.startTime}
onChange={(time) =>
handleChange({ target: { value: time } }, "startTime")
}
/>
</LocalizationProvider>
),
},
{
title: "Duration",
component: (
<Stack
className="duration-config"
gap={theme.spacing(5)}
direction="row"
>
<Field
id="duration-value"
placeholder="60"
onChange={(e) => handleChange(e, "duration")}
value={values.duration}
error={errors.duration}
type="number"
/>
<Select
onChange={(e) => handleChange(e, "unit")}
id="duration-unit"
items={durationOptions}
value={values.unit}
/>
</Stack>
),
},
{
title: "Display name",
component: (
<Field
id="display-name"
placeholder="Maintanence at __ : __ for ___ minutes"
value={values.displayName}
onChange={(e) => handleChange(e, "displayName")}
error={errors.displayName}
/>
),
},
{
title: "Add monitors",
component: (
<Stack
className="add-monitors-fields"
sx={{ width: "60%", maxWidth: "380px" }}
gap={theme.spacing(5)}
>
<Field
id="add-monitors"
placeholder="Start typing to search for current monitors"
value={values.AddMonitors}
onChange={(e) => handleChange(e, "AddMonitors")}
error={errors.addMonitors}
/>
<Typography
sx={{
width: "fit-content",
fontSize: "var(--env-var-font-size-small)",
borderBottom: `1px dashed ${theme.palette.primary.main}`,
paddingBottom: "4px",
}}
>
Add all monitors to this maintenance window
</Typography>
</Stack>
),
},
];
return (
<div className="create-maintenance-window">
<Stack gap={theme.spacing(10)}>
<Button
variant="contained"
color="secondary"
sx={{
width: "100px",
height: "30px",
gap: "10px",
}}
>
<Back />
Back
</Button>
<Box>
<Typography
sx={{
fontSize: "var(--env-var-font-size-large)",
fontWeight: 600,
color: theme.palette.text.secondary,
}}
>
{directory.title}
</Typography>
<Typography sx={{ fontSize: "var(--env-var-font-size-medium)" }}>
{directory.description}
</Typography>
</Box>
<Stack
className="maintenance-options"
gap={theme.spacing(20)}
paddingY={theme.spacing(15)}
paddingX={theme.spacing(20)}
sx={{
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.main,
}}
>
{configOptions.map((item, index) => (
<Stack key={index} display="-webkit-inline-box">
{item.title && configOptionTitle(item.title, item.description)}
{item.component && item.component}
</Stack>
))}
</Stack>
<Stack justifyContent="end" direction="row" marginTop={3}>
<Button
variant="text"
color="info"
sx={{
"&:hover": {
backgroundColor: "transparent",
},
}}
>
Cancel
</Button>
<Button variant="contained" color="primary" onClick={handleSubmit}>
Create
</Button>
</Stack>
</Stack>
</div>
);
};
export default CreateNewMaintenanceWindow;

View File

@@ -0,0 +1,229 @@
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import {
Button,
IconButton,
Menu,
MenuItem,
Modal,
Stack,
Typography,
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import { logger } from "../../../../Utils/Logger";
import Settings from "../../../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
import { networkService } from "../../../../main";
import { createToast } from "../../../../Utils/toastUtils";
const ActionsMenu = ({ isAdmin, maintenanceWindow, updateCallback }) => {
maintenanceWindow;
const { authToken } = useSelector((state) => state.auth);
const [anchorEl, setAnchorEl] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const theme = useTheme();
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
try {
setIsLoading(true);
await networkService.deleteMaintenanceWindow({
authToken,
maintenanceWindowId: maintenanceWindow._id,
});
updateCallback();
createToast({ body: "Maintenance window deleted successfully." });
} catch (error) {
createToast({ body: "Failed to delete maintenance window." });
logger.error("Failed to delete maintenance window", error);
} finally {
setIsLoading(false);
}
setIsOpen(false);
};
const handlePause = async () => {
try {
setIsLoading(true);
const data = {
active: !maintenanceWindow.active,
};
await networkService.editMaintenanceWindow({
authToken,
maintenanceWindowId: maintenanceWindow._id,
maintenanceWindow: data,
});
updateCallback();
} catch (error) {
logger.error(error);
createToast({ body: "Failed to pause maintenance window." });
} finally {
setIsLoading(false);
}
};
const handleEdit = () => {
navigate(`/maintenance/create/${maintenanceWindow._id}`);
};
const openMenu = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.other.icon,
},
}}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
},
},
},
}}
>
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
handleEdit();
}}
>
Edit
</MenuItem>
<MenuItem
onClick={(e) => {
handlePause();
closeMenu(e);
e.stopPropagation();
}}
>
{`${maintenanceWindow.active === true ? "Pause" : "Resume"}`}
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
</Menu>
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
open={isOpen}
onClose={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-maintenance" component="h2" variant="h2">
Do you really want to remove this maintenance window?
</Typography>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
Cancel
</Button>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
onClick={(e) => {
e.stopPropagation(e);
handleRemove(e);
}}
>
Delete
</LoadingButton>
</Stack>
</Stack>
</Modal>
</>
);
};
ActionsMenu.propTypes = {
maintenanceWindow: PropTypes.object,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
};
export default ActionsMenu;

View File

@@ -0,0 +1,363 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
} from "@mui/material";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
import ActionsMenu from "./ActionsMenu";
import { useState, useEffect, memo, useCallback, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import LeftArrowDouble from "../../../assets/icons/left-arrow-double.svg?react";
import RightArrowDouble from "../../../assets/icons/right-arrow-double.svg?react";
import LeftArrow from "../../../assets/icons/left-arrow.svg?react";
import RightArrow from "../../../assets/icons/right-arrow.svg?react";
import SelectorVertical from "../../../assets/icons/selector-vertical.svg?react";
import { formatDurationRounded } from "../../../Utils/timeUtils";
import { StatusLabel } from "../../../Components/Label";
import { setRowsPerPage } from "../../../Features/UI/uiSlice";
import dayjs from "dayjs";
/**
* Component for pagination actions (first, previous, next, last).
*
* @component
* @param {Object} props
* @param {number} props.count - Total number of items.
* @param {number} props.page - Current page number.
* @param {number} props.rowsPerPage - Number of rows per page.
* @param {function} props.onPageChange - Callback function to handle page change.
*
* @returns {JSX.Element} Pagination actions component.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
};
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
const MaintenanceTable = ({
page,
setPage,
sort,
setSort,
maintenanceWindows,
maintenanceWindowCount,
updateCallback,
}) => {
const { rowsPerPage } = useSelector((state) => state.ui.maintenance);
const theme = useTheme();
const dispatch = useDispatch();
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const getTimeToNextWindow = (startTime, endTime, repeat) => {
//1. Advance time closest to next window as possible
const now = dayjs();
let start = dayjs(startTime);
let end = dayjs(endTime);
if (repeat > 0) {
// Advance time closest to next window as possible
while (start.isBefore(now) && end.isBefore(now)) {
start = start.add(repeat, "milliseconds");
end = end.add(repeat, "milliseconds");
}
}
//Check if we are in a window
if (now.isAfter(start) && now.isBefore(end)) {
return "In maintenance window";
}
if (start.isAfter(now)) {
const diffInMinutes = start.diff(now, "minutes");
const diffInHours = start.diff(now, "hours");
const diffInDays = start.diff(now, "days");
if (diffInMinutes < 60) {
return diffInMinutes + " minutes";
} else if (diffInHours < 24) {
return diffInHours + " hours";
} else if (diffInDays < 7) {
return diffInDays + " days";
} else {
return diffInDays + " days";
}
}
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "maintenance",
})
);
setPage(0);
};
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(
page * rowsPerPage + rowsPerPage,
maintenanceWindowCount
);
return `${start} - ${end}`;
};
const handleSort = async (field) => {
let order = "";
if (sort.field !== field) {
order = "desc";
} else {
order = sort.order === "asc" ? "desc" : "asc";
}
setSort({ field, order });
};
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
<Box>
Maintenance Window Name
<span
style={{
visibility: sort.field === "name" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
{" "}
<Box width="max-content">
{" "}
Status
<span
style={{
visibility:
sort.field === "active" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell>Next Window</TableCell>
<TableCell>Repeat</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{maintenanceWindows.map((maintenanceWindow) => {
const text = maintenanceWindow.active ? "active" : "paused";
const status = maintenanceWindow.active ? "up" : "paused";
return (
<TableRow key={maintenanceWindow._id}>
<TableCell>{maintenanceWindow.name}</TableCell>
<TableCell>
<StatusLabel
status={status}
text={text}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{getTimeToNextWindow(
maintenanceWindow.start,
maintenanceWindow.end,
maintenanceWindow.repeat
)}
</TableCell>
<TableCell>
{maintenanceWindow.repeat === 0
? "N/A"
: formatDurationRounded(maintenanceWindow.repeat)}
</TableCell>
<TableCell>
<ActionsMenu
maintenanceWindow={maintenanceWindow}
updateCallback={updateCallback}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
>
<Typography px={theme.spacing(2)} variant="body2" sx={{ opacity: 0.7 }}>
Showing {getRange()} of {maintenanceWindowCount} maintenance window(s)
</Typography>
<TablePagination
component="div"
count={maintenanceWindowCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
disableScrollLock: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
</>
);
};
MaintenanceTable.propTypes = {
isAdmin: PropTypes.bool,
page: PropTypes.number,
setPage: PropTypes.func,
rowsPerPage: PropTypes.number,
setRowsPerPage: PropTypes.func,
sort: PropTypes.object,
setSort: PropTypes.func,
maintenanceWindows: PropTypes.array,
maintenanceWindowCount: PropTypes.number,
updateCallback: PropTypes.func,
};
const MemoizedMaintenanceTable = memo(MaintenanceTable);
export default MemoizedMaintenanceTable;

View File

@@ -1,36 +0,0 @@
.maintenance-checklist-main {
max-width: 350px;
min-height: 390px;
margin: auto;
padding: 5px;
}
.maintenance-image {
width: 280px;
height: 150px;
background-color: whitesmoke;
border-radius: 10px;
}
.maintenance-title {
color: var(--secondary-color-light);
font-size: 16px;
font-weight: bold;
margin-top: 40px;
margin-bottom: 30px;
}
.checklist-item {
font-size: 13px;
color: var(--secondary-color-light);
display: flex;
margin-bottom: 10px;
}
.checklist-item-text {
margin-left: 10px;
}
.maintenance-checklist-main .maintenance-checklist-button {
margin-top: 30px;
}

View File

@@ -1,10 +1,48 @@
import { Box } from "@mui/material";
import { Box, Stack, Button } from "@mui/material";
import { useTheme } from "@emotion/react";
import Fallback from "../../Components/Fallback";
import { useState, useEffect } from "react";
import "./index.css";
import MaintenanceTable from "./MaintenanceTable";
import { useSelector } from "react-redux";
import { networkService } from "../../main";
import Breadcrumbs from "../../Components/Breadcrumbs";
import { useNavigate } from "react-router-dom";
const Maintenance = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const { authToken } = useSelector((state) => state.auth);
const { rowsPerPage } = useSelector((state) => state.ui.maintenance);
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [maintenanceWindowCount, setMaintenanceWindowCount] = useState(0);
const [page, setPage] = useState(0);
const [sort, setSort] = useState({});
const [updateTrigger, setUpdateTrigger] = useState(false);
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
useEffect(() => {
const fetchMaintenanceWindows = async () => {
try {
const response = await networkService.getMaintenanceWindowsByTeamId({
authToken: authToken,
page: page,
rowsPerPage: rowsPerPage,
});
const { maintenanceWindows, maintenanceWindowCount } =
response.data.data;
setMaintenanceWindows(maintenanceWindows);
setMaintenanceWindowCount(maintenanceWindowCount);
} catch (error) {
console.log(error);
}
};
fetchMaintenanceWindows();
}, [authToken, page, rowsPerPage, updateTrigger]);
return (
<Box
@@ -21,16 +59,52 @@ const Maintenance = ({ isAdmin }) => {
},
}}
>
<Fallback
title="maintenance window"
checks={[
"Mark your maintenance periods",
"Eliminate any misunderstandings",
"Stop sending alerts in maintenance windows",
]}
link="/maintenance/create"
isAdmin={isAdmin}
/>
{maintenanceWindows.length > 0 && (
<Stack gap={theme.spacing(8)}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Breadcrumbs
list={[{ name: "maintenance", path: "/maintenance" }]}
/>
<Button
variant="contained"
color="primary"
onClick={() => {
navigate("/maintenance/create");
}}
sx={{ fontWeight: 500 }}
>
Create maintenance window
</Button>
</Stack>
<MaintenanceTable
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sort={sort}
setSort={setSort}
maintenanceWindows={maintenanceWindows}
maintenanceWindowCount={maintenanceWindowCount}
updateCallback={handleActionMenuDelete}
/>
</Stack>
)}
{isAdmin && maintenanceWindows.length === 0 && (
<Fallback
title="maintenance window"
checks={[
"Mark your maintenance periods",
"Eliminate any misunderstandings",
"Stop sending alerts in maintenance windows",
]}
link="/maintenance/create"
isAdmin={isAdmin}
/>
)}
</Box>
);
};

View File

@@ -1,34 +0,0 @@
.create-monitor p.MuiTypography-root,
.create-monitor button.MuiButtonBase-root {
font-size: var(--env-var-font-size-medium);
}
.create-monitor h6.MuiTypography-root,
.create-monitor .MuiBox-root .field + p.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
opacity: 0.8;
}
.create-monitor button.MuiButtonBase-root {
height: 34px;
}
.create-monitor .error-container {
position: relative;
}
.create-monitor .error-container p.MuiTypography-root.input-error {
opacity: 0.8;
position: absolute;
top: 0;
}
.create-monitor .MuiStack-root:has(span.MuiTypography-root.input-error) {
position: relative;
}
.create-monitor span.MuiTypography-root.input-error {
position: absolute;
top: 100%;
}
.create-monitor .MuiStack-root .MuiButtonGroup-root button {
font-size: var(--env-var-font-size-small);
height: 28px;
padding: 8px;
}

View File

@@ -112,7 +112,7 @@ const CreateMonitor = () => {
{ [name]: value },
{ abortEarly: false }
);
console.log(error);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;

View File

@@ -136,7 +136,7 @@ const Monitors = ({ isAdmin }) => {
<Search
options={monitorState?.monitorsSummary?.monitors ?? []}
filteredBy="name"
value={search}
inputValue={search}
handleInputChange={handleSearch}
/>
</Box>

View File

@@ -1,5 +1,7 @@
import store from "../store";
const LOG_LEVEL = import.meta.env.VITE_APP_LOG_LEVEL || "debug";
const NO_OP = () => {};
class Logger {
constructor() {
let logLevel = LOG_LEVEL;
@@ -11,29 +13,37 @@ class Logger {
}
updateLogLevel(logLevel) {
const NO_OP = () => {};
if (logLevel === "none") {
this.info = NO_OP;
this.error = NO_OP;
this.warn = NO_OP;
this.log = NO_OP;
return;
}
this.error = console.error.bind(console);
if (logLevel === "error") {
this.error = console.error.bind(console);
this.info = NO_OP;
this.warn = NO_OP;
this.log = NO_OP;
return;
}
this.warn = console.warn.bind(console);
if (logLevel === "warn") {
this.error = console.error.bind(console);
this.warn = console.warn.bind(console);
this.info = NO_OP;
this.log = NO_OP;
return;
}
if (logLevel === "info") {
this.error = console.error.bind(console);
this.warn = console.warn.bind(console);
this.info = console.info.bind(console);
this.log = NO_OP;
return;
}
this.log = console.log.bind(console);
}
cleanup() {

View File

@@ -1,10 +1,14 @@
import axios from "axios";
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
const FALLBACK_BASE_URL = "http://localhost:5000/api/v1";
import { clearAuthState } from "../Features/Auth/authSlice";
import { clearUptimeMonitorState } from "../Features/UptimeMonitors/uptimeMonitorsSlice";
import { logger } from "./Logger";
class NetworkService {
constructor(store) {
constructor(store, dispatch, navigate) {
this.store = store;
this.dispatch = dispatch;
this.navigate = navigate;
let baseURL = BASE_URL;
this.axiosInstance = axios.create();
this.setBaseUrl(baseURL);
@@ -22,9 +26,10 @@ class NetworkService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
logger.error(error);
if (error.response && error.response.status === 401) {
logger.error("Invalid token received");
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
navigate("/login");
}
return Promise.reject(error);
}
@@ -125,7 +130,7 @@ class NetworkService {
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {string} config.teamId - The ID of the team whose monitors are to be retrieved.
* @param {number} [config.limit] - The maximum number of monitors to retrieve.
* @param {number} [config.limit] - The maximum number of checks to retrieve. 0 for all, -1 for none
* @param {Array<string>} [config.types] - The types of monitors to retrieve.
* @param {string} [config.status] - The status of the monitors to retrieve.
* @param {string} [config.checkOrder] - The order in which to sort the retrieved monitors.
@@ -685,6 +690,155 @@ class NetworkService {
},
});
}
/**
* ************************************
* Creates a maintenance window
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {Object} config.maintenanceWindow - The maintenance window object to be sent in the request body.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async createMaintenanceWindow(config) {
return this.axiosInstance.post(
`/maintenance-window`,
config.maintenanceWindow,
{
headers: {
Authorization: `Bearer ${config.authToken}`,
"Content-Type": "application/json",
},
}
);
}
/**
* ************************************
* Edits a maintenance window
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {Object} config.maintenanceWindowId - The maintenance window id.
* @param {Object} config.maintenanceWindow - The maintenance window object to be sent in the request body.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async editMaintenanceWindow(config) {
return this.axiosInstance.put(
`/maintenance-window/${config.maintenanceWindowId}`,
config.maintenanceWindow,
{
headers: {
Authorization: `Bearer ${config.authToken}`,
"Content-Type": "application/json",
},
}
);
}
/**
* ************************************
* Get maintenance window by id
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {string} [config.maintenanceWindowId] - The id of the maintenance window to delete.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async getMaintenanceWindowById(config) {
const { authToken, maintenanceWindowId } = config;
return this.axiosInstance.get(
`/maintenance-window/${maintenanceWindowId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
}
/**
* ************************************
* Get maintenance windows by teamId
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {string} [config.active] - The status of the maintenance windows to retrieve.
* @param {number} [config.page] - The page number for pagination.
* @param {number} [config.rowsPerPage] - The number of rows per page for pagination.
* @param {string} [config.field] - The field to sort by.
* @param {string} [config.order] - The order in which to sort the field.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async getMaintenanceWindowsByTeamId(config) {
const { authToken, active, page, rowsPerPage, field, order } = config;
const params = new URLSearchParams();
if (active) params.append("status", active);
if (page) params.append("page", page);
if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
if (field) params.append("field", field);
if (order) params.append("order", order);
return this.axiosInstance.get(
`/maintenance-window/team?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
}
/**
* ************************************
* Delete maintenance window by id
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {string} [config.maintenanceWindowId] - The id of the maintenance window to delete.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async deleteMaintenanceWindow(config) {
const { authToken, maintenanceWindowId } = config;
return this.axiosInstance.delete(
`/maintenance-window/${maintenanceWindowId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
}
}
export default NetworkService;
let networkService;
export const setNetworkService = (service) => {
networkService = service;
};
export { networkService };

View File

@@ -0,0 +1,15 @@
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router";
import { setNetworkService } from "./NetworkService";
import NetworkService from "./NetworkService";
import { store } from "../store";
const NetworkServiceProvider = ({ children }) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const networkService = new NetworkService(store, dispatch, navigate);
setNetworkService(networkService);
return children;
};
export default NetworkServiceProvider;

View File

@@ -4,6 +4,12 @@ import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);
export const MS_PER_SECOND = 1000;
export const MS_PER_MINUTE = 60 * MS_PER_SECOND;
export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
export const MS_PER_DAY = 24 * MS_PER_HOUR;
export const MS_PER_WEEK = MS_PER_DAY * 7;
export const formatDuration = (ms) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);

View File

@@ -1,4 +1,5 @@
import joi from "joi";
import dayjs from "dayjs";
const nameSchema = joi
.string()
@@ -113,50 +114,33 @@ const imageValidation = joi.object({
}),
});
const maintenanceWindowValidation = joi.object({
repeat: joi.number().valid(1, 2, 3).required().messages({
"number.base": "Repeat must be a number.",
"any.only": "Repeat must be one of [1, 2, 3].",
"any.required": "Repeat is required.",
}),
date: joi.date().required().messages({
"date.base": "Date must be a valid date.",
"any.required": "Date is required.",
}),
startTime: joi.string().required().messages({
"string.base": "Start time must be a valid time.",
"any.required": "Start time is required.",
}),
duration: joi.number().required().messages({
"number.empty": "duration is required.",
"number.base": "Duration must be a number.",
"any.required": "Duration is required.",
}),
unit: joi.string().valid("minutes", "hours", "days").required().messages({
"string.base": "Unit must be a string.",
"any.only": "Unit must be one of ['minutes', 'hours', 'days'].",
"any.required": "Unit is required.",
}),
displayName: joi.string().max(50).required().messages({
"string.empty": "Display name is required.",
"string.max": "Display name must be less than 50 characters long",
}),
addMonitors: joi.string().max(50).required().messages({
"string.empty": "Add monitors is required.",
"string.max": "Add monitors must be less than 50 characters long",
}),
});
const settingsValidation = joi.object({
ttl: joi.number().required().messages({
"string.empty": "TTL is required",
}),
});
const dayjsValidator = (value, helpers) => {
if (!dayjs(value).isValid()) {
return helpers.error("any.invalid");
}
return value;
};
const maintenanceWindowValidation = joi.object({
repeat: joi.string(),
startDate: joi.custom(dayjsValidator, "Day.js date validation"),
startTime: joi.custom(dayjsValidator, "Day.js date validation"),
duration: joi.number(),
durationUnit: joi.string(),
name: joi.string(),
monitors: joi.array().min(1),
});
export {
credentials,
imageValidation,
monitorValidation,
maintenanceWindowValidation,
settingsValidation,
maintenanceWindowValidation,
};

View File

@@ -0,0 +1,3 @@
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.5 8.33342H1.5M12.3333 1.66675V5.00008M5.66667 1.66675V5.00008M5.5 18.3334H12.5C13.9001 18.3334 14.6002 18.3334 15.135 18.0609C15.6054 17.8212 15.9878 17.4388 16.2275 16.9684C16.5 16.4336 16.5 15.7335 16.5 14.3334V7.33342C16.5 5.93328 16.5 5.23322 16.2275 4.69844C15.9878 4.22803 15.6054 3.84558 15.135 3.6059C14.6002 3.33341 13.9001 3.33341 12.5 3.33341H5.5C4.09987 3.33341 3.3998 3.33341 2.86502 3.6059C2.39462 3.84558 2.01217 4.22803 1.77248 4.69844C1.5 5.23322 1.5 5.93328 1.5 7.33341V14.3334C1.5 15.7335 1.5 16.4336 1.77248 16.9684C2.01217 17.4388 2.39462 17.8212 2.86502 18.0609C3.3998 18.3334 4.09987 18.3334 5.5 18.3334Z" stroke="#344054" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 830 B

View File

@@ -1,18 +1,21 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
import { BrowserRouter as Router, HashRouter } from "react-router-dom";
import { BrowserRouter as Router } from "react-router-dom";
import { Provider } from "react-redux";
import { persistor, store } from "./store";
import { PersistGate } from "redux-persist/integration/react";
import NetworkService from "./Utils/NetworkService.js";
export const networkService = new NetworkService(store);
import NetworkServiceProvider from "./Utils/NetworkServiceProvider.jsx";
import { networkService } from "./Utils/NetworkService";
export { networkService };
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<Router>
<App />
<NetworkServiceProvider>
<App />
</NetworkServiceProvider>
</Router>
</PersistGate>
</Provider>

2
Server/.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules
.env
*.log
*.sh
.nyc_output
coverage

8
Server/.mocharc.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
require: ["chai/register-expect.js"], // Include Chai's "expect" interface globally
spec: "tests/**/*.test.js", // Specify test files
timeout: 5000, // Set test-case timeout in milliseconds
recursive: true, // Include subdirectories
reporter: "spec", // Use the "spec" reporter
exit: true, // Force Mocha to quit after tests complete
};

8
Server/.nycrc Normal file
View File

@@ -0,0 +1,8 @@
{
"all": true,
"include": ["**/*.js"],
"exclude": ["**/*.test.js"],
"reporter": ["html", "text", "lcov"],
"sourceMap": false,
"instrument": true
}

View File

@@ -1,19 +1,20 @@
const {
registrationBodyValidation,
loginValidation,
editUserParamValidation,
editUserBodyValidation,
recoveryValidation,
recoveryTokenValidation,
newPasswordValidation,
registrationBodyValidation,
loginValidation,
editUserParamValidation,
editUserBodyValidation,
recoveryValidation,
recoveryTokenValidation,
newPasswordValidation,
} = require("../validation/joi");
const logger = require("../utils/logger");
require("dotenv").config();
const {errorMessages, successMessages} = require("../utils/messages");
const { errorMessages, successMessages } = require("../utils/messages");
const jwt = require("jsonwebtoken");
const SERVICE_NAME = "AuthController";
const {getTokenFromHeaders} = require("../utils/utils");
const { getTokenFromHeaders } = require("../utils/utils");
const crypto = require("crypto");
const { handleValidationError, handleError } = require("./controllerUtils");
/**
* Creates and returns JWT token with an arbitrary payload
@@ -24,14 +25,12 @@ const crypto = require("crypto");
* @throws {Error}
*/
const issueToken = (payload, appSettings) => {
try {
const tokenTTL = appSettings.jwtTTL ? appSettings.jwtTTL : "2h";
return jwt.sign(payload, appSettings.jwtSecret, {expiresIn: tokenTTL});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "issueToken") : null;
throw error;
}
try {
const tokenTTL = appSettings.jwtTTL ? appSettings.jwtTTL : "2h";
return jwt.sign(payload, appSettings.jwtSecret, { expiresIn: tokenTTL });
} catch (error) {
throw handleError(error, SERVICE_NAME, "issueToken");
}
};
/**
@@ -47,56 +46,61 @@ const issueToken = (payload, appSettings) => {
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
const registerUser = async (req, res, next) => {
// joi validation
try {
await registrationBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
// joi validation
try {
await registrationBodyValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
// Create a new user
try {
const { inviteToken } = req.body;
// If superAdmin exists, a token should be attached to all further register requests
const superAdminExists = await req.db.checkSuperadmin(req, res);
if (superAdminExists) {
await req.db.getInviteTokenAndDelete(inviteToken);
} else {
// This is the first account, create JWT secret to use if one is not supplied by env
const jwtSecret = crypto.randomBytes(64).toString("hex");
await req.db.updateAppSettings({ jwtSecret });
}
// Create a new user
try {
const {inviteToken} = req.body;
// If superAdmin exists, a token should be attached to all further register requests
const superAdminExists = await req.db.checkSuperadmin(req, res);
if (superAdminExists) {
await req.db.getInviteTokenAndDelete(inviteToken);
} else {
// This is the first account, create JWT secret to use if one is not supplied by env
const jwtSecret = crypto.randomBytes(64).toString("hex");
await req.db.updateAppSettings({jwtSecret});
}
const newUser = await req.db.insertUser({...req.body}, req.file);
logger.info(successMessages.AUTH_CREATE_USER, {
service: SERVICE_NAME, userId: newUser._id,
const newUser = await req.db.insertUser({ ...req.body }, req.file);
logger.info(successMessages.AUTH_CREATE_USER, {
service: SERVICE_NAME,
userId: newUser._id,
});
const userForToken = { ...newUser._doc };
delete userForToken.profileImage;
delete userForToken.avatarImage;
const appSettings = await req.settingsService.getSettings();
const token = issueToken(userForToken, appSettings);
req.emailService
.buildAndSendEmail(
"welcomeEmailTemplate",
{ name: newUser.firstName },
newUser.email,
"Welcome to Uptime Monitor"
)
.catch((error) => {
logger.error("Error sending welcome email", {
service: SERVICE_NAME,
error: error.message,
});
});
const userForToken = {...newUser._doc};
delete userForToken.profileImage;
delete userForToken.avatarImage;
const appSettings = await req.settingsService.getSettings();
const token = issueToken(userForToken, appSettings);
req.emailService
.buildAndSendEmail("welcomeEmailTemplate", {name: newUser.firstName}, newUser.email, "Welcome to Uptime Monitor")
.catch((error) => {
logger.error("Error sending welcome email", {
service: SERVICE_NAME, error: error.message,
});
});
return res.status(200).json({
success: true, msg: successMessages.AUTH_CREATE_USER, data: {user: newUser, token: token},
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "registerController") : null;
next(error);
}
return res.status(200).json({
success: true,
msg: successMessages.AUTH_CREATE_USER,
data: { user: newUser, token: token },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "registerController"));
}
};
/**
@@ -112,47 +116,44 @@ const registerUser = async (req, res, next) => {
* @throws {Error} If there is an error during the process, especially if there is a validation error (422) or the password is incorrect.
*/
const loginUser = async (req, res, next) => {
try {
await loginValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
try {
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
const user = await req.db.getUserByEmail(email);
// Compare password
const match = await user.comparePassword(password);
if (match !== true) {
next(new Error(errorMessages.AUTH_INCORRECT_PASSWORD));
return;
}
try {
const {email, password} = req.body;
// Check if user exists
const user = await req.db.getUserByEmail(email);
// Compare password
const match = await user.comparePassword(password);
if (match !== true) {
next(new Error(errorMessages.AUTH_INCORRECT_PASSWORD));
return;
}
// Remove password from user object. Should this be abstracted to DB layer?
const userWithoutPassword = { ...user._doc };
delete userWithoutPassword.password;
delete userWithoutPassword.avatarImage;
// Remove password from user object. Should this be abstracted to DB layer?
const userWithoutPassword = {...user._doc};
delete userWithoutPassword.password;
delete userWithoutPassword.avatarImage;
// Happy path, return token
const appSettings = await req.settingsService.getSettings();
const token = issueToken(userWithoutPassword, appSettings);
// reset avatar image
userWithoutPassword.avatarImage = user.avatarImage;
// Happy path, return token
const appSettings = req.settingsService.getSettings();
const token = issueToken(userWithoutPassword, appSettings);
// reset avatar image
userWithoutPassword.avatarImage = user.avatarImage;
return res.status(200).json({
success: true, msg: successMessages.AUTH_LOGIN_USER, data: {user: userWithoutPassword, token: token},
});
} catch (error) {
error.status = 500;
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "loginController") : null;
next(error);
}
return res.status(200).json({
success: true,
msg: successMessages.AUTH_LOGIN_USER,
data: { user: userWithoutPassword, token: token },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "loginController"));
}
};
/**
@@ -170,60 +171,58 @@ const loginUser = async (req, res, next) => {
* @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).
*/
const editUser = async (req, res, next) => {
try {
await editUserParamValidation.validateAsync(req.params);
await editUserBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
try {
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(errorMessages.AUTH_UNAUTHORIZED);
error.status = 401;
error.service = SERVICE_NAME;
next(error);
return;
}
try {
// Change Password check
if (req.body.password && req.body.newPassword) {
// Get token from headers
const token = getTokenFromHeaders(req.headers);
// Get email from token
const { jwtSecret } = req.settingsService.getSettings();
const { email } = jwt.verify(token, jwtSecret);
// Add user email to body for DB operation
req.body.email = email;
// Get user
const user = await req.db.getUserByEmail(email);
// Compare passwords
const match = await user.comparePassword(req.body.password);
// If not a match, throw a 403
if (!match) {
const error = new Error(errorMessages.AUTH_INCORRECT_PASSWORD);
error.status = 403;
next(error);
return;
}
// If a match, update the password
req.body.password = req.body.newPassword;
}
// TODO is this neccessary any longer? Verify ownership middleware should handle this
if (req.params.userId !== req.user._id.toString()) {
const error = new Error(errorMessages.AUTH_UNAUTHORIZED);
error.status = 401;
error.service = SERVICE_NAME;
next(error);
return;
}
try {
// Change Password check
if (req.body.password && req.body.newPassword) {
// Get token from headers
const token = getTokenFromHeaders(req.headers);
// Get email from token
const {jwtSecret} = req.settingsService.getSettings();
const {email} = jwt.verify(token, jwtSecret);
// Add user email to body for DB operation
req.body.email = email;
// Get user
const user = await req.db.getUserByEmail(email);
// Compare passwords
const match = await user.comparePassword(req.body.password);
// If not a match, throw a 403
if (!match) {
const error = new Error(errorMessages.AUTH_INCORRECT_PASSWORD);
error.status = 403;
next(error)
return;
}
// If a match, update the password
req.body.password = req.body.newPassword;
}
const updatedUser = await req.db.updateUser(req, res);
return res.status(200).json({
success: true, msg: successMessages.AUTH_UPDATE_USER, data: updatedUser,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "userEditController") : null;
next(error);
}
const updatedUser = await req.db.updateUser(req, res);
return res.status(200).json({
success: true,
msg: successMessages.AUTH_UPDATE_USER,
data: updatedUser,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "userEditController"));
}
};
/**
@@ -236,16 +235,16 @@ const editUser = async (req, res, next) => {
* @throws {Error} If there is an error during the process.
*/
const checkSuperAdminExists = async (req, res, next) => {
try {
const superAdminExists = await req.db.checkSuperadmin(req, res);
return res.status(200).json({
success: true, msg: successMessages.AUTH_ADMIN_EXISTS, data: superAdminExists,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "checkSuperadminController") : null;
next(error);
}
try {
const superAdminExists = await req.db.checkSuperadmin(req, res);
return res.status(200).json({
success: true,
msg: successMessages.AUTH_ADMIN_EXISTS,
data: superAdminExists,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "checkSuperadminController"));
}
};
/**
@@ -260,41 +259,44 @@ const checkSuperAdminExists = async (req, res, next) => {
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
const requestRecovery = async (req, res, next) => {
try {
await recoveryValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const {email} = req.body;
const user = await req.db.getUserByEmail(email);
if (user) {
const recoveryToken = await req.db.requestRecoveryToken(req, res);
const name = user.firstName;
const email = req.body.email;
const {clientHost} = req.settingsService.getSettings();
const url = `${clientHost}/set-new-password/${recoveryToken.token}`;
const msgId = await req.emailService.buildAndSendEmail("passwordResetTemplate", {
name,
email,
url
}, email, "Bluewave Uptime Password Reset");
return res.status(200).json({
success: true, msg: successMessages.AUTH_CREATE_RECOVERY_TOKEN, data: msgId,
});
}
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "recoveryRequestController") : null;
next(error);
try {
await recoveryValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const { email } = req.body;
const user = await req.db.getUserByEmail(email);
if (user) {
const recoveryToken = await req.db.requestRecoveryToken(req, res);
const name = user.firstName;
const email = req.body.email;
const { clientHost } = req.settingsService.getSettings();
const url = `${clientHost}/set-new-password/${recoveryToken.token}`;
const msgId = await req.emailService.buildAndSendEmail(
"passwordResetTemplate",
{
name,
email,
url,
},
email,
"Bluewave Uptime Password Reset"
);
return res.status(200).json({
success: true,
msg: successMessages.AUTH_CREATE_RECOVERY_TOKEN,
data: msgId,
});
}
} catch (error) {
next(handleError(error, SERVICE_NAME, "recoveryRequestController"));
}
};
/**
@@ -309,26 +311,23 @@ const requestRecovery = async (req, res, next) => {
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
const validateRecovery = async (req, res, next) => {
try {
await recoveryTokenValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
await recoveryTokenValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
await req.db.validateRecoveryToken(req, res);
return res.status(200).json({
success: true, msg: successMessages.AUTH_VERIFY_RECOVERY_TOKEN,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "validateRecoveryTokenController") : null;
next(error);
}
try {
await req.db.validateRecoveryToken(req, res);
return res.status(200).json({
success: true,
msg: successMessages.AUTH_VERIFY_RECOVERY_TOKEN,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "validateRecoveryTokenController"));
}
};
/**
@@ -344,28 +343,26 @@ const validateRecovery = async (req, res, next) => {
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
const resetPassword = async (req, res, next) => {
try {
await newPasswordValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const user = await req.db.resetPassword(req, res);
try {
await newPasswordValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const user = await req.db.resetPassword(req, res);
const appSettings = await req.settingsService.getSettings();
const token = issueToken(user._doc, appSettings);
res.status(200).json({
success: true, msg: successMessages.AUTH_RESET_PASSWORD, data: {user, token},
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "resetPasswordController") : null;
next(error);
}
const appSettings = await req.settingsService.getSettings();
const token = issueToken(user._doc, appSettings);
res.status(200).json({
success: true,
msg: successMessages.AUTH_RESET_PASSWORD,
data: { user, token },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "resetPasswordController"));
}
};
/**
@@ -378,73 +375,73 @@ const resetPassword = async (req, res, next) => {
* @throws {Error} If user validation fails or user is not found in the database.
*/
const deleteUser = async (req, res, next) => {
try {
const token = getTokenFromHeaders(req.headers);
const decodedToken = jwt.decode(token);
const {email} = decodedToken;
try {
const token = getTokenFromHeaders(req.headers);
const decodedToken = jwt.decode(token);
const { email } = decodedToken;
// Check if the user exists
const user = await req.db.getUserByEmail(email);
if (!user) {
next(new Error(errorMessages.DB_USER_NOT_FOUND));
return;
}
// 1. Find all the monitors associated with the team ID if superadmin
const result = await req.db.getMonitorsByTeamId({
params: {teamId: user.teamId},
});
if (user.role.includes("superadmin")) {
//2. Remove all jobs, delete checks and alerts
result?.monitors.length > 0 && (await Promise.all(result.monitors.map(async (monitor) => {
await req.jobQueue.deleteJob(monitor);
await req.db.deleteChecks(monitor._id);
await req.db.deletePageSpeedChecksByMonitorId(monitor._id);
await req.db.deleteNotificationsByMonitorId(monitor._id);
})));
// 3. Delete team
await req.db.deleteTeam(user.teamId);
// 4. Delete all other team members
await req.db.deleteAllOtherUsers();
// 5. Delete each monitor
await req.db.deleteMonitorsByUserId(user._id);
}
// 6. Delete the user by id
await req.db.deleteUser(user._id);
return res.status(200).json({
success: true, msg: successMessages.AUTH_DELETE_USER,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteUserController") : null;
next(error);
// Check if the user exists
const user = await req.db.getUserByEmail(email);
if (!user) {
next(new Error(errorMessages.DB_USER_NOT_FOUND));
return;
}
// 1. Find all the monitors associated with the team ID if superadmin
const result = await req.db.getMonitorsByTeamId({
params: { teamId: user.teamId },
});
if (user.role.includes("superadmin")) {
//2. Remove all jobs, delete checks and alerts
result?.monitors.length > 0 &&
(await Promise.all(
result.monitors.map(async (monitor) => {
await req.jobQueue.deleteJob(monitor);
await req.db.deleteChecks(monitor._id);
await req.db.deletePageSpeedChecksByMonitorId(monitor._id);
await req.db.deleteNotificationsByMonitorId(monitor._id);
})
));
// 3. Delete team
await req.db.deleteTeam(user.teamId);
// 4. Delete all other team members
await req.db.deleteAllOtherUsers();
// 5. Delete each monitor
await req.db.deleteMonitorsByUserId(user._id);
}
// 6. Delete the user by id
await req.db.deleteUser(user._id);
return res.status(200).json({
success: true,
msg: successMessages.AUTH_DELETE_USER,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteUserController"));
}
};
const getAllUsers = async (req, res) => {
try {
const allUsers = await req.db.getAllUsers(req, res);
res
.status(200)
.json({success: true, msg: "Got all users", data: allUsers});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getAllUsersController") : null;
next(error);
}
try {
const allUsers = await req.db.getAllUsers(req, res);
res
.status(200)
.json({ success: true, msg: "Got all users", data: allUsers });
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAllUsersController"));
}
};
module.exports = {
registerUser,
loginUser,
editUser,
checkSuperadminExists: checkSuperAdminExists,
requestRecovery,
validateRecovery,
resetPassword,
deleteUser,
getAllUsers,
registerUser,
loginUser,
editUser,
checkSuperadminExists: checkSuperAdminExists,
requestRecovery,
validateRecovery,
resetPassword,
deleteUser,
getAllUsers,
};

View File

@@ -13,17 +13,14 @@ const { successMessages } = require("../utils/messages");
const jwt = require("jsonwebtoken");
const { getTokenFromHeaders } = require("../utils/utils");
const SERVICE_NAME = "checkController";
const { handleValidationError, handleError } = require("./controllerUtils");
const createCheck = async (req, res, next) => {
try {
await createCheckParamValidation.validateAsync(req.params);
await createCheckBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -34,9 +31,7 @@ const createCheck = async (req, res, next) => {
.status(200)
.json({ success: true, msg: successMessages.CHECK_CREATE, data: check });
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "createCheck") : null;
next(error);
next(handleError(error, SERVICE_NAME, "createCheck"));
}
};
@@ -45,11 +40,7 @@ const getChecks = async (req, res, next) => {
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -62,9 +53,7 @@ const getChecks = async (req, res, next) => {
data: { checksCount, checks },
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getChecks") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getChecks"));
}
};
@@ -73,11 +62,7 @@ const getTeamChecks = async (req, res, next) => {
await getTeamChecksParamValidation.validateAsync(req.params);
await getTeamChecksQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
@@ -88,9 +73,7 @@ const getTeamChecks = async (req, res, next) => {
data: checkData,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getTeamChecks") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getTeamChecks"));
}
};
@@ -98,11 +81,7 @@ const deleteChecks = async (req, res, next) => {
try {
await deleteChecksParamValidation.validateAsync(req.params);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -114,9 +93,7 @@ const deleteChecks = async (req, res, next) => {
data: { deletedCount },
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteChecks") : null;
next(error);
next(handleError(error, SERVICE_NAME, "deleteChecks"));
}
};
@@ -124,10 +101,7 @@ const deleteChecksByTeamId = async (req, res, next) => {
try {
await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteChecksByTeam") : null;
error.status = 422;
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -139,9 +113,7 @@ const deleteChecksByTeamId = async (req, res, next) => {
data: { deletedCount },
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteChecksByTeamId") : null;
next(error);
next(handleError(error, SERVICE_NAME, "deleteChecksByTeamId"));
}
};
@@ -151,12 +123,7 @@ const updateChecksTTL = async (req, res, next) => {
try {
await updateChecksTTLBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "updateChecksTTL") : null;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -172,9 +139,7 @@ const updateChecksTTL = async (req, res, next) => {
msg: successMessages.CHECK_UPDATE_TTL,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "updateTTL") : null;
next(error);
next(handleError(error, SERVICE_NAME, "updateTTL"));
}
};

View File

@@ -0,0 +1,19 @@
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, code = 500) => {
error.code === undefined ? (error.code = code) : null;
error.service === undefined ? (error.service = serviceName) : null;
error.method === undefined ? (error.method = method) : null;
return error;
};
module.exports = {
handleValidationError,
handleError,
};

View File

@@ -1,22 +1,23 @@
const {
inviteRoleValidation,
inviteBodyValidation,
inviteVerificationBodyValidation,
inviteRoleValidation,
inviteBodyValidation,
inviteVerificationBodyValidation,
} = require("../validation/joi");
const logger = require("../utils/logger");
require("dotenv").config();
const jwt = require("jsonwebtoken");
const { handleError, handleValidationError } = require("./controllerUtils");
const SERVICE_NAME = "inviteController";
const getTokenFromHeaders = (headers) => {
const authorizationHeader = headers.authorization;
if (!authorizationHeader) throw new Error("No auth headers");
const authorizationHeader = headers.authorization;
if (!authorizationHeader) throw new Error("No auth headers");
const parts = authorizationHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer")
throw new Error("Invalid auth headers");
const parts = authorizationHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer")
throw new Error("Invalid auth headers");
return parts[1];
return parts[1];
};
/**
@@ -33,79 +34,65 @@ const getTokenFromHeaders = (headers) => {
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
const issueInvitation = async (req, res, next) => {
try {
// Only admins can invite
const token = getTokenFromHeaders(req.headers);
const { role, firstname, teamId } = jwt.decode(token);
req.body.teamId = teamId;
try {
// 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) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
const inviteToken = await req.db.requestInviteToken({...req.body});
const {clientHost} = req.settingsService.getSettings();
req.emailService
.buildAndSendEmail(
"employeeActivationTemplate",
{
name: firstname,
link: `${clientHost}/register/${inviteToken.token}`,
},
req.body.email,
"Welcome to Uptime Monitor"
)
.catch((error) => {
logger.error("Error sending invite email", {
service: SERVICE_NAME,
error: error.message,
});
});
return res
.status(200)
.json({success: true, msg: "Invite sent", data: inviteToken});
await inviteRoleValidation.validateAsync({ roles: role });
await inviteBodyValidation.validateAsync(req.body);
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "inviteController") : null;
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
const inviteToken = await req.db.requestInviteToken({ ...req.body });
const { clientHost } = req.settingsService.getSettings();
req.emailService
.buildAndSendEmail(
"employeeActivationTemplate",
{
name: firstname,
link: `${clientHost}/register/${inviteToken.token}`,
},
req.body.email,
"Welcome to Uptime Monitor"
)
.catch((error) => {
logger.error("Error sending invite email", {
service: SERVICE_NAME,
error: error.message,
});
});
return res
.status(200)
.json({ success: true, msg: "Invite sent", data: inviteToken });
} catch (error) {
next(handleError(error, SERVICE_NAME, "inviteController"));
}
};
const inviteVerifyController = async (req, res, next) => {
try {
await inviteVerificationBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
await inviteVerificationBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const invite = await req.db.getInviteToken(req.body.token);
res
.status(200)
.json({status: "success", msg: "Invite verified", data: invite});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined
? (error.method = "inviteVerifyController")
: null;
next(error);
}
try {
const invite = await req.db.getInviteToken(req.body.token);
res
.status(200)
.json({ status: "success", msg: "Invite verified", data: invite });
} catch (error) {
next(handleError(error, SERVICE_NAME, "inviteVerifyController"));
}
};
module.exports = {
inviteController: issueInvitation,
inviteVerifyController,
inviteController: issueInvitation,
inviteVerifyController,
};

View File

@@ -1,82 +1,96 @@
const {
createMaintenanceWindowParamValidation,
createMaintenanceWindowBodyValidation,
getMaintenanceWindowsByUserIdParamValidation,
editMaintenanceWindowByIdParamValidation,
editMaintenanceByIdWindowBodyValidation,
getMaintenanceWindowByIdParamValidation,
getMaintenanceWindowsByMonitorIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
deleteMaintenanceWindowByIdParamValidation,
} = require("../validation/joi");
const {successMessages} = require("../utils/messages")
const jwt = require("jsonwebtoken");
const { getTokenFromHeaders } = require("../utils/utils");
const { successMessages } = require("../utils/messages");
const { handleValidationError, handleError } = require("./controllerUtils");
const SERVICE_NAME = "maintenanceWindowController";
const createMaintenanceWindow = async (req, res, next) => {
const createMaintenanceWindows = async (req, res, next) => {
try {
await createMaintenanceWindowParamValidation.validateAsync(req.params);
await createMaintenanceWindowBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const data = {
monitorId: req.params.monitorId,
...req.body,
};
if (data.oneTime === true) {
data.expiry = data.end;
}
const maintenanceWindow = await req.db.createMaintenanceWindow(data);
const token = getTokenFromHeaders(req.headers);
const { jwtSecret } = req.settingsService.getSettings();
const { teamId } = jwt.verify(token, jwtSecret);
const monitorIds = req.body.monitors;
const dbTransactions = monitorIds.map((monitorId) => {
return req.db.createMaintenanceWindow({
teamId,
monitorId,
name: req.body.name,
active: req.body.active ? req.body.active : true,
repeat: req.body.repeat,
start: req.body.start,
end: req.body.end,
});
});
await Promise.all(dbTransactions);
return res.status(201).json({
success: true,
msg: successMessages.MAINTENANCE_WINDOW_CREATE,
data: maintenanceWindow,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined
? (error.method = "createMaintenanceWindow")
: null;
next(error);
next(handleError(error, SERVICE_NAME, "createMaintenanceWindow"));
}
};
const getMaintenanceWindowsByUserId = async (req, res, next) => {
const getMaintenanceWindowById = async (req, res, next) => {
try {
await getMaintenanceWindowsByUserIdParamValidation.validateAsync(
req.params
);
await getMaintenanceWindowByIdParamValidation.validateAsync(req.params);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const maintenanceWindows = await req.db.getMaintenanceWindowsByUserId(
req.params.userId
const maintenanceWindow = await req.db.getMaintenanceWindowById(
req.params.id
);
return res.status(200).json({
success: true,
msg: successMessages.MAINTENANCE_WINDOW_GET_BY_ID,
data: maintenanceWindow,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMaintenanceWindowById"));
}
};
const getMaintenanceWindowsByTeamId = async (req, res, next) => {
try {
await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const token = getTokenFromHeaders(req.headers);
const { jwtSecret } = req.settingsService.getSettings();
const { teamId } = jwt.verify(token, jwtSecret);
const maintenanceWindows = await req.db.getMaintenanceWindowsByTeamId(
teamId,
req.query
);
return res.status(201).json({
success: true,
msg: successMessages.MAINTENANCE_WINDOW_GET_BY_USER,
msg: successMessages.MAINTENANCE_WINDOW_GET_BY_TEAM,
data: maintenanceWindows,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined
? (error.method = "getMaintenanceWindowsByUserId")
: null;
next(error);
next(handleError(error, SERVICE_NAME, "getMaintenanceWindowsByUserId"));
}
};
@@ -86,11 +100,7 @@ const getMaintenanceWindowsByMonitorId = async (req, res, next) => {
req.params
);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -105,15 +115,56 @@ const getMaintenanceWindowsByMonitorId = async (req, res, next) => {
data: maintenanceWindows,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined
? (error.method = "getMaintenanceWindowsByMonitorId")
: null;
next(error);
next(handleError(error, SERVICE_NAME, "getMaintenanceWindowsByMonitorId"));
}
};
module.exports = {
createMaintenanceWindow,
getMaintenanceWindowsByUserId,
getMaintenanceWindowsByMonitorId,
const deleteMaintenanceWindow = async (req, res, next) => {
try {
await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
await req.db.deleteMaintenanceWindowById(req.params.id);
return res.status(201).json({
success: true,
msg: successMessages.MAINTENANCE_WINDOW_DELETE,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteMaintenanceWindow"));
}
};
const editMaintenanceWindow = async (req, res, next) => {
try {
await editMaintenanceWindowByIdParamValidation.validateAsync(req.params);
await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const editedMaintenanceWindow = await req.db.editMaintenanceWindowById(
req.params.id,
req.body
);
return res.status(200).json({
success: true,
msg: successMessages.MAINTENANCE_WINDOW_EDIT,
data: editedMaintenanceWindow,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "editMaintenanceWindow"));
}
};
module.exports = {
createMaintenanceWindows,
getMaintenanceWindowById,
getMaintenanceWindowsByTeamId,
getMaintenanceWindowsByMonitorId,
deleteMaintenanceWindow,
editMaintenanceWindow,
};

View File

@@ -19,6 +19,7 @@ const { errorMessages, successMessages } = require("../utils/messages");
const jwt = require("jsonwebtoken");
const { getTokenFromHeaders } = require("../utils/utils");
const logger = require("../utils/logger");
const { handleError, handleValidationError } = require("./controllerUtils");
/**
* Returns all monitors
@@ -38,9 +39,7 @@ const getAllMonitors = async (req, res, next) => {
data: monitors,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getAllMonitors") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getAllMonitors"));
}
};
@@ -58,10 +57,7 @@ const getMonitorStatsById = async (req, res, next) => {
await getMonitorStatsByIdParamValidation.validateAsync(req.params);
await getMonitorStatsByIdQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -73,9 +69,7 @@ const getMonitorStatsById = async (req, res, next) => {
data: monitorStats,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getMonitorStatsById") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getMonitorStatsById"));
}
};
@@ -83,10 +77,7 @@ const getMonitorCertificate = async (req, res, next) => {
try {
await getCertificateParamValidation.validateAsync(req.params);
} catch (error) {
error.status = 422;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
}
try {
@@ -110,11 +101,7 @@ const getMonitorCertificate = async (req, res, next) => {
});
}
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined
? (error.method = "getMonitorCertificate")
: null;
next(error);
next(handleError(error, SERVICE_NAME, "getMonitorCertificate"));
}
};
@@ -134,10 +121,7 @@ const getMonitorById = async (req, res, next) => {
await getMonitorByIdParamValidation.validateAsync(req.params);
await getMonitorByIdQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -154,9 +138,7 @@ const getMonitorById = async (req, res, next) => {
data: monitor,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getMonitorById") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getMonitorById"));
}
};
@@ -179,14 +161,8 @@ const getMonitorsAndSummaryByTeamId = async (req, res, next) => {
req.params
);
await getMonitorsAndSummaryByTeamIdQueryValidation.validateAsync(req.query);
//validation
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.method === undefined? error.method = "getMonitorsAndSummaryByTeamId": null;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -203,11 +179,7 @@ const getMonitorsAndSummaryByTeamId = async (req, res, next) => {
data: monitorsSummary,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined
? (error.method = "getMonitorsAndSummaryByTeamId")
: null;
next(error);
next(handleError(error, SERVICE_NAME, "getMonitorsAndSummaryByTeamId"));
}
};
@@ -228,11 +200,7 @@ const getMonitorsByTeamId = async (req, res, next) => {
await getMonitorsByTeamIdValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -245,8 +213,7 @@ const getMonitorsByTeamId = async (req, res, next) => {
data: monitors,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getMonitorsByTeamId") : null;
next(handleError(error, SERVICE_NAME, "getMonitorsByTeamId"));
next(error);
}
};
@@ -266,12 +233,7 @@ const createMonitor = async (req, res, next) => {
try {
await createMonitorBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -281,10 +243,10 @@ const createMonitor = async (req, res, next) => {
if (notifications && notifications.length !== 0) {
monitor.notifications = await Promise.all(
notifications.map(async (notification) => {
notification.monitorId = monitor._id;
await req.db.createNotification(notification);
})
notifications.map(async (notification) => {
notification.monitorId = monitor._id;
await req.db.createNotification(notification);
})
);
await monitor.save();
}
@@ -296,9 +258,7 @@ const createMonitor = async (req, res, next) => {
data: monitor,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "createMonitor") : null;
next(error);
next(handleError(error, SERVICE_NAME, "createMonitor"));
}
};
@@ -317,11 +277,7 @@ const deleteMonitor = async (req, res, next) => {
try {
await getMonitorByIdParamValidation.validateAsync(req.params);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -347,9 +303,7 @@ const deleteMonitor = async (req, res, next) => {
.status(200)
.json({ success: true, msg: successMessages.MONITOR_DELETE });
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteMonitor") : null;
next(error);
next(handleError(error, SERVICE_NAME, "deleteMonitor"));
}
};
@@ -382,9 +336,7 @@ const deleteAllMonitors = async (req, res, next) => {
.status(200)
.json({ success: true, msg: `Deleted ${deletedCount} monitors` });
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteAllMonitors") : null;
next(error);
next(handleError(error, SERVICE_NAME, "deleteAllMonitors"));
}
};
@@ -406,11 +358,7 @@ const editMonitor = async (req, res, next) => {
await getMonitorByIdParamValidation.validateAsync(req.params);
await editMonitorBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -444,9 +392,7 @@ const editMonitor = async (req, res, next) => {
data: editedMonitor,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "editMonitor") : null;
next(error);
next(handleError(error, SERVICE_NAME, "editMonitor"));
}
};
@@ -465,11 +411,7 @@ const pauseMonitor = async (req, res, next) => {
try {
await pauseMonitorParamValidation.validateAsync(req.params);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
}
try {
@@ -490,9 +432,7 @@ const pauseMonitor = async (req, res, next) => {
data: monitor,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "pauseMonitor") : null;
next(error);
next(handleError(error, SERVICE_NAME, "pauseMonitor"));
}
};
@@ -511,21 +451,19 @@ const addDemoMonitors = async (req, res, next) => {
try {
const token = getTokenFromHeaders(req.headers);
const { jwtSecret } = req.settingsService.getSettings();
const { _id, teamId } = jwt.verify(token, jwtSecret);
const demoMonitors = await req.db.addDemoMonitors(_id, teamId);
await demoMonitors.forEach(async (monitor) => {
await req.jobQueue.addJob(monitor._id, monitor);
});
await Promise.all(
demoMonitors.map((monitor) => req.jobQueue.addJob(monitor._id, monitor))
);
return res.status(200).json({
success: true,
message: successMessages.MONITOR_DEMO_ADDED,
data: demoMonitors.length,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "addDemoMonitors") : null;
next(error);
next(handleError(error, SERVICE_NAME, "addDemoMonitors"));
}
};

View File

@@ -1,3 +1,5 @@
const { handleError } = require("./controllerUtils");
const SERVICE_NAME = "JobQueueController";
const getMetrics = async (req, res, next) => {
@@ -7,9 +9,7 @@ const getMetrics = async (req, res, next) => {
.status(200)
.json({ success: true, msg: "Metrics retrieved", data: metrics });
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getMetrics") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getMetrics"));
return;
}
};
@@ -19,9 +19,7 @@ const getJobs = async (req, res, next) => {
const jobs = await req.jobQueue.getJobStats();
return res.status(200).json({ jobs });
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getJobs") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getJobs"));
return;
}
};
@@ -31,21 +29,17 @@ const addJob = async (req, res, next) => {
await req.jobQueue.addJob(Math.random().toString(36).substring(7));
return res.send("Added job");
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "addJob") : null;
next(error);
next(handleError(error, SERVICE_NAME, "addJob"));
return;
}
};
const obliterateQueue = async (req, res, next) => {
try {
const obliterated = await req.jobQueue.obliterate();
await req.jobQueue.obliterate();
return res.status(200).send("Obliterated queue");
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "obliterateQueue") : null;
next(error);
next(handleError(error, SERVICE_NAME, "obliterateQueue"));
return;
}
};

View File

@@ -1,6 +1,7 @@
const { successMessages } = require("../utils/messages");
const SERVICE_NAME = "SettingsController";
const { updateAppSettingsBodyValidation } = require("../validation/joi");
const { handleValidationError, handleError } = require("./controllerUtils");
const getAppSettings = async (req, res, next) => {
try {
@@ -12,9 +13,7 @@ const getAppSettings = async (req, res, next) => {
data: settings,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getAppSettings") : null;
next(error);
next(handleError(error, SERVICE_NAME, "getAppSettings"));
}
};
@@ -22,11 +21,7 @@ const updateAppSettings = async (req, res, next) => {
try {
await updateAppSettingsBodyValidation.validateAsync(req.body);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
next(handleValidationError(error, SERVICE_NAME));
return;
}
@@ -40,9 +35,7 @@ const updateAppSettings = async (req, res, next) => {
data: updatedSettings,
});
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "updateAppSettings") : null;
next(error);
next(handleError(error, SERVICE_NAME, "updateAppSettings"));
}
};

View File

@@ -6,7 +6,7 @@ const mongoose = require("mongoose");
* @typedef {Object} MaintenanceWindow
* @property {mongoose.Schema.Types.ObjectId} monitorId - The ID of the monitor. This is a reference to the Monitor model and is immutable.
* @property {Boolean} active - Indicates whether the maintenance window is active.
* @property {Boolean} oneTime - Indicates whether the maintenance window is a one-time event.
* @property {Number} repeat - Indicates how often this maintenance window should repeat.
* @property {Date} start - The start date and time of the maintenance window.
* @property {Date} end - The end date and time of the maintenance window.
* @property {Date} expiry - The expiry date and time of the maintenance window. This is used for MongoDB's TTL index to automatically delete the document at this time. This field is set to the same value as `end` when `oneTime` is `true`.
@@ -16,12 +16,12 @@ const mongoose = require("mongoose");
* let maintenanceWindow = new MaintenanceWindow({
* monitorId: monitorId,
* active: active,
* oneTime: oneTime,
* repeat: repeat,
* start: start,
* end: end,
* });
*
* if (oneTime) {
* if (repeat === 0) {
* maintenanceWindow.expiry = end;
* }
*
@@ -34,16 +34,20 @@ const MaintenanceWindow = mongoose.Schema(
ref: "Monitor",
immutable: true,
},
userId: {
teamId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
ref: "Team",
immutable: true,
},
active: {
type: Boolean,
default: true,
},
oneTime: {
type: Boolean,
name: {
type: String,
},
repeat: {
type: Number,
},
start: {
type: Date,

View File

@@ -118,12 +118,14 @@ const {
//****************************************
const {
createMaintenanceWindow,
getMaintenanceWindowsByUserId,
getMaintenanceWindowById,
getMaintenanceWindowsByTeamId,
getMaintenanceWindowsByMonitorId,
deleteMaintenaceWindowById,
deleteMaintenanceWindowById,
deleteMaintenanceWindowByMonitorId,
deleteMaintenanceWindowByUserId,
} = require("./modules/maintenaceWindowModule");
editMaintenanceWindowById,
} = require("./modules/maintenanceWindowModule");
//****************************************
// Notifications
@@ -181,11 +183,13 @@ module.exports = {
getPageSpeedChecks,
deletePageSpeedChecksByMonitorId,
createMaintenanceWindow,
getMaintenanceWindowsByUserId,
getMaintenanceWindowsByTeamId,
getMaintenanceWindowById,
getMaintenanceWindowsByMonitorId,
deleteMaintenaceWindowById,
deleteMaintenanceWindowById,
deleteMaintenanceWindowByMonitorId,
deleteMaintenanceWindowByUserId,
editMaintenanceWindowById,
createNotification,
getNotificationsByMonitorId,
deleteNotificationsByMonitorId,

View File

@@ -36,7 +36,7 @@ const createMaintenanceWindow = async (maintenanceWindowData) => {
if (maintenanceWindowData.oneTime) {
maintenanceWindow.expiry = maintenanceWindowData.end;
}
const result = maintenanceWindow.save();
const result = await maintenanceWindow.save();
return result;
} catch (error) {
error.service = SERVICE_NAME;
@@ -45,22 +45,60 @@ const createMaintenanceWindow = async (maintenanceWindowData) => {
}
};
const getMaintenanceWindowById = async (maintenanceWindowId) => {
try {
const maintenanceWindow = await MaintenanceWindow.findById({
_id: maintenanceWindowId,
});
return maintenanceWindow;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getMaintenanceWindowById";
throw error;
}
};
/**
* Asynchronously retrieves all MaintenanceWindow documents associated with a specific user ID.
* Asynchronously retrieves all MaintenanceWindow documents associated with a specific team ID.
* @async
* @function getMaintenanceWindowByUserId
* @param {String} userId - The ID of the user.
* @param {String} teamId - The ID of the team.
* @param {Object} query - The request body.
* @returns {Promise<Array<MaintenanceWindow>>} An array of MaintenanceWindow documents.
* @throws {Error} If there is an error retrieving the documents.
* @example
* getMaintenanceWindowByUserId('userId')
* getMaintenanceWindowByUserId(teamId)
* .then(maintenanceWindows => console.log(maintenanceWindows))
* .catch(error => console.error(error));
*/
const getMaintenanceWindowsByUserId = async (userId) => {
const getMaintenanceWindowsByTeamId = async (teamId, query) => {
try {
const maintenanceWindows = await MaintenanceWindow.find({ userId: userId });
return maintenanceWindows;
let { active, page, rowsPerPage, field, order } = query || {};
const maintenanceQuery = { teamId };
if (active !== undefined) maintenanceQuery.active = active;
const maintenanceWindowCount =
await MaintenanceWindow.countDocuments(maintenanceQuery);
// Pagination
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
// Sorting
let sort = {};
if (field !== undefined && order !== undefined) {
sort[field] = order === "asc" ? 1 : -1;
}
const maintenanceWindows = await MaintenanceWindow.find(maintenanceQuery)
.skip(skip)
.limit(rowsPerPage)
.sort(sort);
return { maintenanceWindows, maintenanceWindowCount };
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getMaintenanceWindowByUserId";
@@ -96,23 +134,23 @@ const getMaintenanceWindowsByMonitorId = async (monitorId) => {
/**
* Asynchronously deletes a MaintenanceWindow document by its ID.
* @async
* @function deleteMaintenaceWindowById
* @function deleteMaintenanceWindowById
* @param {mongoose.Schema.Types.ObjectId} maintenanceWindowId - The ID of the MaintenanceWindow document to delete.
* @returns {Promise<MaintenanceWindow>} The deleted MaintenanceWindow document.
* @throws {Error} If there is an error deleting the document.
* @example
* deleteMaintenaceWindowById('maintenanceWindowId')
* deleteMaintenanceWindowById('maintenanceWindowId')
* .then(maintenanceWindow => console.log(maintenanceWindow))
* .catch(error => console.error(error));
*/
const deleteMaintenaceWindowById = async (maintenanceWindowId) => {
const deleteMaintenanceWindowById = async (maintenanceWindowId) => {
try {
const maintenanceWindow =
await MaintenanceWindow.findByIdAndDelete(maintenanceWindowId);
return maintenanceWindow;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "deleteMaintenaceWindowById";
error.method = "deleteMaintenanceWindowById";
throw error;
}
};
@@ -163,11 +201,32 @@ const deleteMaintenanceWindowByUserId = async (userId) => {
}
};
const editMaintenanceWindowById = async (
maintenanceWindowId,
maintenanceWindowData
) => {
console.log(maintenanceWindowData);
try {
const editedMaintenanceWindow = MaintenanceWindow.findByIdAndUpdate(
maintenanceWindowId,
maintenanceWindowData,
{ new: true }
);
return editedMaintenanceWindow;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "editMaintenanceWindowById";
throw error;
}
};
module.exports = {
createMaintenanceWindow,
getMaintenanceWindowsByUserId,
getMaintenanceWindowById,
getMaintenanceWindowsByTeamId,
getMaintenanceWindowsByMonitorId,
deleteMaintenaceWindowById,
deleteMaintenanceWindowById,
deleteMaintenanceWindowByMonitorId,
deleteMaintenanceWindowByUserId,
editMaintenanceWindowById,
};

View File

@@ -1,10 +1,7 @@
const jwt = require("jsonwebtoken");
const logger = require("../utils/logger");
const SERVICE_NAME = "verifyJWT";
const TOKEN_PREFIX = "Bearer ";
const { errorMessages } = require("../utils/messages");
const { parse } = require("path");
const User = require("../db/models/User");
/**
* Verifies the JWT token
* @function
@@ -38,11 +35,14 @@ const verifyJWT = (req, res, next) => {
const { jwtSecret } = req.settingsService.getSettings();
jwt.verify(parsedToken, jwtSecret, (err, decoded) => {
if (err) {
return res
.status(401)
.json({ success: false, msg: errorMessages.INVALID_AUTH_TOKEN });
const errorMessage =
err.name === "TokenExpiredError"
? errorMessages.EXPIRED_AUTH_TOKEN
: errorMessages.INVALID_AUTH_TOKEN;
return res.status(401).json({ success: false, msg: errorMessage });
}
//Add the user to the request object for use in the route
// Add the user to the request object for use in the route
req.user = decoded;
next();
});

2225
Server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "nyc mocha",
"dev": "nodemon index.js"
},
"keywords": [],
@@ -15,6 +15,7 @@
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bullmq": "5.7.15",
"chai": "5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
@@ -24,6 +25,7 @@
"jsonwebtoken": "9.0.2",
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mocha": "10.7.3",
"mongoose": "^8.3.3",
"multer": "1.4.5-lts.1",
"nodemailer": "^6.9.14",
@@ -34,6 +36,8 @@
"winston": "^3.13.0"
},
"devDependencies": {
"nodemon": "3.1.0"
"nodemon": "3.1.0",
"nyc": "17.1.0",
"sinon": "19.0.2"
}
}

View File

@@ -3,21 +3,20 @@ const maintenanceWindowController = require("../controllers/maintenanceWindowCon
const { verifyOwnership } = require("../middleware/verifyOwnership");
const Monitor = require("../db/models/Monitor");
router.post("/", maintenanceWindowController.createMaintenanceWindows);
router.get(
"/monitor/:monitorId",
verifyOwnership(Monitor, "monitorId"),
maintenanceWindowController.getMaintenanceWindowsByMonitorId
);
router.post(
"/monitor/:monitorId",
verifyOwnership(Monitor, "monitorId"),
maintenanceWindowController.createMaintenanceWindow
);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get(
"/user/:userId",
maintenanceWindowController.getMaintenanceWindowsByUserId
);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
router.put("/:id", maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
module.exports = router;

View File

@@ -77,14 +77,21 @@ class JobQueue {
const monitorId = job.data._id;
const maintenanceWindows =
await this.db.getMaintenanceWindowsByMonitorId(monitorId);
// Check for active maintenance window:
const maintenanceWindowActive = maintenanceWindows.reduce(
(acc, window) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
if (start < new Date() && end > new Date()) {
const now = new Date();
const repeatInterval = window.repeat || 0;
while ((start < now) & (repeatInterval !== 0)) {
start.setTime(start.getTime() + repeatInterval);
end.setTime(end.getTime() + repeatInterval);
}
if (start < now && end > now) {
return true;
}
}
@@ -139,9 +146,7 @@ class JobQueue {
return { jobs, load };
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined
? (error.method = "getWorkerStats")
: null;
error.method === undefined ? (error.method = "getWorkerStats") : null;
throw error;
}
}

View File

@@ -0,0 +1,609 @@
const {
registerUser,
loginUser,
editUser,
checkSuperadminExists,
requestRecovery,
validateRecovery,
resetPassword,
deleteUser,
getAllUsers,
} = require("../../controllers/authController");
const jwt = require("jsonwebtoken");
const { errorMessages, successMessages } = require("../../utils/messages");
const sinon = require("sinon");
describe("Auth Controller - registerUser", () => {
// Set up test
beforeEach(() => {
req = {
db: {
checkSuperadmin: sinon.stub(),
getInviteTokenAndDelete: sinon.stub(),
updateAppSettings: sinon.stub(),
insertUser: sinon.stub(),
},
settingsService: {
getSettings: sinon.stub().resolves({
jwtSecret: "my_secret",
}),
},
emailService: {
buildAndSendEmail: sinon.stub().returns(Promise.resolve()),
},
file: {},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
});
it("should register a valid user", async () => {
req.body = {
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
password: "Uptime1!",
inviteToken: "someToken",
role: ["user"],
teamId: "123",
};
req.db.checkSuperadmin.resolves(false);
req.db.insertUser.resolves({
_id: "123",
_doc: {
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
},
});
await registerUser(req, res, next);
expect(res.status.calledWith(200)).to.be.true;
expect(
res.json.calledWith(
sinon.match({
success: true,
msg: sinon.match.string,
data: {
user: sinon.match.object,
token: sinon.match.string,
},
})
)
).to.be.true;
expect(next.notCalled).to.be.true;
});
it("should reject a user with an invalid password", async () => {
req.body = {
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
password: "bad_password",
inviteToken: "someToken",
role: ["user"],
teamId: "123",
};
await registerUser(req, res, next);
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].status).to.equal(422);
});
it("should reject a user with an invalid role", async () => {
req.body = {
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
password: "Uptime1!",
inviteToken: "someToken",
role: ["superman"],
teamId: "123",
};
await registerUser(req, res, next);
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].status).to.equal(422);
});
});
describe("Auth Controller - loginUser", () => {
beforeEach(() => {
req = {
body: { email: "test@example.com", password: "Password123!" },
db: {
getUserByEmail: sinon.stub(),
},
settingsService: {
getSettings: sinon.stub().resolves({
jwtSecret: "my_secret",
}),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
user = {
_doc: {
email: "test@example.com",
},
comparePassword: sinon.stub(),
};
});
it("should login user successfully", async () => {
req.db.getUserByEmail.resolves(user);
user.comparePassword.resolves(true);
await loginUser(req, res, next);
expect(res.status.calledWith(200)).to.be.true;
expect(
res.json.calledWith({
success: true,
msg: successMessages.AUTH_LOGIN_USER,
data: {
user: {
email: "test@example.com",
avatarImage: undefined,
},
token: sinon.match.string,
},
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
it("should reject a user with an incorrect password", async () => {
req.body = {
email: "test@test.com",
password: "Password123!",
};
req.db.getUserByEmail.resolves(user);
user.comparePassword.resolves(false);
await loginUser(req, res, next);
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].message).to.equal(
errorMessages.AUTH_INCORRECT_PASSWORD
);
});
});
describe("Auth Controller - editUser", async () => {
beforeEach(() => {
req = {
params: { userId: "123" },
body: { password: "Password1!", newPassword: "Password2!" },
headers: { authorization: "Bearer token" },
user: { _id: "123" },
settingsService: {
getSettings: sinon.stub().returns({ jwtSecret: "my_secret" }),
},
db: {
getUserByEmail: sinon.stub(),
updateUser: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
});
it("should edit a user if it receives a proper request", async () => {
sinon.stub(jwt, "verify").returns({ email: "test@example.com" });
const user = {
comparePassword: sinon.stub().resolves(true),
};
req.db.getUserByEmail.resolves(user);
req.db.updateUser.resolves({ email: "test@example.com" });
await editUser(req, res, next);
expect(req.db.getUserByEmail.calledOnce).to.be.true;
expect(req.db.updateUser.calledOnce).to.be.true;
expect(res.status.calledWith(200)).to.be.true;
expect(
res.json.calledWith({
success: true,
msg: successMessages.AUTH_UPDATE_USER,
data: { email: "test@example.com" },
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
it("should reject an edit request if password format is incorrect", async () => {
req.body = { password: "bad_password", newPassword: "bad_password" };
const user = {
comparePassword: sinon.stub().resolves(true),
};
req.db.getUserByEmail.resolves(user);
await editUser(req, res, next);
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].status).to.equal(422);
});
});
describe("Auth Controller - checkSuperadminExists", async () => {
beforeEach(() => {
req = {
db: {
checkSuperadmin: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
});
it("should return true if a superadmin exists", async () => {
req.db.checkSuperadmin.resolves(true);
await checkSuperadminExists(req, res, next);
expect(res.status.calledWith(200)).to.be.true;
expect(
res.json.calledWith({
success: true,
msg: successMessages.AUTH_SUPERADMIN_EXISTS,
data: true,
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
it("should return false if a superadmin does not exist", async () => {
req.db.checkSuperadmin.resolves(false);
await checkSuperadminExists(req, res, next);
expect(res.status.calledWith(200)).to.be.true;
expect(
res.json.calledWith({
success: true,
msg: successMessages.AUTH_SUPERADMIN_EXISTS,
data: false,
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
});
describe("Auth Controller - requestRecovery", async () => {
beforeEach(() => {
req = {
body: { email: "test@test.com" },
db: {
getUserByEmail: sinon.stub(),
requestRecoveryToken: sinon.stub(),
},
settingsService: {
getSettings: sinon.stub().returns({ clientHost: "http://localhost" }),
},
emailService: {
buildAndSendEmail: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
});
it("should throw an error if the email is not provided", async () => {
req.body = {};
await requestRecovery(req, res, next);
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].status).to.equal(422);
});
it("should return a success message if the email is provided", async () => {
const user = { firstName: "John" };
const recoveryToken = { token: "recovery-token" };
const msgId = "message-id";
req.db.getUserByEmail.resolves(user);
req.db.requestRecoveryToken.resolves(recoveryToken);
req.emailService.buildAndSendEmail.resolves(msgId);
await requestRecovery(req, res, next);
expect(req.db.getUserByEmail.calledOnceWith("test@test.com")).to.be.true;
expect(req.db.requestRecoveryToken.calledOnceWith(req, res)).to.be.true;
expect(
req.emailService.buildAndSendEmail.calledOnceWith(
"passwordResetTemplate",
{
name: "John",
email: "test@test.com",
url: "http://localhost/set-new-password/recovery-token",
},
"test@test.com",
"Bluewave Uptime Password Reset"
)
).to.be.true;
expect(res.status.calledOnceWith(200)).to.be.true;
expect(
res.json.calledOnceWith({
success: true,
msg: successMessages.AUTH_CREATE_RECOVERY_TOKEN,
data: msgId,
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
});
describe("Auth Controller - validateRecovery", async () => {
beforeEach(() => {
req = {
body: { recoveryToken: "recovery-token" },
db: {
validateRecoveryToken: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
});
it("should call next with a validation error if the token is invalid", async () => {
req = {
body: {},
};
await validateRecovery(req, res, next);
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].status).to.equal(422);
});
it("should return a success message if the token is valid", async () => {
req.db.validateRecoveryToken.resolves();
await validateRecovery(req, res, next);
expect(res.status.calledOnceWith(200)).to.be.true;
expect(
res.json.calledOnceWith({
success: true,
msg: successMessages.AUTH_VERIFY_RECOVERY_TOKEN,
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
});
describe("Auth Controller - resetPassword", async () => {
beforeEach(() => {
req = {
body: {
recoveryToken: "recovery-token",
password: "Password1!",
},
db: {
resetPassword: sinon.stub(),
},
settingsService: {
getSettings: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
newPasswordValidation = {
validateAsync: sinon.stub(),
};
handleValidationError = sinon.stub();
handleError = sinon.stub();
issueToken = sinon.stub();
});
it("should call next with a validation error if the password is invalid", async () => {
req.body = { password: "bad_password" };
await resetPassword(req, res, next);
expect(next.firstCall.args[0]).to.be.an("error");
expect(next.firstCall.args[0].status).to.equal(422);
});
it("should reset password successfully", async () => {
const user = { _doc: {} };
const appSettings = { jwtSecret: "my_secret" };
const token = "token";
newPasswordValidation.validateAsync.resolves();
req.db.resetPassword.resolves(user);
req.settingsService.getSettings.resolves(appSettings);
issueToken.returns(token);
await resetPassword(req, res, next);
expect(req.db.resetPassword.calledOnceWith(req, res)).to.be.true;
expect(req.settingsService.getSettings.calledOnce).to.be.true;
expect(res.status.calledOnceWith(200)).to.be.true;
expect(
res.json.calledOnceWith({
success: true,
msg: successMessages.AUTH_RESET_PASSWORD,
data: { user: sinon.match.object, token: sinon.match.string },
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
});
describe("Auth Controller - deleteUser", async () => {
beforeEach(() => {
req = {
headers: {
authorization: "Bearer token",
},
db: {
getUserByEmail: sinon.stub(),
getMonitorsByTeamId: sinon.stub(),
deleteJob: sinon.stub(),
deleteChecks: sinon.stub(),
deletePageSpeedChecksByMonitorId: sinon.stub(),
deleteNotificationsByMonitorId: sinon.stub(),
deleteTeam: sinon.stub(),
deleteAllOtherUsers: sinon.stub(),
deleteMonitorsByUserId: sinon.stub(),
deleteUser: sinon.stub(),
},
jobQueue: {
deleteJob: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
sinon.stub(jwt, "decode");
handleError = sinon.stub();
});
afterEach(() => {
sinon.restore();
});
it("should return 404 if user is not found", async () => {
jwt.decode.returns({ email: "test@example.com" });
req.db.getUserByEmail.resolves(null);
await deleteUser(req, res, next);
expect(req.db.getUserByEmail.calledOnceWith("test@example.com")).to.be.true;
expect(next.calledOnce).to.be.true;
expect(next.firstCall.args[0].message).to.equal(
errorMessages.DB_USER_NOT_FOUND
);
expect(res.status.notCalled).to.be.true;
expect(res.json.notCalled).to.be.true;
});
it("should delete user and associated data if user is superadmin", async () => {
const user = {
_id: "user_id",
email: "test@example.com",
role: ["superadmin"],
teamId: "team_id",
};
const monitors = [{ _id: "monitor_id" }];
jwt.decode.returns({ email: "test@example.com" });
req.db.getUserByEmail.resolves(user);
req.db.getMonitorsByTeamId.resolves({ monitors });
await deleteUser(req, res, next);
expect(req.db.getUserByEmail.calledOnceWith("test@example.com")).to.be.true;
expect(
req.db.getMonitorsByTeamId.calledOnceWith({
params: { teamId: "team_id" },
})
).to.be.true;
expect(req.jobQueue.deleteJob.calledOnceWith(monitors[0])).to.be.true;
expect(req.db.deleteChecks.calledOnceWith("monitor_id")).to.be.true;
expect(req.db.deletePageSpeedChecksByMonitorId.calledOnceWith("monitor_id"))
.to.be.true;
expect(req.db.deleteNotificationsByMonitorId.calledOnceWith("monitor_id"))
.to.be.true;
expect(req.db.deleteTeam.calledOnceWith("team_id")).to.be.true;
expect(req.db.deleteAllOtherUsers.calledOnce).to.be.true;
expect(req.db.deleteMonitorsByUserId.calledOnceWith("user_id")).to.be.true;
expect(req.db.deleteUser.calledOnceWith("user_id")).to.be.true;
expect(res.status.calledOnceWith(200)).to.be.true;
expect(
res.json.calledOnceWith({
success: true,
msg: successMessages.AUTH_DELETE_USER,
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
it("should delete user if user is not superadmin", async () => {
const user = {
_id: "user_id",
email: "test@example.com",
role: ["user"],
teamId: "team_id",
};
jwt.decode.returns({ email: "test@example.com" });
req.db.getUserByEmail.resolves(user);
await deleteUser(req, res, next);
expect(req.db.getUserByEmail.calledOnceWith("test@example.com")).to.be.true;
expect(
req.db.getMonitorsByTeamId.calledOnceWith({
params: { teamId: "team_id" },
})
).to.be.true;
expect(req.db.deleteUser.calledOnceWith("user_id")).to.be.true;
expect(res.status.calledOnceWith(200)).to.be.true;
expect(
res.json.calledOnceWith({
success: true,
msg: successMessages.AUTH_DELETE_USER,
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
it("should handle errors", async () => {
const error = new Error("Something went wrong");
const SERVICE_NAME = "AuthController";
jwt.decode.returns({ email: "test@example.com" });
req.db.getUserByEmail.rejects(error);
await deleteUser(req, res, next);
expect(next.calledOnce).to.be.true;
expect(next.firstCall.args[0].message).to.equal("Something went wrong");
expect(res.status.notCalled).to.be.true;
expect(res.json.notCalled).to.be.true;
});
});
describe("Auth Controller - getAllUsers", async () => {
beforeEach(() => {
req = {
db: {
getAllUsers: sinon.stub(),
},
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
};
next = sinon.stub();
handleError = sinon.stub();
});
afterEach(() => {
sinon.restore(); // Restore the original methods after each test
});
it("should return 200 and all users", async () => {
const allUsers = [{ id: 1, name: "John Doe" }];
req.db.getAllUsers.resolves(allUsers);
await getAllUsers(req, res, next);
expect(req.db.getAllUsers.calledOnce).to.be.true;
expect(res.status.calledOnceWith(200)).to.be.true;
expect(
res.json.calledOnceWith({
success: true,
msg: "Got all users",
data: allUsers,
})
).to.be.true;
expect(next.notCalled).to.be.true;
});
it("should call next with error when an exception occurs", async () => {
const error = new Error("Something went wrong");
req.db.getAllUsers.rejects(error);
await getAllUsers(req, res, next);
expect(req.db.getAllUsers.calledOnce).to.be.true;
expect(next.calledOnce).to.be.true;
expect(res.status.notCalled).to.be.true;
expect(res.json.notCalled).to.be.true;
});
});

View File

@@ -12,6 +12,7 @@ const errorMessages = {
UNKNOWN_SERVICE: "Unknown service",
NO_AUTH_TOKEN: "No auth token provided",
INVALID_AUTH_TOKEN: "Invalid auth token",
EXPIRED_AUTH_TOKEN: "Token expired",
//Ownership Middleware
VERIFY_OWNER_NOT_FOUND: "Document not found",
@@ -92,8 +93,12 @@ const successMessages = {
JOB_QUEUE_RESUME_JOB: "Job resumed successfully",
//Maintenance Window Controller
MAINTENANCE_WINDOW_GET_BY_ID: "Got Maintenance Window by Id successfully",
MAINTENANCE_WINDOW_CREATE: "Maintenance Window created successfully",
MAINTENANCE_WINDOW_GET_BY_USER: "Got Maintenance Windows by User successfully",
MAINTENANCE_WINDOW_GET_BY_TEAM:
"Got Maintenance Windows by Team successfully",
MAINTENANCE_WINDOW_DELETE: "Maintenance Window deleted successfully",
MAINTENANCE_WINDOW_EDIT: "Maintenance Window edited successfully",
//Ping Operations
PING_SUCCESS: "Success",

View File

@@ -1,5 +1,7 @@
const exp = require("constants");
const joi = require("joi");
const { normalize } = require("path");
const { start } = require("repl");
//****************************************
// Custom Validators
@@ -361,27 +363,51 @@ const deletePageSpeedCheckParamValidation = joi.object({
//****************************************
// MaintenanceWindowValidation
//****************************************
const createMaintenanceWindowParamValidation = joi.object({
monitorId: joi.string().required(),
});
const createMaintenanceWindowBodyValidation = joi.object({
userId: joi.string().required(),
active: joi.boolean().required(),
oneTime: joi.boolean().required(),
monitors: joi.array().items(joi.string()).required(),
name: joi.string().required(),
active: joi.boolean(),
start: joi.date().required(),
end: joi.date().required(),
repeat: joi.number().required(),
expiry: joi.date(),
});
const getMaintenanceWindowsByUserIdParamValidation = joi.object({
userId: joi.string().required(),
const getMaintenanceWindowByIdParamValidation = joi.object({
id: joi.string().required(),
});
const getMaintenanceWindowsByTeamIdQueryValidation = joi.object({
active: joi.boolean(),
page: joi.number(),
rowsPerPage: joi.number(),
field: joi.string(),
order: joi.string().valid("asc", "desc"),
});
const getMaintenanceWindowsByMonitorIdParamValidation = joi.object({
monitorId: joi.string().required(),
});
const deleteMaintenanceWindowByIdParamValidation = joi.object({
id: joi.string().required(),
});
const editMaintenanceWindowByIdParamValidation = joi.object({
id: joi.string().required(),
});
const editMaintenanceByIdWindowBodyValidation = joi.object({
active: joi.boolean(),
name: joi.string(),
repeat: joi.number(),
start: joi.date(),
end: joi.date(),
expiry: joi.date(),
monitors: joi.array(),
});
//****************************************
// SettingsValidation
//****************************************
@@ -447,9 +473,12 @@ module.exports = {
createPageSpeedCheckParamValidation,
deletePageSpeedCheckParamValidation,
createPageSpeedCheckBodyValidation,
createMaintenanceWindowParamValidation,
createMaintenanceWindowBodyValidation,
getMaintenanceWindowsByUserIdParamValidation,
getMaintenanceWindowByIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
getMaintenanceWindowsByMonitorIdParamValidation,
deleteMaintenanceWindowByIdParamValidation,
editMaintenanceWindowByIdParamValidation,
editMaintenanceByIdWindowBodyValidation,
updateAppSettingsBodyValidation,
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -1 +0,0 @@
uptime.bluewavelabs.ca

View File

@@ -1,63 +0,0 @@
---
icon: hand-wave
cover: .gitbook/assets/Group 3763.png
coverY: 0
layout:
cover:
visible: true
size: full
title:
visible: true
description:
visible: false
tableOfContents:
visible: true
outline:
visible: true
pagination:
visible: true
---
# Welcome to Uptime Manager
BlueWave Uptime Manager is an open-source server monitoring application used to track the operational status and performance of servers and websites.&#x20;
It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
## Demo
We have a [demo](https://uptime-demo.bluewavelabs.ca/) where you can test how the Uptime Manager works. The username is [uptimedemo@demo.com](mailto:uptimedemo@demo.com) and the password is Demouser1!
## Questions & ideas
If you have a question, head over to GitHub's [discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) page.
## Features
* [x] Completely open source, deployable on your servers
* [x] Website monitoring
* [x] Port monitoring
* [x] Ping monitoring
* [x] Incidents at a glance
* [x] Page speed monitoring
* [x] E-mail notifications
* [ ] Scheduled maintenance (in the works)
## **Roadmap (short term)**
* [ ] Memory, disk and CPU monitoring
* [ ] 3rd party integrations
* [ ] DNS monitoring
* [ ] SSL monitoring
## **Roadmap (long term)**
* [ ] Status pages
## Tech stack
* [ReactJs](https://react.dev/)
* [MUI (React framework)](https://mui.com/)
* [Node.js](https://nodejs.org/en)
* [MongoDB](https://mongodb.com)

View File

@@ -1,19 +0,0 @@
# Table of contents
* [Welcome to Uptime Manager](README.md)
## USER'S GUIDE
* [Installing Uptime Manager](users-guide/quickstart.md)
* [Using Uptime Manager](users-guide/using-uptime-manager.md)
* [Creating a new monitor](users-guide/creating-a-new-monitor.md)
* [Pagespeed monitoring](users-guide/pagespeed-monitoring.md)
* [Incidents page](users-guide/incidents-page.md)
* [Server settings](users-guide/server-settings.md)
* [User settings](users-guide/user-settings.md)
## DEVELOPER'S GUIDE
* [Contributing to the code](developers-guide/contributing-to-the-code.md)
* [General project structure](developers-guide/general-project-structure.md)
* [High level overview](developers-guide/high-level-overview.md)

View File

@@ -1,31 +0,0 @@
---
icon: plus
---
# Creating a new monitor
Creating a new monitor involves a few steps, mentioned below.&#x20;
### General settings
* **URL to monitor:** Enter the full URL of the website or service you want to monitor.
* **Display name:** Optionally, provide a custom name for your monitor. This helps identify it easily in your dashboard.
### Checks to perform
* **Website monitoring:** This option uses HTTP(s) to monitor your website or API endpoint. You can choose between HTTPS and HTTP protocols.
* **Ping monitoring:** Checks whether your server is available. This option is currently unselected.
### Incident notifications&#x20;
When there's a new incident, you can choose how to be notified:
* Notify via SMS (coming soon)
* Notify via email (to the email address you logged in with)
* Notify via email to multiple addresses (coming soon)
### Advanced settings
* **Check frequency:** Set how often the system should check your monitor. The current setting is 1 minute.
After configuring all settings, click the "Create monitor" button at the bottom right to set up your new monitor.

View File

@@ -1,18 +0,0 @@
---
icon: list-check
---
# Contributing to the code
We generally follow the [gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) workflow model. If youre not familiar with it, the general steps are
1. Create a feature branch in your local depository and make your changes.
2. Push your branch to the remote repository on Github.
3. Open a pull request.
4. The rest of the team will review the pull request and either approve or request changes.
5. If changes are requested, make changes and push.
6. Project maintainer will merge the branch, closing the pull request and deleting the remote branch.
### Git
If you are inexperienced with Git or need a refresher please visit our [Git Quick Start Guide](https://github.com/ajhollid/bluewave\_collaborative\_git) to help get up to speed. If youd like to go further in depth, this [Git for Professionals](https://youtu.be/Uszj\_k0DGsg?si=6rOWEQOMxmwhnb-K) is a good resource.\

View File

@@ -1,48 +0,0 @@
---
icon: diagram-project
---
# General project structure
The Uptime Manager product uses the MERN stack, which is to say that the project uses:
* React on the Front End via Vite
* Express on the Back End via NodeJS
* MongoDB for data with Mongoose for data access
### Front end
The project uses the [Material UI Components](https://mui.com/material-ui/all-components/) (MUI) which allows us to build with a minimum of fuss. The library is highly customisable and the project makes heavy use of the Theme concept and follows MUIs paradigm to avoid writing excessive CSS.\
\
The overriding goal on the Front Eed is to write maintainable and scalable code. If you find yourself writing lots of CSS to customize a component or are having to set a value often like font size or color you probably should be using the theme.\
\
When making changes to the Front end please always keep future developers in mind. Will they know how to make changes to your code? Is your code modular? If a dev makes changes elsewhere in the app will your component be affected? If the team makes a theme change like font size or primary color will your component be updated as well?&#x20;
### Back end
The back end of this project is not especially complex and is built around Express. The back end is a RESTful API and the [documentation can be found here](https://uptime-demo.bluewavelabs.ca/api-docs).
The application consists of several main conceptual models:
1. User
2. Monitor
3. Check
4. Notification
There are several supporting models as well.
1. AppSettings
2. InviteToken
3. Team
All requests are based around these models and manipulation of their data.\
\
In general the models interact in this fashion:
* A `User` has many `Monitors`
* A `Monitor` has many `Checks`
* A `Monitor` has many `Notifications`
A `User` can create a `Monitor`. `Monitors` are enqueued in the `JobQueue`, and their target URLs are queried when they are dequeued. When the query is complete, a `Check` is created that records information about the request.\
\
A `User` may attach a `Notification` to their `Monitor`, if the `Monitor`s status changes then the user will be notified by the method specified in the `Notification`

View File

@@ -1,57 +0,0 @@
---
icon: dove
---
# High level overview
The figure below shows a high level architecture of the Uptime Manager.
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 9.28.27AM.png" alt=""><figcaption></figcaption></figure>
### Typical auth request overview
The following diagram describes a typical request to the /auth endpoints.
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 9.30.30AM.png" alt=""><figcaption></figcaption></figure>
### Typical monitor request overview
The following diagram describes a typical request to the `/monitors` endpoints.
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 12.13.55PM.png" alt=""><figcaption></figcaption></figure>
### JobQueue
The heart of this application is a `JobQueue` class that wraps a BullMQ `Queue`. \
\
A `Monitor` is considered a job, when one is created it is enqueued in the `JobQueue`.\
\
Jobs are handled by a pool of workers in the `JobQueue` and their tasks are executed in the order in which they are enqueued.\
\
Workers are scaled up and down based on the jobs/worker ratio as jobs are enqueued and dequeued.
#### **High level overview of the JobQueue**
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 12.17.44PM.png" alt=""><figcaption></figcaption></figure>
### SSL
SSL is handled by LetsEncrypt and Certbot. This works by Nginx and Certbot sharing the same volume where the certificates are held. The following snippet from the docker-compose.yaml file shows how this works.\
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 12.20.52PM.png" alt=""><figcaption></figcaption></figure>
Please see [this guide](https://phoenixnap.com/kb/letsencrypt-docker) for more information on this setup.
\
\
\
\
\
\

View File

@@ -1,13 +0,0 @@
---
icon: brake-warning
---
# Incidents page
This page shows a list of incidents of all the servers you monitor.&#x20;
<figure><img src=".gitbook/assets/Screenshot 2024-10-03 at 11.54.31PM.png" alt=""><figcaption></figcaption></figure>
Page includes a table where Monitor name, Status (down or cannot resolve), date and time of the incident, status code and corresponding message is shown.
By default, the page shows all incidents from all servers. There is also a dropdown button above the table where you can select a server name.

View File

@@ -1 +0,0 @@
<h1>Under docs/index.html</h1>

View File

@@ -1,89 +0,0 @@
---
icon: gauge-min
---
# Pagespeed monitoring
Under Dashboard > Pagespeed, you can see an overview of pagespeed monitors for different websites. This dashboard helps you view optimal website performance by providing clear, actionable insights into various aspects of your site's speed and user experience.
<figure><img src=".gitbook/assets/Screenshot 2024-10-03 at 11.37.47PM (1).png" alt=""><figcaption></figcaption></figure>
When you add a new page speed monitor, it is added here. Click on the "Create new" button to add a new page speed.&#x20;
Here, both monitors are shown as "Live (Collecting Data)" with a green dot, indicating they are actively monitoring. Each monitor has a small graph, representing recent pagespeed performance over time.
### Details of a pagespeed monitor
<figure><img src=".gitbook/assets/Screenshot 2024-10-03 at 11.41.55PM.png" alt=""><figcaption></figcaption></figure>
When you click on a pagespeed card, you'll see an overview of data collected using Google's PageSpeed Monitor API.&#x20;
### Score history graph
Shows performance trends over the past 24 hours. Four colored lines represent different metrics:
* Accessibility (blue)
* Best Practices (orange)
* Performance (green)
* Search Engine Optimization (purple)
You can toggle these metrics on/off using the checkboxes on the right
### Performance report
You'll see an overall score of your server's pagespeed.&#x20;
### Performance metrics
* **Cumulative Layout Shift:** Measures visual stability
* **Speed Index:** How quickly content is visually displayed
* **First Contentful Paint:** Time until the first content is rendered
* **Largest Contentful Paint:** Time until the largest content element is rendered
* **Total Blocking Time:** Sum of all time periods between FCP and Time to Interactive
### Creating a new pagespeed&#x20;
When you are on a pagespeed screen, click on the "Create pagespeed" button. You'll see a screen similar to this:&#x20;
<figure><img src=".gitbook/assets/Screenshot 2024-10-03 at 11.47.41PM.png" alt=""><figcaption></figcaption></figure>
This page allows you to set up a new pagespeed monitor for your website. Follow these steps to configure your monitor:
#### General settings
* **URL to monitor:** Enter the full URL of the website you want to monitor (e.g., [https://google.com](https://google.com)).
* **Display name (optional):** Enter a custom name for your monitor to easily identify it in your dashboard.
#### Checks to perform
Here, you can choose between HTTPS (recommended for secure sites) or HTTP protocols.
#### Incident notifications&#x20;
Select how you want to be notified when there's a new incident:
* Notify via SMS (coming soon)
* Notify via email (to your email address)
* Also notify via email to multiple addresses (coming soon)
#### Advanced settings
**Check frequency:** Choose how often you want the system to check your website's pagespeed. The default is set to 3 minutes.
After configuring all settings, click the "Create monitor" button at the bottom right of the page.
#### Tips
* Ensure the URL you enter is correct and accessible.
* Choose a descriptive display name if you're monitoring multiple sites.
* Consider the trade-off between check frequency and resource usage. More frequent checks provide more data but may use more resources.
* Remember to enable your preferred notification methods to stay informed about incidents.
After creating the monitor, you'll be able to view its performance data in your dashboard. This will help you track your website's pagespeed and optimize its performance over time.

View File

@@ -1,35 +0,0 @@
---
icon: screwdriver-wrench
---
# Server settings
Under the Settings page, you can configure the server's behaviour.&#x20;
### General settings
Display timezone: The timezone of the dashboard you publicly display.
### History and monitoring
Define here for how long you want to keep the data. You can also remove all past data. You can also clear all stats. This is irreversible.
### Demo monitors
Here you can add and remove demo monitors. It will load roughly 300 demo monitors at the same time so you can test your server.
### Advanced settings&#x20;
Here you can setup the following:&#x20;
**Client settings:** The URL of the APIs and debug level. The more verbose the debug level, the more log is written on the server.
**Email settings:** Set your host email settings here. These settings are used for sending system emails.
Server settings: Several server settings can be done here. Alternatively, you can add a pagespeed API key to bypass Google's limitations (albeit they are generous about it)
\

View File

@@ -1,45 +0,0 @@
---
icon: user-pen
---
# User settings
This screen allows you to manage your personal information and account settings within the application.
### Profile section
<figure><img src=".gitbook/assets/Screenshot 2024-10-04 at 12.17.57AM.png" alt=""><figcaption></figcaption></figure>
Enter your first name, last name and email here. You can also upload or delete a profile photo. If you want to delete an account, this can also be done here. Note that that this is irreversible and cannot be undone.
### Password section
This screen allows you to update your password for your account.
<figure><img src=".gitbook/assets/Screenshot 2024-10-04 at 12.16.53AM.png" alt=""><figcaption></figcaption></figure>
In order to change your password, you need to enter your password, and the new password twice. The new password must meet the specified criteria (e.g., minimum length, uppercase letter, number, symbol).
**Additional notes:**
* Your new password will take effect immediately.
* For security reasons, it is recommended to choose a strong password that is difficult to guess.
### Team section
This screen allows you to manage your team members within the application.
<figure><img src=".gitbook/assets/Screenshot 2024-10-04 at 12.14.45AM.png" alt=""><figcaption></figcaption></figure>
Here you can see a list of all users in your system. A user can be either admin or a member, where an admin can create or delete a monitor, and a user can only view them.&#x20;
To invite a team member,&#x20;
* Click the "Invite a team member" button.
* Enter the email address of the person you want to invite.
* Select their desired role (Administrator or Member).
* Click "Invite" to send the invitation.
The invited team member will receive an email invitation to join your team.
Admin also has the option to edit or remove existing team members.

View File

@@ -1,31 +0,0 @@
---
icon: plus
---
# Creating a new monitor
Creating a new monitor involves a few steps, mentioned below.&#x20;
### General settings
* **URL to monitor:** Enter the full URL of the website or service you want to monitor.
* **Display name:** Optionally, provide a custom name for your monitor. This helps identify it easily in your dashboard.
### Checks to perform
* **Website monitoring:** This option uses HTTP(s) to monitor your website or API endpoint. You can choose between HTTPS and HTTP protocols.
* **Ping monitoring:** Checks whether your server is available. This option is currently unselected.
### Incident notifications&#x20;
When there's a new incident, you can choose how to be notified:
* Notify via SMS (coming soon)
* Notify via email (to the email address you logged in with)
* Notify via email to multiple addresses (coming soon)
### Advanced settings
* **Check frequency:** Set how often the system should check your monitor. The current setting is 1 minute.
After configuring all settings, click the "Create monitor" button at the bottom right to set up your new monitor.

View File

@@ -1,13 +0,0 @@
---
icon: brake-warning
---
# Incidents page
This page shows a list of incidents of all the servers you monitor.&#x20;
<figure><img src="../.gitbook/assets/Screenshot 2024-10-03 at 11.54.31PM.png" alt=""><figcaption></figcaption></figure>
Page includes a table where Monitor name, Status (down or cannot resolve), date and time of the incident, status code and corresponding message is shown.
By default, the page shows all incidents from all servers. There is also a dropdown button above the table where you can select a server name.

View File

@@ -1,89 +0,0 @@
---
icon: gauge-min
---
# Pagespeed monitoring
Under Dashboard > Pagespeed, you can see an overview of pagespeed monitors for different websites. This dashboard helps you view optimal website performance by providing clear, actionable insights into various aspects of your site's speed and user experience.
<figure><img src="../.gitbook/assets/Screenshot 2024-10-03 at 11.37.47PM (1).png" alt=""><figcaption></figcaption></figure>
When you add a new page speed monitor, it is added here. Click on the "Create new" button to add a new page speed.&#x20;
Here, both monitors are shown as "Live (Collecting Data)" with a green dot, indicating they are actively monitoring. Each monitor has a small graph, representing recent pagespeed performance over time.
### Details of a pagespeed monitor
<figure><img src="../.gitbook/assets/Screenshot 2024-10-03 at 11.41.55PM.png" alt=""><figcaption></figcaption></figure>
When you click on a pagespeed card, you'll see an overview of data collected using Google's PageSpeed Monitor API.&#x20;
### Score history graph
Shows performance trends over the past 24 hours. Four colored lines represent different metrics:
* Accessibility (blue)
* Best Practices (orange)
* Performance (green)
* Search Engine Optimization (purple)
You can toggle these metrics on/off using the checkboxes on the right
### Performance report
You'll see an overall score of your server's pagespeed.&#x20;
### Performance metrics
* **Cumulative Layout Shift:** Measures visual stability
* **Speed Index:** How quickly content is visually displayed
* **First Contentful Paint:** Time until the first content is rendered
* **Largest Contentful Paint:** Time until the largest content element is rendered
* **Total Blocking Time:** Sum of all time periods between FCP and Time to Interactive
### Creating a new pagespeed&#x20;
When you are on a pagespeed screen, click on the "Create pagespeed" button. You'll see a screen similar to this:&#x20;
<figure><img src="../.gitbook/assets/Screenshot 2024-10-03 at 11.47.41PM.png" alt=""><figcaption></figcaption></figure>
This page allows you to set up a new pagespeed monitor for your website. Follow these steps to configure your monitor:
#### General settings
* **URL to monitor:** Enter the full URL of the website you want to monitor (e.g., [https://google.com](https://google.com)).
* **Display name (optional):** Enter a custom name for your monitor to easily identify it in your dashboard.
#### Checks to perform
Here, you can choose between HTTPS (recommended for secure sites) or HTTP protocols.
#### Incident notifications&#x20;
Select how you want to be notified when there's a new incident:
* Notify via SMS (coming soon)
* Notify via email (to your email address)
* Also notify via email to multiple addresses (coming soon)
#### Advanced settings
**Check frequency:** Choose how often you want the system to check your website's pagespeed. The default is set to 3 minutes.
After configuring all settings, click the "Create monitor" button at the bottom right of the page.
#### Tips
* Ensure the URL you enter is correct and accessible.
* Choose a descriptive display name if you're monitoring multiple sites.
* Consider the trade-off between check frequency and resource usage. More frequent checks provide more data but may use more resources.
* Remember to enable your preferred notification methods to stay informed about incidents.
After creating the monitor, you'll be able to view its performance data in your dashboard. This will help you track your website's pagespeed and optimize its performance over time.

View File

@@ -1,35 +0,0 @@
---
icon: screwdriver-wrench
---
# Server settings
Under the Settings page, you can configure the server's behaviour.&#x20;
### General settings
Display timezone: The timezone of the dashboard you publicly display.
### History and monitoring
Define here for how long you want to keep the data. You can also remove all past data. You can also clear all stats. This is irreversible.
### Demo monitors
Here you can add and remove demo monitors. It will load roughly 300 demo monitors at the same time so you can test your server.
### Advanced settings&#x20;
Here you can setup the following:&#x20;
**Client settings:** The URL of the APIs and debug level. The more verbose the debug level, the more log is written on the server.
**Email settings:** Set your host email settings here. These settings are used for sending system emails.
Server settings: Several server settings can be done here. Alternatively, you can add a pagespeed API key to bypass Google's limitations (albeit they are generous about it)
\

View File

@@ -1,45 +0,0 @@
---
icon: user-pen
---
# User settings
This screen allows you to manage your personal information and account settings within the application.
### Profile section
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 12.17.57AM.png" alt=""><figcaption></figcaption></figure>
Enter your first name, last name and email here. You can also upload or delete a profile photo. If you want to delete an account, this can also be done here. Note that that this is irreversible and cannot be undone.
### Password section
This screen allows you to update your password for your account.
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 12.16.53AM.png" alt=""><figcaption></figcaption></figure>
In order to change your password, you need to enter your password, and the new password twice. The new password must meet the specified criteria (e.g., minimum length, uppercase letter, number, symbol).
**Additional notes:**
* Your new password will take effect immediately.
* For security reasons, it is recommended to choose a strong password that is difficult to guess.
### Team section
This screen allows you to manage your team members within the application.
<figure><img src="../.gitbook/assets/Screenshot 2024-10-04 at 12.14.45AM.png" alt=""><figcaption></figcaption></figure>
Here you can see a list of all users in your system. A user can be either admin or a member, where an admin can create or delete a monitor, and a user can only view them.&#x20;
To invite a team member,&#x20;
* Click the "Invite a team member" button.
* Enter the email address of the person you want to invite.
* Select their desired role (Administrator or Member).
* Click "Invite" to send the invitation.
The invited team member will receive an email invitation to join your team.
Admin also has the option to edit or remove existing team members.

View File

@@ -1,80 +0,0 @@
---
icon: computer-mouse
description: >-
This user guide helps new users navigate and understand the Uptime Manager
dashboard layout and functionality.
---
# Using Uptime Manager
## **General Overview**
The Uptime Manager is an open-source server monitoring tool designed to track the uptime, downtime, and performance of servers, websites, or web applications.&#x20;
It provides real-time alerts, status updates, and detailed response time metrics.
### **Sidebar menu**&#x20;
The sidebar on the left contains the following sections:
* **Dashboard**: The main section that shows an overview of your monitored services, including their status and performance. It includes "Monitors" and "Pagespeed"
* **Incidents**: A section where alerts and incidents regarding the monitored services are logged. This helps users investigate issues and track downtime history.
* **Account**: User-related settings and account information are accessible here.
* **Support**: Links to customer support or documentation to help troubleshoot or ask for assistance.
* **Settings**: Provides customization options for the applications behavior, monitoring frequency, and notification preferences.
**User info section**
At the bottom of the sidebar, youll see the current logged-in user alongside with the role.
### **Dashboard content**
The main section of the dashboard displays an overview of the monitored services, including their current status and key performance indicators.
The dashboard offers a summary of your uptime monitors:
* **Up**: The number of monitored services that are currently operational.
* **Down**: The number of monitored services that are currently experiencing issues.
* **Paused**: The number of services that have their monitoring paused.
**Actively monitoring table**
This section lists all the services being monitored, showing key details for each service:
<figure><img src="../.gitbook/assets/Screenshot 2024-10-03 at 10.56.43PM.png" alt=""><figcaption></figcaption></figure>
* **Host**: The name and URL of the service being monitored.
* **Status**: The current status of the service, indicated by color-coded icons:
* Green: **Up** (operational)
* Red: **Down** (non-operational)
* Yellow: **Paused** (monitoring paused for this service)
* **Response time**: A visual graph showing the performance trends of the service over time. Each bar represents the response time of the service at a given interval. Green bars indicate a healthy response time, while red bars indicate slower or problematic performance.
* **Type**: Indicates the type of service being monitored (either Ping or HTTP).
* **Actions**: This column has settings icons that allow the user to configure the monitoring details of the specific service.
At the top-right corner of the dashboard, theres a button labeled **Create monitor**. This allows users to add a new server or website to monitor.
A search bar is available above the list of monitored services, enabling users to quickly locate specific services by name or URL.
**Status indicators**
* **Green**: Indicates the service is currently up and operational.
* **Red**: Indicates the service is down or experiencing problems.
* **Yellow**: Indicates that monitoring for this service is paused.
Each service has a graph in the **Response Time** column that displays its performance history over time. This allows you to easily visualize any spikes or drops in performance.
The gear icon next to each monitored service allows for quick access to configuration settings or additional monitoring options.
### Table actions&#x20;
<figure><img src="../.gitbook/assets/Screenshot 2024-10-03 at 10.57.45PM.png" alt="" width="375"><figcaption></figcaption></figure>
When you click on the gear icon, you'll see a few options that correspond to that host:&#x20;
1. **Open site**: Opens the monitored website or server in a new browser tab.
2. **Details**: Displays historical uptime and performance metrics for the service.
3. **Incidents**: Shows a log of all incidents related to the service, including downtime and performance issues.
4. **Configure**: Allows modification of monitoring parameters like check frequency and alert preferences.
5. **Clone**: Duplicates the current services monitoring settings for a new service.
6. **Remove**: Removes the service from the monitored list and stops all monitoring activities.

View File

@@ -1,80 +0,0 @@
---
description: >-
This user guide helps new users navigate and understand the Uptime Manager
dashboard layout and functionality.
icon: computer-mouse
---
# Using Uptime Manager
## **General Overview**
The Uptime Manager is an open-source server monitoring tool designed to track the uptime, downtime, and performance of servers, websites, or web applications.&#x20;
It provides real-time alerts, status updates, and detailed response time metrics.
### **Sidebar menu**&#x20;
The sidebar on the left contains the following sections:
* **Dashboard**: The main section that shows an overview of your monitored services, including their status and performance. It includes "Monitors" and "Pagespeed"
* **Incidents**: A section where alerts and incidents regarding the monitored services are logged. This helps users investigate issues and track downtime history.
* **Account**: User-related settings and account information are accessible here.
* **Support**: Links to customer support or documentation to help troubleshoot or ask for assistance.
* **Settings**: Provides customization options for the applications behavior, monitoring frequency, and notification preferences.
**User info section**
At the bottom of the sidebar, youll see the current logged-in user alongside with the role.
### **Dashboard content**
The main section of the dashboard displays an overview of the monitored services, including their current status and key performance indicators.
The dashboard offers a summary of your uptime monitors:
* **Up**: The number of monitored services that are currently operational.
* **Down**: The number of monitored services that are currently experiencing issues.
* **Paused**: The number of services that have their monitoring paused.
**Actively monitoring table**
This section lists all the services being monitored, showing key details for each service:
<figure><img src=".gitbook/assets/Screenshot 2024-10-03 at 10.56.43PM.png" alt=""><figcaption></figcaption></figure>
* **Host**: The name and URL of the service being monitored.
* **Status**: The current status of the service, indicated by color-coded icons:
* Green: **Up** (operational)
* Red: **Down** (non-operational)
* Yellow: **Paused** (monitoring paused for this service)
* **Response time**: A visual graph showing the performance trends of the service over time. Each bar represents the response time of the service at a given interval. Green bars indicate a healthy response time, while red bars indicate slower or problematic performance.
* **Type**: Indicates the type of service being monitored (either Ping or HTTP).
* **Actions**: This column has settings icons that allow the user to configure the monitoring details of the specific service.
At the top-right corner of the dashboard, theres a button labeled **Create monitor**. This allows users to add a new server or website to monitor.
A search bar is available above the list of monitored services, enabling users to quickly locate specific services by name or URL.
**Status indicators**
* **Green**: Indicates the service is currently up and operational.
* **Red**: Indicates the service is down or experiencing problems.
* **Yellow**: Indicates that monitoring for this service is paused.
Each service has a graph in the **Response Time** column that displays its performance history over time. This allows you to easily visualize any spikes or drops in performance.
The gear icon next to each monitored service allows for quick access to configuration settings or additional monitoring options.
### Table actions&#x20;
<figure><img src=".gitbook/assets/Screenshot 2024-10-03 at 10.57.45PM.png" alt="" width="375"><figcaption></figcaption></figure>
When you click on the gear icon, you'll see a few options that correspond to that host:&#x20;
1. **Open site**: Opens the monitored website or server in a new browser tab.
2. **Details**: Displays historical uptime and performance metrics for the service.
3. **Incidents**: Shows a log of all incidents related to the service, including downtime and performance issues.
4. **Configure**: Allows modification of monitoring parameters like check frequency and alert preferences.
5. **Clone**: Duplicates the current services monitoring settings for a new service.
6. **Remove**: Removes the service from the monitored list and stops all monitoring activities.