This commit is contained in:
MuhammadKhalilzadeh committed 2024-06-26 22:01:01 +03:30
commit 4cc59e6728
42 files changed
+1897 -565

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
Client/node_modules/
Server/node_modules/
*.env
+647 -219
View File
File diff suppressed because it is too large. Load diff
+34 -7
View File
@@ -7,6 +7,7 @@ import HomeLayout from "./Layouts/HomeLayout";
import Demo from "./Pages/Demo/Demo";
import PlayGround from "./Pages/PlayGround/PlayGround";
import Monitors from "./Pages/Monitors";
import CreateNewMonitor from "./Pages/CreateNewMonitor";
import Incidents from "./Pages/Incidents";
import Status from "./Pages/Status";
import Integrations from "./Pages/Integrations";
@@ -16,22 +17,48 @@ import CheckEmail from "./Pages/CheckEmail";
import SetNewPassword from "./Pages/SetNewPassword";
import NewPasswordConfirmed from "./Pages/NewPasswordConfirmed";
import ToastComponent from "./Components/Toast";
import ProtectedRoute from "./Components/ProtectedRoute";
function App() {
return (
<>
<Routes>
<Route exact path="/" element={<HomeLayout />}>
<Route path="monitors" element={<Monitors />} />
<Route path="incidents" element={<Incidents />} />
<Route path="status" element={<Status />} />
<Route path="integrations" element={<Integrations />} />
<Route path="settings" element={<Settings />} />
<Route
exact
path="/"
element={<ProtectedRoute Component={Monitors} />}
/>
<Route
path="/monitors"
element={<ProtectedRoute Component={Monitors} />}
/>
<Route
path="/monitors/create"
element={<ProtectedRoute Component={CreateNewMonitor} />}
/>
<Route
path="incidents"
element={<ProtectedRoute Component={Incidents} />}
/>
<Route
path="status"
element={<ProtectedRoute Component={Status} />}
/>
<Route
path="integrations"
element={<ProtectedRoute Component={Integrations} />}
/>
<Route
path="settings"
element={<ProtectedRoute Component={Settings} />}
/>
</Route>
<Route exact path="/register" element={<Register />} />
<Route exact path="/login" element={<Login />} />
<Route exact path="/register" element={<Register />} />
<Route exact path="/demo" element={<Demo />} />
<Route path="/toast" element={<ToastComponent />} />
{/* <Route path="/toast" element={<ToastComponent />} /> */}
<Route path="*" element={<NotFound />} />
<Route path="/playground" element={<PlayGround />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
+4 -1
View File
@@ -8,13 +8,14 @@ import PropTypes from "prop-types";
* @param {string} props.firstName - The users first name
* @param {string} props.lastName - The users last name
* @param {boolean} props.small - Specifies if avatar should be large
* @param {Object} [props.sx] - Additional styles to apply to the button.
* @returns {JSX.Element}
* @example
* // Render a red label
* <Avatar src="assets/img" first="Alex" last="Holliday" small />
*/
const Avatar = ({ src, firstName, lastName, small }) => {
const Avatar = ({ src, firstName, lastName, small, sx }) => {
const borderColor = "#F0F2F4";
const smallStyle = small ? { width: 32, height: 32 } : {};
return (
@@ -25,6 +26,7 @@ const Avatar = ({ src, firstName, lastName, small }) => {
display: "inline-flex",
border: `0.2rem solid ${borderColor}`,
...smallStyle,
...sx
}}
>
{src === undefined ? `${firstName[0]}${lastName[0]}` : null}
@@ -37,6 +39,7 @@ Avatar.propTypes = {
firstName: PropTypes.string.isRequired,
lastName: PropTypes.string.isRequired,
small: PropTypes.bool,
sx: PropTypes.object,
};
export default Avatar;
@@ -1,17 +1,57 @@
import React from "react";
import "./barChart.css";
import PropTypes from "prop-types";
const RANGE_MAX = 100;
const RANGE_MIN = 1;
/**
* Normalizes the response times of a set of checks to a specified range.
* This function calculates the minimum and maximum response times from the input,
* then scales each check's response time linearly between `RANGE_MIN` and `RANGE_MAX`.
*
* @param {Array<Checks>} checks - An array of check objects. Each check should have a `responseTime` property.
* @returns {Array<Checks>} An array of check objects with normalized `responseTime` values.
*/
const normalizeData = (checks) => {
const min = checks.reduce((accum, cur) => {
return Math.min(accum, cur.responseTime);
}, Infinity);
const max = checks.reduce((accum, cur) => {
return Math.max(accum, cur.responseTime);
}, 0);
const normalizedChecks = checks.map((check) => {
let normailzedResponseTime =
RANGE_MIN +
((check.responseTime - min) * (RANGE_MAX - RANGE_MIN)) / (max - min);
const normalizedCheck = { ...check, responseTime: normailzedResponseTime };
return normalizedCheck;
});
return normalizedChecks;
};
/**
* BarChart renders a bar chart visualization for a set of checks. Each bar represents a check's response time,
* with the color indicating the check's status (green for successful checks, red for failed ones).
*
* @component
* @param {Array<Checks>} checks - An array of check objects to be visualized. Each check object should have an `_id`,
* a `status` indicating success (true) or failure (false), and a `responseTime` representing the height of the bar in percentage.
*/
const BarChart = ({ checks = [] }) => {
const normailzedChecks = normalizeData(checks);
return (
<div className="bar-chart">
{checks.map((value, index) => (
<div
key={index}
className={`bar ${value.status ? "green" : "red"}`}
style={{ height: `${value.responseTime / 3}%` }}
/>
))}
{normailzedChecks.map((check) => {
return (
<div
key={check._id}
className={`bar ${check.status ? "green" : "red"}`}
style={{ height: `${check.responseTime}%` }}
/>
);
})}
</div>
);
};
@@ -7,7 +7,7 @@ import { useTheme } from "@mui/material";
* @component
* @param {Object} props
* @param {string} props.title - The title text for the server status (required)
* @param {string} props.value - The value text to be displayed (required)
* @param {number} props.value - The value text to be displayed (required)
* @param {string} props.state - The state of the server (e.g., "online", "offline", "warning") (required)
* @returns {JSX.Element} - Renders the server status component
*/
@@ -30,7 +30,7 @@ const ServerStatus = ({ title, value, state }) => {
ServerStatus.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
state: PropTypes.string.isRequired,
};
@@ -1,8 +1,15 @@
import SearchTextField from "../TextFields/Search/SearchTextField";
import "./index.css";
import React from "react";
import HostsTable from "../HostsTable";
import Pagination from "../Pagination";
import PropTypes from "prop-types";
/**
* CurrentMonitors displays the current status of monitor
*
* @component
* @param {Array<Monitor>} monitors - An array of monitor objects to be displayed.
*/
const CurrentMonitors = ({ monitors }) => {
return (
@@ -10,7 +17,7 @@ const CurrentMonitors = ({ monitors }) => {
<div className="current-monitors-bar">
<div className="current-monitors-title-holder">
<div className="current-monitors-title">Current monitors</div>
<div className="current-monitors-counter">5</div>
<div className="current-monitors-counter">{monitors.length}</div>
</div>
<div className="current-monitors-search-bar">
<SearchTextField />
@@ -24,4 +31,8 @@ const CurrentMonitors = ({ monitors }) => {
);
};
CurrentMonitors.propTypes = {
monitors: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export default CurrentMonitors;
+60 -10
View File
@@ -1,4 +1,32 @@
import React from "react";
import BarChart from "../Charts/BarChart/BarChart";
import PropTypes from "prop-types";
import Host from "../Host/";
import HostStatus from "../HostStatus";
/**
* HostsTable displays the current status of monitor
*
* @component
* @param {Array<Monitor>} monitors - An array of monitor objects to be displayed.
*/
const getLastCheckStatus = (monitor) => {
if (monitor.checks.length === 0) {
// Default values for when there are no checks
return {
color: "var(--env-var-color-21)",
statusText: "No Data",
dotColor: "var(--env-var-color-19)",
};
}
const lastCheck = monitor.checks[monitor.checks.length - 1];
const isUp = lastCheck.status === true;
return {
color: isUp ? "var(--env-var-color-20)" : "var(--env-var-color-21)",
statusText: isUp ? "Up" : "Down",
dotColor: isUp ? "var(--env-var-color-17)" : "var(--env-var-color-19)",
};
};
const HostsTable = ({ monitors }) => {
return (
@@ -8,23 +36,45 @@ const HostsTable = ({ monitors }) => {
<tr className="theader-row">
<td className="theader-row-cell">Host</td>
<td className="theader-row-cell">Status</td>
<td className="theader-row-cell">Team</td>
<td className="theader-row-cell">Response</td>
<td className="theader-row-cell">Actions</td>
</tr>
</thead>
<tbody>
{monitors.map((monitor, index) => (
<tr className="tbody-row" key={index}>
<td className="tbody-row-cell">{monitor.host}</td>
<td className="tbody-row-cell">{monitor.status}</td>
<td className="tbody-row-cell">{monitor.team}</td>
<td className="tbody-row-cell actions-cell">{monitor.actions}</td>
</tr>
))}
{monitors.map((monitor) => {
const host = Host(
monitor.name,
100,
"var(--env-var-color-17)",
monitor.url
);
const { color, currentStatus, dotColor } =
getLastCheckStatus(monitor);
const status = HostStatus(color, currentStatus, dotColor);
return (
<tr className="tbody-row" key={monitor._id}>
<td className="tbody-row-cell">{host}</td>
<td className="tbody-row-cell">{status}</td>
<td className="tbody-row-cell">
<BarChart checks={monitor.checks} />
</td>
<td className="tbody-row-cell actions-cell">
{monitor.actions}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
HostsTable.propTypes = {
monitors: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export default HostsTable;
+40 -3
View File
@@ -13,6 +13,11 @@ import Tooltip from "@mui/material/Tooltip";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material/styles";
import ChevronDown from "../../assets/Images/Icon-chevron-down.png";
import { clearAuthState } from "../../Features/Auth/authSlice";
import { clearMonitorState } from "../../Features/Monitors/monitorsSlice";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
const settings = ["Profile", "Team", "Invite Colleagues", "Logout"];
@@ -30,6 +35,9 @@ const settings = ["Profile", "Team", "Invite Colleagues", "Logout"];
function NavBar() {
const theme = useTheme();
const [anchorElUser, setAnchorElUser] = useState(null);
const dispatch = useDispatch();
const navigate = useNavigate();
const authState = useSelector((state) => state.auth);
/**
* Handles opening the user menu.
@@ -40,11 +48,38 @@ function NavBar() {
setAnchorElUser(event.currentTarget);
};
/**
* Handles logging out the user
*
*/
const logout = () => {
// Clear auth state
dispatch(clearAuthState());
dispatch(clearMonitorState());
navigate("/login");
};
/**
* Handles closing the user menu.
*/
const handleCloseUserMenu = () => {
const handleCloseUserMenu = (setting) => {
setAnchorElUser(null);
switch (setting) {
case "Profile":
console.log("Profile");
break;
case "Team":
console.log("Team");
break;
case "Invite Colleagues":
console.log("Invite Colleagues");
break;
case "Logout":
logout();
break;
default:
break;
}
};
return (
@@ -122,7 +157,9 @@ function NavBar() {
className="icon-button-avatar"
style={{ width: "25px", height: "25px" }}
/>
<div className="icon-button-toggle-title">Jackie Dawn</div>
<div className="icon-button-toggle-title">
{authState.user.firstname} {authState.user.lastname}
</div>
<img
className="icon-button-toggle-pic"
src={ChevronDown}
@@ -151,7 +188,7 @@ function NavBar() {
<MenuItem
id="menu-item"
key={setting}
onClick={handleCloseUserMenu}
onClick={() => handleCloseUserMenu(setting)}
>
<Typography
fontSize="var(--env-var-font-size-medium)"
@@ -0,0 +1,30 @@
import { Navigate } from "react-router-dom";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
/**
* ProtectedRoute is a higher-order component that wraps the `Route` component from `react-router-dom`
* to create a protected route. It uses Redux to check if the user is authenticated. If the user is
* authenticated, it renders the component passed to it; otherwise, it redirects the user to the login page.
*
* @param {Object} props - The props passed to the ProtectedRoute component.
* @param {React.ComponentType} props.Component - The component to render if the user is authenticated.
* @param {Object} rest - The remaining props passed to the Route component.
* @returns {React.ReactElement} A Route component that conditionally renders the passed Component or redirects to the login page.
*/
const ProtectedRoute = ({ Component, ...rest }) => {
const authState = useSelector((state) => state.auth);
return authState.authToken ? (
<Component {...rest} />
) : (
<Navigate to="/login" replace />
);
};
ProtectedRoute.propTypes = {
Component: PropTypes.elementType.isRequired,
};
export default ProtectedRoute;
@@ -2,21 +2,12 @@ import TabPanel from "@mui/lab/TabPanel";
import React, { useState } from "react";
import AnnouncementsDualButtonWithIcon from "../../Announcements/AnnouncementsDualButtonWithIcon/AnnouncementsDualButtonWithIcon";
import { useTheme } from "@emotion/react";
import {
Box,
Divider,
FormControl,
IconButton,
InputAdornment,
OutlinedInput,
Stack,
Typography,
} from "@mui/material";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
import { Box, Divider, Stack, Typography } from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined";
import ButtonSpinner from "../../ButtonSpinner";
import PasswordTextField from "../../TextFields/Password/PasswordTextField";
import { editPasswordValidation } from "../../../Validation/validation";
/**
* PasswordPanel component manages the form for editing password.
@@ -31,20 +22,85 @@ const PasswordPanel = () => {
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
//TODO - implement save password function
const handleSavePassword = () => {
//for testing, will tweak when I implement redux slice
const idToName = {
"edit-current-password": "password",
//TBD - form field naming
"edit-new-password": "newpassword",
"edit-confirm-password": "confirm",
};
const [localData, setLocalData] = useState({
password: "",
newpassword: "",
confirm: "",
});
const [errors, setErrors] = useState({});
const handleChange = (event) => {
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
const validation = editPasswordValidation.validate(
{ [name]: value },
{ abortEarly: false, context: { newpassword: localData.newpassword } }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error) {
updatedErrors[name] = validation.error.details[0].message;
} else {
delete updatedErrors[name];
}
return updatedErrors;
});
};
const handleSubmit = (event) => {
event.preventDefault();
setIsLoading(true);
const { error } = editPasswordValidation.validate(localData, {
abortEarly: false,
context: { newpassword: localData.newpassword },
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
setIsLoading(false);
} else {
//TODO - submit logic
}
setTimeout(() => {
setIsLoading(false);
setIsOpen(false);
}, 2000);
};
return (
<TabPanel value="1">
<form className="edit-password-form" noValidate spellCheck="false">
<form
onSubmit={handleSubmit}
className="edit-password-form"
noValidate
spellCheck="false"
>
<div className="edit-password-form__wrapper">
<AnnouncementsDualButtonWithIcon
icon={<InfoOutlinedIcon style={{ fill: theme.palette.secondary.main }} />}
icon={
<InfoOutlinedIcon
style={{ fill: theme.palette.secondary.main }}
/>
}
subject="SSO login"
body="Since you logged in via SSO, you cannot reset or modify your password."
/>
@@ -52,64 +108,54 @@ const PasswordPanel = () => {
<div className="edit-password-form__wrapper">
<Stack>
<Typography variant="h4" component="h1">
Current Password
Current password
</Typography>
</Stack>
<FormControl sx={{ flex: 1 }}>
<OutlinedInput
<Stack>
<PasswordTextField
id="edit-current-password"
value="RandomPasswordLol"
type={showPassword ? "text" : "password"}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword((show) => !show)}
sx={{
width: "30px",
height: "30px",
"&:focus": {
outline: "none",
},
}}
>
{!showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
></OutlinedInput>
</FormControl>
label={null}
placeholder="Enter your current password"
autoComplete="current-password"
visibility={showPassword}
setVisibility={setShowPassword}
onChange={handleChange}
error={errors[idToName["edit-current-password"]] ? true : false}
/>
{errors[idToName["edit-current-password"]] ? (
<Typography variant="h5" component="p" className="input-error">
{errors[idToName["edit-current-password"]]}
</Typography>
) : (
""
)}
</Stack>
</div>
<div className="edit-password-form__wrapper">
<Stack>
<Typography variant="h4" component="h1">
Password
New password
</Typography>
</Stack>
<FormControl sx={{ flex: 1 }}>
<OutlinedInput
id="edit-password"
value="RandomPasswordLol"
type={showPassword ? "text" : "password"}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword((show) => !show)}
sx={{
width: "30px",
height: "30px",
"&:focus": {
outline: "none",
},
}}
>
{!showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
></OutlinedInput>
</FormControl>
<Stack>
<PasswordTextField
id="edit-new-password"
label={null}
placeholder="Enter your new password"
autoComplete="new-password"
visibility={showPassword}
setVisibility={setShowPassword}
onChange={handleChange}
error={errors[idToName["edit-new-password"]] ? true : false}
/>
{errors[idToName["edit-new-password"]] ? (
<Typography variant="h5" component="p" className="input-error">
{errors[idToName["edit-new-password"]]}
</Typography>
) : (
""
)}
</Stack>
</div>
<div className="edit-password-form__wrapper">
<Stack>
@@ -117,6 +163,28 @@ const PasswordPanel = () => {
Confirm new password
</Typography>
</Stack>
<Stack>
<PasswordTextField
id="edit-confirm-password"
label={null}
placeholder="Reenter your new password"
autoComplete="new-password"
visibility={showPassword}
setVisibility={setShowPassword}
onChange={handleChange}
error={errors[idToName["edit-confirm-password"]] ? true : false}
/>
{errors[idToName["edit-confirm-password"]] ? (
<Typography variant="h5" component="p" className="input-error">
{errors[idToName["edit-confirm-password"]]}
</Typography>
) : (
""
)}
</Stack>
</div>
<div className="edit-password-form__wrapper">
<Stack></Stack>
<Box sx={{ flex: 1 }}>
<AnnouncementsDualButtonWithIcon
icon={<WarningAmberOutlinedIcon style={{ fill: "#f79009" }} />}
@@ -130,9 +198,10 @@ const PasswordPanel = () => {
<ButtonSpinner
level="primary"
label="Save"
onClick={handleSavePassword}
onClick={handleSubmit}
isLoading={isLoading}
loadingText="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
sx={{
paddingX: "40px",
}}
@@ -2,16 +2,24 @@ import { useTheme } from "@emotion/react";
import { useState } from "react";
import TabPanel from "@mui/lab/TabPanel";
import {
Avatar,
Box,
Divider,
IconButton,
Modal,
Stack,
TextField,
Typography,
} from "@mui/material";
import ButtonSpinner from "../../ButtonSpinner";
import Button from "../../Button";
import EmailTextField from "../../TextFields/Email/EmailTextField";
import StringTextField from "../../TextFields/Text/TextField";
import Avatar from "../../Avatar";
import { editProfileValidation } from "../../../Validation/validation";
import { useDispatch, useSelector } from "react-redux";
import { update } from "../../../Features/Auth/authSlice";
import ImageField from "../../TextFields/Image";
import DoDisturbAltIcon from "@mui/icons-material/DoDisturbAlt";
import CloseIcon from "@mui/icons-material/Close";
/**
* ProfilePanel component displays a form for editing user profile information
@@ -23,34 +31,78 @@ import Button from "../../Button";
const ProfilePanel = () => {
const theme = useTheme();
//TODO - use redux loading state
//!! - currently all loading buttons are tied to the same state
const [isLoading, setIsLoading] = useState(false);
const dispatch = useDispatch();
const { user, authToken, isLoading } = useSelector((state) => state.auth);
const idToName = {
"edit-first-name": "firstname",
"edit-last-name": "lastname",
//Disabled for now, will revisit in the future
// "edit-email": "email",
};
const [localData, setLocalData] = useState({
firstname: user.firstname,
lastname: user.lastname,
//Disabled for now, will revisit in the future
// email: user.email,
//TODO - upload picture
profilePicUrl: "placeholder",
});
const [errors, setErrors] = useState({});
const [isOpen, setIsOpen] = useState("");
const isModalOpen = (name) => isOpen === name;
const [loading, setLoading] = useState(false);
const handleChange = (event) => {
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
const validation = editProfileValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error) {
updatedErrors[name] = validation.error.details[0].message;
} else {
delete updatedErrors[name];
}
return updatedErrors;
});
};
//TODO - implement delete profile picture function
const handleDeletePicture = () => {
setIsLoading(true);
setLoading(true);
setTimeout(() => {
setIsLoading(false);
setLoading(false);
}, 2000);
};
//TODO - implement update profile function
const handleUpdatePicture = () => {};
const [isOpen, setIsOpen] = useState(false);
//TODO - implement delete account function
const handleDeleteAccount = () => {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
setIsOpen(false);
}, 2000);
};
const handleDeleteAccount = () => {};
//TODO - implement save profile function
const handleSaveProfile = () => {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
setIsOpen(false);
}, 2000);
const handleSaveProfile = (event) => {
event.preventDefault();
if (
localData.firstname === user.firstname &&
localData.lastname === user.lastname &&
localData.profilePicUrl === user.profilePicUrl
) {
//TODO - add toast(profile data is unchanged) and maybe disable button
return;
}
dispatch(update({ authToken, localData }));
//TODO - add toast confirmation
};
return (
@@ -62,14 +114,24 @@ const ProfilePanel = () => {
First Name
</Typography>
</Stack>
{/* TODO - use existing textfield components */}
<TextField
id="edit-first-name"
placeholder="Enter your first name"
sx={{
flex: 1,
}}
/>
<Stack>
<StringTextField
id="edit-first-name"
label={null}
value={localData.firstname}
placeholder="Enter your first name"
autoComplete="given-name"
onChange={handleChange}
error={errors[idToName["edit-first-name"]] ? true : false}
/>
{errors[idToName["edit-first-name"]] ? (
<Typography variant="h5" component="p" className="input-error">
{errors[idToName["edit-first-name"]]}
</Typography>
) : (
""
)}
</Stack>
</div>
<div className="edit-profile-form__wrapper">
<Stack>
@@ -77,14 +139,24 @@ const ProfilePanel = () => {
Last Name
</Typography>
</Stack>
{/* TODO - use existing textfield components */}
<TextField
id="edit-last-name"
placeholder="Enter your last name"
sx={{
flex: 1,
}}
/>
<Stack>
<StringTextField
id="edit-last-name"
label={null}
value={localData.lastname}
placeholder="Enter your last name"
autoComplete="family-name"
onChange={handleChange}
error={errors[idToName["edit-last-name"]] ? true : false}
/>
{errors[idToName["edit-last-name"]] ? (
<Typography variant="h5" component="p" className="input-error">
{errors[idToName["edit-last-name"]]}
</Typography>
) : (
""
)}
</Stack>
</div>
<div className="edit-profile-form__wrapper">
<Stack>
@@ -92,17 +164,28 @@ const ProfilePanel = () => {
Email
</Typography>
<Typography variant="h5" component="p">
After updating, you'll receive a confirmation email.
This is your current email address — it cannot be changed.
</Typography>
</Stack>
{/* TODO - use existing textfield components */}
<TextField
id="edit-email"
placeholder="Enter your email"
sx={{
flex: 1,
}}
/>
<Stack>
<EmailTextField
id="edit-email"
label={null}
value={user.email}
placeholder="Enter your email"
autoComplete="email"
// onChange={handleChange}
// error={errors[idToName["edit-email"]] ? true : false}
disabled={true}
/>
{errors[idToName["edit-email"]] ? (
<Typography variant="h5" component="p" className="input-error">
{errors[idToName["edit-email"]]}
</Typography>
) : (
""
)}
</Stack>
</div>
<div className="edit-profile-form__wrapper">
<Stack>
@@ -114,24 +197,29 @@ const ProfilePanel = () => {
</Typography>
</Stack>
<Stack className="row-stack" direction="row" alignItems="center">
{/* TODO - Use Avatar component instead of @mui */}
{/* TODO - Update placeholder values with redux data */}
<Avatar
alt="Remy Sharp"
src="/static/images/avatar/2.jpg"
className="icon-button-avatar"
style={{ width: "64px", height: "64px" }}
firstName={localData.firstname}
lastName={localData.lastname}
sx={{
width: "64px",
height: "64px",
border: "none",
mr: "8px",
}}
/>
<ButtonSpinner
level="tertiary"
label="Delete"
onClick={handleDeletePicture}
isLoading={isLoading}
isLoading={loading}
/>
{/* TODO - modal popup for update pfp? */}
<Button
level="tertiary"
label="Update"
onClick={handleUpdatePicture}
onClick={() => setIsOpen("picture")}
sx={{
color: theme.palette.primary.main,
}}
@@ -152,6 +240,7 @@ const ProfilePanel = () => {
onClick={handleSaveProfile}
isLoading={isLoading}
loadingText="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
sx={{
paddingX: "40px",
}}
@@ -174,7 +263,7 @@ const ProfilePanel = () => {
<Button
level="error"
label="Delete account"
onClick={() => setIsOpen(true)}
onClick={() => setIsOpen("delete")}
/>
</Box>
</Stack>
@@ -184,8 +273,8 @@ const ProfilePanel = () => {
<Modal
aria-labelledby="modal-delete-account"
aria-describedby="delete-account-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
open={isModalOpen("delete")}
onClose={() => setIsOpen("")}
disablePortal
>
<Stack
@@ -222,7 +311,7 @@ const ProfilePanel = () => {
<Button
level="tertiary"
label="Cancel"
onClick={() => setIsOpen(false)}
onClick={() => setIsOpen("")}
/>
<ButtonSpinner
level="error"
@@ -233,6 +322,71 @@ const ProfilePanel = () => {
</Stack>
</Stack>
</Modal>
<Modal
aria-labelledby="modal-update-picture"
aria-describedby="update-profile-picture"
open={isModalOpen("picture")}
onClose={() => setIsOpen("")}
disablePortal
>
<Stack
gap="20px"
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",
},
}}
>
<Stack
direction="row"
mt="-10px"
justifyContent="space-between"
alignItems="center"
>
<Typography id="modal-update-picture" variant="h4" component="h1">
Upload Image
</Typography>
<IconButton
onClick={() => setIsOpen("")}
sx={{
"&:focus": {
outline: "none",
},
}}
>
<CloseIcon />
</IconButton>
</Stack>
<ImageField id="update-profile-picture" />
<Stack direction="row" mt="10px" justifyContent="space-between">
<Button level="secondary" label="Edit" disabled />
<Stack direction="row" gap="10px" justifyContent="flex-end">
<ButtonSpinner
level="tertiary"
label="Remove"
onClick={handleDeletePicture}
isLoading={loading}
/>
<ButtonSpinner
level="primary"
label="Update"
onClick={handleUpdatePicture}
isLoading={isLoading}
/>
</Stack>
</Stack>
</Stack>
</Modal>
</TabPanel>
);
};
@@ -13,12 +13,14 @@ import { useTheme } from "@mui/material";
* @param {function} props.onChange - The function to call when the text field changes.
* @param {string} props.id - The id of the text field.
* @param {string} [props.label="Email"] - The label of the text field.
* @param {string} props.value - The value of the text field.
* @param {string} props.variant - The variant of the text field.
* @param {string} props.placeholder - The placeholder of the text field.
* @param {React.Element} props.icon - The icon to be displayed in the text field.
* @param {string} props.helperText - The helper text to be displayed below the text field.
* @param {boolean} props.error - If true, the text field will indicate an error state.
*
* @param {boolean} props.disabled - If true, the text field will be disabled and cannot be interacted with.
*
* @example
* // To use this component, import it and use it in your JSX like this:
* <EmailTextField id="email" variant="outlined" placeholder="Enter your email" />
@@ -30,12 +32,14 @@ const EmailTextField = ({
onChange,
id,
label = "Email",
value = undefined,
variant,
placeholder,
autoComplete,
icon,
helperText,
error,
disabled
}) => {
const theme = useTheme();
@@ -59,9 +63,11 @@ const EmailTextField = ({
error={error}
className="email-text-field-input"
id={id}
value={value}
variant={variant}
placeholder={placeholder}
autoComplete={autoComplete}
disabled={disabled}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -78,7 +84,7 @@ const EmailTextField = ({
style={{ fill: "red", width: "16px", height: "16px" }}
/>
)}
{!error && showIcon && (
{!error && showIcon && !disabled && (
<HelpOutlineIcon
className="text-field-icon"
style={{ width: "16px", height: "16px" }}
@@ -0,0 +1,15 @@
.image-field-wrapper h2.MuiTypography-root,
.image-field-wrapper p.MuiTypography-root {
color: var(--env-var-color-2);
font-size: var(--env-var-font-size-medium);
}
.image-field-wrapper h2.MuiTypography-root {
margin-top: 10px;
}
.image-field-wrapper p.MuiTypography-root {
opacity: 0.8;
}
.image-field-wrapper h2.MuiTypography-root span {
color: var(--env-var-color-3);
font-weight: 500;
}
@@ -0,0 +1,97 @@
import React, { useState } from "react";
import { Box, IconButton, Stack, TextField, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import "./index.css";
/**
* @param {Object} props - The component props.
* @returns {JSX.Element} The rendered component.
*/
const ImageField = ({ id, onChange }) => {
const theme = useTheme();
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = () => {
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
return (
<Box
className="image-field-wrapper"
sx={{
position: "relative",
width: "fit-content",
height: "fit-content",
border: "dashed",
borderRadius: theme.shape.borderRadius / 2,
borderSpacing: "10px",
borderColor: isDragging
? theme.palette.primary.main
: theme.palette.otherColors.graishWhite,
borderWidth: "2px",
transition: "0.2s",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDragLeave}
>
<TextField
id={id}
type="file"
sx={{
"& .MuiInputBase-input[type='file']": {
opacity: 0,
cursor: "pointer",
padding: 10,
maxHeight: "300px",
height: "100%",
},
"& fieldset": {
padding: 0,
border: "none",
},
}}
/>
<Stack
className="custom-file-text"
alignItems="center"
gap="10px"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "-1",
width: "100%",
}}
>
<IconButton
sx={{
pointerEvents: "none",
borderRadius: "2px",
border: `solid 2px ${theme.palette.otherColors.graishWhite}`,
boxShadow: theme.shape.boxShadow,
}}
>
<CloudUploadIcon />
</IconButton>
<Typography variant="h4" component="h2">
<span>Click to upload</span> or drag and drop
</Typography>
<Typography variant="h4" component="p">
(max. 800x400px)
</Typography>
</Stack>
</Box>
);
};
export default ImageField;
@@ -1,9 +1,11 @@
import "./passwordTextField.css";
import React, { useState } from "react";
import { InputAdornment, TextField } from "@mui/material";
import { IconButton, InputAdornment, TextField } from "@mui/material";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import { useTheme } from "@mui/material";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
/**
* @component
@@ -16,6 +18,8 @@ import { useTheme } from "@mui/material";
* @param {"error" | "help"} [props.icon] - The type of icon to display (error or help) (optional)
* @param {string} props.helperText - The helper text displayed below the text field (optional)
* @param {boolean} props.error - A flag indicating if the text field has an error (required)
* @param {boolean} props.visibility - A flag indicating if the password visibility is toggled (optional)
* @param {function} props.setVisibility - Function to toggle password visibility (optional)
* @returns {JSX.Element} - Renders the password text field component with dynamic icon display
*/
const PasswordTextField = ({
@@ -28,6 +32,8 @@ const PasswordTextField = ({
icon,
helperText,
error,
visibility,
setVisibility,
}) => {
const theme = useTheme();
@@ -48,7 +54,7 @@ const PasswordTextField = ({
<div className="password-text-field">
<TextField
onChange={onChange}
type="password"
type={visibility ? "text" : "password"}
autoComplete={autoComplete}
error={error}
className="password-text-field-input"
@@ -58,26 +64,43 @@ const PasswordTextField = ({
InputProps={{
endAdornment: (
<InputAdornment position="end">
<div className="icon-holder">
{error && showIcon && (
<ErrorOutlineIcon
className="text-field-icon"
style={{ fill: "red", width: "16px", height: "16px" }}
/>
)}
{error && !showIcon && (
<ErrorOutlineIcon
className="text-field-icon"
style={{ fill: "red", width: "16px", height: "16px" }}
/>
)}
{!error && showIcon && (
<HelpOutlineIcon
className="text-field-icon"
style={{ width: "16px", height: "16px" }}
/>
)}
</div>
{visibility !== undefined ? (
<IconButton
aria-label="toggle password visibility"
onClick={() => setVisibility((show) => !show)}
tabIndex={-1}
sx={{
width: "30px",
height: "30px",
"&:focus": {
outline: "none",
},
}}
>
{!visibility ? <VisibilityOff /> : <Visibility />}
</IconButton>
) : (
<div className="icon-holder">
{error && showIcon && (
<ErrorOutlineIcon
className="text-field-icon"
style={{ fill: "red", width: "16px", height: "16px" }}
/>
)}
{error && !showIcon && (
<ErrorOutlineIcon
className="text-field-icon"
style={{ fill: "red", width: "16px", height: "16px" }}
/>
)}
{!error && showIcon && (
<HelpOutlineIcon
className="text-field-icon"
style={{ width: "16px", height: "16px" }}
/>
)}
</div>
)}
</InputAdornment>
),
}}
@@ -10,6 +10,7 @@ import { useTheme } from "@mui/material";
* @param {function} props.onChange - The function to call when the text field changes.
* @param {string} props.id - The ID of the text field.
* @param {string} props.label - The label text for the text field.
* @param {string} props.value - The value of the text field.
* @param {string} props.variant - The variant of the text field.
* @param {string} props.placeholder - The placeholder text for the text field.
* @param {ReactNode} props.icon - The icon to display in the text field.
@@ -22,6 +23,7 @@ const StringTextField = ({
autoComplete,
id,
label,
value = undefined,
variant,
placeholder,
icon,
@@ -51,6 +53,7 @@ const StringTextField = ({
className="email-text-field-input"
id={id}
variant={variant}
value={value}
placeholder={placeholder}
autoComplete={autoComplete}
InputProps={{
+76 -6
View File
@@ -18,7 +18,10 @@ export const register = createAsyncThunk(
const res = await axios.post(`${BASE_URL}/auth/register`, form);
return res.data;
} catch (error) {
return thunkApi.rejectWithValue(error.response.data);
if (error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
return thunkApi.rejectWithValue(error.message);
}
}
);
@@ -28,10 +31,47 @@ export const login = createAsyncThunk("auth/login", async (form, thunkApi) => {
const res = await axios.post(`${BASE_URL}/auth/login`, form);
return res.data;
} catch (error) {
return thunkApi.rejectWithValue(error.response.data);
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
return thunkApi.rejectWithValue(error.message);
}
});
export const update = createAsyncThunk(
"auth/update",
async (data, thunkApi) => {
const { authToken: token, localData: form } = data;
const user = jwtDecode(token);
try {
//1.5s delay to show loading spinner
await new Promise((resolve) => setTimeout(resolve, 1500));
const res = await axios.post(`${BASE_URL}/auth/user/${user._id}`, form, {
headers: {
Authorization: `Bearer ${token}`,
"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 deleteAccount = createAsyncThunk(
"auth/delete",
async (form, thunkApi) => {
try {
//TODO
} catch (error) {}
}
);
const handleAuthFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
@@ -42,30 +82,60 @@ const handleAuthFulfilled = (state, action) => {
state.user = user;
};
const handleAuthRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to login or register";
};
const handleUpdateFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.user = action.payload.data;
};
const handleUpdateRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update profile data.";
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {},
reducers: {
clearAuthState: (state) => {
state.authToken = "";
state.user = "";
state.isLoading = false;
state.success = true;
state.msg = "Logged out successfully";
},
},
extraReducers: (builder) => {
builder
// Register thunk
.addCase(register.pending, (state, action) => {
.addCase(register.pending, (state) => {
state.isLoading = true;
})
.addCase(register.fulfilled, handleAuthFulfilled)
.addCase(register.rejected, handleAuthRejected)
// Login thunk
.addCase(login.pending, (state, action) => {
.addCase(login.pending, (state) => {
state.isLoading = true;
})
.addCase(login.fulfilled, handleAuthFulfilled)
.addCase(login.rejected, handleAuthRejected);
.addCase(login.rejected, handleAuthRejected)
// Update thunk
.addCase(update.pending, (state) => {
state.isLoading = true;
})
.addCase(update.fulfilled, handleUpdateFulfilled)
.addCase(update.rejected, handleUpdateRejected);
},
});
export default authSlice.reducer;
export const { clearAuthState } = authSlice.actions;
+36 -12
View File
@@ -1,5 +1,6 @@
import axios from "axios";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
const initialState = {
isLoading: false,
@@ -17,7 +18,10 @@ export const getMonitors = createAsyncThunk(
);
return res.data;
} catch (error) {
return thunkApi.rejectWithValue(error.response.data);
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
return thunkApi.rejectWithValue(error.message);
}
}
);
@@ -25,11 +29,22 @@ export const getMonitors = createAsyncThunk(
export const getMonitorsByUserId = createAsyncThunk(
"montiors/getMonitorsByUserId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
// TODO get userId and make request
console.log(token);
const res = await axios.get(
import.meta.env.VITE_APP_API_BASE_URL + "/monitors/user/" + user._id,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return res.data;
} catch (error) {
return thunkApi.rejectWithValue(error.response.data);
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
return thunkApi.rejectWithValue(error.message);
}
}
);
@@ -37,7 +52,14 @@ export const getMonitorsByUserId = createAsyncThunk(
const monitorsSlice = createSlice({
name: "monitors",
initialState,
reducers: {},
reducers: {
clearMonitorState: (state) => {
state.isLoading = false;
state.monitors = [];
state.success = null;
state.msg = null;
},
},
extraReducers: (builder) => {
builder
// *****************************************************
@@ -50,13 +72,14 @@ const monitorsSlice = createSlice({
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
console.log(action.payload);
state.monitors = action.payload.data;
})
.addCase(getMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting montiors failed";
})
// *****************************************************
// Monitors by userId
@@ -68,17 +91,18 @@ const monitorsSlice = createSlice({
.addCase(getMonitorsByUserId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
console.log(action.payload);
state.monitors = action.payload.data;
})
.addCase(getMonitorsByUserId.rejected, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting montiors failed";
});
},
});
export const { setMonitors } = monitorsSlice.actions;
export const { setMonitors, clearMonitorState } = monitorsSlice.actions;
export default monitorsSlice.reducer;
-12
View File
@@ -3,24 +3,12 @@ import { Outlet } from "react-router";
import DashboardSidebar from "../../Components/DashboardSidebar";
import "./index.css";
// Demo, remove me
import { useSelector } from "react-redux";
// Emd Demo
const HomeLayout = () => {
const authState = useSelector((state) => state.auth);
const { user, msg } = authState;
return (
<div className="home-layout">
<NavBar />
<div className="main-content">
<DashboardSidebar />
{/* Remove me later */}
{user && <p>{user.firstname}</p>}
{user && <p>{user.lastname}</p>}
{user && <p>{user.email}</p>}
{msg && <p>Msg: {msg}</p>}
{/* I am a demo */}
<Outlet />
</div>
</div>
@@ -0,0 +1,83 @@
.monitors,
.new-monitor {
width: 70vw;
padding: 4vw;
}
.monitors-bar {
display: flex;
justify-content: space-between;
align-items: center;
}
.monitors-bar-title {
font-size: 24px;
font-weight: 700;
}
.monitors-stats {
display: flex;
gap: 24px;
}
.monitors-gaps-medium {
height: var(--env-var-spacing-2);
}
.monitors-gaps-small-plus {
height: var(--env-var-spacing-1-plus);
}
.new-monitor-header {
color: var(--env-var-color-1);
font-size: var(--env-var-font-size-large-plus);
font-weight: 600;
}
.service-check-list-title {
color: var(--env-var-color-5);
font-size: var(--env-var-font-size-medium);
}
.service-check-list-desc {
color: var(--env-var-color-25);
font-size: var(--env-var-font-size-small);
}
.host-status-box-text {
font-size: var(--env-var-font-size-medium);
}
.monitors-dropdown-holder {
padding-left: 30px;
}
#ports-list {
width: 172.92px;
height: 12px;
font-size: var(--env-var-font-size-small);
align-items: center;
}
.incident-notif-config-title,
.adv-setting-title {
color: var(--env-var-color-5);
font-size: var(--env-var-font-size-medium);
}
.adv-setting-title {
font-weight: 500;
}
.config-box-inputs,
.service-check-list,
.incident-notif-config,
.advanced-setting-config,
.proxy-setting-config {
padding-right: 40px;
}
.monitors-create-button-holder {
display: flex;
justify-content: end;
}
+5
View File
@@ -35,6 +35,7 @@ import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { getMonitors } from "../../Features/Monitors/monitorsSlice";
import { getMonitorsByUserId } from "../../Features/Monitors/monitorsSlice";
import ImageField from "../../Components/TextFields/Image";
const cols = [
{
@@ -415,6 +416,10 @@ const Demo = () => {
/>
</Stack>
<Divider sx={{ margin: `${theme.spacing(2)}` }} />
<Stack justifyContent="center" alignItems="center">
<ImageField />
</Stack>
<Divider sx={{ margin: `${theme.spacing(2)}` }} />
</div>
);
};
+13 -4
View File
@@ -52,9 +52,7 @@ const Login = () => {
await loginValidation.validateAsync(form, { abortEarly: false });
const action = await dispatch(login(form));
if (action.meta.requestStatus === "fulfilled") {
const token = action.payload.data;
localStorage.setItem("token", token);
navigate("/");
navigate("/monitors");
}
if (action.meta.requestStatus === "rejected") {
const error = new Error("Request rejected");
@@ -82,6 +80,10 @@ const Login = () => {
setForm(newForm);
};
const handleSignupClick = () => {
navigate("/register");
};
return (
<div className="login-page">
<BackgroundPattern></BackgroundPattern>
@@ -139,7 +141,14 @@ const Login = () => {
<div className="login-form-v3-spacing" />
<div className="new-account-option">
Don’t have an account?
<span className="new-account-option-span">Sign up</span>
<span
onClick={() => {
navigate("/register");
}}
className="new-account-option-span"
>
Sign up
</span>
</div>
</form>
</div>
+36 -40
View File
@@ -1,64 +1,56 @@
import React, { useEffect, useState } from "react";
import Host from "../../Components/Host";
import HostStatus from "../../Components/HostStatus";
import HostActions from "../../Components/HostActions";
import BarChart from "../../Components/Charts/BarChart/BarChart";
import Button from "../../Components/Button";
import ServerStatus from "../../Components/Charts/Servers/ServerStatus";
import CurrentMonitors from "../../Components/CurrentMonitors";
import MockData from "../../Mock/sample_data.json";
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
const CurrentStats = (mockdata = true) => {
// The hardcoded part is passed as default value for the purpose of demonstration.
// Pass an empty array, [], before fetching the data
const [monitors, setMonitors] = useState([]);
/**
* CurrentStats displays the current status of monitor
*
* @component
* @param {Array<Monitor>} monitors - An array of monitor objects to be displayed.
*/
useEffect(() => {
if (mockdata) {
console.log(MockData.data);
setMonitors(
MockData.data.map((item) => ({
host: Host(item.name, 100, "var(--env-var-color-17)", item.url),
status: HostStatus(
item.isActive
? "var(--env-var-color-20)"
: "var(--env-var-color-21)",
item.isActive ? "Up" : "Down",
item.isActive
? "var(--env-var-color-17)"
: "var(--env-var-color-19)"
),
team: <BarChart checks={item.checks} />,
actions: HostActions(),
}))
);
const CurrentStats = ({ monitors }) => {
const navigate = useNavigate();
const authState = useSelector((state) => state.auth);
const up = monitors.reduce((acc, cur) => {
if (cur.checks.length > 0) {
return cur.checks[cur.checks.length - 1].status === true ? acc + 1 : acc;
}
}, [mockdata]);
return 0;
}, 0);
// useEffect(() => {
// fetch("API_URL")
// .then((response) => response.json())
// .then((data) => setMonitors(data))
// .catch((error) => console.error(error));
// }, []);
const down = monitors.reduce((acc, cur) => {
if (cur.checks.length > 0) {
return cur.checks[cur.checks.length - 1].status === false ? acc + 1 : acc;
}
return 0;
}, 0);
return (
<div>
<div className="monitors-gaps-medium"></div>
<div className="monitors-gaps-medium"></div>
<div className="monitors-bar">
<div className="monitors-bar-title">Hello, Jackie</div>
<div className="monitors-bar-title">
Hello, {authState.user.firstname}
</div>
<Button
level="primary"
label="Create new monitor"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ padding: "10px 20px", fontSize: "13px" }}
/>
</div>
<div className="monitors-gaps-medium"></div>
<div className="monitors-stats">
<ServerStatus title="Up" value="4" state="up" />
<ServerStatus title="Down" value="0" state="down" />
<ServerStatus title="Paused" value="0" state="pause" />
<ServerStatus title="Up" value={up} state="up" />
<ServerStatus title="Down" value={down} state="down" />
<ServerStatus title="Paused" value={0} state="pause" />
</div>
<div className="monitors-gaps-medium"></div>
<CurrentMonitors monitors={monitors} />
@@ -66,4 +58,8 @@ const CurrentStats = (mockdata = true) => {
);
};
CurrentStats.propTypes = {
monitors: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export default CurrentStats;
-1
View File
@@ -1,4 +1,3 @@
import CreateNewMonitor from "./CreateNewMonitor";
import CurrentStats from "./CurrentStats";
import "./index.css";
import { useEffect } from "react";
+58 -11
View File
@@ -32,11 +32,11 @@
.announcement-tile {
border-color: var(--env-var-color-29);
}
[class$="-form"] {
.settings [class$="-form"] {
width: inherit;
}
[class$="__wrapper"],
[class$="__wrapper compact"] {
.settings [class$="__wrapper"],
.settings [class$="__wrapper compact"] {
display: flex;
flex-direction: row;
justify-content: space-between;
@@ -64,8 +64,9 @@
margin-top: var(--env-var-spacing-4);
}
.edit-team-form__wrapper.compact {
margin-top: 30px;
color: var(--env-var-color-29);
margin-top: var(--env-var-spacing-3);
justify-content: flex-start;
align-items: center;
}
.edit-profile-form__wrapper input,
.edit-password-form__wrapper input,
@@ -81,7 +82,8 @@
.announcement-content-subject {
margin-bottom: 2px;
}
#edit-password + div > button > svg,
#edit-new-password + div > button > svg,
#edit-confirm-password + div > button > svg,
#edit-current-password + div > button > svg {
color: var(--env-var-color-29);
}
@@ -95,10 +97,12 @@
border-color: var(--env-var-color-27);
background-color: var(--lighter-env-var-color-27);
}
.MuiBox-root > .announcement-tile .announcement-content-body,
.MuiBox-root > .announcement-tile .announcement-close svg {
.MuiBox-root > .announcement-tile .announcement-content-body {
color: var(--env-var-color-28);
fill: var(--env-var-color-28) !important;
}
.MuiBox-root > .announcement-tile .announcement-close svg,
.edit-password-form__wrapper > .announcement-tile .announcement-close svg {
display: none;
}
#org-name-flex-container .Mui-disabled {
color: var(--env-var-color-2) !important;
@@ -116,8 +120,7 @@
padding-left: 20px;
}
.edit-team-form__wrapper td {
font-size: (--env-var-font-size-medium);
color: var(--env-var-color-29);
color: var(--env-var-color-25);
}
#select-actions,
@@ -155,3 +158,47 @@
min-height: 22px;
border-radius: 50%;
}
/* for testing, will remove later */
@media only screen and (max-width: 1600px) {
.settings {
max-width: calc(90vw - 250px) !important;
}
.edit-profile-form__wrapper .MuiStack-root:not(.row-stack),
.edit-password-form__wrapper .MuiStack-root:not(.row-stack),
.edit-organization-form__wrapper .MuiStack-root:not(.row-stack) {
margin-right: 30px;
}
}
.settings .MuiStack-root:has(p.MuiTypography-root.input-error) {
position: relative;
margin-bottom: 8px;
}
.settings .text-field-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 18px !important;
height: 18px !important;
opacity: 0.8;
}
.settings p.MuiTypography-root.input-error {
color: var(--env-var-color-24);
opacity: 0.8;
position: absolute;
top: calc(100% + 5px);
}
.settings .email-text-field,
.settings .password-text-field {
margin: 0;
}
.edit-profile-form__wrapper .MuiFormControl-root,
.edit-password-form__wrapper .MuiFormControl-root {
width: 100%;
}
.edit-profile-form__wrapper .MuiStack-root:has(.MuiFormControl-root),
.edit-password-form__wrapper .MuiStack-root:has(.MuiFormControl-root) {
margin: 0;
}
+5 -1
View File
@@ -27,6 +27,9 @@ const otherColorsGraishWhiteLight = "#f2f2f2";
const otherColorsStrongBlue = "#f2f2f2";
const otherColorsSlateGray = "#667085";
//box shadow
const shadow =
"0px 4px 24px -4px rgba(16, 24, 40, 0.08), 0px 3px 3px -3px rgba(16, 24, 40, 0.03)";
// Global Font Family
const fontFamilyDefault = '"Roboto", "Helvetica", "Arial", sans-serif';
@@ -73,13 +76,14 @@ const theme = createTheme({
slateGray: otherColorsSlateGray,
},
},
font :{
font: {
default: {
font: fontFamilyDefault,
},
},
shape: {
borderRadius: 4,
boxShadow: shadow,
},
});
+73
View File
@@ -63,9 +63,82 @@ const newPasswordValidation = joi.object({
}),
});
const editProfileValidation = joi.object({
firstname: joi.string().trim().pattern(new RegExp("^[A-Za-z]+$")).messages({
"string.empty": "*First name is required.",
"string.pattern.base": "*First name must contain only letters.",
}),
lastname: joi.string().trim().pattern(new RegExp("^[A-Za-z]+$")).messages({
"string.empty": "*Last name is required.",
"string.pattern.base": "*Last name must contain only letters.",
}),
email: joi
.string()
.trim()
.email({ tlds: { allow: false } })
.messages({
"string.empty": "*Email is required.",
"string.email": "*Invalid email address.",
}),
});
const editPasswordValidation = joi.object({
// TBD - validation for current password ?
password : joi
.string().trim()
.messages({
"string.empty": "*Current password is required.",
}),
newpassword: joi
.string()
.trim()
.min(8)
.messages({
"string.empty": "*Password is required.",
"string.min": "*Password must be at least 8 characters long.",
})
.custom((value, helpers) => {
if (!/[A-Z]/.test(value)) {
return helpers.message(
"*Password must contain at least one uppercase letter."
);
}
if (!/[a-z]/.test(value)) {
return helpers.message(
"*Password must contain at least one lowercase letter."
);
}
if (!/\d/.test(value)) {
return helpers.message("*Password must contain at least one number.");
}
if (!/[!@#$%^&*]/.test(value)) {
return helpers.message(
"*Password must contain at least one special character."
);
}
return value;
}),
confirm: joi
.string()
.trim()
.messages({
"string.empty": "*Password confirmation is required.",
})
.custom((value, helpers) => {
const { password } = helpers.prefs.context;
if (value !== password) {
return helpers.message("*Passwords do not match.");
}
return value;
}),
});
export {
registerValidation,
loginValidation,
recoveryValidation,
newPasswordValidation,
editPasswordValidation,
editProfileValidation,
};
+3 -3
View File
@@ -3,9 +3,9 @@
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
/* color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
background-color: #242424; */
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -47,7 +47,7 @@
--env-var-color-27: #fecf60;
--lighter-env-var-color-27: rgba(254, 207, 96, 0.1);
--env-var-color-28: #f79009;
--env-var-color-29: #667085;
--env-var-color-29: #D0D5DD;
--env-var-radius-1: 4px;
--env-var-radius-2: 8px;
+5
View File
@@ -0,0 +1,5 @@
*.sh
!build_images.sh
mongo/data/*
redis/data/*
*.env
+7 -3
View File
@@ -4,15 +4,19 @@
# Change directory to root Server directory for correct Docker Context
cd ..
#Client
client="./Docker/client.Dockerfile"
# MongoDB
mongoDB="./docker/mongoDB.Dockerfile"
mongoDB="./Docker/mongoDB.Dockerfile"
# Redis
redis="./docker/redis.Dockerfile"
redis="./Docker/redis.Dockerfile"
# Server
server="./docker/server.Dockerfile"
server="./Docker/server.Dockerfile"
docker build -f $client -t uptime_client .
docker build -f $mongoDB -t uptime_database_mongo .
docker build -f $redis -t uptime_redis .
docker build -f $server -t uptime_server .
+17
View File
@@ -0,0 +1,17 @@
FROM node:20
WORKDIR /app
COPY ./Client/package*.json ./
RUN npm install
COPY ./Client .
RUN npm run build
RUN npm install -g serve
CMD ["serve","-s", "dist", "-l", "5173"]
EXPOSE 5000
@@ -1,11 +1,19 @@
version: "3"
services:
client:
image: uptime_client:latest
ports:
- "5173:5173"
env_file:
- client.env
depends_on:
- server
server:
image: uptime_server:latest
ports:
- "5000:5000"
env_file:
- .env
- server.env
depends_on:
- redis
- mongodb
File renamed without changes.
File renamed without changes.
@@ -2,11 +2,11 @@ FROM node:20
WORKDIR /app
COPY ../../package*.json ./
COPY ./Server/package*.json ./
RUN npm install
COPY ../../ ./
COPY ./Server/ ./
EXPOSE 5000
+64 -56
View File
@@ -27,18 +27,17 @@ You can see the designs [here](https://www.figma.com/design/RPSfaw66HjzSwzntKcgD
- Clone this repository to your local machine
1. [Docker Quickstart](#docker-compose)
1. [Installation (Client)](#client)
2. [Configuration(Client)](#config-client)
1. [Configuration(Client)](#config-client)
- [Environment](#env-vars-client)
3. [Getting Started (Server)](#server)
- [Docker Compose Quickstart](#docker-quickstart)
- [Manual Installation](#manual-install)
- [Install Server](#install-server)
- [Environment](#env-vars-server)
- [Database](#databases)
- [(Optional) Dockerised Databases](#optional-docker-databases)
- [Start Server](#start-server)
4. [Endpoints](#endpoints)
1. [Getting Started (Server)](#server)
- [Install Server](#install-server)
- [Environment](#env-vars-server)
- [Database](#databases)
- [(Optional) Dockerised Databases](#optional-docker-databases)
- [Start Server](#start-server)
1. [Endpoints](#endpoints)
###### Auth
- <code>POST</code> [/api/v1/auth/register](#post-register)
- <code>POST</code> [/api/v1/auth/login](#post-login)
@@ -64,11 +63,45 @@ You can see the designs [here](https://www.figma.com/design/RPSfaw66HjzSwzntKcgD
- <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)
5. [Error Handling](#error-handling)
6. [Contributors](#contributors)
1. [Error Handling](#error-handling)
1. [Contributors](#contributors)
---
### Docker Quick Start
#### <u>Docker Quickstart</u> <a id="docker-quickstart"></a>
The fastest way to start the application is to use our Dockerfiles and [Docker Compose](https://docs.docker.com/compose/).
To get the application up and running you need to:
1. In the `Docker` directory run the build script `build_images.sh` to build docker images for the client, server, Redis database, and MongoDB database.
2. In the `Docker` directory, create a `server.env` file with the [requried environtmental variables](#env-vars-server) for the server. Sample file:
```
CLIENT_HOST="http://localhost:5173"
JWT_SECRET="my_secret"
DB_TYPE="MongoDB"
DB_CONNECTION_STRING="mongodb://mongodb:27017/uptime_db"
REDIS_HOST="redis"
REDIS_PORT=6379
SYSTEM_EMAIL_ADDRESS="<email>"
SENDGRID_API_KEY="<api_key>"
LOGIN_PAGE_URL="<login_page"
```
3. In the `Docker` directory, create a `client.env` file with the [required environtmental variables](#env-vars-cllient) for the client. Sample file:
```
VITE_APP_API_BASE_URL="http://localhost:5000/api/v1"
```
4. In the `Docker` directory run `docker compose up` to run the `docker-compose.yaml` file and start all four images.
That's it, the application is ready to use on port 5173.
<br/>
### Client
#### <u>Installation</u>
@@ -94,33 +127,7 @@ You can see the designs [here](https://www.figma.com/design/RPSfaw66HjzSwzntKcgD
---
### Getting Started (Server)
#### <u>Docker Quickstart</u> <a id="docker-quickstart"></a>
The fastest way to start the server is to use our Dockerfiles and [Docker Compose](https://docs.docker.com/compose/).
To get the server up and running you need to:
1. In the `Server/docker` directory run the build script `build_images.sh` to build docker images for the server, Redis database, and MongoDB database.
2. In the `Server/docker` directory, create a `.env` file with the [requried environtmental variables](#env-vars-server). Sample file:
```
CLIENT_HOST="http://localhost:5173"
JWT_SECRET="my_secret"
DB_TYPE="MongoDB"
DB_CONNECTION_STRING="mongodb://mongodb:27017/uptime_db"
REDIS_HOST="redis"
REDIS_PORT=6379
SYSTEM_EMAIL_ADDRESS="<email>"
SENDGRID_API_KEY="<api_key>"
LOGIN_PAGE_URL="<login_page"
```
3. In the `Server/docker` directory run `docker compose up` to run the `docker-compose.yaml` file and start all three images.
That's it, the server is ready to use.
<br/>
### Getting Started (Server) <a id="server"></a>
#### <u>Manual Install</u> <a id="manual-install"></a>
@@ -135,18 +142,19 @@ That's it, the server is ready to use.
Configure the server with the following environmental variables:
| ENV Variable Name | Required/Optional | Type | Description | Accepted Values |
| -------------------- | ----------------- | --------- | ------------------------------------------------------------------------------------------- | ------------------- |
| CLIENT_HOST | Required | `string` | Frontend Host | |
| JWT_SECRET | Required | `string` | JWT secret | |
| DB_TYPE | Optional | `string` | Specify DB to use | `MongoDB \| FakeDB` |
| DB_CONNECTION_STRING | Required | `string` | Specifies URL for MongoDB Database | |
| PORT | Optional | `integer` | Specifies Port for Server | |
| SENDGRID_API_KEY | Required | `string` | Specifies API KEY for SendGrid email service | |
| SYSTEM_EMAIL_ADDRESS | Required | `string` | Specifies System email to be used in emailing service, must be a verified email by sendgrid | |
| LOGIN_PAGE_URL | Required | `string` | Login url to be used in emailing service | |
| REDIS_HOST | Required | `string` | Host address for Redis database | |
| REDIS_PORT | Required | `integer` | Port for Redis database | |
| ENV Variable Name | Required/Optional | Type | Description | Accepted Values |
| -------------------- | ----------------- | --------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| CLIENT_HOST | Required | `string` | Frontend Host | |
| JWT_SECRET | Required | `string` | JWT secret | |
| DB_TYPE | Optional | `string` | Specify DB to use | `MongoDB \| FakeDB` |
| DB_CONNECTION_STRING | Required | `string` | Specifies URL for MongoDB Database | |
| PORT | Optional | `integer` | Specifies Port for Server | |
| SENDGRID_API_KEY | Required | `string` | Specifies API KEY for SendGrid email service | |
| SYSTEM_EMAIL_ADDRESS | Required | `string` | Specifies System email to be used in emailing service, must be a verified email by sendgrid | |
| LOGIN_PAGE_URL | Required | `string` | Login url to be used in emailing service | |
| REDIS_HOST | Required | `string` | Host address for Redis database | |
| REDIS_PORT | Required | `integer` | Port for Redis database | |
| TOKEN_TTL | Optional | string | Time for token to live | In vercel/ms format https://github.com/vercel/ms |
<br/>
@@ -161,15 +169,15 @@ You may use the included Dockerfiles to spin up databases quickly if you wish.
###### (Optional) Dockerised Databases <a id="optional-docker-databases"></a>
Dockerfiles for the server and databases are located in the `./Server/docker` directory
Dockerfiles for the server and databases are located in the `Docker` directory
<details>
<summary><b>MongoDB Image</b></summary>
Location: `./Server/docker/mongoDB.Dockerfile`
The `./Server/docker/mongo/data` directory should be mounted to the MongoDB container in order to persist data.
The `Docker/mongo/data` directory should be mounted to the MongoDB container in order to persist data.
From the `Server/docker` directory run
From the `Docker` directory run
1. Build the image: `docker build -f mongoDB.Dockerfile -t uptime_database_mongo .`
2. Run the docker image: `docker run -d -p 27017:27017 -v $(pwd)/mongo/data:/data/db --name uptime_database_mongo uptime_database_mongo`
@@ -179,9 +187,9 @@ From the `Server/docker` directory run
<summary><b>Redis Image</b></summary>
Location `./Server/docker/redislDockerfile`
the `./Server/docker/redis/data` directory should be mounted to the Redis container in order to persist data.
the `Docker/redis/data` directory should be mounted to the Redis container in order to persist data.
From the `Server/docker` directory run
From the `Docker` directory run
1. Build the image: `docker build -f redis.Dockerfile -t uptime_redis .`
2. Run the image: `docker run -d -p 6379:6379 -v $(pwd)/redis/data:/data --name uptime_redis uptime_redis`
-1
View File
@@ -1 +0,0 @@
./docker
-3
View File
@@ -2,6 +2,3 @@ node_modules
.env
*.log
*.sh
docker/mongo/data/*
docker/redis/data/*
!docker/build_images.sh
+7 -8
View File
@@ -26,7 +26,8 @@ const {
*/
const issueToken = (payload) => {
//TODO Add proper expiration date
return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "99d" });
const tokenTTL = process.env.TOKEN_TTL ? process.env.TOKEN_TTL : "2h";
return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: tokenTTL });
};
/**
@@ -223,13 +224,11 @@ const resetPasswordController = async (req, res, next) => {
try {
await newPasswordValidation.validateAsync(req.body);
user = await req.db.resetPassword(req, res);
res
.status(200)
.json({
success: true,
msg: successMessages.AUTH_RESET_PASSWORD,
data: user,
});
res.status(200).json({
success: true,
msg: successMessages.AUTH_RESET_PASSWORD,
data: user,
});
} catch (error) {
error.service = SERVICE_NAME;
next(error);
+3 -2
View File
@@ -2,6 +2,7 @@ const axios = require("axios");
const ping = require("ping");
const logger = require("../utils/logger");
const Check = require("../models/Check");
const { Console } = require("winston/lib/winston/transports");
class NetworkService {
constructor(db) {
@@ -149,9 +150,9 @@ class NetworkService {
const insertedCheck = await check.save();
return insertedCheck.status;
} catch (error) {
logger.error(`Error wrtiting check for ${job.id}`, {
logger.error(`Error wrtiting check for ${check.monitorId}`, {
service: this.SERVICE_NAME,
jobId: job.id,
monitorId: check.monitorId,
error: error,
});
return false;