Merge pull request #575 from bluewave-labs/develop

Develop -> Master
This commit is contained in:
Alexander Holliday authored and GitHub committed 2024-08-08 14:58:50 -07:00
commit d878f64cd0
20 files changed
+1398 -832

No files matched your search

+5 -1
View File
@@ -9,7 +9,7 @@ import {
Pagination,
PaginationItem,
} from "@mui/material";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import "./index.css";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
@@ -73,6 +73,10 @@ const BasicTable = ({ data, paginated, reversed, rowsPerPage = 5 }) => {
const [page, setPage] = useState(0);
useEffect(() => {
setPage(0);
}, [data]);
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
@@ -1,6 +1,5 @@
import PropTypes from "prop-types";
import { AreaChart, Area, XAxis, Tooltip, ResponsiveContainer } from "recharts";
import { NormalizeData } from "../ChartUtils";
import "./index.css";
const CustomToolTip = ({ active, payload, label }) => {
@@ -28,7 +27,7 @@ const CustomToolTip = ({ active, payload, label }) => {
return null;
};
const MonitorDetailsAreaChart = ({ checks, filter }) => {
const MonitorDetailsAreaChart = ({ checks }) => {
// SQUASH ERROR, NOT PERMANENT SOLUTION
const error = console.error;
console.error = (...args) => {
@@ -47,45 +46,12 @@ const MonitorDetailsAreaChart = ({ checks, filter }) => {
});
};
const filterChecks = (checks, filter, numToDisplay) => {
const limits = {
day: 24 * 60 * 60 * 1000,
week: 24 * 60 * 60 * 1000 * 7,
month: 24 * 60 * 60 * 1000 * 30, //TODO better monthly calculations
};
const now = new Date().getTime();
let result = [];
for (let i = 0; i < checks.length; i++) {
const checkTime = new Date(checks[i].createdAt).getTime();
if (now - checkTime < limits[filter]) {
result.push(checks[i]);
}
}
// If more than numToDisplay checks, pick every nth check
if (result.length > numToDisplay) {
const n = Math.ceil(result.length / numToDisplay);
result = result.filter((_, idx) => {
return idx % n === 0;
});
}
return result;
};
let normalizedChecks = [];
if (checks && checks.length > 0) {
const filteredChecks = filterChecks(checks, filter, 75);
normalizedChecks = NormalizeData(filteredChecks, 10, 100);
}
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart
width={500}
height={400}
data={normalizedChecks}
data={checks}
margin={{
top: 10,
right: 0,
@@ -1,21 +1,19 @@
import "./index.css";
import PropTypes from "prop-types";
import { BarChart, Bar, ResponsiveContainer, Cell } from "recharts";
import { NormalizeData } from "../ChartUtils";
const ResponseTimeChart = ({ checks = [] }) => {
const normalizedChecks = NormalizeData(checks, 1, 100);
return (
<div className="chart-container">
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={150}
height={40}
data={normalizedChecks}
data={checks}
style={{ cursor: "pointer" }}
>
<Bar maxBarSize={10} dataKey="responseTime">
{normalizedChecks.map((check, index) => (
{checks.map((check, index) => (
<Cell
key={`cell-${index}`}
fill={
@@ -51,7 +51,7 @@ export const getUptimeMonitorsByUserId = createAsyncThunk(
const user = jwtDecode(token);
try {
const res = await axiosInstance.get(
`/monitors/user/${user._id}?limit=25&type=http&type=ping&sortOrder=desc`,
`/monitors/user/${user._id}?limit=25&type=http&type=ping&sortOrder=desc&normalize=true`,
{
headers: {
Authorization: `Bearer ${token}`,
+48 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { Typography, ButtonGroup, Stack, Box } from "@mui/material";
import { Typography, ButtonGroup, Stack, Box, Skeleton } from "@mui/material";
import WestRoundedIcon from "@mui/icons-material/WestRounded";
import Button from "../../Components/Button";
import axiosInstance from "../../Utils/axiosConfig";
@@ -12,12 +12,39 @@ import Select from "../../Components/Inputs/Select";
import "./index.css";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" alignItems="center" gap={theme.gap.medium}>
<Skeleton variant="rounded" width={150} height={34} />
<Skeleton variant="rounded" width="15%" height={34} />
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ ml: "auto" }}
/>
</Stack>
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</>
);
};
const Incidents = () => {
const theme = useTheme();
const navigate = useNavigate();
const authState = useSelector((state) => state.auth);
const [monitors, setMonitors] = useState({});
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [loading, setLoading] = useState(false);
// TODO do something with these filters
const [filter, setFilter] = useState("all");
@@ -34,6 +61,7 @@ const Incidents = () => {
useEffect(() => {
const fetchIncidents = async () => {
setLoading(true);
const res = await axiosInstance.get(
`/monitors/user/${authState.user._id}?status=false`,
{
@@ -51,6 +79,7 @@ const Incidents = () => {
}, {});
setMonitors(monitorLookup);
}
setLoading(false);
};
fetchIncidents();
@@ -92,8 +121,8 @@ const Incidents = () => {
data: <StatusLabel status="down" text="Down" />,
},
{ id: idx + 1, data: new Date(incident.createdAt).toLocaleString() },
{ id: idx + 2, data: incident.statusCode },
{ id: idx + 3, data: monitors[incident.monitorId].name },
{ id: idx + 2, data: monitors[incident.monitorId].name },
{ id: idx + 3, data: incident.statusCode },
],
};
});
@@ -102,7 +131,9 @@ const Incidents = () => {
return (
<>
<Stack className="incidents" gap={theme.gap.large}>
{hasAnyIncidents ? (
{loading ? (
<SkeletonLayout />
) : hasAnyIncidents ? (
<>
<Stack direction="row" alignItems="center" gap={theme.gap.medium}>
<Typography component="h1">Incident history for: </Typography>
@@ -155,8 +186,7 @@ const Incidents = () => {
)}
</>
) : (
<Stack alignItems="center" mt={theme.gap.xxl} gap={theme.gap.small}>
<Typography>No incidents, yet.</Typography>
<Box>
<Button
level="tertiary"
label="Back"
@@ -165,15 +195,25 @@ const Incidents = () => {
onClick={() => navigate(-1)}
sx={{
backgroundColor: theme.palette.otherColors.fillGray,
mb: theme.gap.medium,
px: theme.gap.ml,
mb: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
/>
</Stack>
<Box
padding={theme.gap.xxl}
gap={theme.gap.small}
border={1}
borderRadius={`${theme.shape.borderRadius}px`}
borderColor={theme.palette.otherColors.graishWhite}
backgroundColor={theme.palette.otherColors.white}
>
<Typography textAlign="center">No incidents, yet.</Typography>
</Box>
</Box>
)}
</Stack>
</>
+265 -203
View File
@@ -4,7 +4,7 @@ import { useDispatch, useSelector } from "react-redux";
import { useEffect, useState } from "react";
import Button from "../../../Components/Button";
import Field from "../../../Components/Inputs/Field";
import { Box, Modal, Stack, Typography } from "@mui/material";
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";
@@ -36,6 +36,54 @@ const parseUrl = (url) => {
}
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack gap={theme.gap.xl} mt={theme.gap.medium}>
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.gap.small }}
/>
<Skeleton variant="rounded" width="50%" height={18} />
</Box>
<Stack
direction="row"
gap={theme.gap.medium}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton variant="rounded" width={150} height={34} />
</Stack>
</Stack>
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={200} />
<Stack direction="row" justifyContent="flex-end">
<Skeleton variant="rounded" width="15%" height={34} />
</Stack>
</Stack>
</>
);
};
/**
* Configure page displays monitor configurations and allows for editing actions.
* @component
@@ -47,7 +95,7 @@ const Configure = () => {
const dispatch = useDispatch();
const { user, authToken } = useSelector((state) => state.auth);
const { monitors } = useSelector((state) => state.uptimeMonitors);
const [monitor, setMonitor] = useState();
const [monitor, setMonitor] = useState({});
const [duration, setDuration] = useState(0);
const [errors, setErrors] = useState({});
const { monitorId } = useParams();
@@ -164,214 +212,228 @@ const Configure = () => {
var lastChecked = formatDurationRounded(getLastChecked(monitor?.checks));
let loading = Object.keys(monitor).length === 0;
return (
<Box className="configure-monitor">
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate(-1)}
sx={{
backgroundColor: "#f4f4f4",
mb: theme.gap.medium,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
/>
<form className="configure-monitor-form" noValidate spellCheck="false">
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
{parsedUrl?.host ? (
<Typography
component="h1"
mb={theme.gap.small}
sx={{ 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>
</Box>
<Stack
direction="row"
gap={theme.gap.medium}
{loading ? (
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate(-1)}
sx={{
ml: "auto",
alignSelf: "flex-end",
backgroundColor: "#f4f4f4",
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
/>
<form
className="configure-monitor-form"
noValidate
spellCheck="false"
style={{
marginTop: theme.gap.medium,
}}
>
<Button
level="tertiary"
label="Pause"
animate="rotate180"
img={<PauseCircleOutlineIcon />}
sx={{
backgroundColor: "#f4f4f4",
pl: theme.gap.small,
pr: theme.gap.medium,
"& svg": {
mr: theme.gap.xs,
},
}}
/>
<Button
level="error"
label="Remove"
sx={{
boxShadow: "none",
px: theme.gap.ml,
}}
onClick={() => setIsOpen(true)}
/>
</Stack>
</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 }}>
Here you can select the URL of the host, together with the type of
monitor.
</Typography>
</Box>
<Stack gap={theme.gap.xl}>
<Field
type={monitor?.type === "http" ? "url" : "text"}
https={protocol === "https"}
id="monitor-url"
label="URL to monitor"
placeholder="google.com"
value={parsedUrl?.host || monitor?.url || ""}
disabled={true}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Friendly name"
isOptional={true}
placeholder="Google"
value={monitor?.name || ""}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</Stack>
<Stack
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}>
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>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={
monitor?.notifications?.some(
(notification) => notification.type === "email"
) || false
}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
isDisabled={true}
/>
{monitor?.notifications?.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={`calc(${theme.gap.ml} * 2)`}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => console.log("disabled")}
/>
<Typography mt={theme.gap.small}>
You can separate multiple emails with a comma
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
{parsedUrl?.host ? (
<Typography
component="h1"
mb={theme.gap.small}
sx={{ 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>
</Box>
) : (
""
)}
</Stack>
</Stack>
<Stack
className="config-box"
direction="row"
justifyContent="space-between"
gap={theme.gap.xxl}
>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.gap.xl}>
<Select
id="monitor-interval-configure"
label="Check frequency"
value={monitor?.interval / MS_PER_MINUTE || 1}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<Button
level="primary"
label="Save"
sx={{ px: theme.gap.large }}
onClick={handleSubmit}
/>
</Stack>
</form>
<Stack
direction="row"
gap={theme.gap.medium}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Button
level="tertiary"
label="Pause"
animate="rotate180"
img={<PauseCircleOutlineIcon />}
sx={{
backgroundColor: "#f4f4f4",
pl: theme.gap.small,
pr: theme.gap.medium,
"& svg": {
mr: theme.gap.xs,
},
}}
/>
<Button
level="error"
label="Remove"
sx={{
boxShadow: "none",
px: theme.gap.ml,
}}
onClick={() => setIsOpen(true)}
/>
</Stack>
</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 }}>
Here you can select the URL of the host, together with the
type of monitor.
</Typography>
</Box>
<Stack gap={theme.gap.xl}>
<Field
type={monitor?.type === "http" ? "url" : "text"}
https={protocol === "https"}
id="monitor-url"
label="URL to monitor"
placeholder="google.com"
value={parsedUrl?.host || monitor?.url || ""}
disabled={true}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Friendly name"
isOptional={true}
placeholder="Google"
value={monitor?.name || ""}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</Stack>
<Stack
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}>
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>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={
monitor?.notifications?.some(
(notification) => notification.type === "email"
) || false
}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => console.log("disabled")}
isDisabled={true}
/>
{monitor?.notifications?.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={`calc(${theme.gap.ml} * 2)`}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => console.log("disabled")}
/>
<Typography mt={theme.gap.small}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</Stack>
<Stack
className="config-box"
direction="row"
justifyContent="space-between"
gap={theme.gap.xxl}
>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.gap.xl}>
<Select
id="monitor-interval-configure"
label="Check frequency"
value={monitor?.interval / MS_PER_MINUTE || 1}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<Button
level="primary"
label="Save"
sx={{ px: theme.gap.large }}
onClick={handleSubmit}
/>
</Stack>
</form>
</>
)}
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
@@ -0,0 +1,121 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
TableFooter,
TablePagination,
} from "@mui/material";
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import axiosInstance from "../../../../Utils/axiosConfig";
import { StatusLabel } from "../../../../Components/Label";
const PaginationTable = ({ monitorId, filter }) => {
const { authToken } = useSelector((state) => state.auth);
const [checks, setChecks] = useState([]);
const [checksCount, setChecksCount] = useState(0);
const [paginationController, setPaginationController] = useState({
page: 0,
rowsPerPage: 5,
});
useEffect(() => {
const fetchPage = async () => {
try {
const res = await axiosInstance.get(
`/checks/${monitorId}?sortOrder=desc&filter=${filter}&page=${paginationController.page}&rowsPerPage=${paginationController.rowsPerPage}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
console.log(error);
}
};
fetchPage();
}, [
authToken,
monitorId,
filter,
paginationController.page,
paginationController.rowsPerPage,
]);
const handlePageChange = (_, newPage) => {
setPaginationController({
...paginationController,
page: newPage,
});
};
const handleChangeRowsPerPage = (event) => {
setPaginationController({
...paginationController,
rowsPerPage: parseInt(event.target.value, 10),
page: 0,
});
};
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
return (
<TableRow key={check._id}>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{new Date(check.createdAt).toLocaleString()}
</TableCell>
<TableCell>{check.statusCode}</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
onPageChange={handlePageChange}
page={paginationController.page}
count={checksCount}
rowsPerPage={paginationController.rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
};
PaginationTable.propTypes = {
monitorId: PropTypes.string.isRequired,
filter: PropTypes.string.isRequired,
};
export default PaginationTable;
@@ -42,9 +42,6 @@
padding: var(--env-var-spacing-1) 18px;
background-color: var(--env-var-color-8);
}
.monitor-details .stat-box:not(:first-of-type) {
margin-left: var(--env-var-spacing-2);
}
.monitor-details .stat-box h6.MuiTypography-root {
margin-bottom: var(--env-var-spacing-1);
}
+225 -166
View File
@@ -1,10 +1,9 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useCallback } from "react";
import PropTypes from "prop-types";
import { Box, Stack, Typography, useTheme } from "@mui/material";
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 BasicTable from "../../../Components/BasicTable";
import MonitorDetailsAreaChart from "../../../Components/Charts/MonitorDetailsAreaChart";
import { StatusLabel } from "../../../Components/Label";
import ButtonGroup from "@mui/material/ButtonGroup";
@@ -13,6 +12,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 SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import PaginationTable from "./PaginationTable";
import {
formatDuration,
formatDurationRounded,
@@ -30,72 +30,114 @@ const StatBox = ({ title, value }) => {
);
};
StatBox.propTypes = {
title: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="20%" height={34} />
<Stack gap={theme.gap.xl} mt={theme.gap.medium}>
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton variant="rounded" width="50%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.gap.small }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.gap.large}
>
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.gap.ml}
>
<Skeleton
variant="rounded"
width="20%"
height={24}
sx={{ alignSelf: "flex-end" }}
/>
<Skeleton variant="rounded" width="20%" height={34} />
</Stack>
<Box sx={{ height: "200px" }}>
<Skeleton variant="rounded" width="100%" height="100%" />
</Box>
</Box>
<Stack gap={theme.gap.ml}>
<Skeleton variant="rounded" width="20%" height={24} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={50} />
</Stack>
</Stack>
</>
);
};
/**
* Details page component displaying monitor details and related information.
* @component
*/
const DetailsPage = () => {
const [monitor, setMonitor] = useState({});
const [data, setData] = useState({});
const { monitorId } = useParams();
const { authToken } = useSelector((state) => state.auth);
const [filter, setFilter] = useState("day");
const navigate = useNavigate();
const fetchMonitor = useCallback(async () => {
try {
const res = await axiosInstance.get(
`/monitors/${monitorId}?sortOrder=asc&filter=${filter}&numToDisplay=50&normalize=true`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
setMonitor(res.data.data);
} catch (error) {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found");
}
}, [authToken, monitorId, navigate, filter]);
useEffect(() => {
const fetchMonitor = async () => {
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 },
],
};
}),
};
setData(data);
} catch (error) {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found");
}
};
fetchMonitor();
}, [monitorId, authToken]);
}, [fetchMonitor]);
const theme = useTheme();
const navigate = useNavigate();
/**
* Function to calculate uptime duration based on the most recent check.
@@ -158,135 +200,152 @@ const DetailsPage = () => {
}, 0);
};
let loading = Object.keys(monitor).length === 0;
return (
<Box className="monitor-details">
<Button
level="tertiary"
label="Back to Monitors"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate("/monitors")}
sx={{
backgroundColor: "#f4f4f4",
mb: theme.gap.medium,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
/>
<Stack gap={theme.gap.xl}>
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
<Typography component="h1" sx={{ lineHeight: 1 }}>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography mt={theme.gap.small}>
<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 {formatDurationRounded(monitor?.interval)}. Last
time checked{" "}
{formatDurationRounded(getLastChecked(monitor?.checks))} ago.
</Typography>
</Box>
{loading ? (
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Configure"
animate="rotate90"
img={
<SettingsIcon
style={{ width: theme.gap.mlplus, height: theme.gap.mlplus }}
/>
}
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
label="Back to Monitors"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate("/monitors")}
sx={{
ml: "auto",
alignSelf: "flex-end",
backgroundColor: "#f4f4f4",
px: theme.gap.medium,
"& svg": {
mr: "6px",
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
/>
</Stack>
<Stack direction="row" justifyContent="space-between">
<StatBox
title="Currently up for"
value={formatDuration(calculateUptimeDuration(monitor.checks))}
/>
<StatBox
title="Last checked"
value={`${formatDuration(getLastChecked(monitor.checks))} ago`}
/>
<StatBox title="Incidents" value={countIncidents(monitor.checks)} />
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.gap.ml}
>
<Typography component="h2" mb={theme.gap.small}>
Response Times
</Typography>
<ButtonGroup>
<Stack gap={theme.gap.xl} mt={theme.gap.medium}>
<Stack direction="row" gap={theme.gap.small} mt={theme.gap.small}>
{monitor?.status ? <GreenCheck /> : <RedCheck />}
<Box>
<Typography component="h1" sx={{ lineHeight: 1 }}>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography mt={theme.gap.small}>
<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 {formatDurationRounded(monitor?.interval)}.
Last time checked{" "}
{formatDurationRounded(getLastChecked(monitor?.checks))} ago.
</Typography>
</Box>
<Button
level="secondary"
label="Day"
onClick={() => setFilter("day")}
level="tertiary"
label="Configure"
animate="rotate90"
img={
<SettingsIcon
style={{
minWidth: theme.gap.mlplus,
minHeight: theme.gap.mlplus,
}}
/>
}
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
sx={{
backgroundColor:
filter === "day" && theme.palette.otherColors.fillGray,
ml: "auto",
alignSelf: "flex-end",
backgroundColor: "#f4f4f4",
px: theme.gap.medium,
"& svg": {
mr: "6px",
},
}}
/>
<Button
level="secondary"
label="Week"
onClick={() => setFilter("week")}
sx={{
backgroundColor:
filter === "week" && theme.palette.otherColors.fillGray,
}}
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.gap.large}
>
<StatBox
title="Currently up for"
value={formatDuration(calculateUptimeDuration(monitor.checks))}
/>
<Button
level="secondary"
label="Month"
onClick={() => setFilter("month")}
sx={{
backgroundColor:
filter === "month" && theme.palette.otherColors.fillGray,
}}
<StatBox
title="Last checked"
value={`${formatDuration(getLastChecked(monitor.checks))} ago`}
/>
</ButtonGroup>
<StatBox
title="Incidents"
value={countIncidents(monitor.checks)}
/>
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.gap.ml}
>
<Typography component="h2" mb={theme.gap.small}>
Response Times
</Typography>
<ButtonGroup>
<Button
level="secondary"
label="Day"
onClick={() => setFilter("day")}
sx={{
backgroundColor:
filter === "day" && theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Week"
onClick={() => setFilter("week")}
sx={{
backgroundColor:
filter === "week" && theme.palette.otherColors.fillGray,
}}
/>
<Button
level="secondary"
label="Month"
onClick={() => setFilter("month")}
sx={{
backgroundColor:
filter === "month" &&
theme.palette.otherColors.fillGray,
}}
/>
</ButtonGroup>
</Stack>
<Box sx={{ height: "200px" }}>
<MonitorDetailsAreaChart
checks={monitor.checks}
filter={filter}
/>
</Box>
</Box>
<Stack gap={theme.gap.ml}>
<Typography component="h2">History</Typography>
{/* TODO New Table */}
<PaginationTable monitorId={monitorId} filter={filter} />
{/* <BasicTable data={data} paginated={true} /> */}
</Stack>
</Stack>
<Box sx={{ height: "200px" }}>
<MonitorDetailsAreaChart checks={monitor.checks} filter={filter} />
</Box>
</Box>
<Box>
<Typography component="h2" mb={theme.gap.ml}>
History
</Typography>
<BasicTable data={data} paginated={true} reversed={true} />
</Box>
</Stack>
</>
)}
</Box>
);
};
StatBox.propTypes = {
title: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default DetailsPage;
+7
View File
@@ -9,6 +9,13 @@
.monitors h2.MuiTypography-root {
font-size: var(--env-var-font-size-large);
}
.monitors p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.monitors h2:has(+ p),
.monitors p.MuiTypography-root {
color: var(--env-var-color-5);
}
.monitors .MuiStack-root > button:not(.MuiIconButton-root) {
font-size: var(--env-var-font-size-medium);
height: var(--env-var-height-2);
+115 -40
View File
@@ -22,6 +22,7 @@ import {
Menu,
MenuItem,
Modal,
Skeleton,
Stack,
Typography,
} from "@mui/material";
@@ -71,6 +72,38 @@ const Host = ({ params }) => {
);
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Skeleton variant="rounded" width="50%" height={36} />
<Skeleton variant="rounded" width="15%" height={36} />
</Stack>
<Stack
gap={theme.gap.large}
direction="row"
justifyContent="space-between"
>
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
</Stack>
<Stack gap={theme.gap.large} p={theme.gap.xl} backgroundColor="#f9fafb">
<Skeleton variant="rounded" width="50%" height={25} />
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</Stack>
</>
);
};
const Monitors = () => {
const theme = useTheme();
const navigate = useNavigate();
@@ -112,7 +145,7 @@ const Monitors = () => {
useEffect(() => {
dispatch(getUptimeMonitorsByUserId(authState.authToken));
}, []);
}, [authState.authToken, dispatch]);
const up = monitorState.monitors.reduce((acc, cur) => {
return cur.status === true ? acc + 1 : acc;
@@ -204,47 +237,89 @@ const Monitors = () => {
};
});
let loading = monitorState.isLoading && monitorState.monitors.length === 0;
return (
<Stack className="monitors" gap={theme.gap.large}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography component="h1">
Hello, {authState.user.firstName}
</Typography>
<Button
level="primary"
label="Create new monitor"
onClick={() => {
navigate("/monitors/create");
}}
/>
</Stack>
<Stack
gap={theme.gap.large}
direction="row"
justifyContent="space-between"
>
<ServerStatus title="Up" value={up} state="up" />
<ServerStatus title="Down" value={down} state="down" />
<ServerStatus title="Paused" value={0} state="pause" />
</Stack>
<Stack
gap={theme.gap.large}
p={theme.gap.xl}
sx={{
border: `solid 1px ${theme.palette.otherColors.graishWhite}`,
borderRadius: `${theme.shape.borderRadius}px`,
backgroundColor: theme.palette.otherColors.white,
}}
>
<Stack direction="row" alignItems="center">
<Typography component="h2">Current monitors</Typography>
<Box className="current-monitors-counter">
{monitorState.monitors.length}
</Box>
{/* TODO - add search bar */}
</Stack>
<BasicTable data={data} paginated={true} />
</Stack>
{loading ? (
<SkeletonLayout />
) : (
<>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography component="h1">
Hello, {authState.user.firstName}
</Typography>
{monitorState.monitors?.length !== 0 && (
<Button
level="primary"
label="Create new monitor"
onClick={() => {
navigate("/monitors/create");
}}
/>
)}
</Stack>
{monitorState.monitors?.length === 0 ? (
<Stack
alignItems="center"
backgroundColor={theme.palette.otherColors.white}
p={theme.gap.xxl}
gap={theme.gap.xs}
border={1}
borderRadius={`${theme.shape.borderRadius}px`}
borderColor={theme.palette.otherColors.graishWhite}
>
<Typography component="h2">No monitors found</Typography>
<Typography>
It looks like you don’t have any monitors set up yet.
</Typography>
<Button
level="primary"
label="Create your first monitor"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ mt: theme.gap.large }}
/>
</Stack>
) : (
<>
<Stack
gap={theme.gap.large}
direction="row"
justifyContent="space-between"
>
<ServerStatus title="Up" value={up} state="up" />
<ServerStatus title="Down" value={down} state="down" />
<ServerStatus title="Paused" value={0} state="pause" />
</Stack>
<Stack
gap={theme.gap.large}
p={theme.gap.xl}
border={1}
borderColor={theme.palette.otherColors.graishWhite}
backgroundColor={theme.palette.otherColors.white}
sx={{
borderRadius: `${theme.shape.borderRadius}px`,
}}
>
<Stack direction="row" alignItems="center">
<Typography component="h2">Current monitors</Typography>
<Box className="current-monitors-counter">
{monitorState.monitors.length}
</Box>
{/* TODO - add search bar */}
</Stack>
<BasicTable data={data} paginated={true} />
</Stack>{" "}
</>
)}
</>
)}
<Menu
className="actions-menu"
anchorEl={anchorEl}
+418 -353
View File
@@ -1,4 +1,4 @@
import { Box, Stack, Typography } from "@mui/material";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { PieChart } from "@mui/x-charts/PieChart";
import { useDrawingArea } from "@mui/x-charts";
import { useEffect, useState } from "react";
@@ -126,6 +126,53 @@ const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => {
);
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack direction="row" gap={theme.gap.small}>
<Skeleton variant="circular" style={{ minWidth: 24, minHeight: 24 }} />
<Box width="80%">
<Skeleton variant="rounded" width="50%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.gap.small }}
/>
</Box>
<Skeleton
variant="rounded"
width="15%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.gap.xl}
flexWrap="wrap"
>
<Skeleton variant="rounded" width="30%" height={90} sx={{ flex: 1 }} />
<Skeleton variant="rounded" width="30%" height={90} sx={{ flex: 1 }} />
<Skeleton variant="rounded" width="30%" height={90} sx={{ flex: 1 }} />
</Stack>
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton variant="rounded" width="100%" height={300} />
</>
);
};
const PageSpeedDetails = () => {
const theme = useTheme();
const navigate = useNavigate();
@@ -136,17 +183,22 @@ const PageSpeedDetails = () => {
useEffect(() => {
const fetchMonitor = async () => {
const res = await axiosInstance.get(
`/monitors/${monitorId}?sortOrder=desc`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
try {
const res = await axiosInstance.get(
`/monitors/${monitorId}?sortOrder=desc`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
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");
}
};
fetchMonitor();
@@ -244,360 +296,373 @@ const PageSpeedDetails = () => {
const [highlightedItem, setHighLightedItem] = useState(null);
const [expand, setExpand] = useState(false);
let loading = Object.keys(monitor).length === 0;
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"
{loading ? (
<SkeletonLayout />
) : (
<>
<Button
level="tertiary"
label="Back"
animate="slideLeft"
img={<WestRoundedIcon />}
onClick={() => navigate("/pagespeed")}
sx={{
borderBottom: `solid 1px ${theme.palette.otherColors.graishWhite}`,
borderBottomStyle: "dashed",
width: "fit-content",
backgroundColor: theme.palette.otherColors.fillGray,
px: theme.gap.ml,
"& svg.MuiSvgIcon-root": {
mr: theme.gap.small,
fill: theme.palette.otherColors.slateGray,
},
}}
>
The{" "}
<Typography
component="span"
/>
<Stack direction="row" gap={theme.gap.small}>
<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={{
color: theme.palette.primary.main,
fontWeight: 500,
textDecoration: "underline",
cursor: "pointer",
ml: "auto",
alignSelf: "flex-end",
backgroundColor: theme.palette.otherColors.fillGray,
px: theme.gap.medium,
"& svg": {
mr: "6px",
},
}}
>
performance score is calculated
</Typography>{" "}
directly from these{" "}
<Typography component="span" fontWeight={600}>
metrics
</Typography>
.
</Typography>
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.gap.xl}
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;
<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>
</>
}
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>
);
})}
/>
<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>
</Box>
</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>
);
};
+72 -3
View File
@@ -1,4 +1,4 @@
import { Box, Grid, Stack, Typography } from "@mui/material";
import { Box, Grid, Skeleton, Stack, Typography } from "@mui/material";
import { useEffect } from "react";
import { useTheme } from "@emotion/react";
import { formatDate, formatDurationRounded } from "../../Utils/timeUtils";
@@ -57,20 +57,89 @@ const Card = ({ data }) => {
);
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack gap={theme.gap.xs}>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.gap.large}
>
<Box width="80%">
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={19.5}
sx={{ mt: theme.gap.xs }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack direction="row" flexWrap="wrap" gap={theme.gap.large}>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
</Stack>
</Stack>
);
};
const PageSpeed = () => {
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
const { authToken } = useSelector((state) => state.auth);
const { monitors } = useSelector((state) => state.pageSpeedMonitors);
const { monitors, isLoading } = useSelector(
(state) => state.pageSpeedMonitors
);
useEffect(() => {
dispatch(getPageSpeedByUserId(authToken));
}, []);
// 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
let isActuallyLoading = isLoading && monitors.length === 0;
return (
<Box className="page-speed">
{monitors?.length !== 0 ? (
{isActuallyLoading ? (
<SkeletonLayout />
) : monitors?.length !== 0 ? (
<Stack gap={theme.gap.xs}>
<Stack
direction="row"
+4
View File
@@ -161,6 +161,10 @@ button:focus-visible {
display: none;
}
body .MuiSkeleton-root {
background-color: var(--env-var-color-15);
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
+9 -4
View File
@@ -2,6 +2,7 @@ const {
createCheckParamValidation,
createCheckBodyValidation,
getChecksParamValidation,
getChecksQueryValidation,
deleteChecksParamValidation,
} = require("../validation/joi");
const { successMessages } = require("../utils/messages");
@@ -35,6 +36,7 @@ const createCheck = async (req, res, next) => {
const getChecks = async (req, res, next) => {
try {
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
@@ -45,10 +47,13 @@ const getChecks = async (req, res, next) => {
}
try {
const checks = await req.db.getChecks(req.params.monitorId);
return res
.status(200)
.json({ success: true, msg: successMessages.CHECK_GET, data: checks });
const checks = await req.db.getChecks(req);
const checksCount = await req.db.getChecksCount(req);
return res.status(200).json({
success: true,
msg: successMessages.CHECK_GET,
data: { checksCount, checks },
});
} catch (error) {
error.service = SERVICE_NAME;
next(error);
+2
View File
@@ -88,6 +88,7 @@ const {
const {
createCheck,
getChecksCount,
getChecks,
deleteChecks,
} = require("./modules/checkModule");
@@ -145,6 +146,7 @@ module.exports = {
deleteAllMonitors,
editMonitor,
createCheck,
getChecksCount,
getChecks,
deleteChecks,
createAlert,
+49 -4
View File
@@ -1,5 +1,9 @@
const Check = require("../../../models/Check");
const filterLookup = {
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
};
/**
* Create a check for a monitor
* @async
@@ -22,6 +26,19 @@ const createCheck = async (checkData) => {
}
};
const getChecksCount = async (req) => {
const monitorId = req.params.monitorId;
const filter = req.query.filter;
// Build query
const checksQuery = { monitorId: monitorId };
// Filter checks by "day", "week", or "month"
if (filter !== undefined) {
checksQuery.createdAt = { $gte: filterLookup[filter] };
}
const count = await Check.countDocuments(checksQuery);
return count;
};
/**
* Get all checks for a monitor
* @async
@@ -30,9 +47,37 @@ const createCheck = async (checkData) => {
* @throws {Error}
*/
const getChecks = async (monitorId) => {
const getChecks = async (req) => {
try {
const checks = await Check.find({ monitorId });
const { monitorId } = req.params;
let { sortOrder, limit, filter, page, rowsPerPage } = req.query;
// Default limit to 0 if not provided
if (limit === undefined) limit = 0;
// Default sort order is newest -> oldest
if (sortOrder === "asc") {
sortOrder = 1;
} else if (sortOrder === "desc") {
sortOrder = -1;
} else sortOrder = -1;
// Build query
const checksQuery = { monitorId: monitorId };
// Filter checks by "day", "week", or "month"
if (filter !== undefined) {
checksQuery.createdAt = { $gte: filterLookup[filter] };
}
// Need to skip and limit here
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
const checks = await Check.find(checksQuery)
.skip(skip)
.limit(rowsPerPage)
.sort({ createdAt: sortOrder });
return checks;
} catch (error) {
throw error;
@@ -55,4 +100,4 @@ const deleteChecks = async (monitorId) => {
throw error;
}
};
module.exports = { createCheck, getChecks, deleteChecks };
module.exports = { createCheck, getChecksCount, getChecks, deleteChecks };
+34 -4
View File
@@ -3,6 +3,7 @@ const Check = require("../../../models/Check");
const PageSpeedCheck = require("../../../models/PageSpeedCheck");
const { errorMessages } = require("../../../utils/messages");
const Notification = require("../../../models/Notification");
const { NormalizeData } = require("../../../utils/dataUtils");
/**
* Get all monitors
@@ -32,7 +33,14 @@ const getAllMonitors = async (req, res) => {
const getMonitorById = async (req, res) => {
try {
const { monitorId } = req.params;
let { status, limit, sortOrder } = req.query;
let { status, limit, sortOrder, filter, numToDisplay, normalize } =
req.query;
const filterLookup = {
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
};
// This effectively removes limit, returning all checks
if (limit === undefined) limit = 0;
@@ -52,18 +60,35 @@ const getMonitorById = async (req, res) => {
checksQuery.status = status;
}
// Filter checks by "day", "week", or "month"
if (filter !== undefined) {
checksQuery.createdAt = { $gte: filterLookup[filter] };
}
// Determine model type
let model =
monitor.type === "http" || monitor.type === "ping"
? Check
: PageSpeedCheck;
const checks = await model
let checks = await model
.find(checksQuery)
.sort({
createdAt: sortOrder,
})
.limit(limit);
// If more than numToDisplay checks, pick every nth check
if (numToDisplay !== undefined && checks && checks.length > numToDisplay) {
const n = Math.ceil(checks.length / numToDisplay);
checks = checks.filter((_, index) => index % n === 0);
}
// Normalize checks if requested
if (normalize) {
checks = NormalizeData(checks, 10, 100);
}
const notifications = await Notification.find({ monitorId: monitor._id });
const monitorWithChecks = { ...monitor.toObject(), checks, notifications };
return monitorWithChecks;
@@ -82,7 +107,7 @@ const getMonitorById = async (req, res) => {
*/
const getMonitorsByUserId = async (req, res) => {
try {
let { limit, type, status, sortOrder } = req.query;
let { limit, type, status, sortOrder, normalize } = req.query;
const monitorQuery = { userId: req.params.userId };
if (type !== undefined) {
@@ -115,13 +140,18 @@ const getMonitorsByUserId = async (req, res) => {
: PageSpeedCheck;
// Checks are order newest -> oldest
const checks = await model
let checks = await model
.find(checksQuery)
.sort({
createdAt: sortOrder,
})
.limit(limit);
//Normalize checks if requested
if (normalize === true) {
checks = NormalizeData(checks, 10, 100);
}
// Get notifications
const notifications = await Notification.find({
monitorId: monitor._id,
@@ -11,7 +11,7 @@ const calculatePercentile = (arr, percentile) => {
);
};
export const NormalizeData = (checks, rangeMin, rangeMax) => {
const NormalizeData = (checks, rangeMin, rangeMax) => {
if (checks.length > 1) {
// Get the 5th and 95th percentile
const min = calculatePercentile(checks, 0);
@@ -31,7 +31,7 @@ export const NormalizeData = (checks, rangeMin, rangeMax) => {
Math.min(rangeMax, normalizedResponseTime)
);
return {
...check,
...check._doc,
responseTime: normalizedResponseTime,
originalResponseTime: originalResponseTime,
};
@@ -44,3 +44,7 @@ export const NormalizeData = (checks, rangeMin, rangeMax) => {
});
}
};
module.exports = {
NormalizeData,
};
+13
View File
@@ -126,6 +126,9 @@ const getMonitorByIdQueryValidation = joi.object({
status: joi.boolean(),
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
filter: joi.string().valid("day", "week", "month"),
numToDisplay: joi.number(),
normalize: joi.boolean(),
});
const getMonitorsByUserIdValidation = joi.object({
@@ -136,6 +139,7 @@ const getMonitorsByUserIdQueryValidation = joi.object({
status: joi.boolean(),
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
normalize: joi.boolean(),
type: joi
.alternatives()
.try(
@@ -228,6 +232,14 @@ const getChecksParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getChecksQueryValidation = joi.object({
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
filter: joi.string().valid("day", "week", "month"),
page: joi.number(),
rowsPerPage: joi.number(),
});
const deleteChecksParamValidation = joi.object({
monitorId: joi.string().required(),
});
@@ -307,6 +319,7 @@ module.exports = {
createCheckParamValidation,
createCheckBodyValidation,
getChecksParamValidation,
getChecksQueryValidation,
deleteChecksParamValidation,
deleteUserParamValidation,
getPageSpeedCheckParamValidation,