Merge pull request #587 from bluewave-labs/develop

Develop -> Master
This commit is contained in:
Alexander Holliday authored and GitHub committed 2024-08-09 15:34:39 -07:00
commit 7e202d0778
41 files changed
+1828 -584

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- Browser [e.g. Chrome, Safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+11 -13
View File
@@ -1,16 +1,14 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./bluewave_favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlueWave Uptime</title>
</head>
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlueWave Uptime</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+5
View File
@@ -26,6 +26,7 @@ import PageSpeed from "./Pages/PageSpeed";
import CreatePageSpeed from "./Pages/PageSpeed/CreatePageSpeed";
import CreateNewMaintenanceWindow from "./Pages/Maintenance/CreateMaintenanceWindow";
import PageSpeedDetails from "./Pages/PageSpeed/Details";
import PageSpeedConfigure from "./Pages/PageSpeed/Configure";
function App() {
const AdminCheckedRegister = withAdminCheck(Register);
@@ -103,6 +104,10 @@ function App() {
path="pagespeed/:monitorId"
element={<ProtectedRoute Component={PageSpeedDetails} />}
/>
<Route
path="pagespeed/configure/:monitorId"
element={<ProtectedRoute Component={PageSpeedConfigure} />}
/>
</Route>
<Route exact path="/login" element={<Login />} />
@@ -79,7 +79,6 @@ const MonitorDetailsAreaChart = ({ checks }) => {
MonitorDetailsAreaChart.propTypes = {
checks: PropTypes.array,
filter: PropTypes.string,
};
CustomToolTip.propTypes = {
+133 -122
View File
@@ -12,7 +12,7 @@ import Visibility from "@mui/icons-material/Visibility";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import Button from "../../Button";
import "./index.css";
import { useState } from "react";
import { forwardRef, useState } from "react";
/**
* @param {Object} props
@@ -30,140 +30,151 @@ import { useState } from "react";
* @param {function} props.onChange - Function called on input change.
* @param {string} [props.error] - Error message to display for the input field.
* @param {boolean} [props.disabled] - Indicates if the input field is disabled.
* @param {React.Ref} [ref] - Ref forwarded to the underlying `TextField` component. Allows for direct interactions such as focusing.
*/
const Field = ({
type = "text",
id,
label,
https,
isRequired,
isOptional,
optionalLabel,
hasCopy,
autoComplete,
placeholder,
value,
onChange,
error,
disabled,
}) => {
const theme = useTheme();
const Field = forwardRef(
(
{
type = "text",
id,
label,
https,
isRequired,
isOptional,
optionalLabel,
hasCopy,
autoComplete,
placeholder,
value,
onChange,
error,
disabled,
},
ref
) => {
const theme = useTheme();
// TODO - are we using this feature anywhere ?
const [copy, setCopy] = useState(false);
const handleCopy = () => {
setCopy(true);
setTimeout(() => setCopy(false), 1000);
};
// TODO - are we using this feature anywhere ?
const [copy, setCopy] = useState(false);
const handleCopy = () => {
setCopy(true);
setTimeout(() => setCopy(false), 1000);
};
const [isVisible, setVisible] = useState(false);
const [isVisible, setVisible] = useState(false);
return (
<Stack gap={theme.gap.xs} className={`field field-${type}`}>
{label && (
<Typography component="h3">
{label}
{isRequired ? <span className="field-required">*</span> : ""}
{isOptional ? (
<span className="field-optional">
{optionalLabel || "(optional)"}
</span>
) : (
""
)}
</Typography>
)}
<TextField
type={type === "password" ? (isVisible ? "text" : type) : type}
id={id}
autoComplete={autoComplete}
placeholder={placeholder}
multiline={type === "description"}
rows={type === "description" ? 4 : 1}
value={value}
onChange={onChange}
disabled={disabled}
sx={
type === "url"
? {
"& .MuiInputBase-root": { padding: 0 },
"& .MuiStack-root": {
borderTopLeftRadius: `${theme.shape.borderRadius}px`,
borderBottomLeftRadius: `${theme.shape.borderRadius}px`,
},
}
: {}
}
InputProps={{
startAdornment: type === "url" && (
<Stack
direction="row"
alignItems="center"
height="100%"
sx={{
borderRight: `solid 1px ${theme.palette.section.borderColor}`,
backgroundColor: "#f9f9fa",
pl: theme.gap.medium,
}}
>
<Typography component="h5" sx={{ lineHeight: 1 }}>
{https ? "https" : "http"}://
</Typography>
</Stack>
),
endAdornment:
type === "password" ? (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setVisible((show) => !show)}
tabIndex={-1}
sx={{
color: theme.palette.section.borderColor,
padding: `calc(${theme.gap.xs} / 2)`,
"&:focus": {
outline: "none",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
>
{!isVisible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
return (
<Stack gap={theme.gap.xs} className={`field field-${type}`}>
{label && (
<Typography component="h3">
{label}
{isRequired ? <span className="field-required">*</span> : ""}
{isOptional ? (
<span className="field-optional">
{optionalLabel || "(optional)"}
</span>
) : (
hasCopy && (
<InputAdornment className="copy" position="end">
<Button
level="tertiary"
label={copy ? "Copied" : "Copy"}
img={<ContentCopyIcon />}
onClick={handleCopy}
""
)}
</Typography>
)}
<TextField
type={type === "password" ? (isVisible ? "text" : type) : type}
id={id}
autoComplete={autoComplete}
placeholder={placeholder}
multiline={type === "description"}
rows={type === "description" ? 4 : 1}
value={value}
onChange={onChange}
disabled={disabled}
inputRef={ref}
sx={
type === "url"
? {
"& .MuiInputBase-root": { padding: 0 },
"& .MuiStack-root": {
borderTopLeftRadius: `${theme.shape.borderRadius}px`,
borderBottomLeftRadius: `${theme.shape.borderRadius}px`,
},
}
: {}
}
InputProps={{
startAdornment: type === "url" && (
<Stack
direction="row"
alignItems="center"
height="100%"
sx={{
borderRight: `solid 1px ${theme.palette.section.borderColor}`,
backgroundColor: "#f9f9fa",
pl: theme.gap.medium,
}}
>
<Typography component="h5" sx={{ lineHeight: 1 }}>
{https ? "https" : "http"}://
</Typography>
</Stack>
),
endAdornment:
type === "password" ? (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setVisible((show) => !show)}
tabIndex={-1}
sx={{
borderLeft: `solid 1px ${theme.palette.section.borderColor}`,
lineHeight: 0,
color: theme.palette.section.borderColor,
padding: `calc(${theme.gap.xs} / 2)`,
"&:focus": {
outline: "none",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
/>
>
{!isVisible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
),
}}
/>
{error && (
<Typography component="span" className="input-error" mt={theme.gap.xs}>
{error}
</Typography>
)}
</Stack>
);
};
) : (
hasCopy && (
<InputAdornment className="copy" position="end">
<Button
level="tertiary"
label={copy ? "Copied" : "Copy"}
img={<ContentCopyIcon />}
onClick={handleCopy}
sx={{
borderLeft: `solid 1px ${theme.palette.section.borderColor}`,
lineHeight: 0,
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
/>
</InputAdornment>
)
),
}}
/>
{error && (
<Typography
component="span"
className="input-error"
mt={theme.gap.xs}
>
{error}
</Typography>
)}
</Stack>
);
}
);
Field.propTypes = {
type: PropTypes.oneOf(["text", "password", "url", "email", "description"]),
@@ -352,7 +352,7 @@ const ProfilePanel = () => {
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
width: 500,
bgcolor: "white",
border: "solid 1px #f2f2f2",
borderRadius: `${theme.shape.borderRadius}px`,
@@ -68,6 +68,58 @@ export const getPageSpeedByUserId = createAsyncThunk(
}
);
export const updatePageSpeed = createAsyncThunk(
"pageSpeedMonitors/updatePageSpeed",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const updatedFields = {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
// notifications: monitor.notifications,
};
const res = await axiosInstance.put(
`/monitors/${monitor._id}`,
updatedFields,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
return thunkApi.rejectWithValue(error.message);
}
}
);
export const deletePageSpeed = createAsyncThunk(
"pageSpeedMonitors/deletePageSpeed",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await axiosInstance.delete(`/monitors/${monitor._id}`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
return thunkApi.rejectWithValue(error.message);
}
}
);
const pageSpeedMonitorSlice = createSlice({
name: "pageSpeedMonitor",
initialState,
@@ -137,6 +189,44 @@ const pageSpeedMonitorSlice = createSlice({
state.msg = action.payload
? action.payload.msg
: "Failed to create page speed monitor";
})
// *****************************************************
// Create Monitor
// *****************************************************
.addCase(updatePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(updatePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(updatePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update page speed monitor";
})
// *****************************************************
// Delete Monitor
// *****************************************************
.addCase(deletePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(deletePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deletePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete page speed monitor";
});
},
});
@@ -80,7 +80,7 @@ export const updateUptimeMonitor = createAsyncThunk(
notifications: monitor.notifications,
};
const res = await axiosInstance.put(
`/monitors/edit/${monitor._id}`,
`/monitors/${monitor._id}`,
updatedFields,
{
headers: {
+1 -4
View File
@@ -1,6 +1,3 @@
.home-layout * {
box-sizing: border-box;
}
.home-layout {
display: grid;
position: relative;
@@ -10,7 +7,7 @@
grid-template-columns: var(--env-var-side-bar-width) auto;
grid-template-rows: var(--env-var-nav-bar-height) auto;
background-color: var(--env-var-color-30);
height: 100vh;
min-height: 100vh;
}
.home-layout aside {
+334 -126
View File
@@ -1,18 +1,221 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import "./index.css";
import Logomark from "../../assets/Images/bwl-logo-2.svg?react";
import Button from "../../Components/Button";
import background from "../../assets/Images/background_pattern_decorative.png";
import axiosInstance from "../../Utils/axiosConfig";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { credentials } from "../../Validation/validation";
import { login } from "../../Features/Auth/authSlice";
import { useDispatch } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import Button from "../../Components/Button";
import axiosInstance from "../../Utils/axiosConfig";
import Field from "../../Components/Inputs/Field";
import { Checkbox, FormControlLabel, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import background from "../../assets/Images/background_pattern_decorative.png";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import Mail from "../../assets/icons/mail.svg?react";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import "./index.css";
/**
* Displays the initial landing page.
*
* @param {Object} props
* @param {Function} props.onContinue - Callback function to handle "Continue with Email" button click.
* @returns {JSX.Element}
*/
const LandingPage = ({ onContinue }) => {
const theme = useTheme();
return (
<>
<Stack gap={theme.gap.large} alignItems="center" textAlign="center">
<Box>
<Typography component="h1">Log In</Typography>
<Typography>We are pleased to see you again!</Typography>
</Box>
<Box width="100%">
<Button
level="secondary"
label="Continue with Email"
img={<Mail />}
onClick={onContinue}
sx={{
width: "100%",
"& svg": {
mr: theme.gap.small,
},
}}
/>
</Box>
<Box maxWidth={400}>
<Typography className="tos-p">
By continuing, you agree to our{" "}
<Typography component="span">Terms of Service</Typography> and{" "}
<Typography component="span">Privacy Policy.</Typography>
</Typography>
</Box>
</Stack>
</>
);
};
/**
* Renders the first step of the login process.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack gap={theme.gap.large} textAlign="center">
<Box>
<Typography component="h1">Log In</Typography>
<Typography>Enter your email address</Typography>
</Box>
<Box textAlign="left">
<form noValidate spellCheck={false} onSubmit={onSubmit}>
<Field
type="email"
id="login-email-input"
label="Email"
isRequired={true}
placeholder="john.doe@domain.com"
autoComplete="email"
value={form.email}
onChange={onChange}
error={errors.email}
ref={inputRef}
/>
</form>
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
level="secondary"
label="Back"
animate="slideLeft"
img={<ArrowBackRoundedIcon />}
onClick={onBack}
sx={{
mb: theme.gap.medium,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.xs,
},
}}
/>
<Button
level="primary"
label="Continue"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
/>
</Stack>
</Stack>
</>
);
};
/**
* Renders the second step of the login process, including a password input field.
*
* @param {Object} props
* @param {Object} props.form - Form state object.
* @param {Object} props.errors - Object containing form validation errors.
* @param {Function} props.onSubmit - Callback function to handle form submission.
* @param {Function} props.onChange - Callback function to handle form input changes.
* @param {Function} props.onBack - Callback function to handle "Back" button click.
* @returns {JSX.Element}
*/
const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const navigate = useNavigate();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack gap={theme.gap.large} textAlign="center">
<Box>
<Typography component="h1">Log In</Typography>
<Typography>Enter your password</Typography>
</Box>
<Box textAlign="left">
<form noValidate spellCheck={false} onSubmit={onSubmit}>
<Field
type="password"
id="login-password-input"
label="Password"
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password}
ref={inputRef}
/>
</form>
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
level="secondary"
label="Back"
animate="slideLeft"
img={<ArrowBackRoundedIcon />}
onClick={onBack}
sx={{
mb: theme.gap.medium,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.xs,
},
}}
/>
<Button
level="primary"
label="Continue"
onClick={onSubmit}
disabled={errors.password && true}
sx={{ width: "30%" }}
/>
</Stack>
<Box textAlign="center">
<Typography className="forgot-p" display="inline-block">
Forgot password?
</Typography>
<Typography
component="span"
ml={theme.gap.xs}
sx={{ userSelect: "none" }}
onClick={() => navigate("/forgot-password")}
>
Reset password
</Typography>
</Box>
</Stack>
</>
);
};
const Login = () => {
const dispatch = useDispatch();
@@ -29,6 +232,7 @@ const Login = () => {
password: "",
});
const [errors, setErrors] = useState({});
const [step, setStep] = useState(0);
useEffect(() => {
axiosInstance
@@ -43,47 +247,6 @@ const Login = () => {
});
}, [navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
const { error } = credentials.validate(form, { abortEarly: false });
if (error) {
// validation errors
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.",
});
} else {
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/monitors");
createToast({
body: "Welcome back! You're successfully logged in.",
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
};
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
@@ -105,91 +268,136 @@ const Login = () => {
});
};
const handleSubmit = async (event) => {
event.preventDefault();
if (step === 1) {
const { error } = credentials.validate(
{ email: form.email },
{ abortEarly: false }
);
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
createToast({ body: error.details[0].message });
} else {
setStep(2);
}
} else if (step === 2) {
const { error } = credentials.validate(form, { abortEarly: false });
if (error) {
// validation errors
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.",
});
} else {
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/monitors");
createToast({
body: "Welcome back! You're successfully logged in.",
});
} else {
setErrors({
password: "The password you provided does not match our records",
});
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
}
};
return (
<div className="login-page">
<Stack className="login-page" overflow="hidden">
<img
className="background-pattern-svg"
src={background}
alt="background pattern"
/>
<form className="login-form" onSubmit={handleSubmit}>
<Stack gap={theme.gap.small} alignItems="center">
<Logomark alt="BlueWave Uptime Icon" />
<Typography component="h1" sx={{ mt: theme.gap.xl }}>
Login to Your Account
</Typography>
</Stack>
<Stack gap={theme.gap.large} sx={{ mt: `calc(${theme.gap.ml}*2)` }}>
<Field
type="email"
id="login-email-input"
label="Email"
isRequired={true}
placeholder="Enter your email"
autoComplete="email"
value={form.email}
<Stack
direction="row"
alignItems="center"
px={theme.gap.large}
gap={theme.gap.small}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
p={theme.gap.xl}
pb={theme.gap.triplexl}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.otherColors.graishWhite,
backgroundColor: theme.palette.otherColors.white,
padding: {
xs: theme.gap.large,
sm: theme.gap.xl,
},
},
}}
>
{step === 0 ? (
<LandingPage onContinue={() => setStep(1)} />
) : step === 1 ? (
<StepOne
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
error={errors.email}
onBack={() => setStep(0)}
/>
<Field
type="password"
id="login-password-input"
label="Password"
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={handleChange}
error={errors.password}
/>
<Stack direction="row" justifyContent="space-between">
<FormControlLabel
control={
<Checkbox
size="small"
sx={{
padding: 0,
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
/>
}
label="Remember me"
) : (
step === 2 && (
<StepTwo
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
onBack={() => setStep(1)}
/>
<Typography component="span" onClick={() => navigate("/forgot-password")}>
Forgot password
</Typography>
</Stack>
</Stack>
<Stack gap={theme.gap.ml} mt={theme.gap.large}>
<Button
type="submit"
level="primary"
label="Continue"
disabled={Object.keys(errors).length !== 0 && true}
/>
<Stack
direction="row"
justifyContent="center"
gap="5px"
mt={theme.gap.ml}
>
<Typography component="p" sx={{ alignSelf: "center" }}>
Don't have an account?
</Typography>
<Typography component="span"
onClick={() => {
navigate("/register");
}}
>
Sign up
</Typography>
</Stack>
</Stack>
</form>
</div>
)
)}
</Stack>
<Box textAlign="center" p={theme.gap.large}>
<Typography display="inline-block">Don't have an account? —</Typography>
<Typography
component="span"
ml={theme.gap.xs}
onClick={() => {
navigate("/register");
}}
sx={{ userSelect: "none" }}
>
Sign Up
</Typography>
</Box>
</Stack>
);
};
+70 -25
View File
@@ -2,7 +2,6 @@
/* SHARED */
/* ////// */
.register-page,
.login-page,
.set-new-password-page,
.password-confirmed-page,
.forgot-password-page,
@@ -12,7 +11,6 @@
height: var(--env-var-height-1);
}
.register-page h1.MuiTypography-root,
.login-page h1.MuiTypography-root,
.set-new-password-page h1.MuiTypography-root,
.password-confirmed-page h1.MuiTypography-root,
.forgot-password-page h1.MuiTypography-root,
@@ -24,8 +22,6 @@
.register-page p.MuiTypography-root,
.register-page span,
.register-page button,
.login-page p.MuiTypography-root,
.login-page span,
.login-page button,
.set-new-password-page p.MuiTypography-root,
.set-new-password-page button,
@@ -38,13 +34,11 @@
.check-email-page span.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.register-page p.MuiTypography-root,
.login-page p.MuiTypography-root {
.register-page p.MuiTypography-root {
color: var(--env-var-color-2);
opacity: 0.8;
}
.register-page button:not(.MuiIconButton-root),
.login-page button:not(.MuiIconButton-root),
.set-new-password-page button:not(.MuiIconButton-root),
.password-confirmed-page button:not(.MuiIconButton-root),
.forgot-password-page button:not(.MuiIconButton-root),
@@ -54,12 +48,10 @@
line-height: 0;
}
.register-page svg rect,
.login-page svg rect {
.register-page svg rect {
fill: none;
}
.register-form,
.login-form {
.register-form {
margin-top: 95px;
}
@@ -77,7 +69,6 @@
color: var(--env-var-color-2);
}
.login-page .MuiFormControlLabel-root .MuiButtonBase-root,
.set-new-password-page button:not(.MuiIconButton-root) svg,
.password-confirmed-page button:not(.MuiIconButton-root) svg,
.forgot-password-page button svg,
@@ -108,19 +99,6 @@
cursor: pointer;
font-weight: 600;
}
.login-page .MuiFormControlLabel-root {
margin: 0;
}
.login-page .MuiFormControlLabel-root span:not(.Mui-checked) svg {
fill: var(--env-var-color-4);
}
.login-page .MuiFormControlLabel-root svg {
margin-bottom: 1px;
}
.login-page .MuiFormControlLabel-root .MuiTypography-root {
color: var(--env-var-color-5);
user-select: none;
}
/* /////////// */
/* Check Email */
@@ -128,3 +106,70 @@
.check-email-page span.MuiTypography-root {
font-weight: 600;
}
/* */
.login-page {
height: 100vh;
}
.login-page h1 {
font-size: 37px;
font-weight: 600;
color: var(--env-var-color-3);
}
.login-page button:not(.MuiIconButton-root),
.login-page .field h3.MuiTypography-root,
.login-page .field input,
.login-page .field .input-error,
.login-page p,
.login-page span {
font-size: var(--env-var-font-size-medium-plus);
}
.login-page p {
color: var(--env-var-color-2-light);
}
.login-page .field h3.MuiTypography-root,
.login-page .field input {
color: var(--env-var-color-5);
}
.login-page p + span {
color: var(--env-var-color-3);
opacity: 0.8;
cursor: pointer;
transition: opacity 300ms ease-in;
}
.login-page p > span {
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
transition: all 200ms;
}
.login-page p > span:hover {
color: var(--env-var-color-2);
text-underline-offset: 4px;
}
.login-page p + span:hover {
opacity: 1;
}
.login-page button:not(.MuiIconButton-root) {
user-select: none;
border-radius: var(--env-var-radius-2);
line-height: 1;
}
.login-page button:not(.MuiIconButton-root),
.login-page .field .MuiInputBase-root:has(input) {
height: 38px;
}
.login-page .field svg {
width: 24px;
height: 24px;
}
.login-page > .MuiStack-root:nth-of-type(1) {
height: var(--env-var-nav-bar-height);
}
.login-page .background-pattern-svg {
top: 14%;
width: 100%;
max-width: 800px;
}
@@ -0,0 +1,142 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Pagination,
PaginationItem,
Paper,
} from "@mui/material";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import axiosInstance from "../../../Utils/axiosConfig";
import { StatusLabel } from "../../../Components/Label";
const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
const { authToken, user } = useSelector((state) => state.auth);
const [checks, setChecks] = useState([]);
const [checksCount, setChecksCount] = useState(0);
const [paginationController, setPaginationController] = useState({
page: 0,
rowsPerPage: 12,
});
useEffect(() => {
const fetchPage = async () => {
try {
let url = `/checks/${selectedMonitor}?sortOrder=desc&filter=${filter}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`;
if (selectedMonitor === "0") {
url = `/checks/user/${user._id}?sortOrder=desc&filter=${filter}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`;
}
const res = await axiosInstance.get(url, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
console.log(error);
}
};
fetchPage();
}, [
authToken,
user,
selectedMonitor,
filter,
paginationController.page,
paginationController.rowsPerPage,
]);
const handlePageChange = (_, newPage) => {
console.log(newPage);
setPaginationController({
...paginationController,
page: newPage - 1, // 0-indexed
});
};
let paginationComponent = <></>;
if (checksCount > paginationController.rowsPerPage) {
paginationComponent = (
<Pagination
count={Math.ceil(checksCount / paginationController.rowsPerPage)}
page={paginationController.page + 1} //0-indexed
onChange={handlePageChange}
shape="rounded"
renderItem={(item) => (
<PaginationItem
slots={{
previous: ArrowBackRoundedIcon,
next: ArrowForwardRoundedIcon,
}}
{...item}
sx={{
"&:focus": {
outline: "none",
},
}}
/>
)}
/>
);
}
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Monitor Name</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
return (
<TableRow key={check._id}>
<TableCell>{monitors[check.monitorId]?.name}</TableCell>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{new Date(check.createdAt).toLocaleString()}
</TableCell>
<TableCell>{check.statusCode}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginationComponent}
</>
);
};
IncidentTable.propTypes = {
monitors: PropTypes.object.isRequired,
selectedMonitor: PropTypes.string.isRequired,
filter: PropTypes.string.isRequired,
};
export default IncidentTable;
+3
View File
@@ -14,6 +14,9 @@
height: 34px;
border-color: var(--env-var-color-16);
}
.incidents .MuiPagination-root {
background-color: var(--env-var-color-8);
}
/* remove later */
.incidents .MuiSelect-select {
+61 -146
View File
@@ -1,15 +1,11 @@
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { Typography, ButtonGroup, Stack, Box, Skeleton } from "@mui/material";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import { ButtonGroup, Stack, Skeleton, Typography } from "@mui/material";
import Button from "../../Components/Button";
import axiosInstance from "../../Utils/axiosConfig";
import BasicTable from "../../Components/BasicTable";
import { StatusLabel } from "../../Components/Label";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router";
import Select from "../../Components/Inputs/Select";
import IncidentTable from "./IncidentTable";
import "./index.css";
/**
@@ -40,8 +36,8 @@ const SkeletonLayout = () => {
const Incidents = () => {
const theme = useTheme();
const navigate = useNavigate();
const authState = useSelector((state) => state.auth);
const [monitors, setMonitors] = useState({});
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [loading, setLoading] = useState(false);
@@ -49,21 +45,11 @@ const Incidents = () => {
// TODO do something with these filters
const [filter, setFilter] = useState("all");
let data = {
cols: [
{ id: 1, name: "Status" },
{ id: 2, name: "Timestamp" },
{ id: 3, name: "Monitor" },
{ id: 4, name: "Message" },
],
rows: [],
};
useEffect(() => {
const fetchIncidents = async () => {
const fetchMonitors = async () => {
setLoading(true);
const res = await axiosInstance.get(
`/monitors/user/${authState.user._id}?status=false`,
`/monitors/user/${authState.user._id}?status=false&limit=1`,
{
headers: {
Authorization: `Bearer ${authState.authToken}`,
@@ -82,141 +68,70 @@ const Incidents = () => {
setLoading(false);
};
fetchIncidents();
fetchMonitors();
}, [authState]);
useEffect(() => {}, []);
const handleSelect = (event) => {
setSelectedMonitor(event.target.value);
};
let incidents = [];
let hasAnyIncidents = false;
let hasSpecificIncidents = false;
if (selectedMonitor === "0") {
Object.values(monitors).forEach((monitor) => {
incidents = incidents.concat(monitor.checks);
});
if (incidents.length !== 0) hasAnyIncidents = true;
hasSpecificIncidents = hasAnyIncidents;
} else {
const monitor = monitors[selectedMonitor];
incidents = monitor.checks;
if (incidents.length !== 0) hasSpecificIncidents = true;
hasAnyIncidents = true;
}
incidents = incidents
.sort((a, b) => {
return new Date(b.createdAt) - new Date(a.createdAt);
})
.filter((incident) => {
if (filter === "all") return true;
else if (filter === "resolve") return incident.statusCode === 5000;
else if (filter === "down") return incident.status === false;
})
.map((incident, idx) => {
return {
id: idx,
data: [
{
id: idx + 0,
data: <StatusLabel status="down" text="Down" />,
},
{ id: idx + 1, data: new Date(incident.createdAt).toLocaleString() },
{ id: idx + 2, data: monitors[incident.monitorId].name },
{ id: idx + 3, data: incident.statusCode },
],
};
});
data.rows = incidents;
return (
<>
<Stack className="incidents" gap={theme.gap.large}>
{loading ? (
<SkeletonLayout />
) : hasAnyIncidents ? (
<>
<Stack direction="row" alignItems="center" gap={theme.gap.medium}>
<Typography component="h1">Incident history for: </Typography>
<Select
id="incidents-select-monitor"
placeholder="All servers"
value={selectedMonitor}
onChange={handleSelect}
items={Object.values(monitors)}
/>
{hasAnyIncidents && hasSpecificIncidents && (
<ButtonGroup sx={{ ml: "auto" }}>
<Button
level="secondary"
label="All"
onClick={() => setFilter("all")}
sx={{
backgroundColor:
filter === "all" && theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Down"
onClick={() => setFilter("down")}
sx={{
backgroundColor:
filter === "down" && theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Cannot Resolve"
onClick={() => setFilter("resolve")}
sx={{
backgroundColor:
filter === "resolve" &&
theme.palette.otherColors.fillGray,
}}
/>
</ButtonGroup>
)}
</Stack>
{hasAnyIncidents && hasSpecificIncidents ? (
<BasicTable data={data} paginated={true} rowsPerPage={12} />
) : (
<Typography alignSelf="center" mt={theme.gap.xxl}>
The monitor you have selected, has no recorded incidents yet.
</Typography>
)}
</>
) : (
<Box>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate(-1)}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
px: theme.gap.ml,
mb: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
<Stack className="incidents" gap={theme.gap.large}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Stack direction="row" alignItems="center" gap={theme.gap.medium}>
<Typography display="inline-block" component="h1">
Incidents for
</Typography>
<Select
id="incidents-select-monitor"
placeholder="All servers"
value={selectedMonitor}
onChange={handleSelect}
items={Object.values(monitors)}
/>
<Box
padding={theme.gap.xxl}
gap={theme.gap.small}
border={1}
borderRadius={`${theme.shape.borderRadius}px`}
borderColor={theme.palette.otherColors.graishWhite}
backgroundColor={theme.palette.otherColors.white}
>
<Typography textAlign="center">No incidents, yet.</Typography>
</Box>
</Box>
)}
</Stack>
</>
<ButtonGroup sx={{ ml: "auto" }}>
<Button
level="secondary"
label="All"
onClick={() => setFilter("all")}
sx={{
backgroundColor:
filter === "all" && theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Down"
onClick={() => setFilter("down")}
sx={{
backgroundColor:
filter === "down" && theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Cannot Resolve"
onClick={() => setFilter("resolve")}
sx={{
backgroundColor:
filter === "resolve" && theme.palette.otherColors.fillGray,
}}
/>
</ButtonGroup>
</Stack>
<IncidentTable
monitors={monitors}
selectedMonitor={selectedMonitor}
filter={filter}
/>
</>
)}
</Stack>
);
};
@@ -1,5 +1,7 @@
.maintenance-options > .MuiStack-root > .MuiStack-root.select-wrapper {
width: 380px;
.maintenance-options .MuiStack-root .MuiStack-root.select-wrapper,
.maintenance-options .MuiStack-root .field {
width: 60%;
max-width: 380px;
}
.date-picker .MuiOutlinedInput-root,
@@ -16,6 +18,54 @@
width: 90px;
}
.MuiInputAdornment-outlined .MuiIconButton-root svg {
.maintenance-options .MuiInputAdornment-outlined .MuiIconButton-root svg {
width: 20px;
}
.duration-config .field-text .MuiTextField-root,
.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;
}
@@ -9,6 +9,7 @@ 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";
const directory = {
title: "Create a maintenance window",
@@ -45,9 +46,21 @@ const configOptionTitle = (title, description) => {
);
};
const durationOptions = [
{ _id: "minutes", name: "minutes" },
{ _id: "hours", name: "hours" },
{ _id: "days", name: "days" },
];
const CreateNewMaintenanceWindow = () => {
const [values, setValues] = useState({
repeat: 1,
date: dayjs(),
startTime: dayjs(),
duration: "60",
unit: "minutes",
friendlyName: "",
AddMonitors: "",
});
const handleChange = (event, name) => {
@@ -77,22 +90,90 @@ const CreateNewMaintenanceWindow = () => {
className="date-localization-provider"
dateAdapter={AdapterDayjs}
>
<DatePicker className="date-picker" defaultValue={dayjs()} />
<DatePicker
className="date-picker"
defaultValue={values.date}
onChange={(date) =>
handleChange({ target: { value: date } }, "date")
}
/>
</LocalizationProvider>
),
},
{
title: "Start time",
description: "Your pings won’t be sent in this time frame.",
component: (
<LocalizationProvider
className="time-localization-provider"
dateAdapter={AdapterDayjs}
>
<MobileTimePicker className="time-picker" defaultValue={dayjs()} />
<MobileTimePicker
className="time-picker"
defaultValue={values.startTime}
onChange={(time) =>
handleChange({ target: { value: time } }, "startTime")
}
/>
</LocalizationProvider>
),
},
{
title: "Duration",
component: (
<Stack className="duration-config" gap={2} direction="row">
<Field
id="duration-value"
placeholder="60"
onChange={(e) => handleChange(e, "duration")}
value={values.duration}
/>
<Select
onChange={(e) => handleChange(e, "unit")}
id="duration-unit"
items={durationOptions}
value={values.unit}
/>
</Stack>
),
},
{
title: "Friendly name",
component: (
<Field
id="friendly-name"
placeholder="Maintanence at __ : __ for ___ minutes"
value={values.friendlyName}
onChange={(e) => handleChange(e, "friendlyName")}
/>
),
},
{
title: "Add monitors",
component: (
<Stack
className="add-monitors-fields"
sx={{ width: "60%", maxWidth: "380px" }}
gap={2}
>
<Field
id="add-monitors"
placeholder="Start typing to search for current monitors"
value={values.AddMonitors}
onChange={(e) => handleChange(e, "AddMonitors")}
/>
<Typography
sx={{
width: "fit-content",
fontSize: "var(--env-var-font-size-small)",
borderBottom: "1px dashed var(--env-var-color-3)",
paddingBottom: "4px",
}}
>
Add all monitors to this maintenance window
</Typography>
</Stack>
),
},
];
return (
@@ -127,22 +208,46 @@ const CreateNewMaintenanceWindow = () => {
</Typography>
</Box>
<Stack
className="maintenance-options"
gap={5}
paddingY={4}
paddingX={8}
paddingBottom={10}
sx={{
border: "1px solid var(--env-var-color-16)",
borderRadius: "var(--env-var-radius-1)",
backgroundColor: "var(--env-var-color-0)",
}}
className="maintenance-options"
>
{configOptions.map((item, index) => (
<Stack key={index} display="-webkit-inline-box">
{configOptionTitle(item.title, item.description)}
{item.title && configOptionTitle(item.title, item.description)}
{item.component && item.component}
</Stack>
))}
</Stack>
<Stack justifyContent="end" direction="row" marginTop={3}>
<Button
sx={{
"&:hover": {
backgroundColor: "transparent",
boxShadow: "none",
},
}}
level="tertiary"
label="Cancel"
/>
<Button
sx={{
"&:hover": {
backgroundColor: "#1570EF",
boxShadow: "none",
},
}}
level="primary"
label="Create"
/>
</Stack>
</Stack>
</div>
);
@@ -460,10 +460,10 @@ const Configure = () => {
}}
>
<Typography id="modal-delete-monitor" component="h2">
Really delete this monitor?
Do you really want to delete this monitor?
</Typography>
<Typography id="delete-monitor-confirmation">
Once deleted, this monitor cannot be retrieved back.
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
@@ -6,16 +6,19 @@ import {
TableRow,
TableCell,
TableBody,
TableFooter,
TablePagination,
PaginationItem,
Pagination,
Paper,
} from "@mui/material";
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import axiosInstance from "../../../../Utils/axiosConfig";
import { StatusLabel } from "../../../../Components/Label";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
const PaginationTable = ({ monitorId, filter }) => {
const PaginationTable = ({ monitorId, dateRange }) => {
const { authToken } = useSelector((state) => state.auth);
const [checks, setChecks] = useState([]);
const [checksCount, setChecksCount] = useState(0);
@@ -28,7 +31,7 @@ const PaginationTable = ({ monitorId, filter }) => {
const fetchPage = async () => {
try {
const res = await axiosInstance.get(
`/checks/${monitorId}?sortOrder=desc&filter=${filter}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`,
`/checks/${monitorId}?sortOrder=desc&dateRange=${dateRange}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
@@ -45,7 +48,7 @@ const PaginationTable = ({ monitorId, filter }) => {
}, [
authToken,
monitorId,
filter,
dateRange,
paginationController.page,
paginationController.rowsPerPage,
]);
@@ -53,69 +56,82 @@ const PaginationTable = ({ monitorId, filter }) => {
const handlePageChange = (_, newPage) => {
setPaginationController({
...paginationController,
page: newPage,
page: newPage - 1, // 0-indexed
});
};
const handleChangeRowsPerPage = (event) => {
setPaginationController({
...paginationController,
rowsPerPage: parseInt(event.target.value, 10),
page: 0,
});
};
let paginationComponent = <></>;
if (checksCount > paginationController.rowsPerPage) {
paginationComponent = (
<Pagination
count={Math.ceil(checksCount / paginationController.rowsPerPage)}
page={paginationController.page + 1} //0-indexed
onChange={handlePageChange}
shape="rounded"
renderItem={(item) => (
<PaginationItem
slots={{
previous: ArrowBackRoundedIcon,
next: ArrowForwardRoundedIcon,
}}
{...item}
sx={{
"&:focus": {
outline: "none",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
/>
)}
/>
);
}
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
return (
<TableRow key={check._id}>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{new Date(check.createdAt).toLocaleString()}
</TableCell>
<TableCell>{check.statusCode}</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
onPageChange={handlePageChange}
page={paginationController.page}
count={checksCount}
rowsPerPage={paginationController.rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
return (
<TableRow key={check._id}>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{new Date(check.createdAt).toLocaleString()}
</TableCell>
<TableCell>{check.statusCode}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginationComponent}
</>
);
};
PaginationTable.propTypes = {
monitorId: PropTypes.string.isRequired,
filter: PropTypes.string.isRequired,
dateRange: PropTypes.string.isRequired,
};
export default PaginationTable;
+13 -14
View File
@@ -113,13 +113,13 @@ const DetailsPage = () => {
const [monitor, setMonitor] = useState({});
const { monitorId } = useParams();
const { authToken } = useSelector((state) => state.auth);
const [filter, setFilter] = useState("day");
const [dateRange, setDateRange] = useState("day");
const navigate = useNavigate();
const fetchMonitor = useCallback(async () => {
try {
const res = await axiosInstance.get(
`/monitors/${monitorId}?sortOrder=asc&filter=${filter}&numToDisplay=50&normalize=true`,
`/monitors/${monitorId}?sortOrder=asc&filter=${dateRange}&numToDisplay=50&normalize=true`,
{
headers: {
Authorization: `Bearer ${authToken}`,
@@ -131,7 +131,7 @@ const DetailsPage = () => {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found");
}
}, [authToken, monitorId, navigate, filter]);
}, [authToken, monitorId, navigate, dateRange]);
useEffect(() => {
fetchMonitor();
@@ -301,44 +301,43 @@ const DetailsPage = () => {
<Button
level="secondary"
label="Day"
onClick={() => setFilter("day")}
onClick={() => setDateRange("day")}
sx={{
backgroundColor:
filter === "day" && theme.palette.otherColors.fillGray,
dateRange === "day" &&
theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Week"
onClick={() => setFilter("week")}
onClick={() => setDateRange("week")}
sx={{
backgroundColor:
filter === "week" && theme.palette.otherColors.fillGray,
dateRange === "week" &&
theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Month"
onClick={() => setFilter("month")}
onClick={() => setDateRange("month")}
sx={{
backgroundColor:
filter === "month" &&
dateRange === "month" &&
theme.palette.otherColors.fillGray,
}}
/>
</ButtonGroup>
</Stack>
<Box sx={{ height: "200px" }}>
<MonitorDetailsAreaChart
checks={monitor.checks}
filter={filter}
/>
<MonitorDetailsAreaChart checks={monitor.checks} />
</Box>
</Box>
<Stack gap={theme.gap.ml}>
<Typography component="h2">History</Typography>
{/* TODO New Table */}
<PaginationTable monitorId={monitorId} filter={filter} />
<PaginationTable monitorId={monitorId} dateRange={dateRange} />
{/* <BasicTable data={data} paginated={true} /> */}
</Stack>
</Stack>
-1
View File
@@ -12,7 +12,6 @@
.monitors p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.monitors h2:has(+ p),
.monitors p.MuiTypography-root {
color: var(--env-var-color-5);
}
@@ -0,0 +1,68 @@
.configure-pagespeed form > .MuiStack-root:nth-child(2) {
background-color: var(--env-var-color-8);
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-1);
padding: var(--env-var-default-2);
}
.configure-pagespeed h1.MuiTypography-root {
font-size: var(--env-var-font-size-large-plus);
}
.configure-pagespeed h1.MuiTypography-root,
.configure-pagespeed h2.MuiTypography-root {
color: var(--env-var-color-1);
}
.configure-pagespeed span.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
.configure-pagespeed p.MuiTypography-root,
.configure-pagespeed h3.MuiTypography-root {
color: var(--env-var-color-5);
}
.configure-pagespeed h1.MuiTypography-root,
.configure-pagespeed h3.MuiTypography-root,
.configure-pagespeed h2.MuiTypography-root {
font-weight: 600;
}
.configure-pagespeed p.MuiTypography-root,
.configure-pagespeed h3.MuiTypography-root,
.configure-pagespeed h3.MuiTypography-root > span.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.configure-pagespeed .MuiBox-root > .field + p.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
.configure-pagespeed .MuiBox-root > .field + p.MuiTypography-root,
.configure-pagespeed h3.MuiTypography-root > span.MuiTypography-root {
opacity: 0.6;
}
.configure-pagespeed .checkbox-wrapper {
margin-bottom: 1px;
}
.configure-pagespeed .MuiInputBase-root:has(> .Mui-disabled) {
background-color: var(--env-var-color-13);
}
/* to be removed */
.configure-pagespeed .section-disabled {
opacity: 0.4;
background-color: #f1f1f2;
padding: 10px;
border-radius: 8px;
user-select: none;
pointer-events: none;
}
.configure-pagespeed button {
height: var(--env-var-height-2);
}
body:has(.configure-pagespeed) .select-dropdown .MuiMenuItem-root,
.configure-pagespeed .select-wrapper .select-component > .MuiSelect-select {
text-transform: lowercase;
}
.configure-pagespeed .field,
.configure-pagespeed .section-disabled,
.configure-pagespeed .select-wrapper,
.configure-pagespeed h3.MuiTypography-root {
flex: 1;
}
@@ -0,0 +1,365 @@
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { Box, Modal, Skeleton, Stack, Typography } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router";
import {
deletePageSpeed,
getPageSpeedByUserId,
updatePageSpeed,
} from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import Button from "../../../Components/Button";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import GreenCheck from "../../../assets/icons/checkbox-green.svg?react";
import RedCheck from "../../../assets/icons/checkbox-red.svg?react";
import "./index.css";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack gap={theme.gap.xl} mt={theme.gap.medium} maxWidth="1000px">
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.gap.small }}
/>
<Skeleton variant="rounded" width="50%" height={18} />
</Box>
<Stack
direction="row"
gap={theme.gap.medium}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton variant="rounded" width={100} height={34} />
<Skeleton variant="rounded" width={100} height={34} />
</Stack>
</Stack>
<Skeleton variant="rounded" width="100%" height={500} />
<Stack direction="row" justifyContent="flex-end">
<Skeleton variant="rounded" width="15%" height={34} />
</Stack>
</Stack>
</>
);
};
const PageSpeedConfigure = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const MS_PER_MINUTE = 60000;
const { authToken } = useSelector((state) => state.auth);
const { monitors } = useSelector((state) => state.pageSpeedMonitors);
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
useEffect(() => {
const data = monitors.find((monitor) => monitor._id === monitorId);
if (!data) {
console.error("Error fetching pagespeed monitor of id: " + monitorId);
navigate("/not-found");
}
setMonitor({
...data,
});
}, [monitorId]);
const handleChange = (event, id) => {
let { value } = event.target;
if (id === "interval") {
value = value * MS_PER_MINUTE;
}
setMonitor((prev) => ({ ...prev, [id]: value }));
const { error } = monitorValidation.validate(
{ [id]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const newErrors = { ...prev };
if (error) newErrors[id] = error.details[0].message;
else delete newErrors[id];
return newErrors;
});
};
const handleSave = async (event) => {
event.preventDefault();
const action = await dispatch(
updatePageSpeed({ authToken, monitor: monitor })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor updated successfully!" });
dispatch(getPageSpeedByUserId(authToken));
} else {
createToast({ body: "Failed to update monitor." });
}
};
const [isOpen, setIsOpen] = useState(false);
const handleRemove = async (event) => {
event.preventDefault();
const action = await dispatch(deletePageSpeed({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/pagespeed");
} else {
createToast({ body: "Failed to delete monitor." });
}
};
let loading = Object.keys(monitor).length === 0;
return (
<Box className="configure-pagespeed">
{loading ? (
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate(-1)}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
mb: theme.gap.large,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
/>
<form
noValidate
spellCheck="false"
onSubmit={handleSave}
style={{
display: "flex",
flexDirection: "column",
gap: theme.gap.large,
maxWidth: "1000px",
}}
>
<Stack direction="row" gap={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
<Typography
component="h1"
mb={theme.gap.xs}
sx={{ lineHeight: 1 }}
>
{monitor?.url}
</Typography>
<Typography
component="span"
sx={{
color: monitor?.status
? "var(--env-var-color-17)"
: "var(--env-var-color-24)",
}}
>
Your pagespeed monitor is {monitor?.status ? "live" : "down"}.
</Typography>
</Box>
<Box alignSelf="flex-end" ml="auto">
<Button
level="tertiary"
label="Pause"
animate="rotate180"
img={<PauseCircleOutlineIcon />}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
pl: theme.gap.small,
pr: theme.gap.medium,
"& svg": {
mr: theme.gap.xs,
},
}}
/>
<Button
level="error"
label="Remove"
sx={{
boxShadow: "none",
px: theme.gap.ml,
ml: theme.gap.medium,
}}
onClick={() => setIsOpen(true)}
/>
</Box>
</Stack>
<Stack gap={theme.gap.xl}>
<Stack direction="row">
<Typography component="h3">Monitor friendly name</Typography>
<Field
type="text"
id="monitor-name"
placeholder="Example monitor"
value={monitor?.name || ""}
onChange={(event) => handleChange(event, "name")}
error={errors.name}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">URL</Typography>
<Field
type="url"
id="monitor-url"
placeholder="random.website.com"
value={monitor?.url?.replace("http://", "") || ""}
onChange={(event) => handleChange(event, "url")}
error={errors.url}
disabled={true}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">Check frequency</Typography>
<Select
id="monitor-frequency"
items={frequencies}
value={monitor?.interval / MS_PER_MINUTE || 3}
onChange={(event) => handleChange(event, "interval")}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">
Incidents notifications{" "}
<Typography component="span">(coming soon)</Typography>
</Typography>
<Stack className="section-disabled">
<Typography mb={theme.gap.small}>
When there is a new incident,
</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
isDisabled={true}
/>
<Checkbox
id="notify-email"
label="Notify via email (to gorkem.cetin@bluewavelabs.ca)"
isChecked={false}
/>
<Checkbox
id="notify-emails"
label="Notify via email to following emails"
isChecked={false}
/>
<Box mx={`calc(${theme.gap.ml} * 2)`}>
<Field
id="notify-emails-list"
placeholder="notifications@gmail.com"
value=""
onChange={() => console.log("disabled")}
error=""
/>
<Typography mt={theme.gap.small}>
You can separate multiple emails with a comma
</Typography>
</Box>
</Stack>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<Button
type="submit"
level="primary"
label="Save"
onClick={handleSave}
sx={{ px: theme.gap.large, mt: theme.gap.large }}
/>
</Stack>
</form>
</>
)}
<Modal
aria-labelledby="modal-delete-pagespeed-monitor"
aria-describedby="delete-pagespeed-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
disablePortal
>
<Stack
gap={theme.gap.xs}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "white",
border: "solid 1px #f2f2f2",
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: 24,
p: "30px",
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-pagespeed-monitor" component="h2">
Do you really want to delete this monitor?
</Typography>
<Typography id="delete-pagespeed-monitor-confirmation">
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
gap={theme.gap.small}
mt={theme.gap.large}
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
onClick={() => setIsOpen(false)}
/>
<Button level="error" label="Delete" onClick={handleRemove} />
</Stack>
</Stack>
</Modal>
</Box>
);
};
export default PageSpeedConfigure;
+3 -3
View File
@@ -139,7 +139,7 @@ const SkeletonLayout = () => {
<Skeleton variant="rounded" width="15%" height={34} />
<Stack direction="row" gap={theme.gap.small}>
<Skeleton variant="circular" style={{ minWidth: 24, minHeight: 24 }} />
<Box width="80%">
<Box width="85%">
<Skeleton variant="rounded" width="50%" height={24} />
<Skeleton
variant="rounded"
@@ -289,7 +289,7 @@ const PageSpeedDetails = () => {
return props;
};
const pieSize = { width: 200, height: 200 };
const pieSize = { width: 210, height: 200 };
const pieData = getPieData(audits);
const colorMap = getColors(performance);
@@ -321,7 +321,7 @@ const PageSpeedDetails = () => {
}}
/>
<Stack direction="row" gap={theme.gap.small}>
<GreenCheck />
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
<Typography
component="h1"
+1
View File
@@ -98,6 +98,7 @@ const theme = createTheme({
large: "24px",
xl: "40px",
xxl: "60px",
triplexl: "120px"
},
alert: {
info: {
+4
View File
@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" fill="#1570EF"/>
<path d="M4.43466 24V9.45455H10.0028C11.054 9.45455 11.9276 9.62026 12.6236 9.9517C13.3243 10.2784 13.8475 10.7259 14.1932 11.294C14.5436 11.8622 14.7188 12.5062 14.7188 13.2259C14.7188 13.8177 14.6051 14.3243 14.3778 14.7457C14.1506 15.1624 13.8452 15.5009 13.4616 15.7614C13.0781 16.0218 12.6496 16.2088 12.1761 16.3224V16.4645C12.6922 16.4929 13.187 16.6515 13.6605 16.9403C14.1387 17.2244 14.5294 17.6269 14.8324 18.1477C15.1354 18.6686 15.2869 19.2983 15.2869 20.0369C15.2869 20.7898 15.1046 21.4669 14.7401 22.0682C14.3755 22.6648 13.8262 23.1359 13.0923 23.4815C12.3584 23.8272 11.4351 24 10.3224 24H4.43466ZM7.0696 21.7983H9.90341C10.8598 21.7983 11.5488 21.616 11.9702 21.2514C12.3963 20.8821 12.6094 20.4086 12.6094 19.831C12.6094 19.4001 12.5028 19.0118 12.2898 18.6662C12.0767 18.3158 11.7737 18.0412 11.3807 17.8423C10.9877 17.6387 10.5189 17.5369 9.97443 17.5369H7.0696V21.7983ZM7.0696 15.6406H9.67614C10.1307 15.6406 10.5402 15.5578 10.9048 15.392C11.2694 15.2216 11.5559 14.9825 11.7642 14.6747C11.9773 14.3622 12.0838 13.9929 12.0838 13.5668C12.0838 13.0033 11.8849 12.5393 11.4872 12.1747C11.0942 11.8101 10.5095 11.6278 9.73295 11.6278H7.0696V15.6406ZM25.5482 9.45455H28.1832V18.9574C28.1832 19.9991 27.937 20.9152 27.4445 21.706C26.9568 22.4967 26.2703 23.1146 25.3849 23.5597C24.4995 24 23.4649 24.2202 22.2812 24.2202C21.0927 24.2202 20.0558 24 19.1704 23.5597C18.285 23.1146 17.5984 22.4967 17.1107 21.706C16.623 20.9152 16.3792 19.9991 16.3792 18.9574V9.45455H19.0141V18.7372C19.0141 19.3433 19.1467 19.883 19.4119 20.3565C19.6817 20.83 20.0605 21.2017 20.5482 21.4716C21.0359 21.7367 21.6136 21.8693 22.2812 21.8693C22.9488 21.8693 23.5264 21.7367 24.0141 21.4716C24.5066 21.2017 24.8853 20.83 25.1505 20.3565C25.4156 19.883 25.5482 19.3433 25.5482 18.7372V9.45455Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5 18L14.8571 12M9.14286 12L2.50003 18M2 7L10.1649 12.7154C10.8261 13.1783 11.1567 13.4097 11.5163 13.4993C11.8339 13.5785 12.1661 13.5785 12.4837 13.4993C12.8433 13.4097 13.1739 13.1783 13.8351 12.7154L22 7M6.8 20H17.2C18.8802 20 19.7202 20 20.362 19.673C20.9265 19.3854 21.3854 18.9265 21.673 18.362C22 17.7202 22 16.8802 22 15.2V8.8C22 7.11984 22 6.27976 21.673 5.63803C21.3854 5.07354 20.9265 4.6146 20.362 4.32698C19.7202 4 18.8802 4 17.2 4H6.8C5.11984 4 4.27976 4 3.63803 4.32698C3.07354 4.6146 2.6146 5.07354 2.32698 5.63803C2 6.27976 2 7.11984 2 8.8V15.2C2 16.8802 2 17.7202 2.32698 18.362C2.6146 18.9265 3.07354 19.3854 3.63803 19.673C4.27976 20 5.11984 20 6.8 20Z" stroke="#475467" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

+6
View File
@@ -1,5 +1,9 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
* {
box-sizing: border-box;
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
font-weight: 400;
@@ -17,8 +21,10 @@
--env-var-font-familt-1: "Roboto", "Helvetica", "Arial", sans-serif;
--env-var-color-0: #fff;
--env-var-color-1: #101828;
--env-var-color-2: #475467;
--env-var-color-2-light: #838c99;
--env-var-color-3: #1570ef;
--env-var-color-4: #d0d5dd;
+34 -18
View File
@@ -20,18 +20,22 @@ BlueWave Uptime is an open source server monitoring application used to track th
## Features
* Completely open source, deployable on your servers
* Website monitoring
* Port monitoring
* Ping monitoring
* Incidents at a glance
* Status pages
* Page speed monitoring (in the works)
- [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
- [ ] Scheduled maintenance (in the works)
- [ ] Status pages (in the works)
**Roadmap:**
* 3rd party integrations
* Domain monitoring
* SSL monitoring
**Roadmap (short term):**
- [ ] 3rd party integrations
- [ ] DNS monitoring
- [ ] SSL monitoring
**Roadmap (long term):**
- [ ] Memory, disk and CPU monitoring using OpenTelemetry
## Tech stack
@@ -76,29 +80,41 @@ Made with [contrib.rocks](https://contrib.rocks).
###### Auth
- <code>POST</code> [/api/v1/auth/register](#post-register)
- <code>POST</code> [/api/v1/auth/login](#post-login)
- <code>POST</code> [/api/v1/auth/user/{userId}](#post-auth-user-edit-id)
- <code>PUT</code> [/api/v1/auth/user/{userId}](#post-auth-user-edit-id)
- <code>GET</code> [/api/v1/auth/users](#get-all-users-id)
- <code>GET</code> [/api/v1/auth/users/admin](#get-all-admin-users-id)
- <code>POST</code> [/api/v1/auth/invite](#post-auth-invite-id)
- <code>POST</code> [/api/v1/auth/invite/verify/](#post-auth-invite-verify-id)
- <code>POST</code> [/api/v1/auth/recovery/request](#post-auth-recovery-request-id)
- <code>POST</code> [/api/v1/auth/recovery/validate](#post-auth-recovery-validate-id)
- <code>POST</code> [/api/v1/auth/recovery/reset](#post-auth-recovery-reset-id)
###### Monitors
- <code>GET</code> [/api/v1/monitors](#get-monitors)
- <code>GET</code> [/api/v1/monitor/{id}](#get-monitor-id)
- <code>GET</code> [/api/v1/monitors/{monitorId}](#get-monitor-id)
- <code>GET</code> [/api/v1/monitors/user/{userId}?limit](#get-monitors-user-userid)
- <code>POST</code> [/api/v1/monitors](#post-monitors)
- <code>POST</code> [/api/v1/monitors/delete/{monitorId}](#post-monitors-del-id)
- <code>POST</code> [/api/v1/monitors/edit/{monitorId}](#post-monitors-edit-id)
- <code>PUT</code> [/api/v1/monitors/{monitorId}](#put-monitors-id)
- <code>DELETE</code> [/api/v1/monitors/{monitorId}](#delete-monitors-id)
- <code>DELETE</code> [/api/v1/monitors/all](#delete-all-monitors)
###### Checks
- <code>POST</code> [/api/v1/checks/{monitorId}](#post-checks)
- <code>GET</code> [/api/v1/checks/{monitorId}](#get-checks)
- <code>POST</code> [/api/v1/checks/delete/{monitorId}](#delete-checks)
- <code>DELETE</code> [/api/v1/checks/{monitorId}](#delete-checks)
###### Alerts
- <code>POST</code> [/api/v1/alerts/{monitorId}](#create-alert)
- <code>GET</code> [/api/v1/alerts/user/{userId}](#get-alerts-user-id)
- <code>GET</code> [/api/v1/alerts/monitor/{monitorId}](#get-alerts-monitor-id)
- <code>GET</code> [/api/v1/alerts/{alertId}](#get-alert-alert-id)
- <code>POST</code> [/api/v1/alerts/edit/{alertId}](#edit-alert)
- <code>POST</code> [/api/v1/alerts/delete/{alertId}](#delete-alert)
- <code>PUT</code> [/api/v1/alerts/{alertId}](#edit-alert)
- <code>DELETE</code> [/api/v1/alerts/{alertId}](#delete-alert)
###### Page Speed
- <code>POST</code> [/api/v1/pagespeed/:monitorId](#post-pagespeed)
- <code>GET</code> [/api/v1/pagespeed/:monitorId](#get-pagespeed)
- <code>DELETE</code> [/api/v1/pagespeed/:monitorId](#delete-pagespeed)
###### Maintenance Window
- <code>POST</code> [/api/v1/maintenance-window/:monitorId](#post-maintenance-window)
- <code>GET</code> [/api/v1/maintenance-window/user/:userId](#get-maintenance-window-by-userid)
- <code>GET</code> [/api/v1/maintenance-window/monitor/:monitorId](#get-maintenance-window-by-monitor-id)
1. [Error Handling](#error-handling)
1. [Contributors](#contributors)
+6
View File
@@ -0,0 +1,6 @@
# Security Policy
If you find a vulnerability, send it to hello@bluewavelabs.ca
Please do not create an issue for security vulnerabilities.
+28
View File
@@ -3,6 +3,8 @@ const {
createCheckBodyValidation,
getChecksParamValidation,
getChecksQueryValidation,
getUserChecksParamValidation,
getUserChecksQueryValidation,
deleteChecksParamValidation,
} = require("../validation/joi");
const { successMessages } = require("../utils/messages");
@@ -60,6 +62,31 @@ const getChecks = async (req, res, next) => {
}
};
const getUserChecks = async (req, res, next) => {
try {
await getUserChecksParamValidation.validateAsync(req.params);
await getUserChecksQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const checkData = await req.db.getUserChecks(req);
return res.status(200).json({
success: true,
msg: successMessages.CHECK_GET,
data: checkData,
});
} catch (error) {
error.service = SERVICE_NAME;
next(error);
}
};
const deleteChecks = async (req, res, next) => {
try {
await deleteChecksParamValidation.validateAsync(req.params);
@@ -88,5 +115,6 @@ const deleteChecks = async (req, res, next) => {
module.exports = {
createCheck,
getChecks,
getUserChecks,
deleteChecks,
};
+2
View File
@@ -90,6 +90,7 @@ const {
createCheck,
getChecksCount,
getChecks,
getUserChecks,
deleteChecks,
} = require("./modules/checkModule");
@@ -148,6 +149,7 @@ module.exports = {
createCheck,
getChecksCount,
getChecks,
getUserChecks,
deleteChecks,
createAlert,
getAlertsByUserId,
+106 -14
View File
@@ -1,9 +1,12 @@
const Check = require("../../../models/Check");
const filterLookup = {
const Monitor = require("../../../models/Monitor");
const mongoose = require("mongoose");
const dateRangeLookup = {
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
};
/**
* Create a check for a monitor
* @async
@@ -28,13 +31,32 @@ const createCheck = async (checkData) => {
const getChecksCount = async (req) => {
const monitorId = req.params.monitorId;
const dateRange = req.query.dateRange;
const filter = req.query.filter;
console.log(filter);
// Build query
const checksQuery = { monitorId: monitorId };
// Filter checks by "day", "week", or "month"
if (filter !== undefined) {
checksQuery.createdAt = { $gte: filterLookup[filter] };
if (dateRange !== undefined) {
checksQuery.createdAt = { $gte: dateRangeLookup[dateRange] };
}
if (filter !== undefined) {
checksQuery.status = false;
switch (filter) {
case "all":
break;
case "down":
break;
case "resolve":
checksQuery.statusCode = 5000;
break;
default:
console.log("default");
break;
}
}
const count = await Check.countDocuments(checksQuery);
return count;
};
@@ -50,23 +72,34 @@ const getChecksCount = async (req) => {
const getChecks = async (req) => {
try {
const { monitorId } = req.params;
let { sortOrder, limit, filter, page, rowsPerPage } = req.query;
let { sortOrder, limit, dateRange, filter, page, rowsPerPage } = req.query;
// Default limit to 0 if not provided
if (limit === undefined) limit = 0;
limit = limit === "undefined" ? 0 : limit;
// Default sort order is newest -> oldest
if (sortOrder === "asc") {
sortOrder = 1;
} else if (sortOrder === "desc") {
sortOrder = -1;
} else sortOrder = -1;
sortOrder = sortOrder === "asc" ? 1 : -1;
// Build query
const checksQuery = { monitorId: monitorId };
// Filter checks by "day", "week", or "month"
if (dateRange !== undefined) {
checksQuery.createdAt = { $gte: dateRangeLookup[dateRange] };
}
// Fitler checks by status
if (filter !== undefined) {
checksQuery.createdAt = { $gte: filterLookup[filter] };
checksQuery.status = false;
switch (filter) {
case "all":
break;
case "down":
break;
case "resolve":
checksQuery.statusCode = 5000;
break;
default:
console.log("default");
break;
}
}
// Need to skip and limit here
@@ -84,8 +117,61 @@ const getChecks = async (req) => {
}
};
const getUserChecks = async (req) => {
const { userId } = req.params;
let { sortOrder, limit, dateRange, filter, page, rowsPerPage } = req.query;
// Get monitorIDs
const userMonitors = await Monitor.find({ userId: userId });
const monitorIds = userMonitors.map((monitor) => monitor._id);
//Build check query
// Default limit to 0 if not provided
limit = limit === "undefined" ? 0 : limit;
// Default sort order is newest -> oldest
sortOrder = sortOrder === "asc" ? 1 : -1;
checksQuery = { monitorId: { $in: monitorIds } };
if (filter !== undefined) {
checksQuery.status = false;
switch (filter) {
case "all":
break;
case "down":
break;
case "resolve":
checksQuery.statusCode = 5000;
break;
default:
console.log("default");
break;
}
}
if (dateRange !== undefined) {
checksQuery.createdAt = { $gte: dateRangeLookup[dateRange] };
}
// Skip and limit for pagination
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
const checksCount = await Check.countDocuments(checksQuery);
const checks = await Check.find(checksQuery)
.skip(skip)
.limit(rowsPerPage)
.sort({ createdAt: sortOrder });
return { checksCount, checks };
};
/**
* Delete all checks for a monitor
* Delete all checks for a user
* @async
* @param {string} monitorId
* @returns {number}
@@ -100,4 +186,10 @@ const deleteChecks = async (monitorId) => {
throw error;
}
};
module.exports = { createCheck, getChecksCount, getChecks, deleteChecks };
module.exports = {
createCheck,
getChecksCount,
getChecks,
getUserChecks,
deleteChecks,
};
+6 -5
View File
@@ -81,14 +81,15 @@ const getMonitorById = async (req, res) => {
// If more than numToDisplay checks, pick every nth check
if (numToDisplay !== undefined && checks && checks.length > numToDisplay) {
const n = Math.ceil(checks.length / numToDisplay);
checks = checks.filter((_, index) => index % n === 0);
checks = checks.filter(
(_, index) => index % n === 0 || index === checks.length - 1
);
}
// Normalize checks if requested
if (normalize) {
checks = NormalizeData(checks, 10, 100);
if (normalize !== undefined) {
checks = NormalizeData(checks, 1, 100);
}
const notifications = await Notification.find({ monitorId: monitor._id });
const monitorWithChecks = { ...monitor.toObject(), checks, notifications };
return monitorWithChecks;
@@ -148,7 +149,7 @@ const getMonitorsByUserId = async (req, res) => {
.limit(limit);
//Normalize checks if requested
if (normalize === true) {
if (normalize !== undefined) {
checks = NormalizeData(checks, 10, 100);
}
+13 -12
View File
@@ -4,7 +4,7 @@ const Notification = require("../models/Notification");
/**
* Check Schema for MongoDB collection.
*
*
* Represents a check associated with a monitor, storing information
* about the status and response of a particular check event.
*/
@@ -12,7 +12,7 @@ const CheckSchema = mongoose.Schema(
{
/**
* Reference to the associated Monitor document.
*
*
* @type {mongoose.Schema.Types.ObjectId}
*/
monitorId: {
@@ -22,7 +22,7 @@ const CheckSchema = mongoose.Schema(
},
/**
* Status of the check (true for up, false for down).
*
*
* @type {Boolean}
*/
status: {
@@ -30,23 +30,23 @@ const CheckSchema = mongoose.Schema(
},
/**
* Response time of the check in milliseconds.
*
*
* @type {Number}
*/
responseTime: {
type: Number,
type: Number,
},
/**
* HTTP status code received during the check.
*
*
* @type {Number}
*/
statusCode: {
type: Number,
type: Number,
},
/**
* Message or description of the check result.
*
*
* @type {String}
*/
message: {
@@ -54,7 +54,7 @@ const CheckSchema = mongoose.Schema(
},
/**
* Expiry date of the check, auto-calculated to expire after 30 days.
*
*
* @type {Date}
*/
expiry: {
@@ -70,7 +70,7 @@ const CheckSchema = mongoose.Schema(
/**
* Pre-save middleware to handle status change notifications.
*
*
* This middleware checks if the status of the monitor associated
* with the check has changed and sends notifications if necessary.
*/
@@ -79,7 +79,9 @@ CheckSchema.pre("save", async function (next) {
const monitor = await mongoose.model("Monitor").findById(this.monitorId);
if (monitor) {
const notifications = await Notification.find({ monitorId: this.monitorId });
const notifications = await Notification.find({
monitorId: this.monitorId,
});
// Check if there are any notifications
if (notifications && notifications.length > 0) {
@@ -128,5 +130,4 @@ CheckSchema.pre("save", async function (next) {
}
});
module.exports = mongoose.model("Check", CheckSchema);
+4 -4
View File
@@ -29,14 +29,14 @@ router.get(
alertController.getAlertById
);
// Edit
router.post(
"/edit/:alertId",
router.put(
"/:alertId",
verifyOwnership(Monitor, "monitorId"),
alertController.editAlert
);
//Delete
router.post(
"/delete/:alertId",
router.delete(
"/:alertId",
verifyOwnership(Monitor, "monitorId"),
alertController.deleteAlert
);
+5 -3
View File
@@ -15,10 +15,12 @@ router.get(
checkController.getChecks
);
router.post(
"/delete/:monitorId",
router.delete(
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
checkController.deleteChecks
);
module.exports = router;
router.get("/user/:userId", checkController.getUserChecks);
module.exports = router;
+2 -2
View File
@@ -14,10 +14,10 @@ router.delete(
monitorController.deleteMonitor
);
router.put(
"/edit/:monitorId",
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
monitorController.editMonitor
);
router.delete("/delete/all", monitorController.deleteAllMonitors);
router.delete("/all", monitorController.deleteAllMonitors);
module.exports = router;
+17 -1
View File
@@ -235,7 +235,21 @@ const getChecksParamValidation = joi.object({
const getChecksQueryValidation = joi.object({
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
filter: joi.string().valid("day", "week", "month"),
dateRange: joi.string().valid("day", "week", "month"),
filter: joi.string().valid("all", "down", "resolve"),
page: joi.number(),
rowsPerPage: joi.number(),
});
const getUserChecksParamValidation = joi.object({
userId: joi.string().required(),
});
const getUserChecksQueryValidation = joi.object({
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
dateRange: joi.string().valid("day", "week", "month"),
filter: joi.string().valid("all", "down", "resolve"),
page: joi.number(),
rowsPerPage: joi.number(),
});
@@ -320,6 +334,8 @@ module.exports = {
createCheckBodyValidation,
getChecksParamValidation,
getChecksQueryValidation,
getUserChecksParamValidation,
getUserChecksQueryValidation,
deleteChecksParamValidation,
deleteUserParamValidation,
getPageSpeedCheckParamValidation,