Merge pull request #686 from bluewave-labs/develop

Develop -> Master
This commit is contained in:
Alexander Holliday authored and GitHub committed 2024-08-21 10:17:59 -07:00
commit 48699823f5
72 files changed
+2000 -1031

No files matched your search

@@ -0,0 +1,9 @@
## Describe your changes
## Issue Number if applicable
## Checklist before requesting a review
- [ ] I have performed a self-review of my code
- [ ] I have inlcuded the issue # in the PR if applicable
- [ ] I have labelled the PR correctly
+65 -1
View File
@@ -10,7 +10,6 @@
}
.MuiTable-root .host div:nth-child(2) {
width: fit-content;
font-weight: 700;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@@ -125,3 +124,68 @@
.MuiPaper-root + .MuiPagination-root div.MuiPaginationItem-root {
user-select: none;
}
.MuiTablePagination-root p {
color: var(--env-var-color-2);
font-weight: 500;
}
.MuiTablePagination-root button,
.MuiTablePagination-root .MuiSelect-select {
border: solid 1px #ebebeb;
border-radius: var(--env-var-radius-1);
}
.MuiTablePagination-root .MuiTablePagination-select.MuiSelect-select {
text-align: left;
text-align-last: left;
}
.MuiTablePagination-root button {
min-width: 0;
padding: 4px;
margin-left: 5px;
}
.MuiTablePagination-root svg {
width: 22px;
height: 22px;
}
.MuiTablePagination-root svg path {
stroke: var(--env-var-color-2);
stroke-width: 1.3;
}
.MuiTablePagination-root .MuiSelect-icon {
width: 16px;
height: 16px;
top: 50%;
right: 8%;
transform: translateY(-50%);
}
.MuiTablePagination-root button.Mui-disabled {
opacity: 0.4;
}
.MuiTablePagination-root button:not(.Mui-disabled):hover {
background-color: #f4f4f4;
border-color: #f4f4f4;
}
.pagination-dropdown.MuiPaper-root {
box-shadow: var(--env-var-shadow-1);
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-1);
}
.pagination-dropdown ul {
padding: 4px;
min-width: 100px;
}
.pagination-dropdown li,
body:has(.pagination-dropdown) p:has(+ .MuiTablePagination-root) {
font-size: var(--env-var-font-size-medium);
color: var(--env-var-color-2);
padding: 4px;
}
.pagination-dropdown li.Mui-selected,
.pagination-dropdown li.Mui-selected.Mui-focusVisible,
.pagination-dropdown li.Mui-selected:hover {
background-color: #f4f4f4;
}
body:has(.pagination-dropdown) p:has(+ .MuiTablePagination-root) {
font-size: var(--env-var-font-size-small-plus);
}
+160 -40
View File
@@ -1,3 +1,6 @@
import PropTypes from "prop-types";
import { useState, useEffect } from "react";
import { useTheme } from "@emotion/react";
import {
TableContainer,
Paper,
@@ -6,14 +9,86 @@ import {
TableRow,
TableCell,
TableBody,
Pagination,
PaginationItem,
TablePagination,
Box,
Typography,
Stack,
} from "@mui/material";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
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";
import LeftArrow from "../../assets/icons/left-arrow.svg?react";
import RightArrow from "../../assets/icons/right-arrow.svg?react";
import SelectorVertical from "../../assets/icons/selector-vertical.svg?react";
import Button from "../Button";
import "./index.css";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
/**
* Component for pagination actions (first, previous, next, last).
*
* @component
* @param {Object} props
* @param {number} props.count - Total number of items.
* @param {number} props.page - Current page number.
* @param {number} props.rowsPerPage - Number of rows per page.
* @param {function} props.onPageChange - Callback function to handle page change.
*
* @returns {JSX.Element} Pagination actions component.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
level="secondary"
label=""
img={<LeftArrowDouble />}
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
/>
<Button
level="secondary"
label=""
img={<LeftArrow />}
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
/>
<Button
level="secondary"
label=""
img={<RightArrow />}
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
/>
<Button
level="secondary"
label=""
img={<RightArrowDouble />}
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
/>
</Box>
);
};
/**
* BasicTable Component
@@ -68,9 +143,13 @@ import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
* <BasicTable data={data} rows={rows} paginated={true} />
*/
const BasicTable = ({ data, paginated, reversed, rowsPerPage = 5 }) => {
// Add headers to props validation
const BasicTable = ({ data, paginated, reversed }) => {
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;
const [page, setPage] = useState(0);
useEffect(() => {
@@ -81,6 +160,16 @@ const BasicTable = ({ data, paginated, reversed, rowsPerPage = 5 }) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: table,
})
);
setPage(0);
};
let displayData = [];
if (data && data.rows) {
@@ -94,35 +183,15 @@ const BasicTable = ({ data, paginated, reversed, rowsPerPage = 5 }) => {
return <div>No data</div>;
}
let paginationComponent = <></>;
if (paginated === true && data.rows.length > rowsPerPage) {
paginationComponent = (
<Pagination
count={Math.ceil(data.rows.length / rowsPerPage)}
page={page + 1}
onChange={(event, value) => handleChangePage(event, value - 1)}
shape="rounded"
renderItem={(item) => (
<PaginationItem
slots={{
previous: ArrowBackRoundedIcon,
next: ArrowForwardRoundedIcon,
}}
{...item}
sx={{
"&:focus": {
outline: "none",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
/>
)}
/>
);
}
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, data.rows.length);
return `${start} - ${end}`;
};
return (
<>
@@ -154,8 +223,59 @@ const BasicTable = ({ data, paginated, reversed, rowsPerPage = 5 }) => {
</TableBody>
</Table>
</TableContainer>
{paginationComponent}
{paginated && (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Typography sx={{ opacity: 0.7 }}>
Showing {getRange()} of {data.rows.length} monitor(s)
</Typography>
<TablePagination
component="div"
count={data.rows.length}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(
0,
Math.ceil(count / rowsPerPage)
)}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
PaperProps: {
className: "pagination-dropdown",
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: "-4px" },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.gap.small,
mr: theme.gap.large,
minWidth: theme.gap.xl,
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.otherColors.white,
},
},
},
}}
sx={{ mt: theme.gap.medium }}
/>
</Stack>
)}
</>
);
};
@@ -0,0 +1,31 @@
.MuiBreadcrumbs-root {
height: 36px;
}
.MuiBreadcrumbs-root svg {
width: 16px;
height: 16px;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li a {
font-size: var(--env-var-font-size-medium);
color: var(--env-var-color-2);
opacity: 0.8;
font-weight: 400;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:hover a {
color: var(--env-var-color-2);
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:not(:last-child):hover a {
opacity: 1;
background-color: #e3e3e3;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:not(:last-child) {
cursor: pointer;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:last-child a {
font-weight: 500;
opacity: 1;
cursor: default;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-separator {
margin: 0;
}
@@ -0,0 +1,63 @@
import PropTypes from "prop-types";
import { Box, Breadcrumbs as MUIBreadcrumbs } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router";
import ArrowRight from "../../assets/icons/right-arrow.svg?react";
import "./index.css";
/**
* Breadcrumbs component that displays a list of breadcrumb items.
*
* @param {Object} props
* @param {Array} props.list - Array of breadcrumb items. Each item should have `name` and `path` properties.
* @param {string} props.list.name - The name to display for the breadcrumb.
* @param {string} props.list.path - The path to navigate to when the breadcrumb is clicked.
*
* @returns {JSX.Element} The rendered Breadcrumbs component.
*/
const Breadcrumbs = ({ list }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<MUIBreadcrumbs
separator={<ArrowRight />}
aria-label="breadcrumb"
p={theme.gap.small}
width="fit-content"
backgroundColor={theme.palette.otherColors.fillGray}
borderRadius={`${theme.shape.borderRadius}px`}
lineHeight="18px"
>
{list.map((item, index) => {
return (
<Box
component="a"
key={`${item.name}-${index}`}
px={theme.gap.small}
pt={theme.gap.xs}
pb="6px"
sx={{ textTransform: "capitalize" }}
borderRadius={`${theme.shape.borderRadius}px`}
onClick={() => navigate(item.path)}
>
{item.name}
</Box>
);
})}
</MUIBreadcrumbs>
);
};
Breadcrumbs.propTypes = {
list: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}).isRequired
).isRequired,
};
export default Breadcrumbs;
@@ -22,9 +22,10 @@
.server-status-tile-title {
font-size: var(--font-size-0);
font-weight: bold;
font-weight: 500;
margin-bottom: calc(var(--spacing-general-1) / 2);
color: var(--env-var-color-1);
color: var(--env-var-color-5);
opacity: 0.6;
}
.server-status-tile-value {
+21 -1
View File
@@ -15,7 +15,7 @@
font-size: var(--env-var-font-size-medium);
color: var(--env-var-color-5);
}
.select-wrapper h3.MuiTypography-root{
.select-wrapper h3.MuiTypography-root {
font-weight: 500;
}
.MuiInputBase-root:has(> .MuiSelect-select) fieldset {
@@ -24,3 +24,23 @@
.MuiInputBase-root:not(.Mui-focused):has(> .MuiSelect-select):hover fieldset {
border-color: var(--env-var-color-29);
}
.select-dropdown.MuiPaper-root {
box-shadow: var(--env-var-shadow-1);
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-1);
}
.select-dropdown .MuiList-root {
min-width: 100px;
}
.select-dropdown li {
font-size: var(--env-var-font-size-medium);
color: var(--env-var-color-5);
padding: 5px 8px;
border-radius: var(--env-var-radius-1);
}
.select-dropdown li.Mui-selected,
.select-dropdown li.Mui-selected.Mui-focusVisible,
.select-dropdown li.Mui-selected:hover {
background-color: #f4f4f4;
}
+51 -6
View File
@@ -1,4 +1,5 @@
aside .selected-path,
.sidebar-popup .selected-path,
aside .MuiListItemButton-root:hover {
background-color: var(--env-var-color-13);
}
@@ -52,12 +53,12 @@ aside
> .MuiListItemButton-root::before {
content: "";
position: absolute;
left: -8px;
left: -7px;
top: 0;
height: 100%;
width: 100%;
border-left: solid 1px var(--env-var-color-5);
opacity: 0.3;
opacity: 0.2;
}
aside
.MuiCollapse-wrapperInner
@@ -65,15 +66,59 @@ aside
> .MuiListItemButton-root:last-child::before {
height: 50%;
}
aside .MuiCollapse-wrapperInner .MuiList-root svg,
aside .MuiList-root .MuiListItemText-root + svg {
width: 18px;
height: 18px;
}
aside .MuiCollapse-wrapperInner .MuiList-root > .MuiListItemButton-root::after {
content: "";
position: absolute;
left: -8px;
top: 50%;
height: 1px;
width: 7px;
transform: translateY(-50%);
height: 3px;
width: 3px;
border-radius: 50%;
background-color: var(--env-var-color-5);
opacity: 0.3;
background-color: #d6d9dd;
}
aside {
transition: flex 300ms ease-out;
}
.sidebar-popup .MuiPaper-root {
box-shadow: var(--env-var-shadow-1);
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-1);
gap: 1px;
}
.sidebar-popup .MuiList-root {
padding: 6px 6px;
min-width: 100px;
}
.sidebar-popup li.MuiButtonBase-root {
font-size: var(--env-var-font-size-medium);
color: var(--env-var-color-5);
padding: 5px 8px;
border-radius: var(--env-var-radius-1);
}
.sidebar-popup li.MuiButtonBase-root:hover{
background-color: var(--env-var-color-13);
}
.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root):hover{
background-color: white;
}
.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{
width: 16px;
height: 16px;
opacity: 0.9;
}
+309 -54
View File
@@ -9,6 +9,8 @@ import {
ListItemIcon,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Stack,
Tooltip,
Typography,
@@ -17,6 +19,7 @@ import { useLocation, useNavigate } from "react-router";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { clearAuthState } from "../../Features/Auth/authSlice";
import { toggleSidebar } from "../../Features/UI/uiSlice";
import { clearUptimeMonitorState } from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Avatar from "../Avatar";
import LockSvg from "../../assets/icons/lock.svg?react";
@@ -24,6 +27,7 @@ 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";
@@ -36,6 +40,8 @@ import PageSpeed from "../../assets/icons/page-speed.svg?react";
import Settings from "../../assets/icons/settings.svg?react";
import ArrowDown from "../../assets/icons/down-arrow.svg?react";
import ArrowUp from "../../assets/icons/up-arrow.svg?react";
import ArrowRight from "../../assets/icons/right-arrow.svg?react";
import ArrowLeft from "../../assets/icons/left-arrow.svg?react";
import "./index.css";
@@ -81,7 +87,19 @@ function Sidebar() {
const location = useLocation();
const dispatch = useDispatch();
const authState = useSelector((state) => state.auth);
const { sidebar } = useSelector((state) => state.ui);
let collapsed = sidebar.collapsed;
const [open, setOpen] = useState({ Dashboard: false, Account: false });
const [anchorEl, setAnchorEl] = useState(null);
const [popup, setPopup] = useState();
const openPopup = (event, id) => {
setAnchorEl(event.currentTarget);
setPopup(id);
};
const closePopup = () => {
setAnchorEl(null);
};
/**
* Handles logging out the user
@@ -105,10 +123,47 @@ function Sidebar() {
}, []);
return (
<Stack component="aside" gap={theme.gap.medium}>
<Box py={theme.gap.large} pl={theme.gap.lgplus}>
<BWULogo alt="BlueWave Uptime Logo" />
</Box>
<Stack
component="aside"
gap={theme.gap.medium}
sx={{ flex: collapsed ? 0 : 1 }}
>
<Stack
alignItems={collapsed ? "center" : "flex-start"}
py={theme.gap.large}
pl={collapsed ? 0 : theme.gap.lgplus}
>
{collapsed ? (
<BWULogoAbbreviated alt="BlueWave Uptime Logo" />
) : (
<BWULogo alt="BlueWave Uptime Logo" />
)}
<IconButton
sx={{
position: "absolute",
right: 0,
transform: `translate(50%, 50%)`,
backgroundColor: theme.palette.otherColors.fillGray,
border: `solid 1px ${theme.palette.otherColors.graishWhite}`,
p: "5px",
"& svg": {
width: theme.gap.ml,
height: theme.gap.ml,
"& path": {
stroke: theme.palette.otherColors.bluishGray,
},
},
"&:focus": { outline: "none" },
"&:hover": {
backgroundColor: "#e3e3e3",
borderColor: theme.palette.otherColors.graishWhite,
},
}}
onClick={() => dispatch(toggleSidebar())}
>
{collapsed ? <ArrowRight /> : <ArrowLeft />}
</IconButton>
</Stack>
{/* menu */}
<List
component="nav"
@@ -118,28 +173,134 @@ function Sidebar() {
<ListSubheader
component="div"
id="nested-menu-subheader"
sx={{ pt: theme.gap.small }}
sx={{
pt: theme.gap.small,
px: collapsed ? theme.gap.xs : theme.gap.ml,
textAlign: collapsed ? "center" : "left",
}}
>
Menu
</ListSubheader>
}
sx={{ px: collapsed ? theme.gap.xs : theme.gap.ml }}
>
{menu.map((item) =>
item.path ? (
<ListItemButton
className={
location.pathname.includes(item.path) ? "selected-path" : ""
}
<Tooltip
key={item.path}
onClick={() => navigate(`/${item.path}`)}
sx={{
gap: theme.gap.medium,
borderRadius: `${theme.shape.borderRadius}px`,
placement="right"
title={collapsed ? item.name : ""}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -16],
},
},
],
},
}}
disableInteractive
>
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
<ListItemText>{item.name}</ListItemText>
</ListItemButton>
<ListItemButton
className={
location.pathname.includes(item.path) ? "selected-path" : ""
}
onClick={() => navigate(`/${item.path}`)}
sx={{
gap: theme.gap.medium,
borderRadius: `${theme.shape.borderRadius}px`,
justifyContent: collapsed ? "center" : "flex-start",
}}
>
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
{!collapsed && <ListItemText>{item.name}</ListItemText>}
</ListItemButton>
</Tooltip>
) : collapsed ? (
<React.Fragment key={item.name}>
<Tooltip
placement="right"
title={collapsed ? item.name : ""}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -16],
},
},
],
},
}}
disableInteractive
>
<ListItemButton
className={
Boolean(anchorEl) && popup === item.name
? "selected-path"
: ""
}
onClick={(event) => openPopup(event, item.name)}
sx={{
position: "relative",
gap: theme.gap.medium,
borderRadius: `${theme.shape.borderRadius}px`,
justifyContent: collapsed ? "center" : "flex-start",
}}
>
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
{!collapsed && <ListItemText>{item.name}</ListItemText>}
</ListItemButton>
</Tooltip>
<Menu
className="sidebar-popup"
anchorEl={anchorEl}
open={Boolean(anchorEl) && popup === item.name}
onClose={closePopup}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
sx={{ ml: theme.gap.small }}
>
{item.nested.map((child) => {
if (
child.name === "Team" &&
authState.user?.role &&
!authState.user.role.includes("admin")
) {
return null;
}
return (
<MenuItem
className={
location.pathname.includes(child.path)
? "selected-path"
: ""
}
key={child.path}
onClick={() => {
navigate(`/${child.path}`);
closePopup();
}}
sx={{
gap: theme.gap.small,
borderRadius: `${theme.shape.borderRadius}px`,
pl: theme.gap.small,
}}
>
{child.icon}
{child.name}
</MenuItem>
);
})}
</Menu>
</React.Fragment>
) : (
<React.Fragment key={item.name}>
<ListItemButton
@@ -152,6 +313,7 @@ function Sidebar() {
sx={{
gap: theme.gap.medium,
borderRadius: `${theme.shape.borderRadius}px`,
justifyContent: collapsed ? "center" : "flex-start",
}}
>
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
@@ -183,7 +345,7 @@ function Sidebar() {
key={child.path}
onClick={() => navigate(`/${child.path}`)}
sx={{
gap: theme.gap.medium,
gap: theme.gap.small,
borderRadius: `${theme.shape.borderRadius}px`,
pl: theme.gap.small,
}}
@@ -210,63 +372,156 @@ function Sidebar() {
<ListSubheader
component="div"
id="nested-other-subheader"
sx={{ pt: theme.gap.small }}
sx={{
pt: theme.gap.small,
px: collapsed ? theme.gap.xs : theme.gap.ml,
textAlign: collapsed ? "center" : "left",
}}
>
Other
</ListSubheader>
}
sx={{ px: collapsed ? theme.gap.xs : theme.gap.ml }}
>
{other.map((item) => (
<ListItemButton
className={
location.pathname.includes(item.path) ? "selected-path" : ""
}
<Tooltip
key={item.path}
onClick={() =>
item.path === "support"
? window.open(
"https://github.com/bluewave-labs/bluewave-uptime",
"_blank",
"noreferrer"
)
: navigate(`/${item.path}`)
}
sx={{
gap: theme.gap.medium,
borderRadius: `${theme.shape.borderRadius}px`,
placement="right"
title={collapsed ? item.name : ""}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -16],
},
},
],
},
}}
disableInteractive
>
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
<ListItemText>{item.name}</ListItemText>
</ListItemButton>
<ListItemButton
className={
location.pathname.includes(item.path) ? "selected-path" : ""
}
onClick={() =>
item.path === "support"
? window.open(
"https://github.com/bluewave-labs/bluewave-uptime",
"_blank",
"noreferrer"
)
: navigate(`/${item.path}`)
}
sx={{
gap: theme.gap.medium,
borderRadius: `${theme.shape.borderRadius}px`,
justifyContent: collapsed ? "center" : "flex-start",
}}
>
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
{!collapsed && <ListItemText>{item.name}</ListItemText>}
</ListItemButton>
</Tooltip>
))}
</List>
<Divider sx={{ mt: "auto" }} />
<Stack
direction="row"
height="50px"
justifyContent={collapsed ? "center" : "flex-start"}
alignItems="center"
py={theme.gap.small}
px={theme.gap.medium}
gap={theme.gap.xs}
borderRadius={`${theme.shape.borderRadius}px`}
>
<Avatar small={true} />
<Box ml={theme.gap.xs}>
<Typography component="span" fontWeight={500}>
{authState.user?.firstName} {authState.user?.lastName}
</Typography>
<Typography sx={{ textTransform: "capitalize" }}>
{authState.user?.role}
</Typography>
</Box>
<Tooltip title="Log Out">
<IconButton
sx={{ ml: "auto", "&:focus": { outline: "none" } }}
onClick={logout}
>
<LogoutSvg style={{ width: "20px", height: "20px" }} />
</IconButton>
</Tooltip>
{collapsed ? (
<>
<Tooltip
title="Options"
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
},
}}
disableInteractive
>
<IconButton
onClick={(event) => openPopup(event, "logout")}
sx={{ p: 0, "&:focus": { outline: "none" } }}
>
<Avatar small={true} />
</IconButton>
</Tooltip>
<Menu
className="sidebar-popup"
anchorEl={anchorEl}
open={Boolean(anchorEl) && popup === "logout"}
onClose={closePopup}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
sx={{ ml: theme.gap.ml }}
>
<MenuItem sx={{ cursor: "default", minWidth: "150px" }}>
<Box>
<Typography component="span" fontWeight={500} fontSize="13px">
{authState.user?.firstName} {authState.user?.lastName}
</Typography>
<Typography
sx={{ textTransform: "capitalize", fontSize: "12px" }}
>
{authState.user?.role}
</Typography>
</Box>
</MenuItem>
<Divider />
<MenuItem
onClick={logout}
sx={{
gap: theme.gap.small,
borderRadius: `${theme.shape.borderRadius}px`,
pl: theme.gap.small,
}}
>
<LogoutSvg />
Log out
</MenuItem>
</Menu>
</>
) : (
<>
<Avatar small={true} />
<Box ml={theme.gap.xs}>
<Typography component="span" fontWeight={500}>
{authState.user?.firstName} {authState.user?.lastName}
</Typography>
<Typography sx={{ textTransform: "capitalize" }}>
{authState.user?.role}
</Typography>
</Box>
<Tooltip title="Log Out">
<IconButton
sx={{ ml: "auto", "&:focus": { outline: "none" } }}
onClick={logout}
>
<LogoutSvg style={{ width: "20px", height: "20px" }} />
</IconButton>
</Tooltip>
</>
)}
</Stack>
</Stack>
);
@@ -98,16 +98,17 @@ const PasswordPanel = () => {
return (
<TabPanel value="password">
<form
<Stack
component="form"
onSubmit={handleSubmit}
className="edit-password-form"
noValidate
spellCheck="false"
gap={theme.gap.xl}
>
<div className="edit-password-form__wrapper">
<Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Current password</Typography>
</Stack>
</Box>
<Field
type="password"
id="edit-current-password"
@@ -117,11 +118,11 @@ const PasswordPanel = () => {
onChange={handleChange}
error={errors[idToName["edit-current-password"]]}
/>
</div>
<div className="edit-password-form__wrapper">
<Stack>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">New password</Typography>
</Stack>
</Box>
<Field
type="password"
id="edit-new-password"
@@ -131,11 +132,11 @@ const PasswordPanel = () => {
onChange={handleChange}
error={errors[idToName["edit-new-password"]]}
/>
</div>
<div className="edit-password-form__wrapper">
<Stack>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Confirm new password</Typography>
</Stack>
</Box>
<Field
type="password"
id="edit-confirm-password"
@@ -145,33 +146,32 @@ const PasswordPanel = () => {
onChange={handleChange}
error={errors[idToName["edit-confirm-password"]]}
/>
</div>
<div className="edit-password-form__wrapper">
<Stack></Stack>
</Stack>
<Stack direction="row">
<Box flex={0.9}></Box>
<Box sx={{ flex: 1 }}>
<Alert
variant="warning"
body="New password must contain at least 8 characters and must have at least one uppercase letter, one number and one symbol."
/>
</Box>
</div>
<Divider aria-hidden="true" width="0" />
<Stack direction="row" justifyContent="flex-end">
<Box width="fit-content">
<ButtonSpinner
level="primary"
label="Save"
onClick={handleSubmit}
isLoading={isLoading}
loadingText="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
sx={{
paddingX: theme.gap.xl,
}}
/>
</Box>
</Stack>
</form>
<Stack direction="row" justifyContent="flex-end">
<ButtonSpinner
level="primary"
label="Save"
onClick={handleSubmit}
isLoading={isLoading}
loadingText="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
sx={{
paddingX: theme.gap.large,
width: "fit-content",
mt: theme.gap.xl,
}}
/>
</Stack>
</Stack>
</TabPanel>
);
};
@@ -19,6 +19,7 @@ import ProgressUpload from "../../ProgressBars";
import { formatBytes } from "../../../Utils/fileUtils";
import { clearUptimeMonitorState } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
/**
* ProfilePanel component displays a form for editing user profile information
@@ -216,11 +217,17 @@ const ProfilePanel = () => {
return (
<TabPanel value="profile">
<form className="edit-profile-form" noValidate spellCheck="false">
<div className="edit-profile-form__wrapper">
<Stack>
<Stack
component="form"
className="edit-profile-form"
noValidate
spellCheck="false"
gap={theme.gap.xl}
>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">First name</Typography>
</Stack>
</Box>
<Field
id="edit-first-name"
value={localData.firstName}
@@ -229,11 +236,11 @@ const ProfilePanel = () => {
onChange={handleChange}
error={errors[idToName["edit-first-name"]]}
/>
</div>
<div className="edit-profile-form__wrapper">
<Stack>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Last name</Typography>
</Stack>
</Box>
<Field
id="edit-last-name"
placeholder="Enter your last name"
@@ -242,11 +249,11 @@ const ProfilePanel = () => {
onChange={handleChange}
error={errors[idToName["edit-last-name"]]}
/>
</div>
<div className="edit-profile-form__wrapper">
<Stack>
</Stack>
<Stack direction="row">
<Stack flex={0.9}>
<Typography component="h1">Email</Typography>
<Typography component="p">
<Typography component="p" sx={{ opacity: 0.6 }}>
This is your current email address — it cannot be changed.
</Typography>
</Stack>
@@ -255,20 +262,19 @@ const ProfilePanel = () => {
value={user.email}
placeholder="Enter your email"
autoComplete="email"
// TODO - add onChange
onChange={() => console.log("Disabled.")}
onChange={() => logger.warn("disabled")}
// error={errors[idToName["edit-email"]]}
disabled={true}
/>
</div>
<div className="edit-profile-form__wrapper">
<Stack>
</Stack>
<Stack direction="row">
<Stack flex={0.9}>
<Typography component="h1">Your photo</Typography>
<Typography component="p">
<Typography component="p" sx={{ opacity: 0.6 }}>
This photo will be displayed in your profile page.
</Typography>
</Stack>
<Stack className="row-stack" direction="row" alignItems="center">
<Stack direction="row" alignItems="center" flex={1}>
<Avatar
src={
localData?.deleteProfileImage
@@ -293,10 +299,9 @@ const ProfilePanel = () => {
}}
/>
</Stack>
</div>
</Stack>
<Divider
aria-hidden="true"
className="short-divider"
width="0"
sx={{ marginY: theme.spacing(1) }}
/>
@@ -312,31 +317,33 @@ const ProfilePanel = () => {
Object.keys(errors).length !== 0 && !errors?.picture && true
}
sx={{
paddingX: "40px",
paddingX: theme.gap.large,
}}
/>
</Box>
</Stack>
</form>
<Divider aria-hidden="true" />
<form className="delete-profile-form" noValidate spellCheck="false">
<div className="delete-profile-form__wrapper">
<Stack direction="column" gap="15px">
<Typography component="h1">Delete account</Typography>
<Typography component="p">
Note that deleting your account will remove all data from our
system. This is permanent and non-recoverable.
</Typography>
<Box sx={{ mt: theme.spacing(1) }}>
<Button
level="error"
label="Delete account"
onClick={() => setIsOpen("delete")}
/>
</Box>
</Stack>
</div>
</form>
</Stack>
<Divider aria-hidden="true" sx={{ marginY: theme.spacing(6) }} />
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.gap.small}
>
<Box>
<Typography component="h1">Delete account</Typography>
<Typography component="p" sx={{ opacity: 0.6 }}>
Note that deleting your account will remove all data from our
system. This is permanent and non-recoverable.
</Typography>
</Box>
<Button
level="error"
label="Delete account"
onClick={() => setIsOpen("delete")}
sx={{ width: "fit-content", mt: theme.gap.small }}
/>
</Stack>
{/* TODO - Update ModalPopup Component with @mui for reusability */}
<Modal
aria-labelledby="modal-delete-account"
@@ -398,7 +405,7 @@ const ProfilePanel = () => {
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
width: 450,
bgcolor: "white",
border: "solid 1px #f2f2f2",
borderRadius: `${theme.shape.borderRadius}px`,
@@ -16,7 +16,7 @@ import { useEffect, useState } from "react";
import EditSvg from "../../../assets/icons/edit.svg?react";
import Field from "../../Inputs/Field";
import { credentials } from "../../../Validation/validation";
import axiosInstance from "../../../Utils/axiosConfig";
import { networkService } from "../../../main";
import { createToast } from "../../../Utils/toastUtils";
import { useSelector } from "react-redux";
import BasicTable from "../../BasicTable";
@@ -52,10 +52,7 @@ const TeamPanel = () => {
useEffect(() => {
const fetchTeam = async () => {
try {
const response = await axiosInstance.get("/auth/users", {
headers: { Authorization: `Bearer ${authToken}` },
});
const response = await networkService.getAllUsers(authToken);
setMembers(response.data.data);
} catch (error) {
createToast({
@@ -134,9 +131,6 @@ const TeamPanel = () => {
};
const handleRename = () => {};
//TODO - implement save team function
const handleSaveTeam = () => {};
// INVITE MEMBER
const [isOpen, setIsOpen] = useState(false);
@@ -179,13 +173,10 @@ const TeamPanel = () => {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
} else
try {
await axiosInstance.post(
"/auth/invite",
{
email: toInvite.email,
role: toInvite.role,
},
{ headers: { Authorization: `Bearer ${authToken}` } }
await networkService.requestInvitationToken(
authToken,
toInvite.email,
toInvite.role
);
closeInviteModal();
@@ -206,12 +197,11 @@ const TeamPanel = () => {
return (
<TabPanel value="team">
<form className="edit-organization-form">
<Stack component="form">
<Box sx={{ alignSelf: "flex-start" }}>
<Typography component="h1">Organization name</Typography>
</Box>
<Stack
className="row-stack"
direction="row"
justifyContent="flex-end"
alignItems="center"
@@ -251,21 +241,13 @@ const TeamPanel = () => {
}}
/>
</Stack>
</form>
<Divider
aria-hidden="true"
className="short-divider"
sx={{ marginY: theme.spacing(4) }}
/>
<form
className="edit-team-form"
</Stack>
<Divider aria-hidden="true" sx={{ marginY: theme.spacing(4) }} />
<Stack
component="form"
noValidate
spellCheck="false"
style={{
display: "flex",
flexDirection: "column",
gap: theme.gap.large,
}}
gap={theme.gap.large}
>
<Typography component="h1">Team members</Typography>
<Stack direction="row" justifyContent="space-between">
@@ -313,22 +295,7 @@ const TeamPanel = () => {
/>
</Stack>
<BasicTable data={tableData} paginated={false} reversed={true} />
<Stack direction="row" justifyContent="flex-end">
<Box width="fit-content">
<ButtonSpinner
level="primary"
label="Save"
onClick={handleSaveTeam}
isLoading={false}
loadingText="Saving..."
disabled={true}
sx={{
paddingX: "40px",
}}
/>
</Box>
</Stack>
</form>
</Stack>
<Modal
aria-labelledby="modal-invite-member"
aria-describedby="invite-member-to-team"
+10 -24
View File
@@ -1,4 +1,4 @@
import axiosInstance from "../../Utils/axiosConfig";
import { networkService } from "../../main";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
@@ -14,7 +14,7 @@ export const register = createAsyncThunk(
"auth/register",
async (form, thunkApi) => {
try {
const res = await axiosInstance.post("/auth/register", form);
const res = await networkService.registerUser(form);
return res.data;
} catch (error) {
if (error.response.data) {
@@ -31,7 +31,7 @@ export const register = createAsyncThunk(
export const login = createAsyncThunk("auth/login", async (form, thunkApi) => {
try {
const res = await axiosInstance.post(`/auth/login`, form);
const res = await networkService.loginUser(form);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -51,16 +51,13 @@ export const update = createAsyncThunk(
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 fd = new FormData();
form.firstName && fd.append("firstName", form.firstName);
form.lastName && fd.append("lastName", form.lastName);
form.password && fd.append("password", form.password);
form.newPassword && fd.append("newPassword", form.newPassword);
if (form.file && form.file !== "") {
const imageResult = await axiosInstance.get(form.file, {
const imageResult = await networkService.get(form.file, {
responseType: "blob",
baseURL: "",
});
@@ -69,12 +66,8 @@ export const update = createAsyncThunk(
form.deleteProfileImage &&
fd.append("deleteProfileImage", form.deleteProfileImage);
const res = await axiosInstance.put(`/auth/user/${user._id}`, fd, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
},
});
const res = await networkService.updateUser(token, user._id, fd);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -95,9 +88,7 @@ export const deleteUser = createAsyncThunk(
const user = jwtDecode(data);
try {
const res = await axiosInstance.delete(`/auth/user/${user._id}`, {
headers: { Authorization: `Bearer ${data}` },
});
const res = await networkService.deleteUser(data, user._id);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -116,7 +107,7 @@ export const forgotPassword = createAsyncThunk(
"auth/forgotPassword",
async (form, thunkApi) => {
try {
const res = await axiosInstance.post("/auth/recovery/request", form);
const res = await networkService.forgotPassword(form);
return res.data;
} catch (error) {
if (error.response.data) {
@@ -136,13 +127,8 @@ export const setNewPassword = createAsyncThunk(
async (data, thunkApi) => {
const { token, form } = data;
try {
await axiosInstance.post("/auth/recovery/validate", {
recoveryToken: token,
});
const res = await axiosInstance.post("/auth/recovery/reset", {
...form,
recoveryToken: token,
});
await networkService.validateRecoveryToken(token);
const res = await networkService.setNewPassword(token, form);
return res.data;
} catch (error) {
if (error.response.data) {
@@ -1,6 +1,6 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
import axiosInstance from "../../Utils/axiosConfig";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitors: [],
@@ -13,32 +13,7 @@ export const createPageSpeed = createAsyncThunk(
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await axiosInstance.post(`/monitors`, monitor, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const getPageSpeedMonitors = createAsyncThunk(
"pageSpeedMonitors/getPageSpeedMonitors",
async (token, thunkApi) => {
try {
const res = await axiosInstance.get("/monitors");
const res = await networkService.createMonitor(authToken, monitor);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -58,14 +33,16 @@ export const getPageSpeedByUserId = createAsyncThunk(
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await axiosInstance.get(
`/monitors/user/${user._id}?limit=25&type=pagespeed&sortOrder=desc`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
const res = await networkService.getMonitorsByUserId(
token,
user._id,
25,
["pagespeed"],
null,
"desc",
false
);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -91,15 +68,10 @@ export const updatePageSpeed = createAsyncThunk(
interval: monitor.interval,
// notifications: monitor.notifications,
};
const res = await axiosInstance.put(
`/monitors/${monitor._id}`,
updatedFields,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
const res = await networkService.updateMonitor(
authToken,
monitor._id,
updatedFields
);
return res.data;
} catch (error) {
@@ -120,12 +92,10 @@ export const deletePageSpeed = createAsyncThunk(
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await axiosInstance.delete(`/monitors/${monitor._id}`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
const res = await networkService.deleteMonitorById(
authToken,
monitor._id
);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -153,25 +123,6 @@ const pageSpeedMonitorSlice = createSlice({
},
extraReducers: (builder) => {
builder
// *****************************************************
// All Monitors
// *****************************************************
.addCase(getPageSpeedMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(getPageSpeedMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.monitors = action.payload.data;
})
.addCase(getPageSpeedMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting montiors failed";
})
// *****************************************************
// Monitors by userId
// *****************************************************
+31
View File
@@ -0,0 +1,31 @@
import { createSlice } from "@reduxjs/toolkit";
// Initial state for UI settings.
// Add more settings as needed (e.g., theme preferences, user settings)
const initialState = {
monitors: {
rowsPerPage: 5,
},
team: {
rowsPerPage: 5,
},
sidebar: {
collapsed: false,
},
};
const uiSlice = createSlice({
name: "ui",
initialState,
reducers: {
setRowsPerPage: (state, action) => {
state[action.payload.table].rowsPerPage = action.payload.value;
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
},
},
});
export default uiSlice.reducer;
export const { setRowsPerPage, toggleSidebar } = uiSlice.actions;
@@ -1,6 +1,6 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
import axiosInstance from "../../Utils/axiosConfig";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitors: [],
@@ -13,32 +13,7 @@ export const createUptimeMonitor = createAsyncThunk(
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await axiosInstance.post(`/monitors`, monitor, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const getUptimeMonitors = createAsyncThunk(
"monitors/getMonitors",
async (token, thunkApi) => {
try {
const res = await axiosInstance.get("/monitors");
const res = await networkService.createMonitor(authToken, monitor);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -58,13 +33,14 @@ export const getUptimeMonitorsByUserId = createAsyncThunk(
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await axiosInstance.get(
`/monitors/user/${user._id}?limit=25&type=http&type=ping&sortOrder=desc&normalize=true`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
const res = await networkService.getMonitorsByUserId(
token,
user._id,
25,
["http", "ping"],
null,
"desc",
true
);
return res.data;
} catch (error) {
@@ -91,15 +67,10 @@ export const updateUptimeMonitor = createAsyncThunk(
interval: monitor.interval,
notifications: monitor.notifications,
};
const res = await axiosInstance.put(
`/monitors/${monitor._id}`,
updatedFields,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
const res = await networkService.updateMonitor(
authToken,
monitor._id,
updatedFields
);
return res.data;
} catch (error) {
@@ -120,12 +91,10 @@ export const deleteUptimeMonitor = createAsyncThunk(
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await axiosInstance.delete(`/monitors/${monitor._id}`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
const res = await networkService.deleteMonitorById(
authToken,
monitor._id
);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -153,25 +122,6 @@ const uptimeMonitorsSlice = createSlice({
},
extraReducers: (builder) => {
builder
// *****************************************************
// All Monitors
// *****************************************************
.addCase(getUptimeMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(getUptimeMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.monitors = action.payload.data;
})
.addCase(getUptimeMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting uptime monitors failed";
})
// *****************************************************
// Monitors by userId
// *****************************************************
+6 -4
View File
@@ -1,21 +1,23 @@
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import axiosInstance from "../Utils/axiosConfig";
import { logger } from "../Utils/Logger";
import { networkService } from "../main";
const withAdminCheck = (WrappedComponent) => {
const WithAdminCheck = (props) => {
const navigate = useNavigate();
useEffect(() => {
axiosInstance
.get("/auth/users/admin")
networkService
.doesAdminExist()
.then((response) => {
if (response.data.data === true) {
navigate("/login");
}
})
.catch((error) => {
console.log(error);
logger.error(error);
});
}, [navigate]);
return <WrappedComponent {...props} isAdmin={true} />;
-5
View File
@@ -19,7 +19,6 @@
height: calc(100vh - var(--env-var-spacing-2) * 2);
max-width: var(--env-var-side-bar-width);
flex: 1;
border: 1px solid var(--color-border-0);
border-radius: var(--env-var-radius-1);
@@ -27,10 +26,6 @@
padding: var(--env-var-spacing-1) 0;
}
.home-layout aside > *:not(:first-child) {
padding-left: var(--env-var-spacing-1-plus);
padding-right: var(--env-var-spacing-1-plus);
}
.home-layout > div {
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
+6 -63
View File
@@ -15,18 +15,10 @@
.account h1.MuiTypography-root {
font-weight: 600;
}
.account .MuiStack-root:not(.table-stack) {
flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
}
.account .MuiTabPanel-root {
padding: 0;
margin-top: 50px;
}
.account .MuiDivider-root:not(.short-divider) {
margin: 50px 0;
}
.account .MuiDivider-root,
.account .MuiButtonGroup-root button {
border-color: var(--env-var-color-16);
@@ -34,50 +26,14 @@
.account button:not(.MuiIconButton-root) {
height: 34px;
}
.account [class$="-form"] {
width: inherit;
}
.account [class$="__wrapper"],
.account [class$="__wrapper compact"] {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.edit-profile-form__wrapper > .MuiStack-root:not(.row-stack),
.edit-password-form__wrapper > .MuiStack-root:not(.row-stack) {
flex-direction: column;
gap: 8px;
margin-right: 10px;
}
.edit-profile-form__wrapper p.MuiTypography-root,
.delete-profile-form__wrapper p.MuiTypography-root {
opacity: 0.6;
.account .field {
flex: 1;
}
#modal-delete-account,
#modal-edit-org-name,
#modal-invite-member {
font-size: var(--env-var-font-size-large);
}
.edit-profile-form__wrapper:not(:first-of-type),
.edit-password-form__wrapper:not(:first-of-type),
.edit-team-form__wrapper:not(:first-of-type) {
margin-top: var(--env-var-spacing-4);
}
.edit-team-form__wrapper.compact {
margin-top: var(--env-var-spacing-3);
justify-content: flex-start;
align-items: center;
}
/* for testing, will remove later */
@media only screen and (max-width: 1600px) {
.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;
}
}
.account .MuiStack-root:has(span.MuiTypography-root.input-error) {
position: relative;
@@ -87,22 +43,9 @@
top: 100%;
}
.edit-profile-form__wrapper .MuiFormControl-root,
.edit-password-form__wrapper .MuiFormControl-root {
width: 100%;
.account .MuiTable-root .MuiTableBody-root .MuiTableCell-root {
color: var(--env-var-color-25);
}
.edit-profile-form__wrapper .MuiStack-root:has(.MuiFormControl-root),
.edit-password-form__wrapper .MuiStack-root:has(.MuiFormControl-root) {
margin: 0;
}
.edit-organization-form {
display: flex;
align-items: center;
}
.edit-team-form .MuiTableCell-root {
color: var(--env-var-color-25) !important;
}
.edit-team-form .MuiTableBody-root .MuiTableCell-root {
padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2) !important;
.account .MuiTableBody-root .MuiTableCell-root {
padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2);
}
+3 -4
View File
@@ -1,14 +1,13 @@
import PropTypes from "prop-types";
import { useState } from "react";
import { useNavigate } from "react-router";
import { useSelector } from "react-redux";
import { Box, Tab, useTheme } from "@mui/material";
import TabContext from "@mui/lab/TabContext";
import TabList from "@mui/lab/TabList";
import "./index.css";
import ProfilePanel from "../../Components/TabPanels/Account/ProfilePanel";
import PasswordPanel from "../../Components/TabPanels/Account/PasswordPanel";
import TeamPanel from "../../Components/TabPanels/Account/TeamPanel";
import { useNavigate } from "react-router";
import { useSelector } from "react-redux";
import "./index.css";
/**
* Account component renders a settings page with tabs for Profile, Password, and Team settings.
+1 -1
View File
@@ -145,7 +145,7 @@ const CheckEmail = () => {
}}
/>
<Typography sx={{ alignSelf: "center", mb: theme.gap.medium }}>
Didn't receive the email?{" "}
Didn&apos;t receive the email?{" "}
<Typography
component="span"
onClick={resendToken}
+1 -1
View File
@@ -131,7 +131,7 @@ const ForgotPassword = () => {
<Key alt="password key icon" />
<Typography component="h1">Forgot password?</Typography>
<Typography>
No worries, we'll send you reset instructions.
No worries, we&apos;ll send you reset instructions.
</Typography>
</Box>
<Box width="100%" textAlign="left">
+38 -8
View File
@@ -4,16 +4,17 @@ import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { credentials } from "../../Validation/validation";
import { login } from "../../Features/Auth/authSlice";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import Button from "../../Components/Button";
import axiosInstance from "../../Utils/axiosConfig";
import { networkService } from "../../main";
import Field from "../../Components/Inputs/Field";
import background from "../../assets/Images/background_pattern_decorative.png";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import Mail from "../../assets/icons/mail.svg?react";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import PropTypes from "prop-types";
import { logger } from "../../Utils/Logger";
import "./index.css";
/**
@@ -63,6 +64,10 @@ const LandingPage = ({ onContinue }) => {
);
};
LandingPage.propTypes = {
onContinue: PropTypes.func.isRequired,
};
/**
* Renders the first step of the login process.
*
@@ -136,6 +141,14 @@ const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
);
};
StepOne.propTypes = {
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
/**
* Renders the second step of the login process, including a password input field.
*
@@ -230,11 +243,22 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
);
};
StepTwo.propTypes = {
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
const Login = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const { authToken } = authState;
const idMap = {
"login-email-input": "email",
"login-password-input": "password",
@@ -248,17 +272,21 @@ const Login = () => {
const [step, setStep] = useState(0);
useEffect(() => {
axiosInstance
.get("/auth/users/admin")
if (authToken) {
navigate("/monitors");
return;
}
networkService
.doesAdminExist()
.then((response) => {
if (response.data.data === false) {
navigate("/register");
}
})
.catch((error) => {
console.log(error);
logger.error(error);
});
}, [navigate]);
}, [authToken, navigate]);
const handleChange = (event) => {
const { value, id } = event.target;
@@ -399,7 +427,9 @@ const Login = () => {
)}
</Stack>
<Box textAlign="center" p={theme.gap.large}>
<Typography display="inline-block">Don't have an account? —</Typography>
<Typography display="inline-block">
Don&apos;t have an account? —
</Typography>
<Typography
component="span"
ml={theme.gap.xs}
+34 -7
View File
@@ -15,8 +15,9 @@ import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import Check from "../../../Components/Check/Check";
import Button from "../../../Components/Button";
import Field from "../../../Components/Inputs/Field";
import axiosInstance from "../../../Utils/axiosConfig";
import { networkService } from "../../../main";
import "../index.css";
import { logger } from "../../../Utils/Logger";
/**
* Displays the initial landing page.
@@ -68,6 +69,11 @@ const LandingPage = ({ isAdmin, onSignup }) => {
);
};
LandingPage.propTypes = {
isAdmin: PropTypes.bool,
onSignup: PropTypes.func,
};
/**
* Renders the first step of the sign up process.
*
@@ -151,6 +157,14 @@ const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
);
};
StepOne.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
/**
* Renders the second step of the sign up process.
*
@@ -223,6 +237,14 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
);
};
StepTwo.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
/**
* Renders the third step of the sign up process.
*
@@ -368,6 +390,14 @@ const StepThree = ({ form, errors, onSubmit, onChange, onBack }) => {
);
};
StepThree.propTypes = {
form: PropTypes.object,
errors: PropTypes.object,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
onBack: PropTypes.func,
};
const Register = ({ isAdmin }) => {
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -397,19 +427,16 @@ const Register = ({ isAdmin }) => {
const fetchInvite = async () => {
if (token !== undefined) {
try {
const res = await axiosInstance.post(`/auth/invite/verify`, {
token,
});
const res = await networkService.verifyInvitationToken(token);
const { role, email } = res.data.data;
console.log(role);
setForm({ ...form, email, role });
} catch (error) {
console.log(error);
logger.error(error);
}
}
};
fetchInvite();
}, [token]);
}, [token, form]);
/**
* Validates the form data against the validation schema.
-1
View File
@@ -1 +0,0 @@
/* Home Page Styles*/
-16
View File
@@ -1,16 +0,0 @@
import DropdownTeamMember from "../../Components/DropdownTeamMember";
import Search from "../../Components/Search";
import "./index.css";
import DashboardMenu from "../../Components/DashboardMenu";
import Integrations from "../../Components/Integrations";
const Home = () => {
const token = localStorage.getItem("token");
return (
<>
<div>Home</div>
</>
);
};
export default Home;
@@ -18,8 +18,9 @@ import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import axiosInstance from "../../../Utils/axiosConfig";
import { networkService } from "../../../main";
import { StatusLabel } from "../../../Components/Label";
import { logger } from "../../../Utils/Logger";
const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
const { authToken, user } = useSelector((state) => state.auth);
@@ -31,10 +32,10 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
});
useEffect(() => {
setPaginationController({
...paginationController,
setPaginationController((prevPaginationController) => ({
...prevPaginationController,
page: 0,
});
}));
}, [filter, selectedMonitor]);
useEffect(() => {
@@ -43,21 +44,34 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
return;
}
try {
let url = `/checks/${selectedMonitor}?sortOrder=desc&filter=${filter}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`;
let res;
if (selectedMonitor === "0") {
url = `/checks/user/${user._id}?sortOrder=desc&filter=${filter}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`;
res = await networkService.getChecksByUser(
authToken,
user._id,
"desc",
null,
null,
filter,
paginationController.page,
paginationController.rowsPerPage
);
} else {
res = await networkService.getChecksByMonitor(
authToken,
selectedMonitor,
"desc",
null,
null,
filter,
paginationController.page,
paginationController.rowsPerPage
);
}
const res = await axiosInstance.get(url, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
console.log(error);
logger.error(error);
}
};
fetchPage();
@@ -72,7 +86,6 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
]);
const handlePageChange = (_, newPage) => {
console.log(newPage);
setPaginationController({
...paginationController,
page: newPage - 1, // 0-indexed
+9 -8
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { ButtonGroup, Stack, Skeleton, Typography } from "@mui/material";
import Button from "../../Components/Button";
import axiosInstance from "../../Utils/axiosConfig";
import { networkService } from "../../main";
import { useTheme } from "@emotion/react";
import Select from "../../Components/Inputs/Select";
import IncidentTable from "./IncidentTable";
@@ -48,13 +48,14 @@ const Incidents = () => {
useEffect(() => {
const fetchMonitors = async () => {
setLoading(true);
const res = await axiosInstance.get(
`/monitors/user/${authState.user._id}?status=false&limit=1`,
{
headers: {
Authorization: `Bearer ${authState.authToken}`,
},
}
const res = await networkService.getMonitorsByUserId(
authState.authToken,
authState.user._id,
1,
null,
null,
null,
null
);
// Reduce to a lookup object for 0(1) lookup
@@ -1,8 +1,8 @@
import { Box, Stack, Typography } from "@mui/material";
import "./index.css";
import React, { useState } from "react";
import { useState } from "react";
import Button from "../../../Components/Button";
import Back from "../../../assets/icons/left-arrow.svg?react";
import Back from "../../../assets/icons/left-arrow-long.svg?react";
import Select from "../../../Components/Inputs/Select";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
-3
View File
@@ -1,10 +1,7 @@
import { useTheme } from "@emotion/react";
import Fallback from "../../Components/Fallback";
import "./index.css";
const Maintenance = () => {
const theme = useTheme();
return (
<div className="maintenance">
<Fallback
+9 -15
View File
@@ -23,8 +23,7 @@
opacity: 0.6;
}
.configure-monitor h6.MuiTypography-root,
.configure-monitor p.MuiTypography-root:has(span),
.configure-monitor p.MuiTypography-root span.MuiTypography-root {
.configure-monitor h1.MuiTypography-root + span.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
.configure-monitor button.MuiButtonBase-root {
@@ -33,19 +32,20 @@
.configure-monitor button.MuiButtonBase-root {
height: var(--env-var-height-2);
}
.configure-monitor button.MuiButtonBase-root:has(svg) {
line-height: 1;
}
.configure-monitor .config-box {
padding: var(--env-var-spacing-4) 50px;
padding-bottom: 60px;
border: 1px solid var(--env-var-color-16);
border-radius: var(--env-var-radius-1);
background-color: var(--env-var-color-8);
}
.configure-monitor .config-box .MuiBox-root,
.configure-monitor .config-box .MuiStack-root {
.configure-monitor .config-box > .MuiBox-root,
.configure-monitor .config-box > .MuiStack-root {
flex: 1;
padding: 24px 30px;
padding-bottom: 40px;
}
.configure-monitor .config-box > *:first-child {
flex: 0.7;
border-right: solid 1px var(--env-var-color-16);
}
.configure-monitor .MuiStack-root:has(span.MuiTypography-root.input-error) {
position: relative;
@@ -72,12 +72,6 @@
border-color: var(--env-var-color-29);
}
.configure-monitor-form {
display: flex;
flex-direction: column;
gap: var(--env-var-spacing-2);
}
body:has(.configure-monitor) .select-dropdown .MuiMenuItem-root,
.configure-monitor .select-wrapper .select-component > .MuiSelect-select {
text-transform: lowercase;
+43 -68
View File
@@ -5,15 +5,11 @@ 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 WestRoundedIcon from "@mui/icons-material/WestRounded";
import GreenCheck from "../../../assets/icons/checkbox-green.svg?react";
import RedCheck from "../../../assets/icons/checkbox-red.svg?react";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
import { getLastChecked } from "../../../Utils/monitorUtils";
import "./index.css";
import { monitorValidation } from "../../../Validation/validation";
import Select from "../../../Components/Inputs/Select";
import { formatDurationRounded } from "../../../Utils/timeUtils";
import { createToast } from "../../../Utils/toastUtils";
import {
updateUptimeMonitor,
@@ -21,6 +17,9 @@ import {
deleteUptimeMonitor,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Checkbox from "../../../Components/Inputs/Checkbox";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import "./index.css";
import { logger } from "../../../Utils/Logger";
/**
* Parses a URL string and returns a URL object.
@@ -96,7 +95,6 @@ const Configure = () => {
const { user, authToken } = useSelector((state) => state.auth);
const { monitors } = useSelector((state) => state.uptimeMonitors);
const [monitor, setMonitor] = useState({});
const [duration, setDuration] = useState(0);
const [errors, setErrors] = useState({});
const { monitorId } = useParams();
@@ -111,14 +109,13 @@ const Configure = () => {
useEffect(() => {
const data = monitors.find((monitor) => monitor._id === monitorId);
if (!data) {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found");
logger.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
setMonitor({
...data,
});
setDuration(formatDurationRounded(data?.interval));
}, [monitorId, authToken, monitors]);
}, [monitorId, authToken, monitors, navigate]);
const handleChange = (event, name) => {
let { value, id } = event.target;
@@ -210,74 +207,54 @@ const Configure = () => {
const parsedUrl = parseUrl(monitor?.url);
const protocol = parsedUrl?.protocol?.replace(":", "") || "";
var lastChecked = formatDurationRounded(getLastChecked(monitor?.checks));
let loading = Object.keys(monitor).length === 0;
return (
<Box className="configure-monitor">
<Stack className="configure-monitor" gap={theme.gap.large}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate(-1)}
sx={{
backgroundColor: "#f4f4f4",
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "details", path: `/monitors/${monitorId}` },
{ name: "configure", path: `/monitors/configure/${monitorId}` },
]}
/>
<form
className="configure-monitor-form"
<Stack
component="form"
noValidate
spellCheck="false"
style={{
marginTop: theme.gap.medium,
}}
gap={theme.gap.large}
flex={1}
>
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
<Stack direction="row" gap={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
{parsedUrl?.host ? (
<Typography
component="h1"
mb={theme.gap.small}
sx={{ lineHeight: 1 }}
>
<Typography component="h1" mb={theme.gap.xs} lineHeight={1}>
{parsedUrl.host || "..."}
</Typography>
) : (
""
)}
<Typography lineHeight={parsedUrl?.host ? 1 : theme.gap.large}>
<Typography
component="span"
sx={{
color: monitor?.status
? "var(--env-var-color-17)"
: "var(--env-var-color-24)",
}}
>
Your site is {monitor?.status ? "up" : "down"}.
</Typography>{" "}
Checking every {duration}.{" "}
{lastChecked ? `Last time checked ${lastChecked} ago.` : ""}
<Typography
component="span"
lineHeight={theme.gap.large}
sx={{
color: monitor?.status
? "var(--env-var-color-17)"
: "var(--env-var-color-24)",
}}
>
Your site is {monitor?.status ? "up" : "down"}.
</Typography>
</Box>
<Stack
direction="row"
gap={theme.gap.medium}
<Box
sx={{
ml: "auto",
alignSelf: "flex-end",
ml: "auto",
}}
>
<Button
@@ -286,9 +263,10 @@ const Configure = () => {
animate="rotate180"
img={<PauseCircleOutlineIcon />}
sx={{
backgroundColor: "#f4f4f4",
backgroundColor: theme.palette.otherColors.fillGray,
pl: theme.gap.small,
pr: theme.gap.medium,
mr: theme.gap.medium,
"& svg": {
mr: theme.gap.xs,
},
@@ -303,17 +281,16 @@ const Configure = () => {
}}
onClick={() => setIsOpen(true)}
/>
</Stack>
</Box>
</Stack>
<Stack
className="config-box"
direction="row"
justifyContent="space-between"
gap={theme.gap.xxl}
>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p" sx={{ mt: theme.gap.small }}>
<Typography component="p" sx={{ mt: theme.gap.xs }}>
Here you can select the URL of the host, together with the
type of monitor.
</Typography>
@@ -345,11 +322,10 @@ const Configure = () => {
className="config-box"
direction="row"
justifyContent="space-between"
gap={theme.gap.xxl}
>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p" mt={theme.gap.small}>
<Typography component="p" mt={theme.gap.xs}>
When there is an incident, notify users.
</Typography>
</Box>
@@ -362,7 +338,7 @@ const Configure = () => {
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
@@ -381,7 +357,7 @@ const Configure = () => {
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor?.notifications?.some(
@@ -393,7 +369,7 @@ const Configure = () => {
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.gap.small}>
You can separate multiple emails with a comma
@@ -408,7 +384,6 @@ const Configure = () => {
className="config-box"
direction="row"
justifyContent="space-between"
gap={theme.gap.xxl}
>
<Box>
<Typography component="h2">Advanced settings</Typography>
@@ -423,7 +398,7 @@ const Configure = () => {
/>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<Stack direction="row" justifyContent="flex-end" mt="auto">
<Button
level="primary"
label="Save"
@@ -431,7 +406,7 @@ const Configure = () => {
onClick={handleSubmit}
/>
</Stack>
</form>
</Stack>
</>
)}
<Modal
@@ -460,7 +435,7 @@ const Configure = () => {
}}
>
<Typography id="modal-delete-monitor" component="h2">
Do you really want to delete this monitor?
Do you really want to delete this monitor?
</Typography>
<Typography id="delete-monitor-confirmation">
Once deleted, this monitor cannot be retrieved.
@@ -480,7 +455,7 @@ const Configure = () => {
</Stack>
</Stack>
</Modal>
</Box>
</Stack>
);
};
@@ -13,7 +13,9 @@
.create-monitor button.MuiButtonBase-root {
font-size: var(--env-var-font-size-medium);
}
.create-monitor h2.MuiTypography-root,
.create-monitor h2.MuiTypography-root {
color: var(--env-var-color-5);
}
.create-monitor h6.MuiTypography-root,
.create-monitor p.MuiTypography-root {
color: var(--env-var-color-2);
@@ -43,23 +45,20 @@
top: 100%;
}
.create-monitor .config-box {
padding: var(--env-var-spacing-4) 50px;
padding-bottom: 60px;
flex-direction: row;
border: 1px solid var(--env-var-color-16);
border-radius: var(--env-var-radius-1);
flex-direction: row;
gap: var(--env-var-spacing-4);
background-color: var(--env-var-color-8);
}
.create-monitor .config-box .MuiBox-root,
.create-monitor .config-box .MuiStack-root {
.create-monitor .config-box > .MuiBox-root,
.create-monitor .config-box > .MuiStack-root {
flex: 1;
padding: 24px 30px;
padding-bottom: 40px;
}
.create-monitor-form {
display: flex;
flex-direction: column;
gap: var(--env-var-spacing-2);
.create-monitor .config-box > *:first-child {
flex: 0.7;
border-right: solid 1px var(--env-var-color-16);
}
.create-monitor .MuiStack-root .MuiButtonGroup-root button {
@@ -1,5 +1,5 @@
import "./index.css";
import React, { useState } from "react";
import { useState } from "react";
import RadioButton from "../../../Components/RadioButton";
import Button from "../../../Components/Button";
import { Box, ButtonGroup, Stack, Typography } from "@mui/material";
@@ -8,11 +8,12 @@ import { monitorValidation } from "../../../Validation/validation";
import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import { createToast } from "../../../Utils/toastUtils";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { logger } from "../../../Utils/Logger";
const CreateMonitor = () => {
const MS_PER_MINUTE = 60000;
@@ -143,33 +144,26 @@ const CreateMonitor = () => {
return (
<Box className="create-monitor">
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate("/monitors")}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
mb: theme.gap.medium,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "create", path: `/monitors/create` },
]}
/>
<form
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
gap={theme.gap.large}
mt={theme.gap.mlplus}
>
<Typography component="h1">Create new monitor</Typography>
<Stack className="config-box">
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p" mt={theme.gap.small}>
<Typography component="p" mt={theme.gap.xs}>
Here you can select the URL of the host, together with the type of
monitor.
</Typography>
@@ -200,7 +194,7 @@ const CreateMonitor = () => {
<Stack className="config-box">
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p" mt={theme.gap.small}>
<Typography component="p" mt={theme.gap.xs}>
You can always add or remove checks after adding your site.
</Typography>
</Box>
@@ -249,34 +243,32 @@ const CreateMonitor = () => {
checked={monitor.type === "ping"}
onChange={(event) => handleChange(event)}
/>
<Box className="error-container">
{errors["type"] ? (
{errors["type"] ? (
<Box className="error-container">
<Typography component="p" className="input-error">
{errors["type"]}
</Typography>
) : (
""
)}
</Box>
</Box>
) : (
""
)}
</Stack>
</Stack>
<Stack className="config-box">
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p" mt={theme.gap.small}>
<Typography component="p" mt={theme.gap.xs}>
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.gap.medium}>
<Typography component="p" mt={theme.gap.small}>
When there is a new incident,
</Typography>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
@@ -293,7 +285,7 @@ const CreateMonitor = () => {
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
@@ -305,7 +297,7 @@ const CreateMonitor = () => {
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.gap.small}>
You can separate multiple emails with a comma
@@ -404,7 +396,7 @@ const CreateMonitor = () => {
disabled={Object.keys(errors).length !== 0 && true}
/>
</Stack>
</form>
</Stack>
</Box>
);
};
@@ -13,10 +13,11 @@ import {
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import axiosInstance from "../../../../Utils/axiosConfig";
import { networkService } from "../../../../main";
import { StatusLabel } from "../../../../Components/Label";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import { logger } from "../../../../Utils/Logger";
const PaginationTable = ({ monitorId, dateRange }) => {
const { authToken } = useSelector((state) => state.auth);
@@ -28,27 +29,29 @@ const PaginationTable = ({ monitorId, dateRange }) => {
});
useEffect(() => {
setPaginationController({
...paginationController,
setPaginationController((prevPaginationController) => ({
...prevPaginationController,
page: 0,
});
}));
}, [dateRange]);
useEffect(() => {
const fetchPage = async () => {
try {
const res = await axiosInstance.get(
`/checks/${monitorId}?sortOrder=desc&dateRange=${dateRange}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
const res = await networkService.getChecksByMonitor(
authToken,
monitorId,
"desc",
null,
dateRange,
null,
paginationController.page,
paginationController.rowsPerPage
);
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
console.log(error);
logger.error(error);
}
};
fetchPage();
+34 -53
View File
@@ -1,13 +1,12 @@
import React, { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback } from "react";
import PropTypes from "prop-types";
import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import axiosInstance from "../../../Utils/axiosConfig";
import { networkService } from "../../../main";
import MonitorDetailsAreaChart from "../../../Components/Charts/MonitorDetailsAreaChart";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "../../../Components/Button";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import GreenCheck from "../../../assets/icons/checkbox-green.svg?react";
import RedCheck from "../../../assets/icons/checkbox-red.svg?react";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
@@ -17,7 +16,8 @@ import {
formatDurationRounded,
} from "../../../Utils/timeUtils";
import "./index.css";
import MonitorDetails60MinChart from "../../../Components/Charts/MonitorDetails60MinChart";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { logger } from "../../../Utils/Logger";
const StatBox = ({ title, value }) => {
return (
@@ -121,18 +121,19 @@ const DetailsPage = () => {
const fetchMonitor = useCallback(async () => {
try {
const res = await axiosInstance.get(
`/monitors/stats/${monitorId}?dateRange=${dateRange}&numToDisplay=50&normalize=true`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
const res = await networkService.getStatsByMonitorId(
authToken,
monitorId,
null,
null,
dateRange,
50,
true
);
setMonitor(res.data.data);
setMonitor(res?.data?.data ?? {});
} catch (error) {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found");
logger.error(error);
navigate("/not-found", { replace: true });
}
}, [authToken, monitorId, navigate, dateRange]);
@@ -142,15 +143,15 @@ const DetailsPage = () => {
useEffect(() => {
const fetchCertificate = async () => {
const res = await axiosInstance.get(
`/monitors/certificate/${monitorId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
setCertificateExpiry(res.data.data.certificateDate);
try {
const res = await networkService.getCertificateExpiry(
authToken,
monitorId
);
setCertificateExpiry(res?.data?.data?.certificateDate ?? "N/A");
} catch (error) {
console.error(error);
}
};
fetchCertificate();
}, [authToken, monitorId]);
@@ -163,39 +164,19 @@ const DetailsPage = () => {
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate("/monitors")}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "details", path: `/monitors/${monitorId}` },
]}
/>
<Stack gap={theme.gap.xl} mt={theme.gap.medium}>
<Stack
direction="row"
gap={theme.gap.small}
mt={theme.gap.small}
alignItems="baseline"
>
<Stack gap={theme.gap.large} mt={theme.gap.large}>
<Stack direction="row" gap={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
<Stack direction="row">
<Typography
component="h1"
sx={{ lineHeight: 1, alignSelf: "flex-end" }}
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<MonitorDetails60MinChart data={monitor.statusBar} />
</Stack>
<Typography component="h1" sx={{ lineHeight: 1 }}>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography mt={theme.gap.small}>
<Typography
component="span"
-2
View File
@@ -11,8 +11,6 @@
}
.monitors p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.monitors p.MuiTypography-root {
color: var(--env-var-color-5);
}
.monitors .MuiStack-root > button:not(.MuiIconButton-root) {
+194 -136
View File
@@ -11,7 +11,6 @@ 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 Settings from "../../assets/icons/settings-bold.svg?react";
import BasicTable from "../../Components/BasicTable";
import { StatusLabel } from "../../Components/Label";
import { createToast } from "../../Utils/toastUtils";
@@ -27,6 +26,180 @@ import {
Typography,
} from "@mui/material";
import Settings from "../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
const ActionsMenu = ({ monitor }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { _id: actions.id };
const action = await dispatch(
deleteUptimeMonitor({ authToken: authState.authToken, monitor })
);
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByUserId(authState.authToken));
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const openMenu = (event, id, url) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(
event,
monitor._id,
monitor.type === "ping" ? null : monitor.url
);
}}
sx={{
"&:focus": {
outline: "none",
},
}}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
>
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/${actions.id}`);
}}
>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem disabled>Incidents</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
</Menu>
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
disablePortal
>
<Stack
gap={theme.gap.xs}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "white",
border: "solid 1px #f2f2f2",
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: 24,
p: "30px",
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-monitor" component="h2">
Do you really want to delete this monitor?
</Typography>
<Typography id="delete-monitor-confirmation">
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
gap={theme.gap.small}
mt={theme.gap.large}
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
onClick={() => setIsOpen(false)}
/>
<Button
level="error"
label="Delete"
onClick={(e) => handleRemove(e)}
/>
</Stack>
</Stack>
</Modal>
</>
);
};
ActionsMenu.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
}).isRequired,
};
/**
* Host component.
* This subcomponent receives a params object and displays the host details.
@@ -52,7 +225,7 @@ const Host = ({ params }) => {
"&:focus": {
outline: "none",
},
mr: "3px",
mr: "5px",
}}
>
<OpenInNewPage
@@ -72,6 +245,15 @@ const Host = ({ params }) => {
);
};
Host.propTypes = {
params: PropTypes.shape({
url: PropTypes.string,
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.number,
}).isRequired,
};
/**
* Renders a skeleton layout.
*
@@ -111,37 +293,6 @@ const Monitors = () => {
const authState = useSelector((state) => state.auth);
const dispatch = useDispatch({});
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const openMenu = (event, id, url) => {
event.preventDefault();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const closeMenu = () => {
setAnchorEl(null);
};
const [isOpen, setIsOpen] = useState(false);
const openRemove = () => {
closeMenu();
setIsOpen(true);
};
const handleRemove = async (event) => {
event.preventDefault();
let monitor = { _id: actions.id };
const action = await dispatch(
deleteUptimeMonitor({ authToken: authState.authToken, monitor })
);
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByUserId(authState.authToken));
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
}
};
useEffect(() => {
dispatch(getUptimeMonitorsByUserId(authState.authToken));
}, [authState.authToken, dispatch]);
@@ -191,7 +342,9 @@ const Monitors = () => {
return {
id: monitor._id,
// disabled for now
handleClick: () => navigate(`/monitors/${monitor._id}`),
handleClick: () => {
navigate(`/monitors/${monitor._id}`);
},
data: [
{ id: idx, data: <Host params={params} /> },
{
@@ -213,28 +366,7 @@ const Monitors = () => {
},
{
id: idx + 4,
data: (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(
event,
monitor._id,
monitor.type === "ping" ? null : monitor.url
);
}}
sx={{
"&:focus": {
outline: "none",
},
}}
>
<Settings />
</IconButton>
</>
),
data: <ActionsMenu monitor={monitor} />,
},
],
};
@@ -243,7 +375,7 @@ const Monitors = () => {
let loading = monitorState.isLoading && monitorState.monitors.length === 0;
return (
<Stack className="monitors" pt={theme.gap.xl} gap={theme.gap.large}>
<Stack className="monitors" gap={theme.gap.large}>
{loading ? (
<SkeletonLayout />
) : (
@@ -262,10 +394,11 @@ const Monitors = () => {
{monitorState.monitors?.length !== 0 && (
<Button
level="primary"
label="Create new monitor"
label="Create monitor"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ fontWeight: 500 }}
/>
)}
</Stack>
@@ -303,10 +436,8 @@ const Monitors = () => {
<ServerStatus title="Down" value={down} state="down" />
<ServerStatus title="Paused" value={0} state="pause" />
</Stack>
<Stack
gap={theme.gap.large}
<Box
p={theme.gap.lgplus}
flex={1}
border={1}
borderColor={theme.palette.otherColors.graishWhite}
backgroundColor={theme.palette.otherColors.white}
@@ -314,7 +445,7 @@ const Monitors = () => {
borderRadius: `${theme.shape.borderRadius}px`,
}}
>
<Stack direction="row" alignItems="center">
<Stack direction="row" alignItems="center" mb={theme.gap.large}>
<Typography component="h2">Current monitors</Typography>
<Box className="current-monitors-counter">
{monitorState.monitors.length}
@@ -322,84 +453,11 @@ const Monitors = () => {
{/* TODO - add search bar */}
</Stack>
<BasicTable data={data} paginated={true} />
</Stack>{" "}
</Box>
</>
)}
</>
)}
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={closeMenu}
>
{actions.url !== null ? (
<MenuItem
onClick={() => {
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem onClick={() => navigate(`/monitors/${actions.id}`)}>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem disabled>Incidents</MenuItem>
<MenuItem onClick={() => navigate(`/monitors/configure/${actions.id}`)}>
Configure
</MenuItem>
<MenuItem onClick={openRemove}>Remove</MenuItem>
</Menu>
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
disablePortal
>
<Stack
gap={theme.gap.xs}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "white",
border: "solid 1px #f2f2f2",
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: 24,
p: "30px",
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-monitor" component="h2">
Do you really want to delete this monitor?
</Typography>
<Typography id="delete-monitor-confirmation">
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
gap={theme.gap.small}
mt={theme.gap.large}
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
onClick={() => setIsOpen(false)}
/>
<Button level="error" label="Delete" onClick={handleRemove} />
</Stack>
</Stack>
</Modal>
</Stack>
);
};
+3 -5
View File
@@ -11,7 +11,7 @@ import NotFoundSvg from "../../../src/assets/Images/sushi_404.svg";
* So That why we're using JavaScript default parameters instead.
*/
const DefaultValue = {
title: "We cannot find this page",
title: "Oh no! You dropped your sushi!",
desc: "Either the URL doesn’t exist, or you don’t have access to it.",
};
@@ -27,20 +27,18 @@ const DefaultValue = {
* />
*
* @param {Object} props - The component props.
* @param {string} [props.title="We cannot find this page"] - The title of the error page.
* @param {string} [props.title="Oh no! You dropped your sushi!"] - The title of the error page.
* @param {string} [props.desc="Either the URL doesn’t exist, or you don’t have access to it."] - The description of the error page.
* @returns {JSX.Element} The rendered error page component.
*/
const NotFound = ({ title = DefaultValue.title, desc = DefaultValue.desc }) => {
const navigate = useNavigate();
console.log("NOT FOUND");
return (
<Stack className="not-found-page" justifyContent="center">
<Stack gap="20px" alignItems="center">
<img src={NotFoundSvg} alt="404" style={{ maxHeight: "25rem" }} />
<Typography component="h1">{title}</Typography>
<Typography>{desc}</Typography>
<img src={NotFoundSvg} alt="404" style={{ maxHeight: "25rem" }} />
<Typography>Oh no! You dropped your sushi!</Typography>
<Button
label="Go to the main dashboard"
level="primary"
@@ -2,7 +2,7 @@
background-color: var(--env-var-color-8);
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-1);
padding: var(--env-var-default-2);
padding: var(--env-var-font-size-large-plus) var(--env-var-font-size-xlarge);
}
.configure-pagespeed h1.MuiTypography-root {
font-size: var(--env-var-font-size-large-plus);
@@ -60,9 +60,12 @@ body:has(.configure-pagespeed) .select-dropdown .MuiMenuItem-root,
text-transform: lowercase;
}
.configure-pagespeed h3.MuiTypography-root {
flex: 0.7;
}
.configure-pagespeed .field,
.configure-pagespeed .section-disabled,
.configure-pagespeed .select-wrapper,
.configure-pagespeed h3.MuiTypography-root {
.configure-pagespeed .select-wrapper {
flex: 1;
}
+20 -31
View File
@@ -15,9 +15,10 @@ import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import GreenCheck from "../../../assets/icons/checkbox-green.svg?react";
import RedCheck from "../../../assets/icons/checkbox-red.svg?react";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { logger } from "../../../Utils/Logger";
import "./index.css";
@@ -92,13 +93,13 @@ const PageSpeedConfigure = () => {
useEffect(() => {
const data = monitors.find((monitor) => monitor._id === monitorId);
if (!data) {
console.error("Error fetching pagespeed monitor of id: " + monitorId);
navigate("/not-found");
logger.error("Error fetching pagespeed monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
setMonitor({
...data,
});
}, [monitorId]);
}, [monitorId, monitors, navigate]);
const handleChange = (event, id) => {
let { value } = event.target;
@@ -147,37 +148,25 @@ const PageSpeedConfigure = () => {
let loading = Object.keys(monitor).length === 0;
return (
<Box className="configure-pagespeed">
<Stack className="configure-pagespeed" gap={theme.gap.large}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate(-1)}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
mb: theme.gap.large,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `/pagespeed/${monitorId}` },
{ name: "configure", path: `/pagespeed/configure/${monitorId}` },
]}
/>
<form
<Stack
component="form"
noValidate
spellCheck="false"
onSubmit={handleSave}
style={{
display: "flex",
flexDirection: "column",
gap: theme.gap.large,
maxWidth: "1000px",
}}
flex={1}
gap={theme.gap.large}
>
<Stack direction="row" gap={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
@@ -290,7 +279,7 @@ const PageSpeedConfigure = () => {
id="notify-emails-list"
placeholder="notifications@gmail.com"
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
error=""
/>
<Typography mt={theme.gap.small}>
@@ -300,7 +289,7 @@ const PageSpeedConfigure = () => {
</Stack>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<Stack direction="row" justifyContent="flex-end" mt="auto">
<Button
type="submit"
level="primary"
@@ -309,7 +298,7 @@ const PageSpeedConfigure = () => {
sx={{ px: theme.gap.large, mt: theme.gap.large }}
/>
</Stack>
</form>
</Stack>
</>
)}
<Modal
@@ -358,7 +347,7 @@ const PageSpeedConfigure = () => {
</Stack>
</Stack>
</Modal>
</Box>
</Stack>
);
};
@@ -2,10 +2,10 @@
background-color: var(--env-var-color-8);
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-1);
padding: var(--env-var-default-2);
padding: var(--env-var-font-size-large-plus) var(--env-var-font-size-xlarge);
}
.create-page-speed h1.MuiTypography-root {
font-size: var(--env-var-font-size-large);
font-size: 22px;
color: var(--env-var-color-1);
}
.create-page-speed p.MuiTypography-root,
@@ -50,9 +50,12 @@ body:has(.create-page-speed) .select-dropdown .MuiMenuItem-root,
text-transform: lowercase;
}
.create-page-speed h3.MuiTypography-root {
flex: 0.7;
}
.create-page-speed .field,
.create-page-speed .section-disabled,
.create-page-speed .select-wrapper,
.create-page-speed h3.MuiTypography-root {
.create-page-speed .select-wrapper {
flex: 1;
}
@@ -10,8 +10,9 @@ import Checkbox from "../../../Components/Inputs/Checkbox";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { createPageSpeed } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import "./index.css";
import { logger } from "../../../Utils/Logger";
const CreatePageSpeed = () => {
const theme = useTheme();
@@ -97,34 +98,22 @@ const CreatePageSpeed = () => {
};
return (
<Box className="create-page-speed">
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate("/pagespeed")}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
mb: theme.gap.medium,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
<Stack className="create-page-speed" gap={theme.gap.mlplus}>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "create", path: `/pagespeed/create` },
]}
/>
<form
<Stack
component="form"
noValidate
spellCheck="false"
onSubmit={handleCreate}
style={{
display: "flex",
flexDirection: "column",
gap: theme.gap.large,
}}
gap={theme.gap.large}
flex={1}
>
<Typography component="h1">Create a page speed monitor</Typography>
<Typography component="h1">Create pagespeed monitor</Typography>
<Stack gap={theme.gap.xl}>
<Stack direction="row">
<Typography component="h3">Monitor friendly name</Typography>
@@ -187,7 +176,7 @@ const CreatePageSpeed = () => {
id="notify-emails-list"
placeholder="notifications@gmail.com"
value=""
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
error=""
/>
<Typography mt={theme.gap.small}>
@@ -197,7 +186,7 @@ const CreatePageSpeed = () => {
</Stack>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<Stack direction="row" justifyContent="flex-end" mt="auto">
<Button
type="submit"
level="primary"
@@ -206,8 +195,8 @@ const CreatePageSpeed = () => {
sx={{ px: theme.gap.large, mt: theme.gap.large }}
/>
</Stack>
</form>
</Box>
</Stack>
</Stack>
);
};
+1 -1
View File
@@ -37,7 +37,7 @@
.page-speed-details > .MuiStack-root:last-child,
.page-speed-details > .MuiBox-root:nth-last-child(3) {
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-2);
border-radius: var(--env-var-radius-1);
background-color: var(--env-var-color-8);
min-width: 200px;
}
+49 -42
View File
@@ -9,28 +9,20 @@ import {
formatDuration,
formatDurationRounded,
} from "../../../Utils/timeUtils";
import axiosInstance from "../../../Utils/axiosConfig";
import { networkService } from "../../../main";
import Button from "../../../Components/Button";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import LastCheckedIcon from "../../../assets/icons/calendar-check.svg?react";
import ClockIcon from "../../../assets/icons/maintenance.svg?react";
import IntervalCheckIcon from "../../../assets/icons/interval-check.svg?react";
import GreenCheck from "../../../assets/icons/checkbox-green.svg?react";
import RedCheck from "../../../assets/icons/checkbox-red.svg?react";
import "./index.css";
import PageSpeedLineChart from "../../../Components/Charts/PagespeedLineChart";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import "./index.css";
import PropTypes from "prop-types";
import { logger } from "../../../Utils/Logger";
/**
* Displays a box with an icon, title, and value.
*
* @param {Object} props
* @param {ReactNode} props.icon - The icon to display in the box.
* @param {string} props.title - The title text to display above the value.
* @param {string | number} props.value - The value text to display in the box.
* @returns {JSX.Element}
*/
const StatBox = ({ icon, title, value }) => {
const theme = useTheme();
@@ -39,7 +31,8 @@ const StatBox = ({ icon, title, value }) => {
className="stat-box"
direction="row"
gap={theme.gap.small}
p={theme.gap.ml}
pt={theme.gap.ml}
px={theme.gap.ml}
pb={theme.gap.mlplus}
>
{icon}
@@ -53,6 +46,12 @@ const StatBox = ({ icon, title, value }) => {
);
};
StatBox.propTypes = {
icon: PropTypes.element,
title: PropTypes.string,
value: PropTypes.node,
};
/**
* Renders a centered label within a pie chart.
*
@@ -85,6 +84,12 @@ const PieCenterLabel = ({ value, color, setExpand }) => {
);
};
PieCenterLabel.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
color: PropTypes.string,
setExpand: PropTypes.func,
};
/**
* A component that renders a label on a pie chart slice.
* The label is positioned relative to the center of the pie chart and is optionally highlighted.
@@ -128,6 +133,14 @@ const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => {
);
};
PieValueLabel.propTypes = {
value: PropTypes.number,
startAngle: PropTypes.number,
endAngle: PropTypes.number,
color: PropTypes.string,
highlighted: PropTypes.bool,
};
/**
* Renders a skeleton layout.
*
@@ -186,25 +199,25 @@ const PageSpeedDetails = () => {
useEffect(() => {
const fetchMonitor = async () => {
try {
const res = await axiosInstance.get(
`/monitors/stats/${monitorId}?sortOrder=desc&limit=50`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
const res = await networkService.getStatsByMonitorId(
authToken,
monitorId,
"desc",
50,
null,
null,
null
);
setMonitor(res.data.data);
setAudits(res.data.data.checks[0].audits);
setMonitor(res?.data?.data ?? {});
setAudits(res?.data?.data?.checks?.[0]?.audits ?? []);
} catch (error) {
console.error("Error fetching pagespeed monitor of id: " + monitorId);
navigate("/not-found");
logger.error(logger);
navigate("/not-found", { replace: true });
}
};
fetchMonitor();
}, [monitorId]);
}, [monitorId, authToken, navigate]);
/**
* Weight constants for different performance metrics.
@@ -306,21 +319,11 @@ const PageSpeedDetails = () => {
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate("/pagespeed")}
sx={{
width: "fit-content",
backgroundColor: theme.palette.otherColors.fillGray,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `/pagespeed/${monitorId}` },
]}
/>
<Stack direction="row" gap={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
@@ -662,4 +665,8 @@ const PageSpeedDetails = () => {
);
};
PageSpeedDetails.propTypes = {
push: PropTypes.func,
};
export default PageSpeedDetails;
+6 -1
View File
@@ -11,6 +11,7 @@ import "./index.css";
import Button from "../../Components/Button";
import { useNavigate } from "react-router";
import { getLastChecked } from "../../Utils/monitorUtils";
import PropTypes from "prop-types";
const Card = ({ data }) => {
const theme = useTheme();
@@ -57,6 +58,10 @@ const Card = ({ data }) => {
);
};
Card.propTypes = {
data: PropTypes.object.isRequired,
};
/**
* Renders a skeleton layout.
*
@@ -129,7 +134,7 @@ const PageSpeed = () => {
);
useEffect(() => {
dispatch(getPageSpeedByUserId(authToken));
}, []);
}, [authToken, dispatch]);
// will show skeletons only on initial load
// since monitor state is being added to redux persist, there's no reason to display skeletons on every render
+4 -4
View File
@@ -4,7 +4,7 @@ import Button from "../../Components/Button";
import Field from "../../Components/Inputs/Field";
import Link from "../../Components/Link";
import Select from "../../Components/Inputs/Select";
import { logger } from "../../Utils/Logger";
import "./index.css";
const Settings = () => {
@@ -40,14 +40,14 @@ const Settings = () => {
id="display-timezone"
label="Display timezone"
value="est"
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
items={[{ _id: "est", name: "America / Toronto" }]}
/>
<Select
id="server-timezone"
label="Server timezone"
value="est"
onChange={() => console.log("disabled")}
onChange={() => logger.warn("disabled")}
items={[{ _id: "est", name: "America / Toronto" }]}
/>
</Stack>
@@ -74,7 +74,7 @@ const Settings = () => {
optionalLabel="0 for infinite"
placeholder="90"
value=""
onChange={() => console.log("Disabled")}
onChange={() => logger.warn("Disabled")}
/>
<Box>
<Typography>Clear all stats. This is irreversible.</Typography>
-3
View File
@@ -1,9 +1,6 @@
import Fallback from "../../Components/Fallback";
import { useTheme } from "@emotion/react";
const Status = () => {
const theme = useTheme();
return (
<div className="status">
<Fallback
+30
View File
@@ -0,0 +1,30 @@
const LOG_LEVEL = import.meta.env.VITE_APP_LOG_LEVEL;
class Logger {
constructor(logLevel) {
const NO_OP = () => {};
if (logLevel === "none") {
this.error = NO_OP;
this.warn = NO_OP;
this.log = NO_OP;
return;
}
this.error = console.error.bind(console);
if (logLevel === "error") {
this.warn = NO_OP;
this.log = NO_OP;
return;
}
this.warn = console.warn.bind(console);
if (logLevel === "warn") {
this.log = NO_OP;
return;
}
this.log = console.log.bind(console);
}
}
export const logger = new Logger(LOG_LEVEL);
+432
View File
@@ -0,0 +1,432 @@
import axios from "axios";
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
import { logger } from "./Logger";
class NetworkService {
constructor(store) {
this.store = store;
this.axiosInstance = axios.create({ baseURL: BASE_URL });
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
logger.error(error);
if (error.response && error.response.status === 401) {
logger.error("Invalid token received");
}
return Promise.reject(error);
}
);
}
/**
*
* ************************************
* Create a new monitor
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {Object} monitor - The monitor object to be sent in the request body.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*/
async createMonitor(authToken, monitor) {
return this.axiosInstance.post(`/monitors`, monitor, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
}
/**
* ************************************
* Get all uptime monitors for a user
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} userId - The ID of the user whose monitors are to be retrieved.
* @param {number} [limit] - The maximum number of monitors to retrieve.
* @param {Array<string>} [types] - The types of monitors to retrieve.
* @param {string} [status] - The status of the monitors to retrieve.
* @param {string} [sortOrder] - The order in which to sort the retrieved monitors.
* @param {boolean} [normalize] - Whether to normalize the retrieved monitors.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*/
async getMonitorsByUserId(
authToken,
userId,
limit,
types,
status,
sortOrder,
normalize
) {
const params = new URLSearchParams();
if (limit) params.append("limit", limit);
if (types) {
types.forEach((type) => {
params.append("type", type);
});
}
if (status) params.append("status", status);
if (sortOrder) params.append("sortOrder", sortOrder);
if (normalize) params.append("normalize", normalize);
return this.axiosInstance.get(
`/monitors/user/${userId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
}
/**
* ************************************
* Get stats for a monitor
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor whose statistics are to be retrieved.
* @param {string} [sortOrder] - The order in which to sort the retrieved statistics.
* @param {number} [limit] - The maximum number of statistics to retrieve.
* @param {string} [dateRange] - The date range for which to retrieve statistics.
* @param {number} [numToDisplay] - The number of statistics to display.
* @param {boolean} [normalize] - Whether to normalize the retrieved statistics.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*/
async getStatsByMonitorId(
authToken,
monitorId,
sortOrder,
limit,
dateRange,
numToDisplay,
normalize
) {
const params = new URLSearchParams();
if (sortOrder) params.append("sortOrder", sortOrder);
if (limit) params.append("limit", limit);
if (dateRange) params.append("dateRange", dateRange);
if (numToDisplay) params.append("numToDisplay", numToDisplay);
if (normalize) params.append("normalize", normalize);
return this.axiosInstance.get(
`/monitors/stats/${monitorId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
}
/**
* ************************************
* Updates a single monitor
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor to be updated.
* @param {Object} updatedFields - The fields to be updated for the monitor.
* @returns {Promise<AxiosResponse>} The response from the axios PUT request.
*/
async updateMonitor(authToken, monitorId, updatedFields) {
return this.axiosInstance.put(`/monitors/${monitorId}`, updatedFields, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
}
/**
* ************************************
* Deletes a single monitor by its ID
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor to be deleted.
* @returns {Promise<AxiosResponse>} The response from the axios DELETE request.
*/
async deleteMonitorById(authToken, monitorId) {
return this.axiosInstance.delete(`/monitors/${monitorId}`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
}
/**
* ************************************
* Gets the certificate expiry for a monitor
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor whose certificate expiry is to be retrieved.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getCertificateExpiry(authToken, monitorId) {
return this.axiosInstance.get(`/monitors/certificate/${monitorId}`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
}
/**
* ************************************
* Registers a new user
* ************************************
*
* @async
* @param {Object} form - The form data for the new user to be registered.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*/
async registerUser(form) {
return this.axiosInstance.post(`/auth/register`, form);
}
/**
* ************************************
* Logs in a user
* ************************************
*
* @async
* @param {Object} form - The form data for the user to be logged in.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async loginUser(form) {
return this.axiosInstance.post(`/auth/login`, form);
}
/**
* ************************************
* Updates a user
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} userId - The ID of the user to be updated.
* @param {Object} form - The form data for the user to be updated.
* @returns {Promise<AxiosResponse>} The response from the axios PUT request.
*
*/
async updateUser(authToken, userId, form) {
return this.axiosInstance.put(`/auth/user/${userId}`, form, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
}
/**
* ************************************
* Forgot password request
* ************************************
*
* @async
* @param {Object} form - The form data for the password recovery request.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async forgotPassword(form) {
return this.axiosInstance.post(`/auth/recovery/request`, form);
}
/**
* ************************************
* Validates a recovery token
* ************************************
*
* @async
* @param {string} recoveryToken - The recovery token to be validated.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async validateRecoveryToken(recoveryToken) {
return this.axiosInstance.post("/auth/recovery/validate", {
recoveryToken,
});
}
/**
* ************************************
* Requests password recovery
* ************************************
*
* @async
* @param {Object} form - The form data for the password recovery request.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async setNewPassword(recoveryToken, form) {
return this.axiosInstance.post("/auth/recovery/reset", {
...form,
recoveryToken,
});
}
/**
* ************************************
* Checks if an admin user exists
* ************************************
*
* @async
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async doesAdminExist() {
return this.axiosInstance.get("/auth/users/admin");
}
/**
* ************************************
* Get all users
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getAllUsers(authToken) {
return this.axiosInstance.get("/auth/users", {
headers: { Authorization: `Bearer ${authToken}` },
});
}
/**
* ************************************
* Requests an invitation token
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} email - The email of the user to be invited.
* @param {string} role - The role of the user to be invited.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async requestInvitationToken(authToken, email, role) {
return this.axiosInstance.post(
`/auth/invite`,
{ email, role },
{
headers: { Authorization: `Bearer ${authToken}` },
}
);
}
/**
* ************************************
* Verifies an invitation token
* ************************************
*
* @async
* @param {string} token - The invitation token to be verified.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*
*/
async verifyInvitationToken(token) {
return this.axiosInstance.post(`/auth/invite/verify`, {
token,
});
}
/**
* ************************************
* Get all checks for a given monitor
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor.
* @param {string} sortOrder - The order in which to sort the checks.
* @param {number} limit - The maximum number of checks to retrieve.
* @param {string} dateRange - The range of dates for which to retrieve checks.
* @param {string} filter - The filter to apply to the checks.
* @param {number} page - The page number to retrieve in a paginated list.
* @param {number} rowsPerPage - The number of rows per page in a paginated list.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getChecksByMonitor(
authToken,
monitorId,
sortOrder,
limit,
dateRange,
filter,
page,
rowsPerPage
) {
const params = new URLSearchParams();
if (sortOrder) params.append("sortOrder", sortOrder);
if (limit) params.append("limit", limit);
if (dateRange) params.append("dateRange", dateRange);
if (filter) params.append("filter", filter);
if (page) params.append("page", page);
if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
return this.axiosInstance.get(`/checks/${monitorId}?${params.toString()}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
}
/**
* ************************************
* Get all checks for a given user
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} userId - The ID of the user.
* @param {string} sortOrder - The order in which to sort the checks.
* @param {number} limit - The maximum number of checks to retrieve.
* @param {string} dateRange - The range of dates for which to retrieve checks.
* @param {string} filter - The filter to apply to the checks.
* @param {number} page - The page number to retrieve in a paginated list.
* @param {number} rowsPerPage - The number of rows per page in a paginated list.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getChecksByUser(
authToken,
userId,
sortOrder,
limit,
dateRange,
filter,
page,
rowsPerPage
) {
const params = new URLSearchParams();
if (sortOrder) params.append("sortOrder", sortOrder);
if (limit) params.append("limit", limit);
if (dateRange) params.append("dateRange", dateRange);
if (filter) params.append("filter", filter);
if (page) params.append("page", page);
if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
return this.axiosInstance.get(
`/checks/user/${userId}?${params.toString()}`,
{
headers: { Authorization: `Bearer ${authToken}` },
}
);
}
}
export default NetworkService;
-27
View File
@@ -1,27 +0,0 @@
import axios from "axios";
import { clearAuthState } from "../Features/Auth/authSlice";
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
let store;
export const injectStore = (s) => {
store = s;
};
const axiosInstance = axios.create({
baseURL: BASE_URL,
});
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
console.error(error);
if (error.response && error.response.status === 401) {
console.log("Invalid token revoked");
store.dispatch(clearAuthState());
}
return Promise.reject(error);
}
);
export default axiosInstance;
@@ -0,0 +1,4 @@
<svg width="26" height="16" viewBox="0 0 26 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.761364 15V0.454545H5.84659C6.85985 0.454545 7.69555 0.629735 8.35369 0.980114C9.01184 1.32576 9.50189 1.79214 9.82386 2.37926C10.1458 2.96165 10.3068 3.60795 10.3068 4.31818C10.3068 4.94318 10.1955 5.45928 9.97301 5.86648C9.75521 6.27367 9.46638 6.59564 9.10653 6.83239C8.75142 7.06913 8.36553 7.24432 7.94886 7.35795V7.5C8.39394 7.52841 8.84138 7.68466 9.29119 7.96875C9.741 8.25284 10.1174 8.66004 10.4205 9.19034C10.7235 9.72064 10.875 10.3693 10.875 11.1364C10.875 11.8655 10.7093 12.5213 10.3778 13.1037C10.0464 13.6861 9.5232 14.1477 8.80824 14.4886C8.09328 14.8295 7.16288 15 6.01705 15H0.761364ZM2.52273 13.4375H6.01705C7.16761 13.4375 7.98438 13.215 8.46733 12.7699C8.95502 12.3201 9.19886 11.7756 9.19886 11.1364C9.19886 10.6439 9.07339 10.1894 8.82244 9.77273C8.5715 9.35133 8.21402 9.01515 7.75 8.7642C7.28598 8.50852 6.73674 8.38068 6.10227 8.38068H2.52273V13.4375ZM2.52273 6.84659H5.78977C6.32008 6.84659 6.7983 6.74242 7.22443 6.53409C7.6553 6.32576 7.99621 6.0322 8.24716 5.65341C8.50284 5.27462 8.63068 4.82955 8.63068 4.31818C8.63068 3.67898 8.40814 3.13684 7.96307 2.69176C7.51799 2.24195 6.8125 2.01705 5.84659 2.01705H2.52273V6.84659Z" fill="#344054"/>
<path d="M23.3146 0.454545H25.076V10.0852C25.076 11.0795 24.8416 11.9673 24.3729 12.7486C23.9089 13.5251 23.2531 14.1383 22.4055 14.5881C21.558 15.0331 20.5637 15.2557 19.4226 15.2557C18.2815 15.2557 17.2872 15.0331 16.4396 14.5881C15.5921 14.1383 14.9339 13.5251 14.4652 12.7486C14.0012 11.9673 13.7692 11.0795 13.7692 10.0852V0.454545H15.5305V9.94318C15.5305 10.6534 15.6868 11.2855 15.9993 11.8395C16.3118 12.3887 16.7569 12.822 17.3345 13.1392C17.9169 13.4517 18.6129 13.608 19.4226 13.608C20.2322 13.608 20.9283 13.4517 21.5107 13.1392C22.093 12.822 22.5381 12.3887 22.8459 11.8395C23.1584 11.2855 23.3146 10.6534 23.3146 9.94318V0.454545Z" fill="#1570EF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 17L13 12L18 7M11 17L6 12L11 7" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 232 B

@@ -0,0 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 7H1M1 7L7 13M1 7L7 1" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 223 B

+2 -2
View File
@@ -1,3 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 7H1M1 7L7 13M1 7L7 1" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 18L9 12L15 6" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 17L11 12L6 7M13 17L18 12L13 7" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 231 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18L15 12L9 6" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 15L12 20L17 15M7 9L12 4L17 9" stroke="#667085" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

+6 -2
View File
@@ -22,7 +22,7 @@
--env-var-font-familt-1: "Roboto", "Helvetica", "Arial", sans-serif;
--env-var-color-0: #fff;
--env-var-color-1: #101828;
--env-var-color-1: #1c2130;
--env-var-color-2: #475467;
--env-var-color-2-light: #838c99;
--env-var-color-3: #1570ef;
@@ -67,7 +67,7 @@
--env-var-width-2: 360px;
--env-var-height-1: 100vh;
--env-var-height-2: 34px;
--env-var-height-2: 36px;
--env-var-nav-bar-height: 70px;
--env-var-side-bar-width: 250px;
@@ -141,6 +141,10 @@ button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.MuiInputBase-root.Mui-disabled input {
cursor: not-allowed;
}
.Toastify__toast-container {
width: auto;
}
+2 -4
View File
@@ -8,10 +8,8 @@ import { ThemeProvider } from "@mui/material";
import { Provider } from "react-redux";
import { persistor, store } from "./store";
import { PersistGate } from "redux-persist/integration/react";
import { injectStore } from "./Utils/axiosConfig.js";
injectStore(store);
import NetworkService from "./Utils/NetworkService.js";
export const networkService = new NetworkService(store);
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
+3 -1
View File
@@ -3,6 +3,7 @@ import { configureStore, combineReducers } from "@reduxjs/toolkit";
import uptimeMonitorsReducer from "./Features/UptimeMonitors/uptimeMonitorsSlice";
import pageSpeedMonitorReducer from "./Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import authReducer from "./Features/Auth/authSlice";
import uiReducer from "./Features/UI/uiSlice";
import storage from "redux-persist/lib/storage";
import { persistReducer, persistStore, createTransform } from "redux-persist";
@@ -20,7 +21,7 @@ const authTransform = createTransform(
const persistConfig = {
key: "root",
storage,
whitielist: ["auth", "monitors", "pageSpeed"],
whitielist: ["auth", "monitors", "pageSpeed", "ui"],
transforms: [authTransform],
};
@@ -28,6 +29,7 @@ const rootReducer = combineReducers({
uptimeMonitors: uptimeMonitorsReducer,
auth: authReducer,
pageSpeedMonitors: pageSpeedMonitorReducer,
ui: uiReducer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
+8 -4
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:20-alpine as build
WORKDIR /app
@@ -10,8 +10,12 @@ COPY ./Client .
RUN npm run build
RUN npm install -g serve
# RUN npm install -g serve
CMD ["serve","-s", "dist", "-l", "5173"]
# CMD ["serve","-s", "dist", "-l", "5173"]
EXPOSE 5000
FROM nginx:1.27.1-alpine
COPY --from=build /app/dist /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"]
+1 -1
View File
@@ -3,7 +3,7 @@ services:
client:
image: uptime_client:latest
ports:
- "80:5173"
- "80:80"
depends_on:
- server
server:
+16 -4
View File
@@ -10,7 +10,7 @@
<p align="center"><strong>An open source server monitoring application</strong></p>
![1](https://github.com/user-attachments/assets/207c74a0-a494-42c5-ab8d-3d7c1e7ead8a)
![Dashboard](https://github.com/user-attachments/assets/0f92d7a3-19cf-430c-8b0f-fe13ff46c51b)
BlueWave Uptime is an open source server monitoring application used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
@@ -22,6 +22,7 @@ BlueWave Uptime is an open source server monitoring application used to track th
- [x] Ping monitoring
- [x] Incidents at a glance
- [x] Page speed monitoring
- [x] E-mail notifications
- [ ] Scheduled maintenance (in the works)
**Roadmap (short term):**
@@ -59,6 +60,15 @@ Made with [contrib.rocks](https://contrib.rocks).
![Alt](https://repobeats.axiom.co/api/embed/c35d999c82dbb31e967427ea4166c14da4172e73.svg "Repobeats analytics image")
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/bluewave-uptime&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
Also check other developer and contributor-friendly projects of BlueWave:
- [BlueWave HRM](https://github.com/bluewave-labs/bluewave-hrm)
- [BlueWave Onboarding](https://github.com/bluewave-labs/bluewave-onboarding)
- [BlueWave DataRoom](https://github.com/bluewave-labs/bluewave-dataroom)
- [BlueWave ChatFabrica](https://github.com/bluewave-labs/bluewave-chatfabrica)
## Getting Started
- Clone this repository to your local machine
@@ -180,6 +190,7 @@ SYSTEM_EMAIL_PASSWORD=<system_email_password>
```
VITE_APP_API_BASE_URL="http://localhost:5000/api/v1"
VITE_APP_API_LOG_LEVEL="debug"
```
4. In the `Docker` directory run `docker compose up` to run the `docker-compose.yaml` file and start all four images.
@@ -200,9 +211,10 @@ That's it, the application is ready to use on port 80.
##### Environmental Variables <a id="env-vars-client"></a>
| ENV Variable Name | Required/Optional | Type | Description | Accepted Values |
| --------------------- | ----------------- | -------- | ------------------ | --------------- |
| VITE_APP_API_BASE_URL | Required | `string` | Base URL of server | {host}/api/v1 |
| ENV Variable Name | Required/Optional | Type | Description | Accepted Values |
| --------------------- | ----------------- | -------- | ------------------ | ---------------------------------- |
| VITE_APP_API_BASE_URL | Required | `string` | Base URL of server | {host}/api/v1 |
| VITE_APP_LOG_LEVEL | Optional | `string` | Log level | `"none"`\|`"error"` \| `"warn"` \| |
<br/>
+2
View File
@@ -450,6 +450,7 @@ const deleteUserController = async (req, res, next) => {
}
// 1. Find all the monitors associated with the user id
const monitors = await req.db.getMonitorsByUserId({
params: { userId: _id },
});
@@ -459,6 +460,7 @@ const deleteUserController = async (req, res, next) => {
await Promise.all(
monitors.map(async (monitor) => {
await req.jobQueue.deleteJob(monitor);
await req.db.deleteChecks(monitor._id);
await req.db.deleteAlertByMonitorId(monitor._id);
await req.db.deletePageSpeedChecksByMonitorId(monitor._id);
+2 -1
View File
@@ -303,7 +303,8 @@ const editMonitor = async (req, res, next) => {
}
try {
const monitorBeforeEdit = await req.db.getMonitorById(req, res);
const { monitorId } = req.params;
const monitorBeforeEdit = await req.db.getMonitorById(monitorId);
// Get notifications from the request body
const notifications = req.body.notifications;
+5 -3
View File
@@ -172,6 +172,9 @@ const getMonitorStatsById = async (req) => {
// Get monitor
const monitor = await Monitor.findById(monitorId);
if (monitor === null || monitor === undefined) {
throw new Error(errorMessages.DB_FIND_MONTIOR_BY_ID(monitorId));
}
// Determine if this is a pagespeed monitor or an http/ping monitor
let model =
@@ -260,10 +263,9 @@ const getMonitorStatsById = async (req) => {
monitorStats.latestResponseTime = getLatestResponseTime(checksAll);
monitorStats.incidents = incidents;
monitorStats.checks = dateRangeChecks;
return monitorStats;
} catch (error) {
console.log(error);
throw error;
}
};
@@ -294,7 +296,7 @@ const getMonitorById = async (monitorId) => {
*/
const getMonitorsByUserId = async (req, res) => {
try {
let { limit, type, status, sortOrder, normalize } = req.query;
let { limit, type, status, sortOrder, normalize } = req.query || {};
const monitorQuery = { userId: req.params.userId };
if (type !== undefined) {
@@ -1,3 +1,4 @@
const UserModel = require("../../../models/user");
const RecoveryToken = require("../../../models/RecoveryToken");
const crypto = require("crypto");
const { errorMessages } = require("../../../utils/messages");
+1 -1
View File
@@ -19,7 +19,7 @@ const JobQueue = require("./service/jobQueue");
const NetworkService = require("./service/networkService");
const EmailService = require("./service/emailService");
const PageSpeedService = require("./service/pageSpeedService");
const SERVICE_NAME = "Server";
let cleaningUp = false;
// Need to wrap server setup in a function to handle async nature of JobQueue
+2 -2
View File
@@ -15,12 +15,12 @@ router.get(
checkController.getChecks
);
router.get("/user/:userId", checkController.getUserChecks);
router.delete(
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
checkController.deleteChecks
);
router.get("/user/:userId", checkController.getUserChecks);
module.exports = router;
+8
View File
@@ -16,6 +16,14 @@ class NetworkService {
async handleNotification(job, isAlive) {
const { _id } = job.data;
const monitor = await this.db.getMonitorById(_id);
if (monitor === null || monitor === undefined) {
logger.error(`Null Monitor: ${_id}`, {
method: "handleNotification",
service: this.SERVICE_NAME,
jobId: job.id,
});
return;
}
// If monitor status changes, update monitor status and send notification
if (monitor.status !== isAlive) {
+1 -1
View File
@@ -40,7 +40,7 @@ const NormalizeData = (checks, rangeMin, rangeMax) => {
return normalizedChecks;
} else {
return checks.map((check) => {
return { ...check, originalResponseTime: check.responseTime };
return { ...check._doc, originalResponseTime: check.responseTime };
});
}
};