Merge remote-tracking branch 'upstream/develop' into feat/notify-via-email

This commit is contained in:
M M
2024-08-06 18:33:12 -07:00
46 changed files with 1402 additions and 259 deletions
+12 -2
View File
@@ -24,6 +24,8 @@ import withAdminCheck from "./HOC/withAdminCheck";
import Configure from "./Pages/Monitors/Configure";
import PageSpeed from "./Pages/PageSpeed";
import CreatePageSpeed from "./Pages/PageSpeed/CreatePageSpeed";
import CreateNewMaintenanceWindow from "./Pages/Maintenance/CreateMaintenanceWindow";
import PageSpeedDetails from "./Pages/PageSpeed/Details";
function App() {
const AdminCheckedRegister = withAdminCheck(Register);
@@ -69,6 +71,10 @@ function App() {
path="maintenance"
element={<ProtectedRoute Component={Maintenance} />}
/>
<Route
path="/maintenance/create"
element={<CreateNewMaintenanceWindow />}
/>
<Route
path="settings"
element={<ProtectedRoute Component={Settings} />}
@@ -86,13 +92,17 @@ function App() {
element={<ProtectedRoute Component={Account} open="team" />}
/>
<Route
path="page-speed"
path="pagespeed"
element={<ProtectedRoute Component={PageSpeed} />}
/>
<Route
path="page-speed/create"
path="pagespeed/create"
element={<ProtectedRoute Component={CreatePageSpeed} />}
/>
<Route
path="pagespeed/:monitorId"
element={<ProtectedRoute Component={PageSpeedDetails} />}
/>
</Route>
<Route exact path="/login" element={<Login />} />
+4 -5
View File
@@ -3,23 +3,22 @@
height: var(--env-var-img-width-2);
color: var(--env-var-color-5);
margin-right: 10px;
margin-bottom: 2px;
}
.MuiTable-root .host a svg {
width: var(--env-var-font-size-large);
height: var(--env-var-font-size-large);
}
.MuiTable-root .host div:nth-child(3) {
font-size: var(--env-var-font-size-small);
}
.MuiTable-root .host div:nth-child(2) {
width: fit-content;
margin-right: 10px;
font-weight: 700;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.MuiTable-root .host div:nth-child(2) span {
font-size: var(--env-var-font-size-small);
margin-left: 10px;
}
.MuiTable-root .label {
line-height: 1;
+3 -1
View File
@@ -135,7 +135,9 @@ const BasicTable = ({ data, paginated, reversed, rowsPerPage = 5 }) => {
{displayData.map((row) => {
return (
<TableRow
sx={{ cursor: row.handleClick ? "pointer" : "default" }}
sx={{
cursor: row.handleClick ? "pointer" : "default",
}}
key={row.id}
onClick={row.handleClick ? row.handleClick : null}
>
@@ -60,8 +60,6 @@ const MonitorDetailsAreaChart = ({ checks, filter }) => {
const checkTime = new Date(checks[i].createdAt).getTime();
if (now - checkTime < limits[filter]) {
result.push(checks[i]);
} else {
break;
}
}
return result;
@@ -0,0 +1,71 @@
import { LineChart } from "@mui/x-charts/LineChart";
import PropTypes from "prop-types";
const PageSpeedLineChart = ({ pageSpeedChecks = [] }) => {
const keyToLabel = {
performance: "Performance",
seo: "SEO",
bestPractices: "Best practices",
accessibility: "Accessibility",
};
const colors = {
performance: "#2aa02b",
seo: "#9467bd",
bestPractices: "#ff7f0e",
accessibility: "#1f76b3",
};
const customize = {
legend: { position: { vertical: "bottom", horizontal: "middle" } },
margin: { bottom: 75 },
};
const xLabels = pageSpeedChecks.map((check) => {
return check.createdAt;
});
return (
<LineChart
series={Object.keys(keyToLabel).map((key) => ({
dataKey: key,
label: keyToLabel[key],
color: colors[key],
showMark: false,
}))}
yAxis={[{ min: 0, max: 100 }]}
xAxis={[
{
scaleType: "point",
data: xLabels,
valueFormatter: (val) => new Date(val).toLocaleDateString(),
},
]}
dataset={pageSpeedChecks}
{...customize}
grid={{ vertical: true, horizontal: true }}
tooltip={{ trigger: "none" }}
slotProps={{
legend: {
direction: "row",
position: { vertical: "bottom", horizontal: "middle" },
padding: 2,
itemMarkWidth: 8,
itemMarkHeight: 8,
markGap: 5,
itemGap: 15,
labelStyle: {
fontSize: 13,
color: "#344054"
}
},
}}
/>
);
};
PageSpeedLineChart.propTypes = {
pageSpeedChecks: PropTypes.array,
};
export default PageSpeedLineChart;
@@ -8,7 +8,12 @@ const ResponseTimeChart = ({ checks = [] }) => {
return (
<div className="chart-container">
<ResponsiveContainer width="100%" height="100%">
<BarChart width={150} height={40} data={normalizedChecks}>
<BarChart
width={150}
height={40}
data={normalizedChecks}
style={{ cursor: "pointer" }}
>
<Bar maxBarSize={10} dataKey="responseTime">
{normalizedChecks.map((check, index) => (
<Cell
+5 -1
View File
@@ -145,7 +145,10 @@ function NavBar() {
</IconButton>
</Tooltip>
<Menu
sx={{ mt: theme.spacing(5.5) }}
sx={{
mt: theme.spacing(5.5),
borderRadius: "4px",
}}
id="menu-appbar"
anchorEl={anchorElUser}
anchorOrigin={{
@@ -172,6 +175,7 @@ function NavBar() {
fontSize="var(--env-var-font-size-medium)"
textAlign="center"
marginLeft="8px"
sx={{ fontWeight: 400, color: "#344054" }}
>
{setting}
</Typography>
+10 -2
View File
@@ -26,7 +26,7 @@ const menu = [
{ name: "Incidents", path: "incidents", icon: <Incidents /> },
{ name: "Status pages", path: "status", icon: <StatusPages /> },
{ name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
{ name: "Page speed", path: "page-speed", icon: <PageSpeed /> },
{ name: "Page speed", path: "pagespeed", icon: <PageSpeed /> },
{ name: "Integrations", path: "integrations", icon: <Integrations /> },
{ name: "Support", path: "support", icon: <Support /> },
{ name: "Settings", path: "settings", icon: <Settings /> },
@@ -50,7 +50,15 @@ function Sidebar() {
alignItems="center"
gap={theme.gap.small}
borderRadius={`${theme.shape.borderRadius}px`}
onClick={() => navigate(`/${item.path}`)}
onClick={() =>
item.path === "support"
? window.open(
"https://github.com/bluewave-labs/bluewave-uptime",
"_blank",
"noreferrer"
)
: navigate(`/${item.path}`)
}
sx={{ p: `${theme.gap.small} ${theme.gap.medium}` }}
>
{item.icon}
+2 -4
View File
@@ -61,7 +61,7 @@ export const update = createAsyncThunk(
form.deleteProfileImage &&
fd.append("deleteProfileImage", form.deleteProfileImage);
const res = await axiosInstance.post(`/auth/user/${user._id}`, fd, {
const res = await axiosInstance.put(`/auth/user/${user._id}`, fd, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
@@ -185,9 +185,7 @@ const handleForgotRejected = (state, action) => {
const handleNewPasswordRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to reset password.";
state.msg = action.payload ? action.payload.msg : "Failed to reset password.";
};
const authSlice = createSlice({
@@ -77,9 +77,9 @@ export const updateUptimeMonitor = createAsyncThunk(
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications
notifications: monitor.notifications,
};
const res = await axiosInstance.post(
const res = await axiosInstance.put(
`/monitors/edit/${monitor._id}`,
updatedFields,
{
@@ -104,16 +104,12 @@ export const deleteUptimeMonitor = createAsyncThunk(
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await axiosInstance.post(
`/monitors/delete/${monitor._id}`,
{},
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
const res = await axiosInstance.delete(`/monitors/${monitor._id}`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -0,0 +1,21 @@
.maintenance-options > .MuiStack-root > .MuiStack-root.select-wrapper {
width: 380px;
}
.date-picker .MuiOutlinedInput-root,
.time-picker .MuiOutlinedInput-root {
height: 34px;
font-size: var(--env-var-font-size-medium);
}
.date-picker .MuiOutlinedInput-root {
width: 140px;
}
.time-picker .MuiOutlinedInput-root {
width: 90px;
}
.MuiInputAdornment-outlined .MuiIconButton-root svg {
width: 20px;
}
@@ -0,0 +1,151 @@
import { Box, Stack, Typography } from "@mui/material";
import "./index.css";
import React, { useState } from "react";
import Button from "../../../Components/Button";
import Back from "../../../assets/icons/left-arrow.svg?react";
import Select from "../../../Components/Inputs/Select";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs from "dayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
const directory = {
title: "Create a maintenance window",
description: "Your pings wont be sent in this time frame.",
};
const repeatOptions = [
{ _id: 1, name: "Don't repeat" },
{ _id: 2, name: "Repeat daily" },
{ _id: 3, name: "Repeat weekly" },
];
const configOptionTitle = (title, description) => {
return (
<Stack width="40%" gap={1}>
<Typography
style={{
fontWeight: 600,
fontSize: "var(--env-var-font-size-medium)",
}}
>
{title}
</Typography>
{description && (
<Typography
style={{
fontSize: "var(--env-var-font-size-small)",
}}
>
{description}
</Typography>
)}
</Stack>
);
};
const CreateNewMaintenanceWindow = () => {
const [values, setValues] = useState({
repeat: 1,
});
const handleChange = (event, name) => {
const { value } = event.target;
setValues((prev) => ({
...prev,
[name]: value,
}));
};
const configOptions = [
{
title: "Repeat",
component: (
<Select
onChange={(e) => handleChange(e, "repeat")}
id="repeat-mode"
items={repeatOptions}
value={values.repeat}
/>
),
},
{
title: "Date",
component: (
<LocalizationProvider
className="date-localization-provider"
dateAdapter={AdapterDayjs}
>
<DatePicker className="date-picker" defaultValue={dayjs()} />
</LocalizationProvider>
),
},
{
title: "Start time",
description: "Your pings wont be sent in this time frame.",
component: (
<LocalizationProvider
className="time-localization-provider"
dateAdapter={AdapterDayjs}
>
<MobileTimePicker className="time-picker" defaultValue={dayjs()} />
</LocalizationProvider>
),
},
];
return (
<div className="create-maintenance-window">
<Stack gap={3}>
<Button
id="btn-back"
sx={{
width: "100px",
height: "30px",
gap: "10px",
backgroundColor: "var(--env-var-color-32)",
color: "var(--env-var-color-5)",
}}
label="Back"
level="tertiary"
img={<Back />}
/>
<Box>
<Typography
sx={{
fontSize: "var(--env-var-font-size-large)",
fontWeight: "600",
color: "var(--env-var-color-5)",
}}
>
{directory.title}
</Typography>
<Typography sx={{ fontSize: "var(--env-var-font-size-medium)" }}>
{directory.description}
</Typography>
</Box>
<Stack
gap={5}
paddingY={4}
paddingX={8}
sx={{
border: "1px solid var(--env-var-color-16)",
borderRadius: "var(--env-var-radius-1)",
}}
className="maintenance-options"
>
{configOptions.map((item, index) => (
<Stack key={index} display="-webkit-inline-box">
{configOptionTitle(item.title, item.description)}
{item.component && item.component}
</Stack>
))}
</Stack>
</Stack>
</div>
);
};
export default CreateNewMaintenanceWindow;
+1
View File
@@ -14,6 +14,7 @@ const Maintenance = () => {
"Eliminate any misunderstandings",
"Stop sending alerts in maintenance windows",
]}
link="/maintenance/create"
/>
</div>
);
+5 -13
View File
@@ -9,7 +9,7 @@ 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";
@@ -36,18 +36,6 @@ const parseUrl = (url) => {
}
};
/**
* Helper function to get duration since last check
* @param {Array} checks Array of check objects.
* @returns {number} Timestamp of the most recent check.
*/
const getLastChecked = (checks) => {
if (!checks || checks.length === 0) {
return 0; // Handle case when no checks are available
}
return new Date() - new Date(checks[0].createdAt);
};
/**
* Configure page displays monitor configurations and allows for editing actions.
* @component
@@ -74,6 +62,10 @@ const Configure = () => {
useEffect(() => {
const data = monitors.find((monitor) => monitor._id === monitorId);
if (!data) {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found");
}
setMonitor({
...data,
});
@@ -150,7 +150,7 @@ const CreateMonitor = () => {
img={<WestRoundedIcon />}
onClick={() => navigate("/monitors")}
sx={{
backgroundColor: "#f4f4f4",
backgroundColor: theme.palette.otherColors.fillGray,
mb: theme.gap.medium,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
+51 -39
View File
@@ -12,7 +12,7 @@ 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.svg?react";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import {
formatDuration,
formatDurationRounded,
@@ -43,45 +43,53 @@ const DetailsPage = () => {
useEffect(() => {
const fetchMonitor = async () => {
const res = await axiosInstance.get(
`/monitors/${monitorId}?sortOrder=asc`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
setMonitor(res.data.data);
const data = {
cols: [
{ id: 1, name: "Status" },
{ id: 2, name: "Date & Time" },
{ id: 3, name: "Message" },
],
rows: res.data.data.checks.map((check, idx) => {
const status = check.status === true ? "up" : "down";
try {
const res = await axiosInstance.get(
`/monitors/${monitorId}?sortOrder=asc`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
setMonitor(res.data.data);
const data = {
cols: [
{ id: 1, name: "Status" },
{ id: 2, name: "Date & Time" },
{ id: 3, name: "Message" },
],
rows: res.data.data.checks.map((check, idx) => {
const status = check.status === true ? "up" : "down";
return {
id: check._id,
data: [
{
id: idx,
data: (
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
),
},
{ id: idx + 1, data: new Date(check.createdAt).toLocaleString() },
{ id: idx + 2, data: check.statusCode },
],
};
}),
};
return {
id: check._id,
data: [
{
id: idx,
data: (
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
),
},
{
id: idx + 1,
data: new Date(check.createdAt).toLocaleString(),
},
{ id: idx + 2, data: check.statusCode },
],
};
}),
};
setData(data);
setData(data);
} catch (error) {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found");
}
};
fetchMonitor();
}, [monitorId, authToken]);
@@ -195,7 +203,11 @@ const DetailsPage = () => {
level="tertiary"
label="Configure"
animate="rotate90"
img={<SettingsIcon />}
img={
<SettingsIcon
style={{ width: theme.gap.mlplus, height: theme.gap.mlplus }}
/>
}
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
sx={{
ml: "auto",
+2 -2
View File
@@ -9,7 +9,7 @@
.monitors h2.MuiTypography-root {
font-size: var(--env-var-font-size-large);
}
.monitors .MuiStack-root > button {
.monitors .MuiStack-root > button:not(.MuiIconButton-root) {
font-size: var(--env-var-font-size-medium);
height: var(--env-var-height-2);
min-width: fit-content;
@@ -51,7 +51,7 @@ body:has(.monitors) .actions-menu li.MuiButtonBase-root {
body:has(.monitors) .actions-menu li.MuiButtonBase-root:last-of-type {
color: var(--env-var-color-26);
}
body:has(.monitors) .MuiModal-root p.MuiTypography-root {
body:has(.monitors) .actions-menu .MuiModal-root p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
color: var(--env-var-color-2);
}
+37 -10
View File
@@ -35,17 +35,38 @@ import {
* @param {string} params.url - The URL of the host.
* @param {string} params.title - The name of the host.
* @param {string} params.percentageColor - The color of the percentage text.
* @param {number} params.precentage - The percentage to display.
* @param {number} params.percentage - The percentage to display.
* @returns {React.ElementType} Returns a div element with the host details.
*/
const Host = ({ params }) => {
return (
<Stack direction="row" alignItems="center" className="host">
<a href={params.url} target="_blank" rel="noreferrer">
<OpenInNewPage />
</a>
<Box>{params.title}</Box>
<Box sx={{ color: params.percentageColor }}>{params.precentage}%</Box>
<IconButton
aria-label="monitor link"
onClick={(event) => {
event.stopPropagation();
window.open(params.url, "_blank", "noreferrer");
}}
sx={{
"&:focus": {
outline: "none",
},
mr: "3px",
}}
>
<OpenInNewPage
style={{
marginTop: "-1px",
marginRight: "-1px",
}}
/>
</IconButton>
<Box>
{params.title}
<Typography component="span" sx={{ color: params.percentageColor }}>
{params.percentage}%
</Typography>
</Box>
</Stack>
);
};
@@ -60,6 +81,7 @@ const Monitors = () => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const openMenu = (event, id, url) => {
event.preventDefault();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
@@ -123,7 +145,7 @@ const Monitors = () => {
const params = {
url: monitor.url,
title: monitor.name,
precentage: 100,
percentage: 100,
percentageColor:
monitor.status === true
? "var(--env-var-color-17)"
@@ -137,7 +159,7 @@ 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} /> },
{
@@ -163,7 +185,10 @@ const Monitors = () => {
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => openMenu(event, monitor._id, monitor.url)}
onClick={(event) => {
event.stopPropagation();
openMenu(event, monitor._id, monitor.url);
}}
sx={{
"&:focus": {
outline: "none",
@@ -227,7 +252,9 @@ const Monitors = () => {
onClose={closeMenu}
>
<MenuItem
onClick={() => window.open(actions.url, "_blank", "noreferrer")}
onClick={() => {
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
@@ -1,37 +1,31 @@
.create-page-speed form {
position: relative;
margin: auto;
margin-top: var(--env-var-default-2);
.create-page-speed form > .MuiStack-root:not(:last-of-type) {
background-color: var(--env-var-color-8);
width: 100%;
max-width: 500px;
box-shadow: var(--env-var-shadow-1);
border: solid 1px var(--env-var-color-6);
border-radius: var(--env-var-radius-1);
padding: var(--env-var-default-2);
}
.create-page-speed h1.MuiTypography-root,
.create-page-speed h2.MuiTypography-root,
.create-page-speed h1.MuiTypography-root {
font-size: var(--env-var-font-size-large);
color: var(--env-var-color-1);
}
.create-page-speed p.MuiTypography-root,
.create-page-speed h2.MuiTypography-root > span.MuiTypography-root {
.create-page-speed h3.MuiTypography-root {
color: var(--env-var-color-5);
}
.create-page-speed h1.MuiTypography-root,
.create-page-speed h2.MuiTypography-root {
.create-page-speed h3.MuiTypography-root {
font-weight: 600;
}
.create-page-speed h2.MuiTypography-root {
font-size: var(--env-var-font-size-medium-plus);
}
.create-page-speed p.MuiTypography-root,
.create-page-speed button,
.create-page-speed h2.MuiTypography-root > span.MuiTypography-root {
.create-page-speed h3.MuiTypography-root,
.create-page-speed h3.MuiTypography-root > span.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.create-page-speed .MuiBox-root > .field + p.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
.create-page-speed .MuiBox-root > .field + p.MuiTypography-root,
.create-page-speed h2.MuiTypography-root > span.MuiTypography-root {
.create-page-speed h3.MuiTypography-root > span.MuiTypography-root {
opacity: 0.6;
}
.create-page-speed .checkbox-wrapper {
@@ -47,17 +41,18 @@
pointer-events: none;
}
.create-page-speed button:not(.MuiIconButton-root) {
.create-page-speed button {
height: var(--env-var-height-2);
}
.create-page-speed .MuiIconButton-root {
position: absolute;
top: 0;
right: 0;
margin: var(--env-var-spacing-1);
}
body:has(.create-page-speed) .select-dropdown .MuiMenuItem-root,
.create-page-speed .select-wrapper .select-component > .MuiSelect-select {
text-transform: lowercase;
}
.create-page-speed .field,
.create-page-speed .section-disabled,
.create-page-speed .select-wrapper,
.create-page-speed h3.MuiTypography-root {
flex: 1;
}
@@ -1,9 +1,8 @@
import { Box, IconButton, Stack, Typography } from "@mui/material";
import { Box, Stack, Typography } from "@mui/material";
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Button from "../../../Components/Button";
@@ -11,6 +10,7 @@ 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 "./index.css";
const CreatePageSpeed = () => {
@@ -22,17 +22,19 @@ const CreatePageSpeed = () => {
const { user, authToken } = useSelector((state) => state.auth);
const frequencies = [
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 3, name: "3 minutes" },
{ _id: 4, name: "4 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
const [form, setForm] = useState({
name: "",
url: "",
interval: 1,
interval: 3,
});
const [errors, setErrors] = useState({});
@@ -81,7 +83,7 @@ const CreatePageSpeed = () => {
try {
const action = await dispatch(createPageSpeed({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/page-speed");
navigate("/pagespeed");
}
} catch (error) {
createToast({
@@ -96,102 +98,114 @@ 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.large,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
/>
<form
noValidate
spellCheck="false"
onSubmit={handleCreate}
style={{ display: "flex", flexDirection: "column", gap: theme.gap.xl }}
style={{
display: "flex",
flexDirection: "column",
gap: theme.gap.large,
// TODO
maxWidth: "1000px",
}}
>
<IconButton
aria-label="close modal"
onClick={() => navigate("/page-speed")}
sx={{
p: "5px",
opacity: 0.6,
"&:focus": { outline: "none" },
}}
>
<CloseRoundedIcon />
</IconButton>
<Stack gap={theme.gap.large}>
<Typography component="h1">Create a page speed monitor</Typography>
<Field
type="text"
id="monitor-name"
label="Monitor friendly name"
placeholder="Example monitor"
isOptional={true}
value={form.name}
onChange={(event) => handleChange(event, "name")}
error={errors.name}
/>
<Field
type="url"
id="monitor-url"
label="URL"
placeholder="random.website.com"
value={form.url}
onChange={(event) => handleChange(event, "url")}
error={errors.url}
/>
<Select
id="monitor-frequency"
label="Check frequency"
items={frequencies}
value={form.interval}
onChange={(event) => handleChange(event, "interval")}
/>
</Stack>
<Stack gap={theme.gap.small}>
<Typography component="h2">
Incidents notifications{" "}
<Typography component="span">(coming soon)</Typography>
</Typography>
<Box className="section-disabled">
<Typography mb={theme.gap.small}>
When there is a new incident,
<Typography component="h1">Create a page speed monitor</Typography>
<Stack gap={theme.gap.xl}>
<Stack direction="row">
<Typography component="h3">Monitor friendly name</Typography>
<Field
type="text"
id="monitor-name"
placeholder="Example monitor"
value={form.name}
onChange={(event) => handleChange(event, "name")}
error={errors.name}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">URL</Typography>
<Field
type="url"
id="monitor-url"
placeholder="random.website.com"
value={form.url}
onChange={(event) => handleChange(event, "url")}
error={errors.url}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">Check frequency</Typography>
<Select
id="monitor-frequency"
items={frequencies}
value={form.interval}
onChange={(event) => handleChange(event, "interval")}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">
Incidents notifications{" "}
<Typography component="span">(coming soon)</Typography>
</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
isDisabled={true}
/>
<Checkbox
id="notify-email"
label="Notify via email (to gorkem.cetin@bluewavelabs.ca)"
isChecked={false}
/>
<Checkbox
id="notify-emails"
label="Notify via email to following emails"
isChecked={false}
/>
<Box mx={`calc(${theme.gap.ml} * 2)`}>
<Field
id="notify-emails-list"
placeholder="notifications@gmail.com"
value=""
onChange={() => console.log("disabled")}
error=""
/>
<Typography mt={theme.gap.small}>
You can separate multiple emails with a comma
<Stack className="section-disabled">
<Typography mb={theme.gap.small}>
When there is a new incident,
</Typography>
</Box>
</Box>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
isDisabled={true}
/>
<Checkbox
id="notify-email"
label="Notify via email (to gorkem.cetin@bluewavelabs.ca)"
isChecked={false}
/>
<Checkbox
id="notify-emails"
label="Notify via email to following emails"
isChecked={false}
/>
<Box mx={`calc(${theme.gap.ml} * 2)`}>
<Field
id="notify-emails-list"
placeholder="notifications@gmail.com"
value=""
onChange={() => console.log("disabled")}
error=""
/>
<Typography mt={theme.gap.small}>
You can separate multiple emails with a comma
</Typography>
</Box>
</Stack>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" gap={theme.gap.small}>
<Button
level="tertiary"
label="Cancel"
onClick={() => navigate("/page-speed")}
/>
<Stack direction="row" justifyContent="flex-end">
<Button
type="submit"
level="primary"
label="Create"
onClick={handleCreate}
sx={{ px: theme.gap.large, mt: theme.gap.large }}
/>
</Stack>
</form>
@@ -0,0 +1,72 @@
.page-speed-details h1 {
font-size: var(--env-var-font-size-large-plus);
color: var(--env-var-color-1);
}
.page-speed-details h2,
.page-speed-details h6 {
font-size: var(--env-var-font-size-large);
}
.page-speed-details h4,
.page-speed-details p,
.page-speed-details p > span {
font-size: var(--env-var-font-size-medium);
}
.page-speed-details h1 + span,
.page-speed-details h4 > span {
font-size: var(--env-var-font-size-small-plus);
}
.page-speed-details h2,
.page-speed-details h4,
.page-speed-details p {
color: var(--env-var-color-5);
}
.page-speed-details h6 {
color: var(--env-var-color-3);
line-height: 22px;
}
.page-speed-details h1,
.page-speed-details h2,
.page-speed-details h6 {
font-weight: 600;
}
.page-speed-details button.MuiButtonBase-root {
height: 34px;
}
.page-speed-details .stat-box,
.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);
background-color: var(--env-var-color-8);
min-width: 200px;
}
.page-speed-details .stat-box {
flex: 1;
}
.page-speed-details .stat-box:last-of-type {
flex-shrink: 1;
}
.page-speed-details .stat-box svg {
width: 22px;
height: 22px;
}
.page-speed-details .MuiPieArcLabel-root {
font-size: var(--env-var-font-size-small-plus);
transition: fill 300ms ease;
}
.page-speed-details .MuiPieArcLabel-faded {
fill: rgba(0, 0, 0, 0.3);
}
.page-speed-details .pie-label,
.page-speed-details .pie-value-label {
transition: all 400ms ease;
}
.page-speed-details .MuiPieArc-root {
stroke: inherit;
}
.page-speed-details .metric {
min-width: 200px;
flex: 1;
}
@@ -0,0 +1,605 @@
import { Box, Stack, Typography } from "@mui/material";
import { PieChart } from "@mui/x-charts/PieChart";
import { useDrawingArea } from "@mui/x-charts";
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { formatDate, formatDurationRounded } from "../../../Utils/timeUtils";
import { getLastChecked } from "../../../Utils/monitorUtils";
import axiosInstance from "../../../Utils/axiosConfig";
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";
/**
* 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();
return (
<Stack
className="stat-box"
direction="row"
gap={theme.gap.small}
p={theme.gap.ml}
pb={theme.gap.mlplus}
>
{icon}
<Box>
<Typography variant="h6" mb={theme.gap.medium}>
{title}
</Typography>
<Typography variant="h4">{value}</Typography>
</Box>
</Stack>
);
};
/**
* Renders a centered label within a pie chart.
*
* @param {Object} props
* @param {string | number} props.value - The value to display in the label.
* @param {string} props.color - The color of the text.
* @returns {JSX.Element}
*/
const PieCenterLabel = ({ value, color, setExpand }) => {
const { width, height } = useDrawingArea();
return (
<g
transform={`translate(${width / 2}, ${height / 2})`}
onMouseEnter={() => setExpand(true)}
>
<circle cx={0} cy={0} r={width / 3} fill="transparent" />
<text
className="pie-label"
style={{
fill: color,
fontSize: "45px",
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
}}
>
{value}
</text>
</g>
);
};
/**
* 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.
*
* @param {Object} props
* @param {number} props.value - The value to display inside the pie slice.
* @param {number} props.startAngle - The starting angle of the pie slice in degrees.
* @param {number} props.endAngle - The ending angle of the pie slice in degrees.
* @param {string} props.color - The color of the label text when highlighted.
* @param {boolean} props.highlighted - Determines if the label should be highlighted or not.
* @returns {JSX.Element}
*/
const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => {
const { width, height } = useDrawingArea();
// Compute the midpoint angle in radians
const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180;
const radius = height / 3.5; // length from center of the circle to where the text is positioned
// Calculate x and y positions
const x = Math.sin(angle) * radius;
const y = -Math.cos(angle) * radius;
return (
<g transform={`translate(${width / 2}, ${height / 2})`}>
<text
className="pie-value-label"
x={x}
y={y}
style={{
fill: highlighted ? color : "rgba(0,0,0,0)",
fontSize: "12px",
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
}}
>
+{value}
</text>
</g>
);
};
const PageSpeedDetails = () => {
const theme = useTheme();
const navigate = useNavigate();
const [monitor, setMonitor] = useState({});
const [audits, setAudits] = useState({});
const { monitorId } = useParams();
const { authToken } = useSelector((state) => state.auth);
useEffect(() => {
const fetchMonitor = async () => {
const res = await axiosInstance.get(
`/monitors/${monitorId}?sortOrder=desc`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
setMonitor(res.data.data);
setAudits(res.data.data.checks[0].audits);
};
fetchMonitor();
}, [monitorId]);
/**
* Weight constants for different performance metrics.
* @type {Object}
*/
const weights = {
fcp: 10,
si: 10,
lcp: 25,
tbt: 30,
cls: 25,
};
/**
* Retrieves color properties based on the performance value.
*
* @param {number} value - The performance score used to determine the color properties.
* @returns {{stroke: string, text: string, bg: string}} The color properties for the given performance value.
*/
const getColors = (value) => {
if (value >= 90 && value <= 100) return theme.pie.green;
else if (value >= 50 && value < 90) return theme.pie.yellow;
else if (value >= 0 && value < 50) return theme.pie.red;
return theme.pie.default;
};
/**
* Calculates and formats the data needed for rendering a pie chart based on audit scores and weights.
* This function generates properties for each pie slice, including angles, radii, and colors.
* It also calculates performance based on the weighted values.
*
* @returns {Array<Object>} An array of objects, each representing the properties for a slice of the pie chart.
* @returns {number} performance - A variable updated with the rounded sum of weighted values.
*/
let performance = 0;
const getPieData = (audits) => {
let props = [];
let startAngle = 0;
const padding = 3; // padding between arcs
const max = 360 - padding * (Object.keys(audits).length - 1); // _id is a child of audits
Object.keys(audits).forEach((key) => {
if (audits[key].score) {
let value = audits[key].score * weights[key];
let endAngle = startAngle + (weights[key] * max) / 100;
let theme = getColors(audits[key].score * 100);
props.push({
id: key,
data: [
{
value: value,
color: theme.stroke,
label: key.toUpperCase(),
},
{
value: weights[key] - value,
color: theme.strokeBg,
label: "",
},
],
arcLabel: (item) => `${item.label}`,
arcLabelRadius: 95,
startAngle: startAngle,
endAngle: endAngle,
innerRadius: 73,
outerRadius: 80,
cornerRadius: 2,
highlightScope: { faded: "global", highlighted: "series" },
faded: {
innerRadius: 63,
outerRadius: 70,
additionalRadius: -20,
arcLabelRadius: 5,
},
cx: pieSize.width / 2,
});
performance += Math.round(value);
startAngle = endAngle + padding;
}
});
return props;
};
const pieSize = { width: 200, height: 200 };
const pieData = getPieData(audits);
const colorMap = getColors(performance);
const [highlightedItem, setHighLightedItem] = useState(null);
const [expand, setExpand] = useState(false);
return (
<Stack className="page-speed-details" gap={theme.gap.large}>
<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,
},
}}
/>
<Stack
direction="row"
gap={theme.gap.small}
justifyContent="space-between"
>
<GreenCheck />
<Box>
<Typography component="h1" mb={theme.gap.xs} sx={{ lineHeight: 1 }}>
{monitor?.url}
</Typography>
<Typography
component="span"
sx={{ color: "var(--env-var-color-17)" }}
>
Your pagespeed monitor is live.
</Typography>
</Box>
<Button
level="tertiary"
label="Configure"
animate="rotate90"
img={
<SettingsIcon
style={{ width: theme.gap.mlplus, height: theme.gap.mlplus }}
/>
}
onClick={() => navigate(`/pagespeed/configure/${monitorId}`)}
sx={{
ml: "auto",
alignSelf: "flex-end",
backgroundColor: theme.palette.otherColors.fillGray,
px: theme.gap.medium,
"& svg": {
mr: "6px",
},
}}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.gap.xl}
flexWrap="wrap"
>
<StatBox
icon={<LastCheckedIcon />}
title="Last checked"
value={
<>
{formatDate(getLastChecked(monitor?.checks, false))}{" "}
<Typography
component="span"
fontStyle="italic"
sx={{ opacity: 0.8 }}
>
({formatDurationRounded(getLastChecked(monitor?.checks))} ago)
</Typography>
</>
}
/>
<StatBox
icon={<ClockIcon />}
title="Checks since"
value={
<>
{formatDate(new Date(monitor?.createdAt))}{" "}
<Typography
component="span"
fontStyle="italic"
sx={{ opacity: 0.8 }}
>
(
{formatDurationRounded(
new Date() - new Date(monitor?.createdAt)
)}{" "}
ago)
</Typography>
</>
}
></StatBox>
<StatBox
icon={<IntervalCheckIcon />}
title="Checks every"
value={formatDurationRounded(monitor?.interval)}
></StatBox>
</Stack>
<Typography component="h2">Score history</Typography>
<Box height="300px">
<PageSpeedLineChart pageSpeedChecks={monitor?.checks?.slice(0, 25)} />
</Box>
<Typography component="h2">Performance report</Typography>
<Stack direction="row" alignItems="center" overflow="hidden">
<Stack
alignItems="center"
textAlign="center"
minWidth="300px"
flex={1}
px={theme.gap.xl}
py={theme.gap.ml}
>
<Box onMouseLeave={() => setExpand(false)}>
{expand ? (
<PieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 67,
cx: pieSize.width / 2,
},
...pieData,
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
onHighlightChange={setHighLightedItem}
slotProps={{
legend: { hidden: true },
}}
tooltip={{ trigger: "none" }}
sx={{
"&:has(.MuiPieArcLabel-faded) .pie-label": {
fill: "rgba(0,0,0,0) !important",
},
}}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
{pieData?.map((pie) => (
<PieValueLabel
key={pie.id}
value={Math.round(pie.data[0].value * 10) / 10}
startAngle={pie.startAngle}
endAngle={pie.endAngle}
color={pie.data[0].color}
highlighted={highlightedItem?.seriesId === pie.id}
/>
))}
</PieChart>
) : (
<PieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 67,
cx: pieSize.width / 2,
},
{
data: [
{
value: performance,
color: colorMap.stroke,
},
],
innerRadius: 63,
outerRadius: 70,
paddingAngle: 5,
cornerRadius: 2,
startAngle: 0,
endAngle: (performance / 100) * 360,
cx: pieSize.width / 2,
},
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
tooltip={{ trigger: "none" }}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
</PieChart>
)}
</Box>
<Typography mt={theme.gap.medium}>
Values are estimated and may vary.{" "}
<Typography
component="span"
sx={{
color: theme.palette.primary.main,
fontWeight: 500,
textDecoration: "underline",
cursor: "pointer",
}}
>
See calculator
</Typography>
</Typography>
</Stack>
<Box
px={theme.gap.xl}
py={theme.gap.ml}
height="100%"
flex={1}
sx={{
borderLeft: `solid 1px ${theme.palette.otherColors.graishWhite}`,
}}
>
<Typography
mb={theme.gap.medium}
pb={theme.gap.ml}
color={theme.palette.secondary.main}
textAlign="center"
sx={{
borderBottom: `solid 1px ${theme.palette.otherColors.graishWhite}`,
borderBottomStyle: "dashed",
}}
>
The{" "}
<Typography
component="span"
sx={{
color: theme.palette.primary.main,
fontWeight: 500,
textDecoration: "underline",
cursor: "pointer",
}}
>
performance score is calculated
</Typography>{" "}
directly from these{" "}
<Typography component="span" fontWeight={600}>
metrics
</Typography>
.
</Typography>
<Stack
direction="row"
flexWrap="wrap"
pt={theme.gap.ml}
gap={theme.gap.ml}
>
{Object.keys(audits).map((key) => {
if (key === "_id") return;
let audit = audits[key];
let metricParams = getColors(audit.score * 100);
let shape = (
<Box
sx={{
width: theme.gap.medium,
height: theme.gap.medium,
borderRadius: "50%",
backgroundColor: metricParams.stroke,
}}
></Box>
);
if (metricParams.shape === "square")
shape = (
<Box
sx={{
width: theme.gap.medium,
height: theme.gap.medium,
backgroundColor: metricParams.stroke,
}}
></Box>
);
else if (metricParams.shape === "triangle")
shape = (
<Box
sx={{
width: 0,
height: 0,
ml: `calc((${theme.gap.medium} - ${theme.gap.small}) / -2)`,
borderLeft: `${theme.gap.small} solid transparent`,
borderRight: `${theme.gap.small} solid transparent`,
borderBottom: `${theme.gap.medium} solid ${metricParams.stroke}`,
}}
></Box>
);
// Find the position where the number ends and the unit begins
const match = audit.displayValue.match(/(\d+\.?\d*)\s*([a-zA-Z]+)/);
let value;
let unit;
if (match) {
value = match[1];
unit = match[2];
} else {
value = audit.displayValue;
}
return (
<Stack
className="metric"
key={`${key}-box`}
direction="row"
gap={theme.gap.small}
>
{shape}
<Box>
<Typography sx={{ lineHeight: 1 }}>
{audit.title}
</Typography>
<Typography
component="span"
sx={{
color: metricParams.text,
fontSize: "16px",
fontWeight: 600,
}}
>
{value}
<Typography
component="span"
ml="2px"
sx={{
color: theme.palette.secondary.main,
fontSize: "13px",
}}
>
{unit}
</Typography>
</Typography>
</Box>
</Stack>
);
})}
</Stack>
</Box>
</Stack>
</Stack>
);
};
export default PageSpeedDetails;
+4 -1
View File
@@ -29,7 +29,10 @@
height: 22px;
font-size: var(--env-var-font-size-small);
}
.page-speed button {
.page-speed:not(:has([class*="fallback__"])) button {
height: 34px;
align-self: flex-end;
}
.page-speed [class*="fallback__"] h1 {
margin-left: var(--env-var-spacing-3);
}
+18 -23
View File
@@ -10,31 +10,26 @@ import Fallback from "../../Components/Fallback";
import "./index.css";
import Button from "../../Components/Button";
import { useNavigate } from "react-router";
import { getLastChecked } from "../../Utils/monitorUtils";
const Card = ({ data }) => {
const theme = useTheme();
/**
* Helper function to get duration since last check or the last date checked
* @param {Array} checks Array of check objects.
* @param {boolean} duration Whether the function should return the duration since last checked or the date itself
* @returns {number} Timestamp of the most recent check.
*/
const getLastChecked = (checks, duration = true) => {
if (!checks || checks.length === 0) {
return 0; // Handle case when no checks are available
}
// Data is sorted newest -> oldest, so newest check is the most recent
if (!duration) {
return new Date(checks[0].createdAt);
}
return new Date() - new Date(checks[0].createdAt);
};
const navigate = useNavigate();
return (
<Grid item lg={6} flexGrow={1}>
<Stack direction="row" gap={theme.gap.medium} p={theme.gap.ml}>
<Grid
item
lg={6}
flexGrow={1}
sx={{ "&:hover > .MuiStack-root": { backgroundColor: "#f9fafb" } }}
>
<Stack
direction="row"
gap={theme.gap.medium}
p={theme.gap.ml}
onClick={() => navigate(`/pagespeed/${data._id}`)}
sx={{ cursor: "pointer" }}
>
<PageSpeedIcon style={{ width: theme.gap.ml, height: theme.gap.ml }} />
<Box flex={1}>
<Stack direction="row" justifyContent="space-between">
@@ -75,7 +70,7 @@ const PageSpeed = () => {
return (
<Box className="page-speed">
{monitors ? (
{monitors?.length !== 0 ? (
<Stack gap={theme.gap.xs}>
<Stack
direction="row"
@@ -91,7 +86,7 @@ const PageSpeed = () => {
<Button
level="primary"
label="Create new"
onClick={() => navigate("/page-speed/create")}
onClick={() => navigate("/pagespeed/create")}
/>
</Stack>
<Grid container spacing={theme.gap.large}>
@@ -108,7 +103,7 @@ const PageSpeed = () => {
"Help analyze webpage speed",
"Give suggestions on how the page can be improved",
]}
link="/page-speed/create"
link="/pagespeed/create"
/>
)}
</Box>
+31
View File
@@ -94,6 +94,7 @@ const theme = createTheme({
small: "8px",
medium: "12px",
ml: "16px",
mlplus: "20px",
large: "24px",
xl: "40px",
xxl: "60px",
@@ -133,6 +134,36 @@ const theme = createTheme({
dotColor: "#4e5ba6",
},
},
pie: {
green: {
stroke: "#17b26a",
strokeBg: "#d4f4e1",
text: "#079455",
bg: "#ecfdf3",
shape: "circle",
},
yellow: {
stroke: "#fdb022",
strokeBg: "rgba(255, 192, 34, 0.3)",
text: "#dc6803",
bg: "#fffcf5",
shape: "square",
},
red: {
stroke: "#f04438",
strokeBg: "#ffecea",
text: "#f04438",
bg: "#ffeaea",
shape: "triangle",
},
default: {
stroke: "#4e5ba6",
strokeBg: "#f2f4f7",
text: "#4e5ba6",
bg: "#f2f4f7",
shape: "",
},
},
});
export default theme;
+17
View File
@@ -0,0 +1,17 @@
/**
* Helper function to get duration since last check or the last date checked
* @param {Array} checks Array of check objects.
* @param {boolean} duration Whether the function should return the duration since last checked or the date itself
* @returns {number} Timestamp of the most recent check.
*/
export const getLastChecked = (checks, duration = true) => {
if (!checks || checks.length === 0) {
return 0; // Handle case when no checks are available
}
// Data is sorted newest -> oldest, so newest check is the most recent
if (!duration) {
return new Date(checks[0].createdAt);
}
return new Date() - new Date(checks[0].createdAt);
};
-1
View File
@@ -50,7 +50,6 @@ export const formatDate = (date) => {
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: true,
};
@@ -0,0 +1,3 @@
<svg width="20" height="22" viewBox="0 0 20 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 10.5V7.8C19 6.11984 19 5.27976 18.673 4.63803C18.3854 4.07354 17.9265 3.6146 17.362 3.32698C16.7202 3 15.8802 3 14.2 3H5.8C4.11984 3 3.27976 3 2.63803 3.32698C2.07354 3.6146 1.6146 4.07354 1.32698 4.63803C1 5.27976 1 6.11984 1 7.8V16.2C1 17.8802 1 18.7202 1.32698 19.362C1.6146 19.9265 2.07354 20.3854 2.63803 20.673C3.27976 21 4.11984 21 5.8 21H10.5M19 9H1M14 1V5M6 1V5M16 20V14M13 17H19" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,3 @@
<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.9118 14.2882C14.7835 13.0993 16 11.1735 16 9C16 5.38891 12.6421 2.46154 8.5 2.46154H8.05882M8.5 15.5385C4.35786 15.5385 1 12.6111 1 9C1 6.82651 2.21647 4.90072 4.08824 3.71185M7.61765 17L9.38235 15.4615L7.61765 13.9231M9.38235 4.07692L7.61765 2.53846L9.38235 1" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 463 B

+3
View File
@@ -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

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="16" height="16" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 5L13 1M13 1H9M13 1L7.66667 6.33333M5.66667 2.33333H4.2C3.0799 2.33333 2.51984 2.33333 2.09202 2.55132C1.71569 2.74307 1.40973 3.04903 1.21799 3.42535C1 3.85318 1 4.41323 1 5.53333V9.8C1 10.9201 1 11.4802 1.21799 11.908C1.40973 12.2843 1.71569 12.5903 2.09202 12.782C2.51984 13 3.0799 13 4.2 13H8.46667C9.58677 13 10.1468 13 10.5746 12.782C10.951 12.5903 11.2569 12.2843 11.4487 11.908C11.6667 11.4802 11.6667 10.9201 11.6667 9.8V8.33333" stroke="#667085" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 620 B

After

Width:  |  Height:  |  Size: 620 B

+2
View File
@@ -50,6 +50,8 @@
--env-var-color-28: #f79009;
--env-var-color-29: #d0d5dd;
--env-var-color-30: #fcfcfd;
--env-var-color-31: #1a1919;
--env-var-color-32: #f5f5f5;
--env-var-radius-1: 4px;
--env-var-radius-2: 8px;
+3
View File
@@ -439,7 +439,9 @@ const deleteUserController = async (req, res, next) => {
const decodedToken = jwt.decode(token);
const { _id, email } = decodedToken;
// TODO fix this hack
const decodedTokenCastedAsRequest = {
query: {},
params: {
userId: _id,
},
@@ -480,6 +482,7 @@ const deleteUserController = async (req, res, next) => {
await req.jobQueue.deleteJob(monitor);
await req.db.deleteChecks(monitor._id);
await req.db.deleteAlertByMonitorId(monitor._id);
await req.db.deletePageSpeedChecksByMonitorId(monitor._id);
await req.db.deleteNotificationsByMonitorId(monitor._id);
})
);
+1
View File
@@ -182,6 +182,7 @@ const deleteMonitor = async (req, res, next) => {
await req.jobQueue.deleteJob(monitor);
await req.db.deleteChecks(monitor._id);
await req.db.deleteAlertByMonitorId(monitor._id);
await req.db.deletePageSpeedChecksByMonitorId(monitor._id);
await req.db.deleteNotificationsByMonitorId(monitor._id);
/**
+1 -1
View File
@@ -4,7 +4,7 @@ const SERVICE_NAME = "JobQueue";
const getJobs = async (req, res, next) => {
try {
const jobs = await req.jobQueue.getJobs();
const jobs = await req.jobQueue.getJobStats();
return res.status(200).json({ jobs });
} catch (error) {
error.service = SERVICE_NAME;
+2 -2
View File
@@ -79,7 +79,7 @@ const {
const {
createPageSpeedCheck,
getPageSpeedChecks,
deletePageSpeedChecks,
deletePageSpeedChecksByMonitorId,
} = require("./modules/pageSpeedCheckModule");
//****************************************
@@ -157,7 +157,7 @@ module.exports = {
deleteMonitorsByUserId,
createPageSpeedCheck,
getPageSpeedChecks,
deletePageSpeedChecks,
deletePageSpeedChecksByMonitorId,
createMaintenanceWindow,
getMaintenanceWindowsByUserId,
getMaintenanceWindowsByMonitorId,
@@ -48,7 +48,7 @@ const getPageSpeedChecks = async (monitorId) => {
* @throws {Error}
*/
const deletePageSpeedChecks = async (monitorId) => {
const deletePageSpeedChecksByMonitorId = async (monitorId) => {
try {
const result = await PageSpeedCheck.deleteMany({ monitorId });
return result.deletedCount;
@@ -60,5 +60,5 @@ const deletePageSpeedChecks = async (monitorId) => {
module.exports = {
createPageSpeedCheck,
getPageSpeedChecks,
deletePageSpeedChecks,
deletePageSpeedChecksByMonitorId,
};
+7
View File
@@ -19,6 +19,8 @@ const JobQueue = require("./service/jobQueue");
const EmailService = require("./service/emailService");
const PageSpeedService = require("./service/pageSpeedService");
let cleaningUp = false;
// Need to wrap server setup in a function to handle async nature of JobQueue
const startApp = async () => {
// **************************
@@ -117,6 +119,11 @@ const startApp = async () => {
const pageSpeedService = new PageSpeedService();
const cleanup = async () => {
if (cleaningUp) {
console.log("Already cleaning up");
return;
}
cleaningUp = true;
console.log("Shutting down gracefully");
await jobQueue.obliterate();
console.log("Finished cleanup");
+38
View File
@@ -1,5 +1,39 @@
const mongoose = require("mongoose");
const AuditSchema = mongoose.Schema({
id: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
score: { type: Number, required: true },
scoreDisplayMode: { type: String, required: true },
displayValue: { type: String, required: true },
numericValue: { type: Number, required: true },
numericUnit: { type: String, required: true },
});
const AuditsSchema = mongoose.Schema({
cls: {
type: AuditSchema,
required: true,
},
si: {
type: AuditSchema,
required: true,
},
fcp: {
type: AuditSchema,
required: true,
},
lcp: {
type: AuditSchema,
required: true,
},
tbt: {
type: AuditSchema,
required: true,
},
});
/**
* Mongoose schema for storing metrics from Google Lighthouse.
* @typedef {Object} PageSpeedCheck
@@ -37,6 +71,10 @@ const PageSpeedCheck = mongoose.Schema(
type: Number,
required: true,
},
audits: {
type: AuditsSchema,
required: true,
},
},
{
timestamps: true,
+1 -1
View File
@@ -23,7 +23,7 @@ const {
//Auth routes
router.post("/register", upload.single("profileImage"), registerController);
router.post("/login", loginController);
router.post(
router.put(
"/user/:userId",
upload.single("profileImage"),
verifyJWT,
+3 -3
View File
@@ -8,12 +8,12 @@ router.get("/:monitorId", monitorController.getMonitorById);
router.get("/user/:userId", monitorController.getMonitorsByUserId);
router.post("/", monitorController.createMonitor);
router.post(
"/delete/:monitorId",
router.delete(
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
monitorController.deleteMonitor
);
router.post(
router.put(
"/edit/:monitorId",
verifyOwnership(Monitor, "monitorId"),
monitorController.editMonitor
+47 -14
View File
@@ -124,6 +124,8 @@ class JobQueue {
const load = jobs.length / this.workers.length;
return { jobs, load };
} catch (error) {
console.log(error);
throw error;
}
}
@@ -143,8 +145,10 @@ class JobQueue {
async scaleWorkers(workerStats) {
if (this.workers.length === 0) {
// There are no workers, need to add one
const worker = this.createWorker();
this.workers.push(worker);
for (let i = 0; i < 5; i++) {
const worker = this.createWorker();
this.workers.push(worker);
}
return true;
}
@@ -168,15 +172,17 @@ class JobQueue {
const excessCapacity = workerCapacity - workerStats.jobs.length;
// Calculate how many workers to remove
const workersToRemove = Math.floor(excessCapacity / JOBS_PER_WORKER);
for (let i = 0; i < workersToRemove; i++) {
const worker = this.workers.pop();
try {
await worker.close();
} catch (error) {
// Catch the error instead of throwing it
logger.error(errorMessages.JOB_QUEUE_WORKER_CLOSE, {
service: SERVICE_NAME,
});
if (this.workers.length > 5) {
for (let i = 0; i < workersToRemove; i++) {
const worker = this.workers.pop();
try {
await worker.close();
} catch (error) {
// Catch the error instead of throwing it
logger.error(errorMessages.JOB_QUEUE_WORKER_CLOSE, {
service: SERVICE_NAME,
});
}
}
}
return true;
@@ -196,6 +202,25 @@ class JobQueue {
const jobs = await this.queue.getRepeatableJobs();
return jobs;
} catch (error) {
console.log(error);
throw error;
}
}
async getJobStats() {
try {
const jobs = await this.queue.getJobs();
const ret = await Promise.all(
jobs.map(async (job) => {
const state = await job.getState();
return { url: job.data.url, state };
})
);
return { jobs: ret, workers: this.workers.length };
} catch (error) {
console.log(error);
throw error;
}
}
@@ -210,6 +235,10 @@ class JobQueue {
*/
async addJob(jobName, payload) {
try {
console.log("Adding job", payload.url);
// Execute job immediately
await this.queue.add(jobName, payload);
await this.queue.add(jobName, payload, {
repeat: {
every: payload.interval,
@@ -218,6 +247,7 @@ class JobQueue {
const workerStats = await this.getWorkerStats();
await this.scaleWorkers(workerStats);
} catch (error) {
console.log(error);
throw error;
}
}
@@ -261,9 +291,12 @@ class JobQueue {
await this.queue.removeRepeatableByKey(job.key);
await this.queue.remove(job.id);
}
this.workers.forEach(async (worker) => {
await worker.close();
});
await Promise.all(
this.workers.map(async (worker) => {
await worker.close();
})
);
await this.queue.obliterate();
logger.info(successMessages.JOB_QUEUE_OBLITERATE, {
service: SERVICE_NAME,
+24
View File
@@ -129,6 +129,22 @@ class NetworkService {
);
const pageSpeedResults = response.data;
const categories = pageSpeedResults.lighthouseResult?.categories;
const audits = pageSpeedResults.lighthouseResult?.audits;
const {
"cumulative-layout-shift": cls,
"speed-index": si,
"first-contentful-paint": fcp,
"largest-contentful-paint": lcp,
"total-blocking-time": tbt,
} = audits;
// Weights
// First Contentful Paint 10%
// Speed Index 10%
// Largest Contentful Paint 25%
// Total Blocking Time 30%
// Cumulative Layout Shift 25%
const checkData = {
monitorId: job.data._id,
status: true,
@@ -136,7 +152,15 @@ class NetworkService {
bestPractices: (categories["best-practices"]?.score || 0) * 100,
seo: (categories.seo?.score || 0) * 100,
performance: (categories.performance?.score || 0) * 100,
audits: {
cls,
si,
fcp,
lcp,
tbt,
},
};
this.logAndStoreCheck(checkData, this.db.createPageSpeedCheck);
} catch (error) {
const checkData = {
-1
View File
@@ -1 +0,0 @@
uptime.bluewavelabs.ca
+1
View File
@@ -0,0 +1 @@
<h1>BlueWave Uptime web page</h1>