Merge branch 'develop' into enhancement/check-if-url-resolves
This commit is contained in:
commit
34a13530b3
202 files changed
+33101
-32065
No files matched your search
@@ -1,9 +1,9 @@
|
||||
.configure-pagespeed button {
|
||||
height: var(--env-var-height-2);
|
||||
height: var(--env-var-height-2);
|
||||
}
|
||||
|
||||
.configure-pagespeed .field,
|
||||
.configure-pagespeed .section-disabled,
|
||||
.configure-pagespeed .select-wrapper {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import { Box, Button, Modal, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
deletePageSpeed,
|
||||
getPagespeedMonitorById,
|
||||
getPageSpeedByTeamId,
|
||||
updatePageSpeed,
|
||||
pausePageSpeed,
|
||||
deletePageSpeed,
|
||||
getPagespeedMonitorById,
|
||||
getPageSpeedByTeamId,
|
||||
updatePageSpeed,
|
||||
pausePageSpeed,
|
||||
} from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
|
||||
import { monitorValidation } from "../../../Validation/validation";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
@@ -27,456 +27,472 @@ import useUtils from "../../Monitors/utils";
|
||||
import "./index.css";
|
||||
|
||||
const PageSpeedConfigure = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const MS_PER_MINUTE = 60000;
|
||||
const { user, authToken } = useSelector((state) => state.auth);
|
||||
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
|
||||
const { monitorId } = useParams();
|
||||
const [monitor, setMonitor] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
|
||||
const idMap = {
|
||||
"monitor-url": "url",
|
||||
"monitor-name": "name",
|
||||
"monitor-checks-http": "type",
|
||||
"monitor-checks-ping": "type",
|
||||
"notify-email-default": "notification-email",
|
||||
};
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const MS_PER_MINUTE = 60000;
|
||||
const { user, authToken } = useSelector((state) => state.auth);
|
||||
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
|
||||
const { monitorId } = useParams();
|
||||
const [monitor, setMonitor] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
|
||||
const idMap = {
|
||||
"monitor-url": "url",
|
||||
"monitor-name": "name",
|
||||
"monitor-checks-http": "type",
|
||||
"monitor-checks-ping": "type",
|
||||
"notify-email-default": "notification-email",
|
||||
};
|
||||
|
||||
const frequencies = [
|
||||
{ _id: 3, name: "3 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 frequencies = [
|
||||
{ _id: 3, name: "3 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" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitor = async () => {
|
||||
try {
|
||||
const action = await dispatch(
|
||||
getPagespeedMonitorById({ authToken, monitorId })
|
||||
);
|
||||
useEffect(() => {
|
||||
const fetchMonitor = async () => {
|
||||
try {
|
||||
const action = await dispatch(getPagespeedMonitorById({ authToken, monitorId }));
|
||||
|
||||
if (getPagespeedMonitorById.fulfilled.match(action)) {
|
||||
const monitor = action.payload.data;
|
||||
setMonitor(monitor);
|
||||
} else if (getPagespeedMonitorById.rejected.match(action)) {
|
||||
throw new Error(action.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error fetching monitor of id: " + monitorId);
|
||||
navigate("/not-found", { replace: true });
|
||||
}
|
||||
};
|
||||
fetchMonitor();
|
||||
}, [dispatch, authToken, monitorId, navigate]);
|
||||
if (getPagespeedMonitorById.fulfilled.match(action)) {
|
||||
const monitor = action.payload.data;
|
||||
setMonitor(monitor);
|
||||
} else if (getPagespeedMonitorById.rejected.match(action)) {
|
||||
throw new Error(action.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error fetching monitor of id: " + monitorId);
|
||||
navigate("/not-found", { replace: true });
|
||||
}
|
||||
};
|
||||
fetchMonitor();
|
||||
}, [dispatch, authToken, monitorId, navigate]);
|
||||
|
||||
const handleChange = (event, name) => {
|
||||
let { value, id } = event.target;
|
||||
if (!name) name = idMap[id];
|
||||
const handleChange = (event, name) => {
|
||||
let { value, id } = event.target;
|
||||
if (!name) name = idMap[id];
|
||||
|
||||
if (name.includes("notification-")) {
|
||||
name = name.replace("notification-", "");
|
||||
let hasNotif = monitor.notifications.some(
|
||||
(notification) => notification.type === name
|
||||
);
|
||||
setMonitor((prev) => {
|
||||
const notifs = [...prev.notifications];
|
||||
if (hasNotif) {
|
||||
return {
|
||||
...prev,
|
||||
notifications: notifs.filter((notif) => notif.type !== name),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
notifications: [
|
||||
...notifs,
|
||||
name === "email"
|
||||
? { type: name, address: value }
|
||||
: // TODO - phone number
|
||||
{ type: name, phone: value },
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (name === "interval") {
|
||||
value = value * MS_PER_MINUTE;
|
||||
}
|
||||
setMonitor((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
if (name.includes("notification-")) {
|
||||
name = name.replace("notification-", "");
|
||||
let hasNotif = monitor.notifications.some(
|
||||
(notification) => notification.type === name
|
||||
);
|
||||
setMonitor((prev) => {
|
||||
const notifs = [...prev.notifications];
|
||||
if (hasNotif) {
|
||||
return {
|
||||
...prev,
|
||||
notifications: notifs.filter((notif) => notif.type !== name),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
notifications: [
|
||||
...notifs,
|
||||
name === "email"
|
||||
? { type: name, address: value }
|
||||
: // TODO - phone number
|
||||
{ type: name, phone: value },
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (name === "interval") {
|
||||
value = value * MS_PER_MINUTE;
|
||||
}
|
||||
setMonitor((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
const validation = monitorValidation.validate(
|
||||
{ [name]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
const validation = monitorValidation.validate(
|
||||
{ [name]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
|
||||
if (validation.error)
|
||||
updatedErrors[name] = validation.error.details[0].message;
|
||||
else delete updatedErrors[name];
|
||||
return updatedErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
if (validation.error) updatedErrors[name] = validation.error.details[0].message;
|
||||
else delete updatedErrors[name];
|
||||
return updatedErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
const action = await dispatch(pausePageSpeed({ authToken, monitorId }));
|
||||
if (pausePageSpeed.fulfilled.match(action)) {
|
||||
const monitor = action.payload.data;
|
||||
setMonitor(monitor);
|
||||
} else if (pausePageSpeed.rejected.match(action)) {
|
||||
throw new Error(action.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error pausing monitor: " + monitorId);
|
||||
createToast({ body: "Failed to pause monitor" });
|
||||
}
|
||||
};
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
const action = await dispatch(pausePageSpeed({ authToken, monitorId }));
|
||||
if (pausePageSpeed.fulfilled.match(action)) {
|
||||
const monitor = action.payload.data;
|
||||
setMonitor(monitor);
|
||||
} else if (pausePageSpeed.rejected.match(action)) {
|
||||
throw new Error(action.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error pausing monitor: " + monitorId);
|
||||
createToast({ body: "Failed to pause monitor" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (event) => {
|
||||
event.preventDefault();
|
||||
const action = await dispatch(
|
||||
updatePageSpeed({ authToken, monitor: monitor })
|
||||
);
|
||||
if (action.meta.requestStatus === "fulfilled") {
|
||||
createToast({ body: "Monitor updated successfully!" });
|
||||
dispatch(getPageSpeedByTeamId(authToken));
|
||||
} else {
|
||||
createToast({ body: "Failed to update monitor." });
|
||||
}
|
||||
};
|
||||
const handleSave = async (event) => {
|
||||
event.preventDefault();
|
||||
const action = await dispatch(updatePageSpeed({ authToken, monitor: monitor }));
|
||||
if (action.meta.requestStatus === "fulfilled") {
|
||||
createToast({ body: "Monitor updated successfully!" });
|
||||
dispatch(getPageSpeedByTeamId(authToken));
|
||||
} else {
|
||||
createToast({ body: "Failed to update monitor." });
|
||||
}
|
||||
};
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const handleRemove = async (event) => {
|
||||
event.preventDefault();
|
||||
const action = await dispatch(deletePageSpeed({ authToken, monitor }));
|
||||
if (action.meta.requestStatus === "fulfilled") {
|
||||
navigate("/pagespeed");
|
||||
} else {
|
||||
createToast({ body: "Failed to delete monitor." });
|
||||
}
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const handleRemove = async (event) => {
|
||||
event.preventDefault();
|
||||
const action = await dispatch(deletePageSpeed({ authToken, monitor }));
|
||||
if (action.meta.requestStatus === "fulfilled") {
|
||||
navigate("/pagespeed");
|
||||
} else {
|
||||
createToast({ body: "Failed to delete monitor." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="configure-pagespeed" gap={theme.spacing(10)}>
|
||||
{Object.keys(monitor).length === 0 ? (
|
||||
<SkeletonLayout />
|
||||
) : (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
list={[
|
||||
{ name: "pagespeed", path: "/pagespeed" },
|
||||
{ name: "details", path: `/pagespeed/${monitorId}` },
|
||||
{ name: "configure", path: `/pagespeed/configure/${monitorId}` },
|
||||
]}
|
||||
/>
|
||||
<Stack
|
||||
component="form"
|
||||
noValidate
|
||||
spellCheck="false"
|
||||
onSubmit={handleSave}
|
||||
flex={1}
|
||||
gap={theme.spacing(10)}
|
||||
>
|
||||
<Stack direction="row" gap={theme.spacing(2)}>
|
||||
<Box>
|
||||
<Typography component="h1" variant="h1">
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
height="fit-content"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Tooltip
|
||||
title={pagespeedStatusMsg[determineState(monitor)]}
|
||||
disableInteractive
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -8],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<PulseDot color={statusColor[determineState(monitor)]} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Typography component="h2" variant="h2">
|
||||
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
|
||||
</Typography>
|
||||
<Typography
|
||||
position="relative"
|
||||
variant="body2"
|
||||
ml={theme.spacing(6)}
|
||||
mt={theme.spacing(1)}
|
||||
sx={{
|
||||
"&:before": {
|
||||
position: "absolute",
|
||||
content: `""`,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.text.tertiary,
|
||||
opacity: 0.8,
|
||||
left: -10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Editing...
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box alignSelf="flex-end" ml="auto">
|
||||
<LoadingButton
|
||||
onClick={handlePause}
|
||||
loading={isLoading}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{
|
||||
pl: theme.spacing(4),
|
||||
pr: theme.spacing(6),
|
||||
"& svg": {
|
||||
mr: theme.spacing(2),
|
||||
"& path": {
|
||||
stroke: theme.palette.other.icon,
|
||||
strokeWidth: 0.1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{monitor?.isActive ? (
|
||||
<>
|
||||
<PauseCircleOutlineIcon />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlineRoundedIcon />
|
||||
Resume
|
||||
</>
|
||||
)}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
loading={isLoading}
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
ml: theme.spacing(6),
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</LoadingButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">General settings</Typography>
|
||||
<Typography component="p">
|
||||
Here you can select the URL of the host, together with the
|
||||
type of monitor.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
gap={theme.spacing(20)}
|
||||
sx={{
|
||||
".MuiInputBase-root:has(> .Mui-disabled)": {
|
||||
backgroundColor: theme.palette.background.accent,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
type="url"
|
||||
id="monitor-url"
|
||||
label="URL"
|
||||
placeholder="random.website.com"
|
||||
value={monitor?.url?.replace("http://", "") || ""}
|
||||
onChange={handleChange}
|
||||
error={errors.url}
|
||||
disabled={true}
|
||||
/>
|
||||
<Field
|
||||
type="text"
|
||||
id="monitor-name"
|
||||
label="Monitor display name"
|
||||
placeholder="Example monitor"
|
||||
isOptional={true}
|
||||
value={monitor?.name || ""}
|
||||
onChange={handleChange}
|
||||
error={errors.name}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">Incident notifications</Typography>
|
||||
<Typography component="p">
|
||||
When there is an incident, notify users.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="p">
|
||||
When there is a new incident,
|
||||
</Typography>
|
||||
<Checkbox
|
||||
id="notify-sms"
|
||||
label="Notify via SMS (coming soon)"
|
||||
isChecked={false}
|
||||
value=""
|
||||
onChange={() => logger.warn("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={() => logger.warn("disabled")}
|
||||
isDisabled={true}
|
||||
/>
|
||||
{monitor?.notifications?.some(
|
||||
(notification) => notification.type === "emails"
|
||||
) ? (
|
||||
<Box mx={theme.spacing(16)}>
|
||||
<Field
|
||||
id="notify-email-list"
|
||||
type="text"
|
||||
placeholder="name@gmail.com"
|
||||
value=""
|
||||
onChange={() => logger.warn("disabled")}
|
||||
/>
|
||||
<Typography mt={theme.spacing(4)}>
|
||||
You can separate multiple emails with a comma
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">Advanced settings</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(20)}>
|
||||
<Select
|
||||
id="monitor-frequency"
|
||||
label="Check frequency"
|
||||
items={frequencies}
|
||||
value={monitor?.interval / MS_PER_MINUTE || 3}
|
||||
onChange={(event) => handleChange(event, "interval")}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<Stack direction="row" justifyContent="flex-end" mt="auto">
|
||||
<LoadingButton
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
sx={{ px: theme.spacing(12) }}
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
aria-labelledby="modal-delete-pagespeed-monitor"
|
||||
aria-describedby="delete-pagespeed-monitor-confirmation"
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
disablePortal
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(2)}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 400,
|
||||
bgcolor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: 24,
|
||||
p: theme.spacing(15),
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
id="modal-delete-pagespeed-monitor"
|
||||
component="h2"
|
||||
variant="h2"
|
||||
fontWeight={500}
|
||||
>
|
||||
Do you really want to delete this monitor?
|
||||
</Typography>
|
||||
<Typography
|
||||
id="delete-pagespeed-monitor-confirmation"
|
||||
variant="body1"
|
||||
>
|
||||
Once deleted, this monitor cannot be retrieved.
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(12)}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
color="info"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" color="error" onClick={handleRemove}>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack
|
||||
className="configure-pagespeed"
|
||||
gap={theme.spacing(10)}
|
||||
>
|
||||
{Object.keys(monitor).length === 0 ? (
|
||||
<SkeletonLayout />
|
||||
) : (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
list={[
|
||||
{ name: "pagespeed", path: "/pagespeed" },
|
||||
{ name: "details", path: `/pagespeed/${monitorId}` },
|
||||
{ name: "configure", path: `/pagespeed/configure/${monitorId}` },
|
||||
]}
|
||||
/>
|
||||
<Stack
|
||||
component="form"
|
||||
noValidate
|
||||
spellCheck="false"
|
||||
onSubmit={handleSave}
|
||||
flex={1}
|
||||
gap={theme.spacing(10)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
height="fit-content"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Tooltip
|
||||
title={pagespeedStatusMsg[determineState(monitor)]}
|
||||
disableInteractive
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -8],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<PulseDot color={statusColor[determineState(monitor)]} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="h2"
|
||||
>
|
||||
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
|
||||
</Typography>
|
||||
<Typography
|
||||
position="relative"
|
||||
variant="body2"
|
||||
ml={theme.spacing(6)}
|
||||
mt={theme.spacing(1)}
|
||||
sx={{
|
||||
"&:before": {
|
||||
position: "absolute",
|
||||
content: `""`,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.text.tertiary,
|
||||
opacity: 0.8,
|
||||
left: -10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Editing...
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box
|
||||
alignSelf="flex-end"
|
||||
ml="auto"
|
||||
>
|
||||
<LoadingButton
|
||||
onClick={handlePause}
|
||||
loading={isLoading}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{
|
||||
pl: theme.spacing(4),
|
||||
pr: theme.spacing(6),
|
||||
"& svg": {
|
||||
mr: theme.spacing(2),
|
||||
"& path": {
|
||||
stroke: theme.palette.other.icon,
|
||||
strokeWidth: 0.1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{monitor?.isActive ? (
|
||||
<>
|
||||
<PauseCircleOutlineIcon />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlineRoundedIcon />
|
||||
Resume
|
||||
</>
|
||||
)}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
loading={isLoading}
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
ml: theme.spacing(6),
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</LoadingButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">General settings</Typography>
|
||||
<Typography component="p">
|
||||
Here you can select the URL of the host, together with the type of
|
||||
monitor.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
gap={theme.spacing(20)}
|
||||
sx={{
|
||||
".MuiInputBase-root:has(> .Mui-disabled)": {
|
||||
backgroundColor: theme.palette.background.accent,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
type="url"
|
||||
id="monitor-url"
|
||||
label="URL"
|
||||
placeholder="random.website.com"
|
||||
value={monitor?.url?.replace("http://", "") || ""}
|
||||
onChange={handleChange}
|
||||
error={errors.url}
|
||||
disabled={true}
|
||||
/>
|
||||
<Field
|
||||
type="text"
|
||||
id="monitor-name"
|
||||
label="Monitor display name"
|
||||
placeholder="Example monitor"
|
||||
isOptional={true}
|
||||
value={monitor?.name || ""}
|
||||
onChange={handleChange}
|
||||
error={errors.name}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">Incident notifications</Typography>
|
||||
<Typography component="p">
|
||||
When there is an incident, notify users.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="p">When there is a new incident,</Typography>
|
||||
<Checkbox
|
||||
id="notify-sms"
|
||||
label="Notify via SMS (coming soon)"
|
||||
isChecked={false}
|
||||
value=""
|
||||
onChange={() => logger.warn("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={() => logger.warn("disabled")}
|
||||
isDisabled={true}
|
||||
/>
|
||||
{monitor?.notifications?.some(
|
||||
(notification) => notification.type === "emails"
|
||||
) ? (
|
||||
<Box mx={theme.spacing(16)}>
|
||||
<Field
|
||||
id="notify-email-list"
|
||||
type="text"
|
||||
placeholder="name@gmail.com"
|
||||
value=""
|
||||
onChange={() => logger.warn("disabled")}
|
||||
/>
|
||||
<Typography mt={theme.spacing(4)}>
|
||||
You can separate multiple emails with a comma
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">Advanced settings</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(20)}>
|
||||
<Select
|
||||
id="monitor-frequency"
|
||||
label="Check frequency"
|
||||
items={frequencies}
|
||||
value={monitor?.interval / MS_PER_MINUTE || 3}
|
||||
onChange={(event) => handleChange(event, "interval")}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
mt="auto"
|
||||
>
|
||||
<LoadingButton
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
sx={{ px: theme.spacing(12) }}
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
aria-labelledby="modal-delete-pagespeed-monitor"
|
||||
aria-describedby="delete-pagespeed-monitor-confirmation"
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
disablePortal
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(2)}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 400,
|
||||
bgcolor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: 24,
|
||||
p: theme.spacing(15),
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
id="modal-delete-pagespeed-monitor"
|
||||
component="h2"
|
||||
variant="h2"
|
||||
fontWeight={500}
|
||||
>
|
||||
Do you really want to delete this monitor?
|
||||
</Typography>
|
||||
<Typography
|
||||
id="delete-pagespeed-monitor-confirmation"
|
||||
variant="body1"
|
||||
>
|
||||
Once deleted, this monitor cannot be retrieved.
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(12)}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
color="info"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageSpeedConfigure;
|
||||
@@ -7,45 +7,80 @@ import { useTheme } from "@emotion/react";
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const SkeletonLayout = () => {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Skeleton variant="rounded" width="15%" height={34} />
|
||||
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)} maxWidth="1000px">
|
||||
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
style={{ minWidth: 24, minHeight: 24 }}
|
||||
/>
|
||||
<Box width="80%">
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="50%"
|
||||
height={24}
|
||||
sx={{ mb: theme.spacing(4) }}
|
||||
/>
|
||||
<Skeleton variant="rounded" width="50%" height={18} />
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(6)}
|
||||
sx={{
|
||||
ml: "auto",
|
||||
alignSelf: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="rounded" width={100} height={34} />
|
||||
<Skeleton variant="rounded" width={100} height={34} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" width="100%" height={500} />
|
||||
<Stack direction="row" justifyContent="flex-end">
|
||||
<Skeleton variant="rounded" width="15%" height={34} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="15%"
|
||||
height={34}
|
||||
/>
|
||||
<Stack
|
||||
gap={theme.spacing(20)}
|
||||
mt={theme.spacing(6)}
|
||||
maxWidth="1000px"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(4)}
|
||||
>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
style={{ minWidth: 24, minHeight: 24 }}
|
||||
/>
|
||||
<Box width="80%">
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="50%"
|
||||
height={24}
|
||||
sx={{ mb: theme.spacing(4) }}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="50%"
|
||||
height={18}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(6)}
|
||||
sx={{
|
||||
ml: "auto",
|
||||
alignSelf: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width={100}
|
||||
height={34}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width={100}
|
||||
height={34}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="100%"
|
||||
height={500}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="15%"
|
||||
height={34}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
@@ -25,86 +25,86 @@ const CreatePageSpeed = () => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
|
||||
const idMap = {
|
||||
"monitor-url": "url",
|
||||
"monitor-name": "name",
|
||||
"monitor-checks-http": "type",
|
||||
"monitor-checks-ping": "type",
|
||||
"notify-email-default": "notification-email",
|
||||
};
|
||||
const idMap = {
|
||||
"monitor-url": "url",
|
||||
"monitor-name": "name",
|
||||
"monitor-checks-http": "type",
|
||||
"monitor-checks-ping": "type",
|
||||
"notify-email-default": "notification-email",
|
||||
};
|
||||
|
||||
const [monitor, setMonitor] = useState({
|
||||
url: "",
|
||||
name: "",
|
||||
type: "pagespeed",
|
||||
notifications: [],
|
||||
interval: 3,
|
||||
});
|
||||
const [https, setHttps] = useState(true);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [monitor, setMonitor] = useState({
|
||||
url: "",
|
||||
name: "",
|
||||
type: "pagespeed",
|
||||
notifications: [],
|
||||
interval: 3,
|
||||
});
|
||||
const [https, setHttps] = useState(true);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleChange = (event, name) => {
|
||||
const { value, id } = event.target;
|
||||
if (!name) name = idMap[id];
|
||||
const handleChange = (event, name) => {
|
||||
const { value, id } = event.target;
|
||||
if (!name) name = idMap[id];
|
||||
|
||||
if (name.includes("notification-")) {
|
||||
name = name.replace("notification-", "");
|
||||
let hasNotif = monitor.notifications.some(
|
||||
(notification) => notification.type === name
|
||||
);
|
||||
setMonitor((prev) => {
|
||||
const notifs = [...prev.notifications];
|
||||
if (hasNotif) {
|
||||
return {
|
||||
...prev,
|
||||
notifications: notifs.filter((notif) => notif.type !== name),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
notifications: [
|
||||
...notifs,
|
||||
name === "email"
|
||||
? { type: name, address: value }
|
||||
: // TODO - phone number
|
||||
{ type: name, phone: value },
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setMonitor((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
if (name.includes("notification-")) {
|
||||
name = name.replace("notification-", "");
|
||||
let hasNotif = monitor.notifications.some(
|
||||
(notification) => notification.type === name
|
||||
);
|
||||
setMonitor((prev) => {
|
||||
const notifs = [...prev.notifications];
|
||||
if (hasNotif) {
|
||||
return {
|
||||
...prev,
|
||||
notifications: notifs.filter((notif) => notif.type !== name),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
notifications: [
|
||||
...notifs,
|
||||
name === "email"
|
||||
? { type: name, address: value }
|
||||
: // TODO - phone number
|
||||
{ type: name, phone: value },
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setMonitor((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
const { error } = monitorValidation.validate(
|
||||
{ [name]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
const { error } = monitorValidation.validate(
|
||||
{ [name]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
if (error) updatedErrors[name] = error.details[0].message;
|
||||
else delete updatedErrors[name];
|
||||
return updatedErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
if (error) updatedErrors[name] = error.details[0].message;
|
||||
else delete updatedErrors[name];
|
||||
return updatedErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMonitor = async (event) => {
|
||||
event.preventDefault();
|
||||
//obj to submit
|
||||
let form = {
|
||||
url: `http${https ? "s" : ""}://` + monitor.url,
|
||||
name: monitor.name === "" ? monitor.url : monitor.name,
|
||||
type: monitor.type,
|
||||
interval: monitor.interval * MS_PER_MINUTE,
|
||||
};
|
||||
const handleCreateMonitor = async (event) => {
|
||||
event.preventDefault();
|
||||
//obj to submit
|
||||
let form = {
|
||||
url: `http${https ? "s" : ""}://` + monitor.url,
|
||||
name: monitor.name === "" ? monitor.url : monitor.name,
|
||||
type: monitor.type,
|
||||
interval: monitor.interval * MS_PER_MINUTE,
|
||||
};
|
||||
|
||||
const { error } = monitorValidation.validate(form, {
|
||||
abortEarly: false,
|
||||
});
|
||||
const { error } = monitorValidation.validate(form, {
|
||||
abortEarly: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const newErrors = {};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Text,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Text,
|
||||
} from "recharts";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -15,26 +15,26 @@ import { formatDateWithTz } from "../../../../Utils/timeUtils";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const config = {
|
||||
seo: {
|
||||
id: "seo",
|
||||
text: "SEO",
|
||||
color: "unresolved",
|
||||
},
|
||||
performance: {
|
||||
id: "performance",
|
||||
text: "performance",
|
||||
color: "success",
|
||||
},
|
||||
bestPractices: {
|
||||
id: "bestPractices",
|
||||
text: "best practices",
|
||||
color: "warning",
|
||||
},
|
||||
accessibility: {
|
||||
id: "accessibility",
|
||||
text: "accessibility",
|
||||
color: "primary",
|
||||
},
|
||||
seo: {
|
||||
id: "seo",
|
||||
text: "SEO",
|
||||
color: "unresolved",
|
||||
},
|
||||
performance: {
|
||||
id: "performance",
|
||||
text: "performance",
|
||||
color: "success",
|
||||
},
|
||||
bestPractices: {
|
||||
id: "bestPractices",
|
||||
text: "best practices",
|
||||
color: "warning",
|
||||
},
|
||||
accessibility: {
|
||||
id: "accessibility",
|
||||
text: "accessibility",
|
||||
color: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -47,81 +47,79 @@ const config = {
|
||||
*/
|
||||
|
||||
const CustomToolTip = ({ active, payload, label, config }) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.dark,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
py: theme.spacing(2),
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
{Object.keys(config)
|
||||
.reverse()
|
||||
.map((key) => {
|
||||
const { color } = config[key];
|
||||
const dotColor = theme.palette[color].main;
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.dark,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
py: theme.spacing(2),
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
{Object.keys(config)
|
||||
.reverse()
|
||||
.map((key) => {
|
||||
const { color } = config[key];
|
||||
const dotColor = theme.palette[color].main;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
key={`${key}-tooltip`}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(3)}
|
||||
mt={theme.spacing(1)}
|
||||
sx={{
|
||||
"& span": {
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
backgroundColor={dotColor}
|
||||
sx={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
textTransform="capitalize"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{config[key].text}
|
||||
</Typography>{" "}
|
||||
<Typography component="span">
|
||||
{payload[0].payload[key]}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return (
|
||||
<Stack
|
||||
key={`${key}-tooltip`}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(3)}
|
||||
mt={theme.spacing(1)}
|
||||
sx={{
|
||||
"& span": {
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
backgroundColor={dotColor}
|
||||
sx={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
textTransform="capitalize"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{config[key].text}
|
||||
</Typography>{" "}
|
||||
<Typography component="span">{payload[0].payload[key]}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
CustomToolTip.propTypes = {
|
||||
active: PropTypes.bool,
|
||||
payload: PropTypes.array,
|
||||
label: PropTypes.string,
|
||||
config: PropTypes.object,
|
||||
active: PropTypes.bool,
|
||||
payload: PropTypes.array,
|
||||
label: PropTypes.string,
|
||||
config: PropTypes.object,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -131,38 +129,38 @@ CustomToolTip.propTypes = {
|
||||
* @returns {Array} The formatted data with gaps.
|
||||
*/
|
||||
const processDataWithGaps = (data, interval) => {
|
||||
if (data.length === 0) return [];
|
||||
let formattedData = [];
|
||||
let last = new Date(data[0].createdAt).getTime();
|
||||
if (data.length === 0) return [];
|
||||
let formattedData = [];
|
||||
let last = new Date(data[0].createdAt).getTime();
|
||||
|
||||
// Helper function to add a null entry
|
||||
const addNullEntry = (timestamp) => {
|
||||
formattedData.push({
|
||||
accessibility: "N/A",
|
||||
bestPractices: "N/A",
|
||||
performance: "N/A",
|
||||
seo: "N/A",
|
||||
createdAt: timestamp,
|
||||
});
|
||||
};
|
||||
// Helper function to add a null entry
|
||||
const addNullEntry = (timestamp) => {
|
||||
formattedData.push({
|
||||
accessibility: "N/A",
|
||||
bestPractices: "N/A",
|
||||
performance: "N/A",
|
||||
seo: "N/A",
|
||||
createdAt: timestamp,
|
||||
});
|
||||
};
|
||||
|
||||
data.forEach((entry) => {
|
||||
const current = new Date(entry.createdAt).getTime();
|
||||
data.forEach((entry) => {
|
||||
const current = new Date(entry.createdAt).getTime();
|
||||
|
||||
if (current - last > interval * 2) {
|
||||
// Insert null entries for each interval
|
||||
let temp = last + interval;
|
||||
while (temp < current) {
|
||||
addNullEntry(new Date(temp).toISOString());
|
||||
temp += interval;
|
||||
}
|
||||
}
|
||||
if (current - last > interval * 2) {
|
||||
// Insert null entries for each interval
|
||||
let temp = last + interval;
|
||||
while (temp < current) {
|
||||
addNullEntry(new Date(temp).toISOString());
|
||||
temp += interval;
|
||||
}
|
||||
}
|
||||
|
||||
formattedData.push(entry);
|
||||
last = current;
|
||||
});
|
||||
formattedData.push(entry);
|
||||
last = current;
|
||||
});
|
||||
|
||||
return formattedData;
|
||||
return formattedData;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -177,32 +175,32 @@ const processDataWithGaps = (data, interval) => {
|
||||
* @returns {JSX.Element|null} The tick element or null if the tick should be hidden.
|
||||
*/
|
||||
const CustomTick = ({ x, y, payload, index }) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
// Render nothing for the first tick
|
||||
if (index === 0) return null;
|
||||
// Render nothing for the first tick
|
||||
if (index === 0) return null;
|
||||
|
||||
return (
|
||||
<Text
|
||||
x={x}
|
||||
y={y + 8}
|
||||
textAnchor="middle"
|
||||
fill={theme.palette.text.tertiary}
|
||||
fontSize={11}
|
||||
fontWeight={400}
|
||||
>
|
||||
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
|
||||
</Text>
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
x={x}
|
||||
y={y + 8}
|
||||
textAnchor="middle"
|
||||
fill={theme.palette.text.tertiary}
|
||||
fontSize={11}
|
||||
fontWeight={400}
|
||||
>
|
||||
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
CustomTick.propTypes = {
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number,
|
||||
payload: PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
}),
|
||||
index: PropTypes.number,
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number,
|
||||
payload: PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
}),
|
||||
index: PropTypes.number,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -214,102 +212,115 @@ CustomTick.propTypes = {
|
||||
*/
|
||||
|
||||
const PagespeedDetailsAreaChart = ({ data, interval, metrics }) => {
|
||||
const theme = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const memoizedData = useMemo(
|
||||
() => processDataWithGaps(data, interval),
|
||||
[data[0]]
|
||||
);
|
||||
const theme = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const memoizedData = useMemo(() => processDataWithGaps(data, interval), [data[0]]);
|
||||
|
||||
const filteredConfig = Object.keys(config).reduce((result, key) => {
|
||||
if (metrics[key]) {
|
||||
result[key] = config[key];
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
const filteredConfig = Object.keys(config).reduce((result, key) => {
|
||||
if (metrics[key]) {
|
||||
result[key] = config[key];
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" minWidth={25} height={215}>
|
||||
<AreaChart
|
||||
width="100%"
|
||||
height="100%"
|
||||
data={memoizedData}
|
||||
margin={{ top: 10 }}
|
||||
onMouseMove={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.border.light}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
stroke={theme.palette.border.dark}
|
||||
dataKey="createdAt"
|
||||
tick={<CustomTick />}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
height={18}
|
||||
minTickGap={0}
|
||||
interval="equidistantPreserveStart"
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: theme.palette.border.light }}
|
||||
content={<CustomToolTip config={filteredConfig} />}
|
||||
/>
|
||||
<defs>
|
||||
{Object.values(filteredConfig).map(({ id, color }) => {
|
||||
const startColor = theme.palette[color].main;
|
||||
const endColor = theme.palette[color].light;
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
minWidth={25}
|
||||
height={215}
|
||||
>
|
||||
<AreaChart
|
||||
width="100%"
|
||||
height="100%"
|
||||
data={memoizedData}
|
||||
margin={{ top: 10 }}
|
||||
onMouseMove={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.border.light}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
stroke={theme.palette.border.dark}
|
||||
dataKey="createdAt"
|
||||
tick={<CustomTick />}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
height={18}
|
||||
minTickGap={0}
|
||||
interval="equidistantPreserveStart"
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: theme.palette.border.light }}
|
||||
content={<CustomToolTip config={filteredConfig} />}
|
||||
/>
|
||||
<defs>
|
||||
{Object.values(filteredConfig).map(({ id, color }) => {
|
||||
const startColor = theme.palette[color].main;
|
||||
const endColor = theme.palette[color].light;
|
||||
|
||||
return (
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1" key={id}>
|
||||
<stop offset="0%" stopColor={startColor} stopOpacity={0.8} />
|
||||
<stop offset="100%" stopColor={endColor} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
{Object.keys(filteredConfig).map((key) => {
|
||||
const { color } = filteredConfig[key];
|
||||
const strokeColor = theme.palette[color].main;
|
||||
const bgColor = theme.palette.background.main;
|
||||
return (
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
key={id}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={startColor}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={endColor}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
{Object.keys(filteredConfig).map((key) => {
|
||||
const { color } = filteredConfig[key];
|
||||
const strokeColor = theme.palette[color].main;
|
||||
const bgColor = theme.palette.background.main;
|
||||
|
||||
return (
|
||||
<Area
|
||||
connectNulls
|
||||
key={key}
|
||||
dataKey={key}
|
||||
stackId={1}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isHovered ? 2.5 : 1.5}
|
||||
fill={`url(#${filteredConfig[key].id})`}
|
||||
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
return (
|
||||
<Area
|
||||
connectNulls
|
||||
key={key}
|
||||
dataKey={key}
|
||||
stackId={1}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isHovered ? 2.5 : 1.5}
|
||||
fill={`url(#${filteredConfig[key].id})`}
|
||||
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
PagespeedDetailsAreaChart.propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
accessibility: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
.isRequired,
|
||||
bestPractices: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
.isRequired,
|
||||
performance: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
.isRequired,
|
||||
seo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
interval: PropTypes.number.isRequired,
|
||||
metrics: PropTypes.object,
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
createdAt: PropTypes.string.isRequired,
|
||||
accessibility: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
bestPractices: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
performance: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
seo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
interval: PropTypes.number.isRequired,
|
||||
metrics: PropTypes.object,
|
||||
};
|
||||
|
||||
export default PagespeedDetailsAreaChart;
|
||||
@@ -14,33 +14,38 @@ import { Box } from "@mui/material";
|
||||
* @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 / 4} fill="transparent" />
|
||||
<text
|
||||
className="pie-label"
|
||||
style={{
|
||||
fill: color,
|
||||
fontSize: 48,
|
||||
textAnchor: "middle",
|
||||
dominantBaseline: "central",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
const { width, height } = useDrawingArea();
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${width / 2}, ${height / 2})`}
|
||||
onMouseEnter={() => setExpand(true)}
|
||||
>
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={width / 4}
|
||||
fill="transparent"
|
||||
/>
|
||||
<text
|
||||
className="pie-label"
|
||||
style={{
|
||||
fill: color,
|
||||
fontSize: 48,
|
||||
textAnchor: "middle",
|
||||
dominantBaseline: "central",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
PieCenterLabel.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
color: PropTypes.string,
|
||||
setExpand: PropTypes.func,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
color: PropTypes.string,
|
||||
setExpand: PropTypes.func,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,257 +61,257 @@ PieCenterLabel.propTypes = {
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => {
|
||||
const { width, height } = useDrawingArea();
|
||||
const { width, height } = useDrawingArea();
|
||||
|
||||
// Compute the midpoint angle in radians
|
||||
const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180;
|
||||
const radius = height / 4; // length from center of the circle to where the text is positioned
|
||||
// Compute the midpoint angle in radians
|
||||
const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180;
|
||||
const radius = height / 4; // 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;
|
||||
// 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: 12,
|
||||
textAnchor: "middle",
|
||||
dominantBaseline: "central",
|
||||
userSelect: "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
+{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
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: 12,
|
||||
textAnchor: "middle",
|
||||
dominantBaseline: "central",
|
||||
userSelect: "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
+{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// Validate props using PropTypes
|
||||
PieValueLabel.propTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
startAngle: PropTypes.number.isRequired,
|
||||
endAngle: PropTypes.number.isRequired,
|
||||
color: PropTypes.string.isRequired,
|
||||
highlighted: PropTypes.bool.isRequired,
|
||||
value: PropTypes.number.isRequired,
|
||||
startAngle: PropTypes.number.isRequired,
|
||||
endAngle: PropTypes.number.isRequired,
|
||||
color: PropTypes.string.isRequired,
|
||||
highlighted: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const PieChart = ({ audits }) => {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
/**
|
||||
* Weight constants for different performance metrics.
|
||||
* @type {Object}
|
||||
*/
|
||||
const weights = {
|
||||
fcp: 10,
|
||||
si: 10,
|
||||
lcp: 25,
|
||||
tbt: 30,
|
||||
cls: 25,
|
||||
};
|
||||
/**
|
||||
* 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, strokeBg: string, text: string, bg: string}} The color properties for the given performance value.
|
||||
*/
|
||||
const getColors = (value) => {
|
||||
if (value >= 90 && value <= 100)
|
||||
return {
|
||||
stroke: theme.palette.success.main,
|
||||
strokeBg: theme.palette.success.light,
|
||||
text: theme.palette.success.text,
|
||||
bg: theme.palette.success.bg,
|
||||
};
|
||||
else if (value >= 50 && value < 90)
|
||||
return {
|
||||
stroke: theme.palette.warning.main,
|
||||
strokeBg: theme.palette.warning.light,
|
||||
text: theme.palette.warning.text,
|
||||
bg: theme.palette.warning.bg,
|
||||
};
|
||||
else if (value >= 0 && value < 50)
|
||||
return {
|
||||
stroke: theme.palette.error.text,
|
||||
strokeBg: theme.palette.error.light,
|
||||
text: theme.palette.error.text,
|
||||
bg: theme.palette.error.bg,
|
||||
};
|
||||
return {
|
||||
stroke: theme.palette.unresolved.main,
|
||||
strokeBg: theme.palette.unresolved.light,
|
||||
text: theme.palette.unresolved.main,
|
||||
bg: theme.palette.unresolved.bg,
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Retrieves color properties based on the performance value.
|
||||
*
|
||||
* @param {number} value - The performance score used to determine the color properties.
|
||||
* @returns {{stroke: string, strokeBg: string, text: string, bg: string}} The color properties for the given performance value.
|
||||
*/
|
||||
const getColors = (value) => {
|
||||
if (value >= 90 && value <= 100)
|
||||
return {
|
||||
stroke: theme.palette.success.main,
|
||||
strokeBg: theme.palette.success.light,
|
||||
text: theme.palette.success.text,
|
||||
bg: theme.palette.success.bg,
|
||||
};
|
||||
else if (value >= 50 && value < 90)
|
||||
return {
|
||||
stroke: theme.palette.warning.main,
|
||||
strokeBg: theme.palette.warning.light,
|
||||
text: theme.palette.warning.text,
|
||||
bg: theme.palette.warning.bg,
|
||||
};
|
||||
else if (value >= 0 && value < 50)
|
||||
return {
|
||||
stroke: theme.palette.error.text,
|
||||
strokeBg: theme.palette.error.light,
|
||||
text: theme.palette.error.text,
|
||||
bg: theme.palette.error.bg,
|
||||
};
|
||||
return {
|
||||
stroke: theme.palette.unresolved.main,
|
||||
strokeBg: theme.palette.unresolved.light,
|
||||
text: theme.palette.unresolved.main,
|
||||
bg: theme.palette.unresolved.bg,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 data = [];
|
||||
let startAngle = 0;
|
||||
const padding = 3; // padding between arcs
|
||||
const max = 360 - padding * (Object.keys(audits).length - 1); // _id is a child of audits
|
||||
/**
|
||||
* 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 data = [];
|
||||
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;
|
||||
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);
|
||||
data.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: 73,
|
||||
outerRadius: 80,
|
||||
additionalRadius: -20,
|
||||
arcLabelRadius: 5,
|
||||
},
|
||||
cx: pieSize.width / 2,
|
||||
});
|
||||
let theme = getColors(audits[key].score * 100);
|
||||
data.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: 73,
|
||||
outerRadius: 80,
|
||||
additionalRadius: -20,
|
||||
arcLabelRadius: 5,
|
||||
},
|
||||
cx: pieSize.width / 2,
|
||||
});
|
||||
|
||||
performance += Math.floor(value);
|
||||
startAngle = endAngle + padding;
|
||||
}
|
||||
});
|
||||
performance += Math.floor(value);
|
||||
startAngle = endAngle + padding;
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
const pieSize = { width: 230, height: 230 };
|
||||
const pieData = getPieData(audits);
|
||||
const colorMap = getColors(performance);
|
||||
const pieSize = { width: 230, height: 230 };
|
||||
const pieData = getPieData(audits);
|
||||
const colorMap = getColors(performance);
|
||||
|
||||
const [highlightedItem, setHighLightedItem] = useState(null);
|
||||
const [expand, setExpand] = useState(false);
|
||||
return (
|
||||
<Box onMouseLeave={() => setExpand(false)}>
|
||||
{expand ? (
|
||||
<MuiPieChart
|
||||
series={[
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
color: colorMap.bg,
|
||||
},
|
||||
],
|
||||
outerRadius: 77,
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</MuiPieChart>
|
||||
) : (
|
||||
<MuiPieChart
|
||||
series={[
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
color: colorMap.bg,
|
||||
},
|
||||
],
|
||||
outerRadius: 77,
|
||||
cx: pieSize.width / 2,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: performance,
|
||||
color: colorMap.stroke,
|
||||
},
|
||||
],
|
||||
innerRadius: 73,
|
||||
outerRadius: 80,
|
||||
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}
|
||||
/>
|
||||
</MuiPieChart>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
const [highlightedItem, setHighLightedItem] = useState(null);
|
||||
const [expand, setExpand] = useState(false);
|
||||
return (
|
||||
<Box onMouseLeave={() => setExpand(false)}>
|
||||
{expand ? (
|
||||
<MuiPieChart
|
||||
series={[
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
color: colorMap.bg,
|
||||
},
|
||||
],
|
||||
outerRadius: 77,
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</MuiPieChart>
|
||||
) : (
|
||||
<MuiPieChart
|
||||
series={[
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
color: colorMap.bg,
|
||||
},
|
||||
],
|
||||
outerRadius: 77,
|
||||
cx: pieSize.width / 2,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: performance,
|
||||
color: colorMap.stroke,
|
||||
},
|
||||
],
|
||||
innerRadius: 73,
|
||||
outerRadius: 80,
|
||||
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}
|
||||
/>
|
||||
</MuiPieChart>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
PieChart.propTypes = {
|
||||
audits: PropTypes.object,
|
||||
audits: PropTypes.object,
|
||||
};
|
||||
|
||||
export default PieChart;
|
||||
@@ -1,18 +1,18 @@
|
||||
.page-speed-details button.MuiButtonBase-root {
|
||||
height: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.page-speed-details .MuiPieArcLabel-root {
|
||||
font-size: var(--env-var-font-size-small-plus);
|
||||
transition: fill 300ms ease;
|
||||
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);
|
||||
fill: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.page-speed-details .pie-label,
|
||||
.page-speed-details .pie-value-label {
|
||||
transition: all 400ms ease;
|
||||
transition: all 400ms ease;
|
||||
}
|
||||
.page-speed-details .MuiPieArc-root {
|
||||
stroke: inherit;
|
||||
stroke: inherit;
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Box, Button, Divider, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
formatDurationRounded,
|
||||
formatDurationSplit,
|
||||
} from "../../../Utils/timeUtils";
|
||||
import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils";
|
||||
import { ChartBox, IconBox, StatBox } from "./styled";
|
||||
import { logger } from "../../../Utils/Logger";
|
||||
import { networkService } from "../../../main";
|
||||
@@ -33,370 +23,416 @@ import useUtils from "../../Monitors/utils";
|
||||
import "./index.css";
|
||||
|
||||
const PageSpeedDetails = ({ isAdmin }) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
|
||||
const [monitor, setMonitor] = useState({});
|
||||
const [audits, setAudits] = useState({});
|
||||
const { monitorId } = useParams();
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
|
||||
const [monitor, setMonitor] = useState({});
|
||||
const [audits, setAudits] = useState({});
|
||||
const { monitorId } = useParams();
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitor = async () => {
|
||||
try {
|
||||
const res = await networkService.getStatsByMonitorId({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
sortOrder: "desc",
|
||||
limit: 50,
|
||||
dateRange: "day",
|
||||
numToDisplay: null,
|
||||
normalize: null,
|
||||
});
|
||||
setMonitor(res?.data?.data ?? {});
|
||||
setAudits(res?.data?.data?.checks?.[0]?.audits ?? {});
|
||||
} catch (error) {
|
||||
logger.error(logger);
|
||||
navigate("/not-found", { replace: true });
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchMonitor = async () => {
|
||||
try {
|
||||
const res = await networkService.getStatsByMonitorId({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
sortOrder: "desc",
|
||||
limit: 50,
|
||||
dateRange: "day",
|
||||
numToDisplay: null,
|
||||
normalize: null,
|
||||
});
|
||||
setMonitor(res?.data?.data ?? {});
|
||||
setAudits(res?.data?.data?.checks?.[0]?.audits ?? {});
|
||||
} catch (error) {
|
||||
logger.error(logger);
|
||||
navigate("/not-found", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
fetchMonitor();
|
||||
}, [monitorId, authToken, navigate]);
|
||||
fetchMonitor();
|
||||
}, [monitorId, authToken, navigate]);
|
||||
|
||||
let loading = Object.keys(monitor).length === 0;
|
||||
const data = monitor?.checks ? [...monitor.checks].reverse() : [];
|
||||
let loading = Object.keys(monitor).length === 0;
|
||||
const data = monitor?.checks ? [...monitor.checks].reverse() : [];
|
||||
|
||||
const splitDuration = (duration) => {
|
||||
const { time, format } = formatDurationSplit(duration);
|
||||
return (
|
||||
<>
|
||||
{time}
|
||||
<Typography component="span">{format}</Typography>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const splitDuration = (duration) => {
|
||||
const { time, format } = formatDurationSplit(duration);
|
||||
return (
|
||||
<>
|
||||
{time}
|
||||
<Typography component="span">{format}</Typography>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const [metrics, setMetrics] = useState({
|
||||
accessibility: true,
|
||||
bestPractices: true,
|
||||
performance: true,
|
||||
seo: true,
|
||||
});
|
||||
const handleMetrics = (id) => {
|
||||
setMetrics((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
const [metrics, setMetrics] = useState({
|
||||
accessibility: true,
|
||||
bestPractices: true,
|
||||
performance: true,
|
||||
seo: true,
|
||||
});
|
||||
const handleMetrics = (id) => {
|
||||
setMetrics((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="page-speed-details" gap={theme.spacing(10)}>
|
||||
{loading ? (
|
||||
<SkeletonLayout />
|
||||
) : (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
list={[
|
||||
{ name: "pagespeed", path: "/pagespeed" },
|
||||
{ name: "details", path: `/pagespeed/${monitorId}` },
|
||||
]}
|
||||
/>
|
||||
<Stack direction="row" gap={theme.spacing(2)}>
|
||||
<Box>
|
||||
<Typography component="h1" variant="h1">
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
height="fit-content"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Tooltip
|
||||
title={pagespeedStatusMsg[determineState(monitor)]}
|
||||
disableInteractive
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -8],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<PulseDot color={statusColor[determineState(monitor)]} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Typography component="h2" variant="h2">
|
||||
{monitor?.url}
|
||||
</Typography>
|
||||
<Typography
|
||||
position="relative"
|
||||
variant="body2"
|
||||
mt={theme.spacing(1)}
|
||||
ml={theme.spacing(6)}
|
||||
sx={{
|
||||
"&:before": {
|
||||
position: "absolute",
|
||||
content: `""`,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.text.tertiary,
|
||||
opacity: 0.8,
|
||||
left: -9,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Checking every {formatDurationRounded(monitor?.interval)}.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/pagespeed/configure/${monitorId}`)}
|
||||
sx={{
|
||||
ml: "auto",
|
||||
alignSelf: "flex-end",
|
||||
px: theme.spacing(5),
|
||||
"& svg": {
|
||||
mr: theme.spacing(3),
|
||||
"& path": {
|
||||
stroke: theme.palette.other.icon,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsIcon />
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" gap={theme.spacing(8)}>
|
||||
<StatBox>
|
||||
<Typography component="h2">checks since</Typography>
|
||||
<Typography>
|
||||
{splitDuration(monitor?.uptimeDuration)}
|
||||
<Typography component="span">ago</Typography>
|
||||
</Typography>
|
||||
</StatBox>
|
||||
<StatBox>
|
||||
<Typography component="h2">last check</Typography>
|
||||
<Typography>
|
||||
{splitDuration(monitor?.lastChecked)}
|
||||
<Typography component="span">ago</Typography>
|
||||
</Typography>
|
||||
</StatBox>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Typography variant="body2" my={theme.spacing(8)}>
|
||||
Showing statistics for past 24 hours.
|
||||
</Typography>
|
||||
<ChartBox sx={{ gridTemplateColumns: "75% 25%" }}>
|
||||
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
|
||||
<IconBox>
|
||||
<ScoreIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Score history</Typography>
|
||||
</Stack>
|
||||
<Box>
|
||||
<PagespeedDetailsAreaChart
|
||||
data={data}
|
||||
interval={monitor?.interval}
|
||||
metrics={metrics}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<IconBox>
|
||||
<MetricsIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Metrics</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(16)}
|
||||
sx={{
|
||||
"& label": { pl: theme.spacing(6) },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography fontSize={11} fontWeight={500}>
|
||||
Shown
|
||||
</Typography>
|
||||
<Divider sx={{ mt: theme.spacing(2) }} />
|
||||
</Box>
|
||||
<Checkbox
|
||||
id="accessibility-toggle"
|
||||
label="Accessibility"
|
||||
isChecked={metrics.accessibility}
|
||||
onChange={() => handleMetrics("accessibility")}
|
||||
/>
|
||||
<Divider />
|
||||
<Checkbox
|
||||
id="best-practices-toggle"
|
||||
label="Best Practices"
|
||||
isChecked={metrics.bestPractices}
|
||||
onChange={() => handleMetrics("bestPractices")}
|
||||
/>
|
||||
<Divider />
|
||||
<Checkbox
|
||||
id="performance-toggle"
|
||||
label="Performance"
|
||||
isChecked={metrics.performance}
|
||||
onChange={() => handleMetrics("performance")}
|
||||
/>
|
||||
<Divider />
|
||||
<Checkbox
|
||||
id="seo-toggle"
|
||||
label="Search Engine Optimization"
|
||||
isChecked={metrics.seo}
|
||||
onChange={() => handleMetrics("seo")}
|
||||
/>
|
||||
<Divider />
|
||||
</Stack>
|
||||
</Box>
|
||||
</ChartBox>
|
||||
</Box>
|
||||
<ChartBox
|
||||
flex={1}
|
||||
sx={{ gridTemplateColumns: "50% 50%", gridTemplateRows: "15% 85%" }}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
|
||||
<IconBox>
|
||||
<PerformanceIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Performance report</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
minWidth="300px"
|
||||
flex={1}
|
||||
pt={theme.spacing(6)}
|
||||
pb={theme.spacing(12)}
|
||||
>
|
||||
<PieChart audits={audits} />
|
||||
<Typography variant="body1" mt="auto">
|
||||
Values are estimated and may vary.{" "}
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 500,
|
||||
textDecoration: "underline",
|
||||
textUnderlineOffset: 2,
|
||||
transition: "all 200ms",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
textUnderlineOffset: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
See calculator
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box px={theme.spacing(20)} py={theme.spacing(8)} height="100%">
|
||||
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
|
||||
<IconBox>
|
||||
<SpedometerIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Performance Metrics</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
flexWrap="wrap"
|
||||
mt={theme.spacing(4)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
{Object.keys(audits).map((key) => {
|
||||
if (key === "_id") return;
|
||||
return (
|
||||
<Stack
|
||||
className="page-speed-details"
|
||||
gap={theme.spacing(10)}
|
||||
>
|
||||
{loading ? (
|
||||
<SkeletonLayout />
|
||||
) : (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
list={[
|
||||
{ name: "pagespeed", path: "/pagespeed" },
|
||||
{ name: "details", path: `/pagespeed/${monitorId}` },
|
||||
]}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
height="fit-content"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Tooltip
|
||||
title={pagespeedStatusMsg[determineState(monitor)]}
|
||||
disableInteractive
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -8],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<PulseDot color={statusColor[determineState(monitor)]} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="h2"
|
||||
>
|
||||
{monitor?.url}
|
||||
</Typography>
|
||||
<Typography
|
||||
position="relative"
|
||||
variant="body2"
|
||||
mt={theme.spacing(1)}
|
||||
ml={theme.spacing(6)}
|
||||
sx={{
|
||||
"&:before": {
|
||||
position: "absolute",
|
||||
content: `""`,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.text.tertiary,
|
||||
opacity: 0.8,
|
||||
left: -9,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Checking every {formatDurationRounded(monitor?.interval)}.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/pagespeed/configure/${monitorId}`)}
|
||||
sx={{
|
||||
ml: "auto",
|
||||
alignSelf: "flex-end",
|
||||
px: theme.spacing(5),
|
||||
"& svg": {
|
||||
mr: theme.spacing(3),
|
||||
"& path": {
|
||||
stroke: theme.palette.other.icon,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsIcon />
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<StatBox>
|
||||
<Typography component="h2">checks since</Typography>
|
||||
<Typography>
|
||||
{splitDuration(monitor?.uptimeDuration)}
|
||||
<Typography component="span">ago</Typography>
|
||||
</Typography>
|
||||
</StatBox>
|
||||
<StatBox>
|
||||
<Typography component="h2">last check</Typography>
|
||||
<Typography>
|
||||
{splitDuration(monitor?.lastChecked)}
|
||||
<Typography component="span">ago</Typography>
|
||||
</Typography>
|
||||
</StatBox>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
my={theme.spacing(8)}
|
||||
>
|
||||
Showing statistics for past 24 hours.
|
||||
</Typography>
|
||||
<ChartBox sx={{ gridTemplateColumns: "75% 25%" }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<IconBox>
|
||||
<ScoreIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Score history</Typography>
|
||||
</Stack>
|
||||
<Box>
|
||||
<PagespeedDetailsAreaChart
|
||||
data={data}
|
||||
interval={monitor?.interval}
|
||||
metrics={metrics}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<IconBox>
|
||||
<MetricsIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Metrics</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(16)}
|
||||
sx={{
|
||||
"& label": { pl: theme.spacing(6) },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
>
|
||||
Shown
|
||||
</Typography>
|
||||
<Divider sx={{ mt: theme.spacing(2) }} />
|
||||
</Box>
|
||||
<Checkbox
|
||||
id="accessibility-toggle"
|
||||
label="Accessibility"
|
||||
isChecked={metrics.accessibility}
|
||||
onChange={() => handleMetrics("accessibility")}
|
||||
/>
|
||||
<Divider />
|
||||
<Checkbox
|
||||
id="best-practices-toggle"
|
||||
label="Best Practices"
|
||||
isChecked={metrics.bestPractices}
|
||||
onChange={() => handleMetrics("bestPractices")}
|
||||
/>
|
||||
<Divider />
|
||||
<Checkbox
|
||||
id="performance-toggle"
|
||||
label="Performance"
|
||||
isChecked={metrics.performance}
|
||||
onChange={() => handleMetrics("performance")}
|
||||
/>
|
||||
<Divider />
|
||||
<Checkbox
|
||||
id="seo-toggle"
|
||||
label="Search Engine Optimization"
|
||||
isChecked={metrics.seo}
|
||||
onChange={() => handleMetrics("seo")}
|
||||
/>
|
||||
<Divider />
|
||||
</Stack>
|
||||
</Box>
|
||||
</ChartBox>
|
||||
</Box>
|
||||
<ChartBox
|
||||
flex={1}
|
||||
sx={{ gridTemplateColumns: "50% 50%", gridTemplateRows: "15% 85%" }}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<IconBox>
|
||||
<PerformanceIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Performance report</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
minWidth="300px"
|
||||
flex={1}
|
||||
pt={theme.spacing(6)}
|
||||
pb={theme.spacing(12)}
|
||||
>
|
||||
<PieChart audits={audits} />
|
||||
<Typography
|
||||
variant="body1"
|
||||
mt="auto"
|
||||
>
|
||||
Values are estimated and may vary.{" "}
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 500,
|
||||
textDecoration: "underline",
|
||||
textUnderlineOffset: 2,
|
||||
transition: "all 200ms",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
textUnderlineOffset: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
See calculator
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box
|
||||
px={theme.spacing(20)}
|
||||
py={theme.spacing(8)}
|
||||
height="100%"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<IconBox>
|
||||
<SpedometerIcon />
|
||||
</IconBox>
|
||||
<Typography component="h2">Performance Metrics</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
flexWrap="wrap"
|
||||
mt={theme.spacing(4)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
{Object.keys(audits).map((key) => {
|
||||
if (key === "_id") return;
|
||||
|
||||
let audit = audits[key];
|
||||
let score = audit.score * 100;
|
||||
let bg =
|
||||
score >= 90
|
||||
? theme.palette.success.main
|
||||
: score >= 50
|
||||
? theme.palette.warning.main
|
||||
: score >= 0
|
||||
? theme.palette.error.text
|
||||
: theme.palette.unresolved.main;
|
||||
let audit = audits[key];
|
||||
let score = audit.score * 100;
|
||||
let bg =
|
||||
score >= 90
|
||||
? theme.palette.success.main
|
||||
: score >= 50
|
||||
? theme.palette.warning.main
|
||||
: score >= 0
|
||||
? theme.palette.error.text
|
||||
: theme.palette.unresolved.main;
|
||||
|
||||
// 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];
|
||||
match[2] === "s" ? (unit = "seconds") : (unit = match[2]);
|
||||
} else {
|
||||
value = audit.displayValue;
|
||||
}
|
||||
// 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];
|
||||
match[2] === "s" ? (unit = "seconds") : (unit = match[2]);
|
||||
} else {
|
||||
value = audit.displayValue;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
key={`${key}-box`}
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
p={theme.spacing(3)}
|
||||
border={1}
|
||||
borderColor={theme.palette.border.light}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
fontSize={12}
|
||||
fontWeight={500}
|
||||
lineHeight={1}
|
||||
mb={1}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{audit.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize={14}
|
||||
fontWeight={500}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{value}
|
||||
<Typography component="span" variant="body2" ml={2}>
|
||||
{unit}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box width={4} backgroundColor={bg} borderRadius={4} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</ChartBox>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack
|
||||
key={`${key}-box`}
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
p={theme.spacing(3)}
|
||||
border={1}
|
||||
borderColor={theme.palette.border.light}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
fontSize={12}
|
||||
fontWeight={500}
|
||||
lineHeight={1}
|
||||
mb={1}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{audit.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize={14}
|
||||
fontWeight={500}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{value}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
ml={2}
|
||||
>
|
||||
{unit}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
width={4}
|
||||
backgroundColor={bg}
|
||||
borderRadius={4}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</ChartBox>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
PageSpeedDetails.propTypes = {
|
||||
isAdmin: PropTypes.bool,
|
||||
push: PropTypes.func,
|
||||
isAdmin: PropTypes.bool,
|
||||
push: PropTypes.func,
|
||||
};
|
||||
|
||||
export default PageSpeedDetails;
|
||||
@@ -6,38 +6,88 @@ import { Box, Skeleton, Stack } from "@mui/material";
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const SkeletonLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<Skeleton variant="rounded" width="15%" height={34} />
|
||||
<Stack direction="row" gap={4}>
|
||||
<Skeleton variant="circular" style={{ minWidth: 24, minHeight: 24 }} />
|
||||
<Box width="85%">
|
||||
<Skeleton variant="rounded" width="50%" height={24} />
|
||||
<Skeleton variant="rounded" width="50%" height={18} sx={{ mt: 4 }} />
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="15%"
|
||||
height={34}
|
||||
sx={{ alignSelf: "flex-end" }}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
gap={20}
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="15%"
|
||||
height={34}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={4}
|
||||
>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
style={{ minWidth: 24, minHeight: 24 }}
|
||||
/>
|
||||
<Box width="85%">
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="50%"
|
||||
height={24}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="50%"
|
||||
height={18}
|
||||
sx={{ mt: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="15%"
|
||||
height={34}
|
||||
sx={{ alignSelf: "flex-end" }}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
gap={20}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
@@ -1,93 +1,93 @@
|
||||
import { Box, Stack, styled } from "@mui/material";
|
||||
|
||||
export const ChartBox = styled(Stack)(({ theme }) => ({
|
||||
display: "grid",
|
||||
height: 300,
|
||||
minWidth: 250,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: 4,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
"& h2": {
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
},
|
||||
"& p": { color: theme.palette.text.tertiary },
|
||||
"& > :nth-of-type(1)": {
|
||||
gridColumn: 1,
|
||||
gridRow: 1,
|
||||
height: "fit-content",
|
||||
paddingTop: theme.spacing(8),
|
||||
paddingLeft: theme.spacing(8),
|
||||
},
|
||||
"& > :nth-of-type(2)": { gridColumn: 1, gridRow: 2 },
|
||||
"& > :nth-of-type(3)": {
|
||||
gridColumn: 2,
|
||||
gridRow: "span 2",
|
||||
padding: theme.spacing(8),
|
||||
borderLeft: 1,
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: theme.palette.border.light,
|
||||
borderRadius: 16,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
background: `linear-gradient(325deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
|
||||
},
|
||||
"& path": {
|
||||
transition: "stroke-width 400ms ease",
|
||||
},
|
||||
display: "grid",
|
||||
height: 300,
|
||||
minWidth: 250,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: 4,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
"& h2": {
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
},
|
||||
"& p": { color: theme.palette.text.tertiary },
|
||||
"& > :nth-of-type(1)": {
|
||||
gridColumn: 1,
|
||||
gridRow: 1,
|
||||
height: "fit-content",
|
||||
paddingTop: theme.spacing(8),
|
||||
paddingLeft: theme.spacing(8),
|
||||
},
|
||||
"& > :nth-of-type(2)": { gridColumn: 1, gridRow: 2 },
|
||||
"& > :nth-of-type(3)": {
|
||||
gridColumn: 2,
|
||||
gridRow: "span 2",
|
||||
padding: theme.spacing(8),
|
||||
borderLeft: 1,
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: theme.palette.border.light,
|
||||
borderRadius: 16,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
background: `linear-gradient(325deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
|
||||
},
|
||||
"& path": {
|
||||
transition: "stroke-width 400ms ease",
|
||||
},
|
||||
}));
|
||||
|
||||
export const IconBox = styled(Box)(({ theme }) => ({
|
||||
height: 34,
|
||||
minWidth: 34,
|
||||
width: 34,
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.border.dark,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.palette.background.accent,
|
||||
"& svg": {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 20,
|
||||
height: 20,
|
||||
"& path": {
|
||||
stroke: theme.palette.text.tertiary,
|
||||
},
|
||||
},
|
||||
height: 34,
|
||||
minWidth: 34,
|
||||
width: 34,
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.border.dark,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.palette.background.accent,
|
||||
"& svg": {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 20,
|
||||
height: 20,
|
||||
"& path": {
|
||||
stroke: theme.palette.text.tertiary,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const StatBox = styled(Box)(({ theme }) => ({
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
minWidth: 200,
|
||||
width: 225,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
background: `linear-gradient(340deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
|
||||
"& h2": {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
"& p": {
|
||||
fontSize: 18,
|
||||
color: theme.palette.text.primary,
|
||||
marginTop: theme.spacing(2),
|
||||
"& span": {
|
||||
color: theme.palette.text.tertiary,
|
||||
marginLeft: theme.spacing(2),
|
||||
fontSize: 15,
|
||||
},
|
||||
},
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
minWidth: 200,
|
||||
width: 225,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
background: `linear-gradient(340deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
|
||||
"& h2": {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
"& p": {
|
||||
fontSize: 18,
|
||||
color: theme.palette.text.primary,
|
||||
marginTop: theme.spacing(2),
|
||||
"& span": {
|
||||
color: theme.palette.text.tertiary,
|
||||
marginLeft: theme.spacing(2),
|
||||
fontSize: 15,
|
||||
},
|
||||
},
|
||||
}));
|
||||
+256
-252
@@ -5,13 +5,7 @@ import { Box, Grid, Stack, Typography } from "@mui/material";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { IconBox } from "./Details/styled";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { useSelector } from "react-redux";
|
||||
import { formatDateWithTz, formatDurationSplit } from "../../Utils/timeUtils";
|
||||
import useUtils from "../Monitors/utils";
|
||||
@@ -24,68 +18,68 @@ import { useState } from "react";
|
||||
* @returns {JSX.Element} The rendered tooltip component
|
||||
*/
|
||||
const CustomToolTip = ({ payload }) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.dark,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
py: theme.spacing(2),
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatDateWithTz(
|
||||
payload[0]?.payload.createdAt,
|
||||
"ddd, MMMM D, YYYY, h:mm A",
|
||||
uiTimezone
|
||||
)}
|
||||
</Typography>
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.dark,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
py: theme.spacing(2),
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatDateWithTz(
|
||||
payload[0]?.payload.createdAt,
|
||||
"ddd, MMMM D, YYYY, h:mm A",
|
||||
uiTimezone
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(3)}
|
||||
mt={theme.spacing(1)}
|
||||
sx={{
|
||||
"& span": {
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
backgroundColor={payload[0]?.color}
|
||||
sx={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
textTransform="capitalize"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{payload[0]?.name}
|
||||
</Typography>{" "}
|
||||
<Typography component="span">{payload[0]?.payload.score}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(3)}
|
||||
mt={theme.spacing(1)}
|
||||
sx={{
|
||||
"& span": {
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
backgroundColor={payload[0]?.color}
|
||||
sx={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
textTransform="capitalize"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{payload[0]?.name}
|
||||
</Typography>{" "}
|
||||
<Typography component="span">{payload[0]?.payload.score}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
CustomToolTip.propTypes = {
|
||||
payload: PropTypes.array,
|
||||
payload: PropTypes.array,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,25 +88,21 @@ CustomToolTip.propTypes = {
|
||||
* @returns {Array<Object>} - The formatted data array with scores.
|
||||
*/
|
||||
const processData = (data) => {
|
||||
if (data.length === 0) return [];
|
||||
let formattedData = [];
|
||||
if (data.length === 0) return [];
|
||||
let formattedData = [];
|
||||
|
||||
const calculateScore = (entry) => {
|
||||
return (
|
||||
(entry.accessibility +
|
||||
entry.bestPractices +
|
||||
entry.performance +
|
||||
entry.seo) /
|
||||
4
|
||||
);
|
||||
};
|
||||
const calculateScore = (entry) => {
|
||||
return (
|
||||
(entry.accessibility + entry.bestPractices + entry.performance + entry.seo) / 4
|
||||
);
|
||||
};
|
||||
|
||||
data.forEach((entry) => {
|
||||
entry = { ...entry, score: calculateScore(entry) };
|
||||
formattedData.push(entry);
|
||||
});
|
||||
data.forEach((entry) => {
|
||||
entry = { ...entry, score: calculateScore(entry) };
|
||||
formattedData.push(entry);
|
||||
});
|
||||
|
||||
return formattedData;
|
||||
return formattedData;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -123,80 +113,84 @@ const processData = (data) => {
|
||||
* @returns {JSX.Element} - The rendered area chart.
|
||||
*/
|
||||
const PagespeedAreaChart = ({ data, status }) => {
|
||||
const theme = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { pagespeedStyles } = useUtils();
|
||||
const theme = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { pagespeedStyles } = useUtils();
|
||||
|
||||
const formattedData = processData(data);
|
||||
const formattedData = processData(data);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" minWidth={25} height={85}>
|
||||
<AreaChart
|
||||
width="100%"
|
||||
height="100%"
|
||||
data={formattedData}
|
||||
margin={{ top: 10, bottom: -5 }}
|
||||
style={{ cursor: "pointer" }}
|
||||
onMouseMove={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.border.light}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: theme.palette.border.light }}
|
||||
content={<CustomToolTip />}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`pagespeed-chart-${status}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={pagespeedStyles[status].stroke}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={pagespeedStyles[status].light}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="score"
|
||||
stroke={pagespeedStyles[status].stroke}
|
||||
strokeWidth={isHovered ? 2.5 : 1.5}
|
||||
fill={`url(#pagespeed-chart-${status})`}
|
||||
activeDot={{
|
||||
stroke: pagespeedStyles[status].light,
|
||||
fill: pagespeedStyles[status].stroke,
|
||||
r: 4.5,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
minWidth={25}
|
||||
height={85}
|
||||
>
|
||||
<AreaChart
|
||||
width="100%"
|
||||
height="100%"
|
||||
data={formattedData}
|
||||
margin={{ top: 10, bottom: -5 }}
|
||||
style={{ cursor: "pointer" }}
|
||||
onMouseMove={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.border.light}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: theme.palette.border.light }}
|
||||
content={<CustomToolTip />}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`pagespeed-chart-${status}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={pagespeedStyles[status].stroke}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={pagespeedStyles[status].light}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="score"
|
||||
stroke={pagespeedStyles[status].stroke}
|
||||
strokeWidth={isHovered ? 2.5 : 1.5}
|
||||
fill={`url(#pagespeed-chart-${status})`}
|
||||
activeDot={{
|
||||
stroke: pagespeedStyles[status].light,
|
||||
fill: pagespeedStyles[status].stroke,
|
||||
r: 4.5,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
PagespeedAreaChart.propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
accessibility: PropTypes.number.isRequired,
|
||||
bestPractices: PropTypes.number.isRequired,
|
||||
performance: PropTypes.number.isRequired,
|
||||
seo: PropTypes.number.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
accessibility: PropTypes.number.isRequired,
|
||||
bestPractices: PropTypes.number.isRequired,
|
||||
performance: PropTypes.number.isRequired,
|
||||
seo: PropTypes.number.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -206,113 +200,123 @@ PagespeedAreaChart.propTypes = {
|
||||
* @returns {JSX.Element} - The rendered card.
|
||||
*/
|
||||
const Card = ({ monitor }) => {
|
||||
const { determineState, pagespeedStatusMsg } = useUtils();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const monitorState = determineState(monitor);
|
||||
const { determineState, pagespeedStatusMsg } = useUtils();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const monitorState = determineState(monitor);
|
||||
|
||||
return (
|
||||
<Grid item lg={6} flexGrow={1}>
|
||||
<Box
|
||||
position="relative"
|
||||
p={theme.spacing(8)}
|
||||
onClick={() => navigate(`/pagespeed/${monitor._id}`)}
|
||||
border={1}
|
||||
borderColor={theme.palette.border.light}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
backgroundColor={theme.palette.background.main}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "34px 2fr 1fr",
|
||||
columnGap: theme.spacing(5),
|
||||
gridTemplateRows: "34px 1fr 3fr",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.background.accent,
|
||||
},
|
||||
"& path": {
|
||||
transition: "stroke-width 400ms ease",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconBox>
|
||||
<PageSpeedIcon />
|
||||
</IconBox>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="h2"
|
||||
fontWeight={500}
|
||||
alignSelf="center"
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<StatusLabel
|
||||
status={monitorState}
|
||||
text={pagespeedStatusMsg[monitorState] || "Pending..."}
|
||||
customStyles={{
|
||||
width: "max-content",
|
||||
textTransform: "capitalize",
|
||||
alignSelf: "flex-start",
|
||||
justifySelf: "flex-end",
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
mt={theme.spacing(-2)}
|
||||
sx={{ gridColumnStart: 2 }}
|
||||
>
|
||||
{monitor.url}
|
||||
</Typography>
|
||||
<Box
|
||||
mx={theme.spacing(-8)}
|
||||
mt={theme.spacing(4)}
|
||||
mb={theme.spacing(-8)}
|
||||
sx={{ gridColumnStart: 1, gridColumnEnd: 4 }}
|
||||
>
|
||||
<PagespeedAreaChart data={monitor.checks} status={monitorState} />
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
py={theme.spacing(1)}
|
||||
px={theme.spacing(4)}
|
||||
borderTop={1}
|
||||
borderRight={1}
|
||||
borderColor={theme.palette.border.light}
|
||||
backgroundColor={theme.palette.background.accent}
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Typography fontSize={11} color={theme.palette.text.accent}>
|
||||
Checking every{" "}
|
||||
{(() => {
|
||||
const { time, format } = formatDurationSplit(monitor?.interval);
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize={12}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{time}{" "}
|
||||
</Typography>
|
||||
{format}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
return (
|
||||
<Grid
|
||||
item
|
||||
lg={6}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Box
|
||||
position="relative"
|
||||
p={theme.spacing(8)}
|
||||
onClick={() => navigate(`/pagespeed/${monitor._id}`)}
|
||||
border={1}
|
||||
borderColor={theme.palette.border.light}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
backgroundColor={theme.palette.background.main}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "34px 2fr 1fr",
|
||||
columnGap: theme.spacing(5),
|
||||
gridTemplateRows: "34px 1fr 3fr",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.background.accent,
|
||||
},
|
||||
"& path": {
|
||||
transition: "stroke-width 400ms ease",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconBox>
|
||||
<PageSpeedIcon />
|
||||
</IconBox>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="h2"
|
||||
fontWeight={500}
|
||||
alignSelf="center"
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<StatusLabel
|
||||
status={monitorState}
|
||||
text={pagespeedStatusMsg[monitorState] || "Pending..."}
|
||||
customStyles={{
|
||||
width: "max-content",
|
||||
textTransform: "capitalize",
|
||||
alignSelf: "flex-start",
|
||||
justifySelf: "flex-end",
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
mt={theme.spacing(-2)}
|
||||
sx={{ gridColumnStart: 2 }}
|
||||
>
|
||||
{monitor.url}
|
||||
</Typography>
|
||||
<Box
|
||||
mx={theme.spacing(-8)}
|
||||
mt={theme.spacing(4)}
|
||||
mb={theme.spacing(-8)}
|
||||
sx={{ gridColumnStart: 1, gridColumnEnd: 4 }}
|
||||
>
|
||||
<PagespeedAreaChart
|
||||
data={monitor.checks}
|
||||
status={monitorState}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
py={theme.spacing(1)}
|
||||
px={theme.spacing(4)}
|
||||
borderTop={1}
|
||||
borderRight={1}
|
||||
borderColor={theme.palette.border.light}
|
||||
backgroundColor={theme.palette.background.accent}
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
fontSize={11}
|
||||
color={theme.palette.text.accent}
|
||||
>
|
||||
Checking every{" "}
|
||||
{(() => {
|
||||
const { time, format } = formatDurationSplit(monitor?.interval);
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize={12}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{time}{" "}
|
||||
</Typography>
|
||||
{format}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
Card.propTypes = {
|
||||
monitor: PropTypes.object.isRequired,
|
||||
monitor: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -1,8 +1,8 @@
|
||||
.page-speed .label {
|
||||
padding: 7px;
|
||||
height: 24px;
|
||||
font-size: var(--env-var-font-size-small);
|
||||
padding: 7px;
|
||||
height: 24px;
|
||||
font-size: var(--env-var-font-size-small);
|
||||
}
|
||||
.page-speed:not(:has([class*="fallback__"])) button {
|
||||
height: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
@@ -14,114 +14,120 @@ import Card from "./card";
|
||||
import { networkService } from "../../main";
|
||||
|
||||
const PageSpeed = ({ isAdmin }) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user, authToken } = useSelector((state) => state.auth);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [monitors, setMonitors] = useState([]);
|
||||
useEffect(() => {
|
||||
dispatch(getPageSpeedByTeamId(authToken));
|
||||
}, [authToken, dispatch]);
|
||||
const { user, authToken } = useSelector((state) => state.auth);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [monitors, setMonitors] = useState([]);
|
||||
useEffect(() => {
|
||||
dispatch(getPageSpeedByTeamId(authToken));
|
||||
}, [authToken, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await networkService.getMonitorsByTeamId({
|
||||
authToken: authToken,
|
||||
teamId: user.teamId,
|
||||
limit: 10,
|
||||
types: ["pagespeed"],
|
||||
status: null,
|
||||
checkOrder: "desc",
|
||||
normalize: true,
|
||||
page: null,
|
||||
rowsPerPage: null,
|
||||
filter: null,
|
||||
field: null,
|
||||
order: null,
|
||||
});
|
||||
if (res?.data?.data?.monitors) {
|
||||
setMonitors(res.data.data.monitors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await networkService.getMonitorsByTeamId({
|
||||
authToken: authToken,
|
||||
teamId: user.teamId,
|
||||
limit: 10,
|
||||
types: ["pagespeed"],
|
||||
status: null,
|
||||
checkOrder: "desc",
|
||||
normalize: true,
|
||||
page: null,
|
||||
rowsPerPage: null,
|
||||
filter: null,
|
||||
field: null,
|
||||
order: null,
|
||||
});
|
||||
if (res?.data?.data?.monitors) {
|
||||
setMonitors(res.data.data.monitors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMonitors();
|
||||
}, []);
|
||||
fetchMonitors();
|
||||
}, []);
|
||||
|
||||
// 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"
|
||||
sx={{
|
||||
':has(> [class*="fallback__"])': {
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: theme.palette.background.main,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isActuallyLoading ? (
|
||||
<SkeletonLayout />
|
||||
) : monitors?.length !== 0 ? (
|
||||
<Box>
|
||||
<Box mb={theme.spacing(12)}>
|
||||
<Breadcrumbs list={[{ name: `pagespeed`, path: "/pagespeed" }]} />
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
mt={theme.spacing(5)}
|
||||
>
|
||||
<Greeting type="pagespeed" />
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate("/pagespeed/create")}
|
||||
sx={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Create new
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Grid container spacing={theme.spacing(12)}>
|
||||
{monitors?.map((monitor) => (
|
||||
<Card monitor={monitor} key={monitor._id} />
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
) : (
|
||||
<Fallback
|
||||
title="pagespeed monitor"
|
||||
checks={[
|
||||
"Report on the user experience of a page",
|
||||
"Help analyze webpage speed",
|
||||
"Give suggestions on how the page can be improved",
|
||||
]}
|
||||
link="/pagespeed/create"
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
// 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"
|
||||
sx={{
|
||||
':has(> [class*="fallback__"])': {
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderColor: theme.palette.border.light,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: theme.palette.background.main,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isActuallyLoading ? (
|
||||
<SkeletonLayout />
|
||||
) : monitors?.length !== 0 ? (
|
||||
<Box>
|
||||
<Box mb={theme.spacing(12)}>
|
||||
<Breadcrumbs list={[{ name: `pagespeed`, path: "/pagespeed" }]} />
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
mt={theme.spacing(5)}
|
||||
>
|
||||
<Greeting type="pagespeed" />
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate("/pagespeed/create")}
|
||||
sx={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Create new
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(12)}
|
||||
>
|
||||
{monitors?.map((monitor) => (
|
||||
<Card
|
||||
monitor={monitor}
|
||||
key={monitor._id}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
) : (
|
||||
<Fallback
|
||||
title="pagespeed monitor"
|
||||
checks={[
|
||||
"Report on the user experience of a page",
|
||||
"Help analyze webpage speed",
|
||||
"Give suggestions on how the page can be improved",
|
||||
]}
|
||||
link="/pagespeed/create"
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
PageSpeed.propTypes = {
|
||||
isAdmin: PropTypes.bool,
|
||||
isAdmin: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PageSpeed;
|
||||
@@ -7,59 +7,67 @@ import { useTheme } from "@emotion/react";
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const SkeletonLayout = () => {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(2)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
mb={theme.spacing(12)}
|
||||
>
|
||||
<Box width="80%">
|
||||
<Skeleton variant="rounded" width="25%" height={24} />
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="50%"
|
||||
height={19.5}
|
||||
sx={{ mt: theme.spacing(2) }}
|
||||
/>
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="20%"
|
||||
height={34}
|
||||
sx={{ alignSelf: "flex-end" }}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction="row" flexWrap="wrap" gap={theme.spacing(12)}>
|
||||
<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>
|
||||
);
|
||||
return (
|
||||
<Stack gap={theme.spacing(2)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
mb={theme.spacing(12)}
|
||||
>
|
||||
<Box width="80%">
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="25%"
|
||||
height={24}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="50%"
|
||||
height={19.5}
|
||||
sx={{ mt: theme.spacing(2) }}
|
||||
/>
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="20%"
|
||||
height={34}
|
||||
sx={{ alignSelf: "flex-end" }}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
gap={theme.spacing(12)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
Reference in new issue
Block a user