diff --git a/Client/src/Components/Animated/PulseDot.jsx b/Client/src/Components/Animated/PulseDot.jsx new file mode 100644 index 000000000..38e956b50 --- /dev/null +++ b/Client/src/Components/Animated/PulseDot.jsx @@ -0,0 +1,62 @@ +import PropTypes from "prop-types"; +import { Box, Stack } from "@mui/material"; + +/** + * A component that renders a pulsating dot with a specified color. + * + * @component + * @example + * // Example usage: + * + * + * @param {Object} props + * @param {string} props.color - The color of the dot. + * @returns {JSX.Element} The PulseDot component. + */ + +const PulseDot = ({ color }) => { + return ( + + + + ); +}; + +PulseDot.propTypes = { + color: PropTypes.string.isRequired, +}; + +export default PulseDot; diff --git a/Client/src/Components/BasicTable/index.css b/Client/src/Components/BasicTable/index.css index 604dc412e..47a47439d 100644 --- a/Client/src/Components/BasicTable/index.css +++ b/Client/src/Components/BasicTable/index.css @@ -1,22 +1,12 @@ -.MuiTable-root .host a { - width: var(--env-var-img-width-2); - height: var(--env-var-img-width-2); - color: var(--env-var-color-5); - margin-right: 10px; -} -.MuiTable-root .host a svg { - width: var(--env-var-font-size-large); - height: var(--env-var-font-size-large); -} -.MuiTable-root .host div:nth-child(2) { +.MuiTable-root .host { width: fit-content; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } -.MuiTable-root .host div:nth-child(2) span { +.MuiTable-root .host span { font-size: var(--env-var-font-size-small); - margin-left: 10px; + margin-left: 8px; } .MuiTable-root .label { @@ -128,6 +118,7 @@ .MuiTablePagination-root p { color: var(--env-var-color-2); font-weight: 500; + font-size: var(--env-var-font-size-small-plus); } .MuiTablePagination-root button, .MuiTablePagination-root .MuiSelect-select { diff --git a/Client/src/Components/BasicTable/index.jsx b/Client/src/Components/BasicTable/index.jsx index 8b6cafd91..bef5bd651 100644 --- a/Client/src/Components/BasicTable/index.jsx +++ b/Client/src/Components/BasicTable/index.jsx @@ -15,7 +15,6 @@ import { Stack, } from "@mui/material"; import { useDispatch, useSelector } from "react-redux"; -import { useLocation, useParams } from "react-router-dom"; import { setRowsPerPage } from "../../Features/UI/uiSlice"; import LeftArrowDouble from "../../assets/icons/left-arrow-double.svg?react"; import RightArrowDouble from "../../assets/icons/right-arrow-double.svg?react"; @@ -90,6 +89,13 @@ const TablePaginationActions = (props) => { ); }; +TablePaginationActions.propTypes = { + count: PropTypes.number.isRequired, + page: PropTypes.number.isRequired, + rowsPerPage: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, +}; + /** * BasicTable Component * Renders a table with optional pagination. @@ -143,13 +149,12 @@ const TablePaginationActions = (props) => { * */ -const BasicTable = ({ data, paginated, reversed }) => { +const BasicTable = ({ data, paginated, reversed, table }) => { + const DEFAULT_ROWS_PER_PAGE = 5; const theme = useTheme(); - const location = useLocation(); const dispatch = useDispatch(); const uiState = useSelector((state) => state.ui); - let table = location.pathname.split("/").pop(); - let rowsPerPage = uiState[table].rowsPerPage; + let rowsPerPage = uiState?.[table]?.rowsPerPage ?? DEFAULT_ROWS_PER_PAGE; const [page, setPage] = useState(0); useEffect(() => { @@ -285,6 +290,7 @@ BasicTable.propTypes = { paginated: PropTypes.bool, reversed: PropTypes.bool, rowPerPage: PropTypes.number, + table: PropTypes.string, }; export default BasicTable; diff --git a/Client/src/Components/Charts/BarChart/index.css b/Client/src/Components/Charts/BarChart/index.css new file mode 100644 index 000000000..e386aef1c --- /dev/null +++ b/Client/src/Components/Charts/BarChart/index.css @@ -0,0 +1,19 @@ +.bar-tooltip .MuiTooltip-tooltip { + background-color: white; + border: solid 1px black; + box-shadow: var(--env-var-shadow-1); + border: solid 1px var(--env-var-color-6); + border-radius: var(--env-var-radius-1); + padding: 4px 8px; +} + +.bar-tooltip .MuiTooltip-tooltip p { + font-size: var(--env-var-font-size-small-plus); + color: var(--env-var-color-2); + font-weight: 500; +} +.bar-tooltip .MuiTooltip-tooltip span { + font-size: var(--env-var-font-size-small); + color: var(--env-var-color-2); + font-weight: 600; +} diff --git a/Client/src/Components/Charts/BarChart/index.jsx b/Client/src/Components/Charts/BarChart/index.jsx new file mode 100644 index 000000000..9238374c2 --- /dev/null +++ b/Client/src/Components/Charts/BarChart/index.jsx @@ -0,0 +1,142 @@ +import { useTheme } from "@emotion/react"; +import { Box, Stack, Tooltip, Typography } from "@mui/material"; +import { formatDate } from "../../../Utils/timeUtils"; +import { useEffect, useState } from "react"; +import "./index.css"; + +const BarChart = ({ checks = [] }) => { + const theme = useTheme(); + const [animate, setAnimate] = useState(false); + + useEffect(() => { + setAnimate(true); + }); + + // set responseTime to average if there's only one check + if (checks.length === 1) { + checks[0] = { ...checks[0], responseTime: 50 }; + } + + if (checks.length !== 25) { + const placeholders = Array(25 - checks.length).fill("placeholder"); + checks = [...checks, ...placeholders]; + } + + return ( + event.stopPropagation()} + sx={{ + cursor: "default", + }} + > + {checks.map((check, index) => + check === "placeholder" ? ( + + ) : ( + + + {formatDate(new Date(check.createdAt), { year: undefined })} + + + + + + Response Time + + + {check.originalResponseTime} + + {" "} + ms + + + + + + } + placement="top" + key={`check-${check?._id}`} + slotProps={{ + popper: { + className: "bar-tooltip", + modifiers: [ + { + name: "offset", + options: { + offset: [0, -10], + }, + }, + ], + }, + }} + > + .MuiBox-root": { + filter: "brightness(0.8)", + }, + }} + > + + + + ) + )} + + ); +}; + +export default BarChart; diff --git a/Client/src/Components/Inputs/Field/index.jsx b/Client/src/Components/Inputs/Field/index.jsx index 0b2fe56fe..335ed178f 100644 --- a/Client/src/Components/Inputs/Field/index.jsx +++ b/Client/src/Components/Inputs/Field/index.jsx @@ -48,6 +48,7 @@ const Field = forwardRef( placeholder, value, onChange, + onInput, error, disabled, }, @@ -87,6 +88,7 @@ const Field = forwardRef( multiline={type === "description"} rows={type === "description" ? 4 : 1} value={value} + onInput={onInput} onChange={onChange} disabled={disabled} inputRef={ref} @@ -176,6 +178,8 @@ const Field = forwardRef( } ); +Field.displayName = "Field"; + Field.propTypes = { type: PropTypes.oneOf(["text", "password", "url", "email", "description"]), id: PropTypes.string.isRequired, @@ -189,6 +193,7 @@ Field.propTypes = { placeholder: PropTypes.string, value: PropTypes.string.isRequired, onChange: PropTypes.func, + onInput: PropTypes.func, error: PropTypes.string, disabled: PropTypes.bool, }; diff --git a/Client/src/Components/Sidebar/index.css b/Client/src/Components/Sidebar/index.css index 10cdfc77d..8ce0089bc 100644 --- a/Client/src/Components/Sidebar/index.css +++ b/Client/src/Components/Sidebar/index.css @@ -76,16 +76,19 @@ aside .MuiCollapse-wrapperInner .MuiList-root > .MuiListItemButton-root::after { content: ""; position: absolute; left: -8px; - top: 50%; - transform: translateY(-50%); + top: 45%; height: 3px; width: 3px; border-radius: 50%; background-color: #d6d9dd; } -aside { - transition: flex 300ms ease-out; +aside + .MuiCollapse-wrapperInner + .MuiList-root + > .selected-path.MuiListItemButton-root::after { + background-color: var(--env-var-color-2); + transform: scale(1.2); } .sidebar-popup .MuiPaper-root { @@ -95,7 +98,7 @@ aside { gap: 1px; } .sidebar-popup .MuiList-root { - padding: 6px 6px; + padding: 3px; min-width: 100px; } .sidebar-popup li.MuiButtonBase-root { @@ -104,21 +107,65 @@ aside { padding: 5px 8px; border-radius: var(--env-var-radius-1); } -.sidebar-popup li.MuiButtonBase-root:hover{ +.sidebar-popup li.MuiButtonBase-root:hover { background-color: var(--env-var-color-13); } -.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root):hover{ +.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root):hover { background-color: white; } -.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root){ +.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root) { padding-bottom: 0; } .sidebar-popup .MuiModal-root p.MuiTypography-root { font-size: var(--env-var-font-size-medium); color: var(--env-var-color-2); } -.sidebar-popup svg{ +.sidebar-popup svg { width: 16px; height: 16px; opacity: 0.9; } + +/* TRANSITIONS */ +aside { + flex: 1; + transition: max-width 650ms cubic-bezier(0.36, -0.01, 0, 0.77); +} +.home-layout aside.collapsed { + max-width: 64px; +} + +aside.expanded .MuiTypography-root, +aside.expanded p.MuiTypography-root, +aside.expanded .MuiListItemText-root + svg, +aside.expanded .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root { + visibility: visible; + animation: fadeIn 1s ease; +} + +aside.collapsed .MuiTypography-root, +aside.collapsed p.MuiTypography-root, +aside.collapsed .MuiListItemText-root + svg, +aside.collapsed .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root { + opacity: 0; + visibility: hidden; +} + +aside .MuiListSubheader-root { + transition: all 200ms ease; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + visibility: hidden; + } + 30% { + opacity: 0; + visibility: hidden; + } + 100% { + opacity: 0.9; + visibility: visible; + } +} diff --git a/Client/src/Components/Sidebar/index.jsx b/Client/src/Components/Sidebar/index.jsx index 69b44c125..ae6507b69 100644 --- a/Client/src/Components/Sidebar/index.jsx +++ b/Client/src/Components/Sidebar/index.jsx @@ -26,8 +26,6 @@ import LockSvg from "../../assets/icons/lock.svg?react"; import UserSvg from "../../assets/icons/user.svg?react"; import TeamSvg from "../../assets/icons/user-two.svg?react"; import LogoutSvg from "../../assets/icons/logout.svg?react"; -import BWULogo from "../../assets/Images/bwl-logo.svg?react"; -import BWULogoAbbreviated from "../../assets/icons/bwu-abbreviated.svg?react"; import Support from "../../assets/icons/support.svg?react"; import Dashboard from "../../assets/icons/dashboard.svg?react"; import Account from "../../assets/icons/user-edit.svg?react"; @@ -55,9 +53,9 @@ const menu = [ ], }, { name: "Incidents", path: "incidents", icon: }, - { name: "Status pages", path: "status", icon: }, + // { name: "Status pages", path: "status", icon: }, { name: "Maintenance", path: "maintenance", icon: }, - { name: "Integrations", path: "integrations", icon: }, + // { name: "Integrations", path: "integrations", icon: }, { name: "Account", icon: , @@ -125,24 +123,41 @@ function Sidebar() { return ( - - {collapsed ? ( - - ) : ( - - )} + + + + BU + + + BlueWave Uptime + + dispatch(toggleSidebar())} + onClick={() => { + setOpen({ Dashboard: false, Account: false }); + dispatch(toggleSidebar()); + }} > {collapsed ? : } @@ -175,14 +193,13 @@ function Sidebar() { id="nested-menu-subheader" sx={{ pt: theme.gap.small, - px: collapsed ? theme.gap.xs : theme.gap.ml, - textAlign: collapsed ? "center" : "left", + px: collapsed ? theme.gap.xs : theme.gap.small, }} > Menu } - sx={{ px: collapsed ? theme.gap.xs : theme.gap.ml }} + sx={{ px: theme.gap.medium }} > {menu.map((item) => item.path ? ( @@ -210,13 +227,14 @@ function Sidebar() { } onClick={() => navigate(`/${item.path}`)} sx={{ - gap: theme.gap.medium, + height: "37px", + gap: theme.gap.small, borderRadius: `${theme.shape.borderRadius}px`, - justifyContent: collapsed ? "center" : "flex-start", + px: theme.gap.small, }} > {item.icon} - {!collapsed && {item.name}} + {item.name} ) : collapsed ? ( @@ -247,13 +265,13 @@ function Sidebar() { onClick={(event) => openPopup(event, item.name)} sx={{ position: "relative", - gap: theme.gap.medium, + gap: theme.gap.small, borderRadius: `${theme.shape.borderRadius}px`, - justifyContent: collapsed ? "center" : "flex-start", + px: theme.gap.small, }} > {item.icon} - {!collapsed && {item.name}} + {item.name} {item.nested.map((child) => { if ( @@ -292,6 +310,7 @@ function Sidebar() { gap: theme.gap.small, borderRadius: `${theme.shape.borderRadius}px`, pl: theme.gap.small, + mb: "1px", }} > {child.icon} @@ -311,9 +330,9 @@ function Sidebar() { })) } sx={{ - gap: theme.gap.medium, + gap: theme.gap.small, borderRadius: `${theme.shape.borderRadius}px`, - justifyContent: collapsed ? "center" : "flex-start", + px: theme.gap.small, }} > {item.icon} @@ -324,7 +343,7 @@ function Sidebar() { {item.nested.map((child) => { if ( @@ -374,14 +393,13 @@ function Sidebar() { id="nested-other-subheader" sx={{ pt: theme.gap.small, - px: collapsed ? theme.gap.xs : theme.gap.ml, - textAlign: collapsed ? "center" : "left", + px: collapsed ? 0 : theme.gap.small, }} > Other } - sx={{ px: collapsed ? theme.gap.xs : theme.gap.ml }} + sx={{ px: theme.gap.medium }} > {other.map((item) => ( item.path === "support" ? window.open( - "https://github.com/bluewave-labs/bluewave-uptime", + "https://github.com/bluewave-labs/bluewave-uptime/issues", "_blank", "noreferrer" ) : navigate(`/${item.path}`) } sx={{ - gap: theme.gap.medium, + gap: theme.gap.small, borderRadius: `${theme.shape.borderRadius}px`, - justifyContent: collapsed ? "center" : "flex-start", + px: theme.gap.small, }} > {item.icon} - {!collapsed && {item.name}} + {item.name} ))} @@ -432,10 +450,9 @@ function Sidebar() { diff --git a/Client/src/Components/TabPanels/Account/TeamPanel.jsx b/Client/src/Components/TabPanels/Account/TeamPanel.jsx index a6c378a60..a0393977d 100644 --- a/Client/src/Components/TabPanels/Account/TeamPanel.jsx +++ b/Client/src/Components/TabPanels/Account/TeamPanel.jsx @@ -74,7 +74,8 @@ const TeamPanel = () => { { id: 1, name: "NAME" }, { id: 2, name: "EMAIL" }, { id: 3, name: "ROLE" }, - { id: 4, name: "ACTION" }, + // FEATURE STILL TO BE IMPLEMENTED + // { id: 4, name: "ACTION" }, ], rows: team?.map((member, idx) => { return { @@ -101,22 +102,23 @@ const TeamPanel = () => { id: idx + 2, data: member.role.includes("admin") ? "Administrator" : "Member", }, - { - // TODO - Add delete onClick - id: idx + 3, - data: ( - - - - ), - }, + // FEATURE STILL TO BE IMPLEMENTED + // { + // // TODO - Add delete onClick + // id: idx + 3, + // data: ( + // + // + // + // ), + // }, ], }; }), @@ -197,7 +199,8 @@ const TeamPanel = () => { return ( - + {/* FEATURE STILL TO BE IMPLEMENTED */} + {/* Organization name @@ -242,7 +245,7 @@ const TeamPanel = () => { /> - { By continuing, you agree to our{" "} - Terms of Service and{" "} - Privacy Policy. + { + window.open( + "https://bluewavelabs.ca/terms-of-service-open-source", + "_blank", + "noreferrer" + ); + }} + > + Terms of Service + {" "} + and{" "} + { + window.open( + "https://bluewavelabs.ca/privacy-policy-open-source", + "_blank", + "noreferrer" + ); + }} + > + Privacy Policy. + @@ -106,6 +129,7 @@ const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => { placeholder="jordan.ellis@domain.com" autoComplete="email" value={form.email} + onInput={(e) => (e.target.value = e.target.value.toLowerCase())} onChange={onChange} error={errors.email} ref={inputRef} diff --git a/Client/src/Pages/Auth/Register/Register.jsx b/Client/src/Pages/Auth/Register/Register.jsx index 046aad64b..4680c1821 100644 --- a/Client/src/Pages/Auth/Register/Register.jsx +++ b/Client/src/Pages/Auth/Register/Register.jsx @@ -60,8 +60,31 @@ const LandingPage = ({ isAdmin, onSignup }) => { By signing up, you agree to our{" "} - Terms of Service and{" "} - Privacy Policy. + { + window.open( + "https://bluewavelabs.ca/terms-of-service-open-source", + "_blank", + "noreferrer" + ); + }} + > + Terms of Service + {" "} + and{" "} + { + window.open( + "https://bluewavelabs.ca/privacy-policy-open-source", + "_blank", + "noreferrer" + ); + }} + > + Privacy Policy. + @@ -203,6 +226,7 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => { placeholder="jordan.ellis@domain.com" autoComplete="email" value={form.email} + onInput={(e) => (e.target.value = e.target.value.toLowerCase())} onChange={onChange} error={errors.email} ref={inputRef} diff --git a/Client/src/Pages/Monitors/Configure/index.jsx b/Client/src/Pages/Monitors/Configure/index.jsx index 6b809433d..bd2a26df0 100644 --- a/Client/src/Pages/Monitors/Configure/index.jsx +++ b/Client/src/Pages/Monitors/Configure/index.jsx @@ -2,24 +2,23 @@ import { useNavigate, useParams } from "react-router"; import { useTheme } from "@emotion/react"; import { useDispatch, useSelector } from "react-redux"; import { useEffect, useState } from "react"; -import Button from "../../../Components/Button"; -import Field from "../../../Components/Inputs/Field"; import { Box, Modal, Skeleton, Stack, Typography } from "@mui/material"; -import GreenCheck from "../../../assets/icons/checkbox-green.svg?react"; -import RedCheck from "../../../assets/icons/checkbox-red.svg?react"; -import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline"; import { monitorValidation } from "../../../Validation/validation"; -import Select from "../../../Components/Inputs/Select"; import { createToast } from "../../../Utils/toastUtils"; +import { logger } from "../../../Utils/Logger"; import { updateUptimeMonitor, getUptimeMonitorsByUserId, deleteUptimeMonitor, } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"; +import Button from "../../../Components/Button"; +import Field from "../../../Components/Inputs/Field"; +import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline"; +import Select from "../../../Components/Inputs/Select"; import Checkbox from "../../../Components/Inputs/Checkbox"; import Breadcrumbs from "../../../Components/Breadcrumbs"; +import PulseDot from "../../../Components/Animated/PulseDot"; import "./index.css"; -import { logger } from "../../../Utils/Logger"; /** * Parses a URL string and returns a URL object. @@ -229,8 +228,14 @@ const Configure = () => { gap={theme.gap.large} flex={1} > - - {monitor?.status ? : } + + {parsedUrl?.host ? ( diff --git a/Client/src/Pages/Monitors/Details/index.jsx b/Client/src/Pages/Monitors/Details/index.jsx index b636444bd..98904587e 100644 --- a/Client/src/Pages/Monitors/Details/index.jsx +++ b/Client/src/Pages/Monitors/Details/index.jsx @@ -4,20 +4,19 @@ import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material"; import { useSelector } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import { networkService } from "../../../main"; -import MonitorDetailsAreaChart from "../../../Components/Charts/MonitorDetailsAreaChart"; -import ButtonGroup from "@mui/material/ButtonGroup"; -import Button from "../../../Components/Button"; -import GreenCheck from "../../../assets/icons/checkbox-green.svg?react"; -import RedCheck from "../../../assets/icons/checkbox-red.svg?react"; -import SettingsIcon from "../../../assets/icons/settings-bold.svg?react"; -import PaginationTable from "./PaginationTable"; +import { logger } from "../../../Utils/Logger"; import { formatDuration, formatDurationRounded, } from "../../../Utils/timeUtils"; -import "./index.css"; +import MonitorDetailsAreaChart from "../../../Components/Charts/MonitorDetailsAreaChart"; +import ButtonGroup from "@mui/material/ButtonGroup"; +import Button from "../../../Components/Button"; +import SettingsIcon from "../../../assets/icons/settings-bold.svg?react"; +import PaginationTable from "./PaginationTable"; import Breadcrumbs from "../../../Components/Breadcrumbs"; -import { logger } from "../../../Utils/Logger"; +import PulseDot from "../../../Components/Animated/PulseDot"; +import "./index.css"; const StatBox = ({ title, value }) => { return ( @@ -171,8 +170,14 @@ const DetailsPage = () => { ]} /> - - {monitor?.status ? : } + + {monitor.url?.replace(/^https?:\/\//, "") || "..."} @@ -325,9 +330,7 @@ const DetailsPage = () => { History - {/* TODO New Table */} - {/* */} diff --git a/Client/src/Pages/Monitors/index.jsx b/Client/src/Pages/Monitors/index.jsx index dc293ef49..9eef7ec3a 100644 --- a/Client/src/Pages/Monitors/index.jsx +++ b/Client/src/Pages/Monitors/index.jsx @@ -10,11 +10,9 @@ import Button from "../../Components/Button"; import ServerStatus from "../../Components/Charts/Servers/ServerStatus"; import { useTheme } from "@emotion/react"; import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded"; -import OpenInNewPage from "../../assets/icons/open-in-new-page.svg?react"; import BasicTable from "../../Components/BasicTable"; import { StatusLabel } from "../../Components/Label"; import { createToast } from "../../Utils/toastUtils"; -import ResponseTimeChart from "../../Components/Charts/ResponseTimeChart"; import { Box, IconButton, @@ -28,6 +26,7 @@ import { import Settings from "../../assets/icons/settings-bold.svg?react"; import PropTypes from "prop-types"; +import BarChart from "../../Components/Charts/BarChart"; const ActionsMenu = ({ monitor }) => { const [anchorEl, setAnchorEl] = useState(null); @@ -206,7 +205,6 @@ ActionsMenu.propTypes = { * * @component * @param {Object} params - An object containing the following properties: - * @param {string} params.url - The URL of the host. * @param {string} params.title - The name of the host. * @param {string} params.percentageColor - The color of the percentage text. * @param {number} params.percentage - The percentage to display. @@ -214,40 +212,17 @@ ActionsMenu.propTypes = { */ const Host = ({ params }) => { return ( - - { - event.stopPropagation(); - window.open(params.url, "_blank", "noreferrer"); - }} - sx={{ - "&:focus": { - outline: "none", - }, - mr: "5px", - }} - > - - - - {params.title} - - {params.percentage}% - - + + {params.title} + + {params.percentage}% + ); }; Host.propTypes = { params: PropTypes.shape({ - url: PropTypes.string, title: PropTypes.string, percentageColor: PropTypes.string, percentage: PropTypes.number, @@ -326,7 +301,6 @@ const Monitors = () => { data.rows = monitorState.monitors.map((monitor, idx) => { const params = { - url: monitor.url, title: monitor.name, percentage: 100, percentageColor: @@ -357,7 +331,7 @@ const Monitors = () => { /> ), }, - { id: idx + 2, data: }, + { id: idx + 2, data: }, { id: idx + 3, data: ( @@ -437,6 +411,7 @@ const Monitors = () => { { {/* TODO - add search bar */} - + )} diff --git a/Client/src/Pages/PageSpeed/Configure/index.jsx b/Client/src/Pages/PageSpeed/Configure/index.jsx index bc759db17..83484de7e 100644 --- a/Client/src/Pages/PageSpeed/Configure/index.jsx +++ b/Client/src/Pages/PageSpeed/Configure/index.jsx @@ -10,15 +10,14 @@ import { } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice"; import { monitorValidation } from "../../../Validation/validation"; import { createToast } from "../../../Utils/toastUtils"; +import { logger } from "../../../Utils/Logger"; 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 GreenCheck from "../../../assets/icons/checkbox-green.svg?react"; -import RedCheck from "../../../assets/icons/checkbox-red.svg?react"; import Breadcrumbs from "../../../Components/Breadcrumbs"; -import { logger } from "../../../Utils/Logger"; +import PulseDot from "../../../Components/Animated/PulseDot"; import "./index.css"; @@ -168,8 +167,14 @@ const PageSpeedConfigure = () => { flex={1} gap={theme.gap.large} > - - {monitor?.status ? : } + + { const theme = useTheme(); @@ -325,8 +324,14 @@ const PageSpeedDetails = () => { { name: "details", path: `/pagespeed/${monitorId}` }, ]} /> - - {monitor?.status ? : } + + { return time; }; -export const formatDate = (date) => { +export const formatDate = (date, customOptions) => { const options = { year: "numeric", month: "long", @@ -51,6 +51,7 @@ export const formatDate = (date) => { hour: "numeric", minute: "numeric", hour12: true, + ...customOptions }; // Return the date using the specified options diff --git a/Client/src/Utils/toastUtils.jsx b/Client/src/Utils/toastUtils.jsx index 0f272a74e..ff3f341f8 100644 --- a/Client/src/Utils/toastUtils.jsx +++ b/Client/src/Utils/toastUtils.jsx @@ -1,5 +1,5 @@ import PropTypes from "prop-types"; -import { toast } from "react-toastify"; +import { toast, Slide } from "react-toastify"; import Alert from "../Components/Alert"; /** @@ -23,6 +23,7 @@ export const createToast = ({ autoClose: 3000, hideProgressBar: true, closeButton: false, + transition: Slide, ...config, }; diff --git a/Client/src/Validation/validation.js b/Client/src/Validation/validation.js index ab2501199..35d47d6b1 100644 --- a/Client/src/Validation/validation.js +++ b/Client/src/Validation/validation.js @@ -49,6 +49,13 @@ const credentials = joi.object({ .string() .trim() .email({ tlds: { allow: false } }) + .custom((value, helpers) => { + const lowercasedValue = value.toLowerCase(); + if (value !== lowercasedValue) { + return helpers.message("Email must be in lowercase"); + } + return lowercasedValue; + }) .messages({ "string.empty": "Email is required", "string.email": "Must be a valid email address", @@ -72,14 +79,10 @@ const credentials = joi.object({ }); const monitorValidation = joi.object({ - url: joi - .string() - .uri({ allowRelative: true }) - .trim() - .messages({ - "string.empty": "This field is required.", - "string.uri": "The URL you provided is not valid.", - }), + url: joi.string().uri({ allowRelative: true }).trim().messages({ + "string.empty": "This field is required.", + "string.uri": "The URL you provided is not valid.", + }), name: joi.string().trim().max(50).allow("").messages({ "string.max": "This field should not exceed the 50 characters limit.", }), diff --git a/Client/src/assets/icons/bwu-abbreviated.svg b/Client/src/assets/icons/bwu-abbreviated.svg deleted file mode 100644 index a5e00f484..000000000 --- a/Client/src/assets/icons/bwu-abbreviated.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/Client/src/index.css b/Client/src/index.css index 1c80b4dab..887d7da07 100644 --- a/Client/src/index.css +++ b/Client/src/index.css @@ -67,7 +67,7 @@ --env-var-width-2: 360px; --env-var-height-1: 100vh; - --env-var-height-2: 36px; + --env-var-height-2: 34px; --env-var-nav-bar-height: 70px; --env-var-side-bar-width: 250px; @@ -168,6 +168,17 @@ body .MuiSkeleton-root { background-color: var(--env-var-color-15); } +@keyframes ripple { + from { + opacity: 1; + transform: scale(0); + } + to { + opacity: 0; + transform: scale(2); + } +} + @media (prefers-color-scheme: light) { :root { color: #213547; diff --git a/Docker/.gitignore b/Docker/.gitignore index e1b726025..89aa6bfac 100644 --- a/Docker/.gitignore +++ b/Docker/.gitignore @@ -2,4 +2,5 @@ !build_images.sh mongo/data/* redis/data/* -*.env \ No newline at end of file +*.env +certbot/* \ No newline at end of file diff --git a/Docker/client.Dockerfile b/Docker/client.Dockerfile index 5b43527f2..0f7935cd1 100644 --- a/Docker/client.Dockerfile +++ b/Docker/client.Dockerfile @@ -16,6 +16,7 @@ RUN npm run build FROM nginx:1.27.1-alpine +# COPY ./Docker/nginx/default.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Docker/docker-compose.yaml b/Docker/docker-compose.yaml index 1abbce9d4..c9fa3431f 100644 --- a/Docker/docker-compose.yaml +++ b/Docker/docker-compose.yaml @@ -1,11 +1,21 @@ -version: "3" services: client: image: uptime_client:latest ports: - "80:80" + - "443:443" depends_on: - server + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d/:ro + - ./certbot/www:/var/www/certbot/:ro + - ./certbot/conf/:/etc/nginx/ssl/:ro + + certbot: + image: certbot/certbot:latest + volumes: + - ./certbot/www/:/var/www/certbot/:rw + - ./certbot/conf/:/etc/letsencrypt/:rw server: image: uptime_server:latest ports: diff --git a/Docker/nginx/conf.d/default.conf b/Docker/nginx/conf.d/default.conf new file mode 100644 index 000000000..a04d32c87 --- /dev/null +++ b/Docker/nginx/conf.d/default.conf @@ -0,0 +1,51 @@ +server { + listen 80; + listen [::]:80; + + server_name uptime-demo.bluewavelabs.ca; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 443 default_server ssl http2; + listen [::]:443 ssl http2; + + server_name uptime-demo.bluewavelabs.ca; + + ssl_certificate /etc/nginx/ssl/live/uptime-demo.bluewavelabs.ca/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/uptime-demo.bluewavelabs.ca/privkey.pem; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/Server/controllers/authController.js b/Server/controllers/authController.js index 4b5f51121..6fbcdb669 100644 --- a/Server/controllers/authController.js +++ b/Server/controllers/authController.js @@ -90,7 +90,7 @@ const registerController = async (req, res, next) => { const token = issueToken(userForToken); - req.emailService.buildAndSendEmail( + await req.emailService.buildAndSendEmail( "welcomeEmailTemplate", { name: newUser.firstName }, newUser.email, @@ -240,7 +240,7 @@ const inviteController = async (req, res, next) => { } const inviteToken = await req.db.requestInviteToken(req, res); - req.emailService.buildAndSendEmail( + await req.emailService.buildAndSendEmail( "employeeActivationTemplate", { name: firstname, diff --git a/Server/service/emailService.js b/Server/service/emailService.js index 79d6213c6..27518484c 100644 --- a/Server/service/emailService.js +++ b/Server/service/emailService.js @@ -3,6 +3,8 @@ const path = require("path"); const nodemailer = require("nodemailer"); const { compile } = require("handlebars"); const mjml2html = require("mjml"); +const SERVICE_NAME = "EmailService"; +const logger = require("../utils/logger"); /** * Represents an email service that can load templates, build, and send emails. @@ -19,12 +21,19 @@ class EmailService { * @returns {Function} A compiled template function that can be used to generate HTML email content. */ this.loadTemplate = (templateName) => { - const templatePath = path.join( - __dirname, - `../templates/${templateName}.mjml` - ); - const templateContent = fs.readFileSync(templatePath, "utf8"); - return compile(templateContent); + try { + const templatePath = path.join( + __dirname, + `../templates/${templateName}.mjml` + ); + const templateContent = fs.readFileSync(templatePath, "utf8"); + return compile(templateContent); + } catch (error) { + logger.error("Error loading Email templates", { + error, + service: this.SERVICE_NAME, + }); + } }; /** @@ -67,25 +76,51 @@ class EmailService { */ buildAndSendEmail = async (template, context, to, subject) => { const buildHtml = async (template, context) => { - const mjml = this.templateLookup[template](context); - const html = await mjml2html(mjml); - return html.html; + try { + const mjml = this.templateLookup[template](context); + const html = await mjml2html(mjml); + return html.html; + } catch (error) { + logger.error("Error building Email HTML", { + error, + service: SERVICE_NAME, + }); + } }; const sendEmail = async (to, subject, html) => { - const info = await this.transporter.sendMail({ - to: to, - subject: subject, - html: html, - }); - return info; + try { + const info = await this.transporter.sendMail({ + to: to, + subject: subject, + html: html, + }); + return info; + } catch (error) { + logger.error("Error sending Email", { + error, + service: SERVICE_NAME, + }); + } }; - const info = await sendEmail( - to, - subject, - await buildHtml(template, context) - ); - return info.messageId; + + try { + const info = await sendEmail( + to, + subject, + await buildHtml(template, context) + ); + return info.messageId; + } catch (error) { + error.service = SERVICE_NAME; + if (error.method === undefined) { + error.method = "buildAndSendEmail"; + } + logger.error("Error building and sending Email", { + error, + service: SERVICE_NAME, + }); + } }; } diff --git a/Server/validation/joi.js b/Server/validation/joi.js index fe7090083..4f8042e12 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -16,7 +16,17 @@ const roleValidatior = (role) => (value, helpers) => { //**************************************** const loginValidation = joi.object({ - email: joi.string().email().required(), + email: joi + .string() + .email() + .required() + .custom((value, helpers) => { + const lowercasedValue = value.toLowerCase(); + if (value !== lowercasedValue) { + return helpers.message("Email must be in lowercase"); + } + return lowercasedValue; + }), password: joi .string() .min(8) @@ -35,7 +45,17 @@ const registerValidation = joi.object({ .string() .required() .pattern(/^[A-Za-z]+$/), - email: joi.string().email().required(), + email: joi + .string() + .email() + .required() + .custom((value, helpers) => { + const lowercasedValue = value.toLowerCase(); + if (value !== lowercasedValue) { + return helpers.message("Email must be in lowercase"); + } + return lowercasedValue; + }), password: joi .string() .min(8) @@ -44,7 +64,11 @@ const registerValidation = joi.object({ /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*()])[A-Za-z0-9!@#$%^&*()]+$/ ), profileImage: joi.any(), - role: joi.array().required(), + role: joi + .array() + .items(joi.string().valid("admin", "user")) + .min(1) + .required(), }); const editUserParamValidation = joi.object({