24 files changed
+1028
-50
No files matched your search
+12
-2
@@ -24,6 +24,8 @@ import withAdminCheck from "./HOC/withAdminCheck";
|
||||
import Configure from "./Pages/Monitors/Configure";
|
||||
import PageSpeed from "./Pages/PageSpeed";
|
||||
import CreatePageSpeed from "./Pages/PageSpeed/CreatePageSpeed";
|
||||
import CreateNewMaintenanceWindow from "./Pages/Maintenance/CreateMaintenanceWindow";
|
||||
import PageSpeedDetails from "./Pages/PageSpeed/Details";
|
||||
|
||||
function App() {
|
||||
const AdminCheckedRegister = withAdminCheck(Register);
|
||||
@@ -69,6 +71,10 @@ function App() {
|
||||
path="maintenance"
|
||||
element={<ProtectedRoute Component={Maintenance} />}
|
||||
/>
|
||||
<Route
|
||||
path="/maintenance/create"
|
||||
element={<CreateNewMaintenanceWindow />}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={<ProtectedRoute Component={Settings} />}
|
||||
@@ -86,13 +92,17 @@ function App() {
|
||||
element={<ProtectedRoute Component={Account} open="team" />}
|
||||
/>
|
||||
<Route
|
||||
path="page-speed"
|
||||
path="pagespeed"
|
||||
element={<ProtectedRoute Component={PageSpeed} />}
|
||||
/>
|
||||
<Route
|
||||
path="page-speed/create"
|
||||
path="pagespeed/create"
|
||||
element={<ProtectedRoute Component={CreatePageSpeed} />}
|
||||
/>
|
||||
<Route
|
||||
path="pagespeed/:monitorId"
|
||||
element={<ProtectedRoute Component={PageSpeedDetails} />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route exact path="/login" element={<Login />} />
|
||||
|
||||
@@ -60,8 +60,6 @@ const MonitorDetailsAreaChart = ({ checks, filter }) => {
|
||||
const checkTime = new Date(checks[i].createdAt).getTime();
|
||||
if (now - checkTime < limits[filter]) {
|
||||
result.push(checks[i]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,71 @@
|
||||
import { LineChart } from "@mui/x-charts/LineChart";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const PageSpeedLineChart = ({ pageSpeedChecks = [] }) => {
|
||||
const keyToLabel = {
|
||||
performance: "Performance",
|
||||
seo: "SEO",
|
||||
bestPractices: "Best practices",
|
||||
accessibility: "Accessibility",
|
||||
};
|
||||
|
||||
const colors = {
|
||||
performance: "#2aa02b",
|
||||
seo: "#9467bd",
|
||||
bestPractices: "#ff7f0e",
|
||||
accessibility: "#1f76b3",
|
||||
};
|
||||
|
||||
const customize = {
|
||||
legend: { position: { vertical: "bottom", horizontal: "middle" } },
|
||||
margin: { bottom: 75 },
|
||||
};
|
||||
|
||||
const xLabels = pageSpeedChecks.map((check) => {
|
||||
return check.createdAt;
|
||||
});
|
||||
|
||||
return (
|
||||
<LineChart
|
||||
series={Object.keys(keyToLabel).map((key) => ({
|
||||
dataKey: key,
|
||||
label: keyToLabel[key],
|
||||
color: colors[key],
|
||||
showMark: false,
|
||||
}))}
|
||||
yAxis={[{ min: 0, max: 100 }]}
|
||||
xAxis={[
|
||||
{
|
||||
scaleType: "point",
|
||||
data: xLabels,
|
||||
valueFormatter: (val) => new Date(val).toLocaleDateString(),
|
||||
},
|
||||
]}
|
||||
dataset={pageSpeedChecks}
|
||||
{...customize}
|
||||
grid={{ vertical: true, horizontal: true }}
|
||||
tooltip={{ trigger: "none" }}
|
||||
slotProps={{
|
||||
legend: {
|
||||
direction: "row",
|
||||
position: { vertical: "bottom", horizontal: "middle" },
|
||||
padding: 2,
|
||||
itemMarkWidth: 8,
|
||||
itemMarkHeight: 8,
|
||||
markGap: 5,
|
||||
itemGap: 15,
|
||||
labelStyle: {
|
||||
fontSize: 13,
|
||||
color: "#344054"
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
PageSpeedLineChart.propTypes = {
|
||||
pageSpeedChecks: PropTypes.array,
|
||||
};
|
||||
|
||||
export default PageSpeedLineChart;
|
||||
@@ -26,7 +26,7 @@ const menu = [
|
||||
{ name: "Incidents", path: "incidents", icon: <Incidents /> },
|
||||
{ name: "Status pages", path: "status", icon: <StatusPages /> },
|
||||
{ name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
|
||||
{ name: "Page speed", path: "page-speed", icon: <PageSpeed /> },
|
||||
{ name: "Page speed", path: "pagespeed", icon: <PageSpeed /> },
|
||||
{ name: "Integrations", path: "integrations", icon: <Integrations /> },
|
||||
{ name: "Support", path: "support", icon: <Support /> },
|
||||
{ name: "Settings", path: "settings", icon: <Settings /> },
|
||||
@@ -50,7 +50,15 @@ function Sidebar() {
|
||||
alignItems="center"
|
||||
gap={theme.gap.small}
|
||||
borderRadius={`${theme.shape.borderRadius}px`}
|
||||
onClick={() => navigate(`/${item.path}`)}
|
||||
onClick={() =>
|
||||
item.path === "support"
|
||||
? window.open(
|
||||
"https://github.com/bluewave-labs/bluewave-uptime",
|
||||
"_blank",
|
||||
"noreferrer"
|
||||
)
|
||||
: navigate(`/${item.path}`)
|
||||
}
|
||||
sx={{ p: `${theme.gap.small} ${theme.gap.medium}` }}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.maintenance-options > .MuiStack-root > .MuiStack-root.select-wrapper {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
.date-picker .MuiOutlinedInput-root,
|
||||
.time-picker .MuiOutlinedInput-root {
|
||||
height: 34px;
|
||||
font-size: var(--env-var-font-size-medium);
|
||||
}
|
||||
|
||||
.date-picker .MuiOutlinedInput-root {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.time-picker .MuiOutlinedInput-root {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.MuiInputAdornment-outlined .MuiIconButton-root svg {
|
||||
width: 20px;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import "./index.css";
|
||||
import React, { useState } from "react";
|
||||
import Button from "../../../Components/Button";
|
||||
import Back from "../../../assets/icons/left-arrow.svg?react";
|
||||
import Select from "../../../Components/Inputs/Select";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import dayjs from "dayjs";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
|
||||
|
||||
const directory = {
|
||||
title: "Create a maintenance window",
|
||||
description: "Your pings won’t be sent in this time frame.",
|
||||
};
|
||||
|
||||
const repeatOptions = [
|
||||
{ _id: 1, name: "Don't repeat" },
|
||||
{ _id: 2, name: "Repeat daily" },
|
||||
{ _id: 3, name: "Repeat weekly" },
|
||||
];
|
||||
|
||||
const configOptionTitle = (title, description) => {
|
||||
return (
|
||||
<Stack width="40%" gap={1}>
|
||||
<Typography
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "var(--env-var-font-size-medium)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
style={{
|
||||
fontSize: "var(--env-var-font-size-small)",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateNewMaintenanceWindow = () => {
|
||||
const [values, setValues] = useState({
|
||||
repeat: 1,
|
||||
});
|
||||
|
||||
const handleChange = (event, name) => {
|
||||
const { value } = event.target;
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const configOptions = [
|
||||
{
|
||||
title: "Repeat",
|
||||
component: (
|
||||
<Select
|
||||
onChange={(e) => handleChange(e, "repeat")}
|
||||
id="repeat-mode"
|
||||
items={repeatOptions}
|
||||
value={values.repeat}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Date",
|
||||
component: (
|
||||
<LocalizationProvider
|
||||
className="date-localization-provider"
|
||||
dateAdapter={AdapterDayjs}
|
||||
>
|
||||
<DatePicker className="date-picker" defaultValue={dayjs()} />
|
||||
</LocalizationProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Start time",
|
||||
description: "Your pings won’t be sent in this time frame.",
|
||||
component: (
|
||||
<LocalizationProvider
|
||||
className="time-localization-provider"
|
||||
dateAdapter={AdapterDayjs}
|
||||
>
|
||||
<MobileTimePicker className="time-picker" defaultValue={dayjs()} />
|
||||
</LocalizationProvider>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="create-maintenance-window">
|
||||
<Stack gap={3}>
|
||||
<Button
|
||||
id="btn-back"
|
||||
sx={{
|
||||
width: "100px",
|
||||
height: "30px",
|
||||
gap: "10px",
|
||||
backgroundColor: "var(--env-var-color-32)",
|
||||
color: "var(--env-var-color-5)",
|
||||
}}
|
||||
label="Back"
|
||||
level="tertiary"
|
||||
img={<Back />}
|
||||
/>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "var(--env-var-font-size-large)",
|
||||
fontWeight: "600",
|
||||
color: "var(--env-var-color-5)",
|
||||
}}
|
||||
>
|
||||
{directory.title}
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ fontSize: "var(--env-var-font-size-medium)" }}>
|
||||
{directory.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
gap={5}
|
||||
paddingY={4}
|
||||
paddingX={8}
|
||||
sx={{
|
||||
border: "1px solid var(--env-var-color-16)",
|
||||
borderRadius: "var(--env-var-radius-1)",
|
||||
}}
|
||||
className="maintenance-options"
|
||||
>
|
||||
{configOptions.map((item, index) => (
|
||||
<Stack key={index} display="-webkit-inline-box">
|
||||
{configOptionTitle(item.title, item.description)}
|
||||
{item.component && item.component}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateNewMaintenanceWindow;
|
||||
@@ -14,6 +14,7 @@ const Maintenance = () => {
|
||||
"Eliminate any misunderstandings",
|
||||
"Stop sending alerts in maintenance windows",
|
||||
]}
|
||||
link="/maintenance/create"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import WestRoundedIcon from "@mui/icons-material/WestRounded";
|
||||
import GreenCheck from "../../../assets/icons/checkbox-green.svg?react";
|
||||
import RedCheck from "../../../assets/icons/checkbox-red.svg?react";
|
||||
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
|
||||
|
||||
import { getLastChecked } from "../../../Utils/monitorUtils";
|
||||
import "./index.css";
|
||||
import { monitorValidation } from "../../../Validation/validation";
|
||||
import Select from "../../../Components/Inputs/Select";
|
||||
@@ -36,18 +36,6 @@ const parseUrl = (url) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get duration since last check
|
||||
* @param {Array} checks Array of check objects.
|
||||
* @returns {number} Timestamp of the most recent check.
|
||||
*/
|
||||
const getLastChecked = (checks) => {
|
||||
if (!checks || checks.length === 0) {
|
||||
return 0; // Handle case when no checks are available
|
||||
}
|
||||
return new Date() - new Date(checks[0].createdAt);
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure page displays monitor configurations and allows for editing actions.
|
||||
* @component
|
||||
|
||||
@@ -150,7 +150,7 @@ const CreateMonitor = () => {
|
||||
img={<WestRoundedIcon />}
|
||||
onClick={() => navigate("/monitors")}
|
||||
sx={{
|
||||
backgroundColor: "#f4f4f4",
|
||||
backgroundColor: theme.palette.otherColors.fillGray,
|
||||
mb: theme.gap.medium,
|
||||
px: theme.gap.ml,
|
||||
"& svg.MuiSvgIcon-root": {
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
* @param {string} params.url - The URL of the host.
|
||||
* @param {string} params.title - The name of the host.
|
||||
* @param {string} params.percentageColor - The color of the percentage text.
|
||||
* @param {number} params.precentage - The percentage to display.
|
||||
* @param {number} params.percentage - The percentage to display.
|
||||
* @returns {React.ElementType} Returns a div element with the host details.
|
||||
*/
|
||||
const Host = ({ params }) => {
|
||||
@@ -54,7 +54,7 @@ const Host = ({ params }) => {
|
||||
<Box>
|
||||
{params.title}
|
||||
<Typography component="span" sx={{ color: params.percentageColor }}>
|
||||
{params.precentage}%
|
||||
{params.percentage}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -135,7 +135,7 @@ const Monitors = () => {
|
||||
const params = {
|
||||
url: monitor.url,
|
||||
title: monitor.name,
|
||||
precentage: 100,
|
||||
percentage: 100,
|
||||
percentageColor:
|
||||
monitor.status === true
|
||||
? "var(--env-var-color-17)"
|
||||
|
||||
@@ -82,7 +82,7 @@ const CreatePageSpeed = () => {
|
||||
try {
|
||||
const action = await dispatch(createPageSpeed({ authToken, monitor }));
|
||||
if (action.meta.requestStatus === "fulfilled") {
|
||||
navigate("/page-speed");
|
||||
navigate("/pagespeed");
|
||||
}
|
||||
} catch (error) {
|
||||
createToast({
|
||||
@@ -102,9 +102,9 @@ const CreatePageSpeed = () => {
|
||||
label="Back"
|
||||
animate="slideLeft"
|
||||
img={<WestRoundedIcon />}
|
||||
onClick={() => navigate("/page-speed")}
|
||||
onClick={() => navigate("/pagespeed")}
|
||||
sx={{
|
||||
backgroundColor: "#f4f4f4",
|
||||
backgroundColor: theme.palette.otherColors.fillGray,
|
||||
mb: theme.gap.large,
|
||||
px: theme.gap.ml,
|
||||
"& svg.MuiSvgIcon-root": {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.page-speed-details h1 {
|
||||
font-size: var(--env-var-font-size-large-plus);
|
||||
color: var(--env-var-color-1);
|
||||
}
|
||||
.page-speed-details h2,
|
||||
.page-speed-details h6 {
|
||||
font-size: var(--env-var-font-size-large);
|
||||
}
|
||||
.page-speed-details h4,
|
||||
.page-speed-details p,
|
||||
.page-speed-details p > span {
|
||||
font-size: var(--env-var-font-size-medium);
|
||||
}
|
||||
.page-speed-details h1 + span,
|
||||
.page-speed-details h4 > span {
|
||||
font-size: var(--env-var-font-size-small-plus);
|
||||
}
|
||||
.page-speed-details h2,
|
||||
.page-speed-details h4,
|
||||
.page-speed-details p {
|
||||
color: var(--env-var-color-5);
|
||||
}
|
||||
.page-speed-details h6 {
|
||||
color: var(--env-var-color-3);
|
||||
line-height: 22px;
|
||||
}
|
||||
.page-speed-details h1,
|
||||
.page-speed-details h2,
|
||||
.page-speed-details h6 {
|
||||
font-weight: 600;
|
||||
}
|
||||
.page-speed-details button.MuiButtonBase-root {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.page-speed-details .stat-box,
|
||||
.page-speed-details > .MuiStack-root:last-child,
|
||||
.page-speed-details > .MuiBox-root:nth-last-child(3) {
|
||||
border: solid 1px var(--env-var-color-6);
|
||||
border-radius: var(--env-var-radius-2);
|
||||
background-color: var(--env-var-color-8);
|
||||
min-width: 200px;
|
||||
}
|
||||
.page-speed-details .stat-box {
|
||||
flex: 1;
|
||||
}
|
||||
.page-speed-details .stat-box:last-of-type {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.page-speed-details .stat-box svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.page-speed-details .MuiPieArcLabel-root {
|
||||
font-size: var(--env-var-font-size-small-plus);
|
||||
transition: fill 300ms ease;
|
||||
}
|
||||
.page-speed-details .MuiPieArcLabel-faded {
|
||||
fill: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.page-speed-details .pie-label,
|
||||
.page-speed-details .pie-value-label {
|
||||
transition: all 400ms ease;
|
||||
}
|
||||
.page-speed-details .MuiPieArc-root {
|
||||
stroke: inherit;
|
||||
}
|
||||
|
||||
.page-speed-details .metric {
|
||||
min-width: 200px;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { PieChart } from "@mui/x-charts/PieChart";
|
||||
import { useDrawingArea } from "@mui/x-charts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { formatDate, formatDurationRounded } from "../../../Utils/timeUtils";
|
||||
import { getLastChecked } from "../../../Utils/monitorUtils";
|
||||
import axiosInstance from "../../../Utils/axiosConfig";
|
||||
import Button from "../../../Components/Button";
|
||||
import WestRoundedIcon from "@mui/icons-material/WestRounded";
|
||||
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
|
||||
import LastCheckedIcon from "../../../assets/icons/calendar-check.svg?react";
|
||||
import ClockIcon from "../../../assets/icons/maintenance.svg?react";
|
||||
import IntervalCheckIcon from "../../../assets/icons/interval-check.svg?react";
|
||||
import GreenCheck from "../../../assets/icons/checkbox-green.svg?react";
|
||||
import RedCheck from "../../../assets/icons/checkbox-red.svg?react";
|
||||
|
||||
import "./index.css";
|
||||
import PageSpeedLineChart from "../../../Components/Charts/PagespeedLineChart";
|
||||
|
||||
/**
|
||||
* Displays a box with an icon, title, and value.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {ReactNode} props.icon - The icon to display in the box.
|
||||
* @param {string} props.title - The title text to display above the value.
|
||||
* @param {string | number} props.value - The value text to display in the box.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const StatBox = ({ icon, title, value }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="stat-box"
|
||||
direction="row"
|
||||
gap={theme.gap.small}
|
||||
p={theme.gap.ml}
|
||||
pb={theme.gap.mlplus}
|
||||
>
|
||||
{icon}
|
||||
<Box>
|
||||
<Typography variant="h6" mb={theme.gap.medium}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4">{value}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a centered label within a pie chart.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string | number} props.value - The value to display in the label.
|
||||
* @param {string} props.color - The color of the text.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const PieCenterLabel = ({ value, color, setExpand }) => {
|
||||
const { width, height } = useDrawingArea();
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${width / 2}, ${height / 2})`}
|
||||
onMouseEnter={() => setExpand(true)}
|
||||
>
|
||||
<circle cx={0} cy={0} r={width / 3} fill="transparent" />
|
||||
<text
|
||||
className="pie-label"
|
||||
style={{
|
||||
fill: color,
|
||||
fontSize: "45px",
|
||||
textAnchor: "middle",
|
||||
dominantBaseline: "central",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that renders a label on a pie chart slice.
|
||||
* The label is positioned relative to the center of the pie chart and is optionally highlighted.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {number} props.value - The value to display inside the pie slice.
|
||||
* @param {number} props.startAngle - The starting angle of the pie slice in degrees.
|
||||
* @param {number} props.endAngle - The ending angle of the pie slice in degrees.
|
||||
* @param {string} props.color - The color of the label text when highlighted.
|
||||
* @param {boolean} props.highlighted - Determines if the label should be highlighted or not.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => {
|
||||
const { width, height } = useDrawingArea();
|
||||
|
||||
// Compute the midpoint angle in radians
|
||||
const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180;
|
||||
const radius = height / 3.5; // length from center of the circle to where the text is positioned
|
||||
|
||||
// Calculate x and y positions
|
||||
const x = Math.sin(angle) * radius;
|
||||
const y = -Math.cos(angle) * radius;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${width / 2}, ${height / 2})`}>
|
||||
<text
|
||||
className="pie-value-label"
|
||||
x={x}
|
||||
y={y}
|
||||
style={{
|
||||
fill: highlighted ? color : "rgba(0,0,0,0)",
|
||||
fontSize: "12px",
|
||||
textAnchor: "middle",
|
||||
dominantBaseline: "central",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
+{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const PageSpeedDetails = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [monitor, setMonitor] = useState({});
|
||||
const [audits, setAudits] = useState({});
|
||||
const { monitorId } = useParams();
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitor = async () => {
|
||||
const res = await axiosInstance.get(
|
||||
`/monitors/${monitorId}?sortOrder=desc`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setMonitor(res.data.data);
|
||||
setAudits(res.data.data.checks[0].audits);
|
||||
};
|
||||
|
||||
fetchMonitor();
|
||||
}, [monitorId]);
|
||||
|
||||
/**
|
||||
* Weight constants for different performance metrics.
|
||||
* @type {Object}
|
||||
*/
|
||||
const weights = {
|
||||
fcp: 10,
|
||||
si: 10,
|
||||
lcp: 25,
|
||||
tbt: 30,
|
||||
cls: 25,
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves color properties based on the performance value.
|
||||
*
|
||||
* @param {number} value - The performance score used to determine the color properties.
|
||||
* @returns {{stroke: string, text: string, bg: string}} The color properties for the given performance value.
|
||||
*/
|
||||
const getColors = (value) => {
|
||||
if (value >= 90 && value <= 100) return theme.pie.green;
|
||||
else if (value >= 50 && value < 90) return theme.pie.yellow;
|
||||
else if (value >= 0 && value < 50) return theme.pie.red;
|
||||
return theme.pie.default;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates and formats the data needed for rendering a pie chart based on audit scores and weights.
|
||||
* This function generates properties for each pie slice, including angles, radii, and colors.
|
||||
* It also calculates performance based on the weighted values.
|
||||
*
|
||||
* @returns {Array<Object>} An array of objects, each representing the properties for a slice of the pie chart.
|
||||
* @returns {number} performance - A variable updated with the rounded sum of weighted values.
|
||||
*/
|
||||
let performance = 0;
|
||||
const getPieData = (audits) => {
|
||||
let props = [];
|
||||
let startAngle = 0;
|
||||
const padding = 3; // padding between arcs
|
||||
const max = 360 - padding * (Object.keys(audits).length - 1); // _id is a child of audits
|
||||
|
||||
Object.keys(audits).forEach((key) => {
|
||||
if (audits[key].score) {
|
||||
let value = audits[key].score * weights[key];
|
||||
let endAngle = startAngle + (weights[key] * max) / 100;
|
||||
|
||||
let theme = getColors(audits[key].score * 100);
|
||||
props.push({
|
||||
id: key,
|
||||
data: [
|
||||
{
|
||||
value: value,
|
||||
color: theme.stroke,
|
||||
label: key.toUpperCase(),
|
||||
},
|
||||
{
|
||||
value: weights[key] - value,
|
||||
color: theme.strokeBg,
|
||||
label: "",
|
||||
},
|
||||
],
|
||||
arcLabel: (item) => `${item.label}`,
|
||||
arcLabelRadius: 95,
|
||||
startAngle: startAngle,
|
||||
endAngle: endAngle,
|
||||
innerRadius: 73,
|
||||
outerRadius: 80,
|
||||
cornerRadius: 2,
|
||||
highlightScope: { faded: "global", highlighted: "series" },
|
||||
faded: {
|
||||
innerRadius: 63,
|
||||
outerRadius: 70,
|
||||
additionalRadius: -20,
|
||||
arcLabelRadius: 5,
|
||||
},
|
||||
cx: pieSize.width / 2,
|
||||
});
|
||||
|
||||
performance += Math.round(value);
|
||||
startAngle = endAngle + padding;
|
||||
}
|
||||
});
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
const pieSize = { width: 200, height: 200 };
|
||||
const pieData = getPieData(audits);
|
||||
const colorMap = getColors(performance);
|
||||
|
||||
const [highlightedItem, setHighLightedItem] = useState(null);
|
||||
const [expand, setExpand] = useState(false);
|
||||
|
||||
return (
|
||||
<Stack className="page-speed-details" gap={theme.gap.large}>
|
||||
<Button
|
||||
level="tertiary"
|
||||
label="Back"
|
||||
animate="slideLeft"
|
||||
img={<WestRoundedIcon />}
|
||||
onClick={() => navigate("/pagespeed")}
|
||||
sx={{
|
||||
width: "fit-content",
|
||||
backgroundColor: theme.palette.otherColors.fillGray,
|
||||
px: theme.gap.ml,
|
||||
"& svg.MuiSvgIcon-root": {
|
||||
mr: theme.gap.small,
|
||||
fill: theme.palette.otherColors.slateGray,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.gap.small}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<GreenCheck />
|
||||
<Box>
|
||||
<Typography component="h1" mb={theme.gap.xs} sx={{ lineHeight: 1 }}>
|
||||
{monitor?.url}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ color: "var(--env-var-color-17)" }}
|
||||
>
|
||||
Your pagespeed monitor is live.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
level="tertiary"
|
||||
label="Configure"
|
||||
animate="rotate90"
|
||||
img={
|
||||
<SettingsIcon
|
||||
style={{ width: theme.gap.mlplus, height: theme.gap.mlplus }}
|
||||
/>
|
||||
}
|
||||
onClick={() => navigate(`/pagespeed/configure/${monitorId}`)}
|
||||
sx={{
|
||||
ml: "auto",
|
||||
alignSelf: "flex-end",
|
||||
backgroundColor: theme.palette.otherColors.fillGray,
|
||||
px: theme.gap.medium,
|
||||
"& svg": {
|
||||
mr: "6px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
gap={theme.gap.xl}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StatBox
|
||||
icon={<LastCheckedIcon />}
|
||||
title="Last checked"
|
||||
value={
|
||||
<>
|
||||
{formatDate(getLastChecked(monitor?.checks, false))}{" "}
|
||||
<Typography
|
||||
component="span"
|
||||
fontStyle="italic"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
({formatDurationRounded(getLastChecked(monitor?.checks))} ago)
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
icon={<ClockIcon />}
|
||||
title="Checks since"
|
||||
value={
|
||||
<>
|
||||
{formatDate(new Date(monitor?.createdAt))}{" "}
|
||||
<Typography
|
||||
component="span"
|
||||
fontStyle="italic"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
(
|
||||
{formatDurationRounded(
|
||||
new Date() - new Date(monitor?.createdAt)
|
||||
)}{" "}
|
||||
ago)
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
></StatBox>
|
||||
<StatBox
|
||||
icon={<IntervalCheckIcon />}
|
||||
title="Checks every"
|
||||
value={formatDurationRounded(monitor?.interval)}
|
||||
></StatBox>
|
||||
</Stack>
|
||||
<Typography component="h2">Score history</Typography>
|
||||
<Box height="300px">
|
||||
<PageSpeedLineChart pageSpeedChecks={monitor?.checks?.slice(0, 25)} />
|
||||
</Box>
|
||||
<Typography component="h2">Performance report</Typography>
|
||||
<Stack direction="row" alignItems="center" overflow="hidden">
|
||||
<Stack
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
minWidth="300px"
|
||||
flex={1}
|
||||
px={theme.gap.xl}
|
||||
py={theme.gap.ml}
|
||||
>
|
||||
<Box onMouseLeave={() => setExpand(false)}>
|
||||
{expand ? (
|
||||
<PieChart
|
||||
series={[
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
color: colorMap.bg,
|
||||
},
|
||||
],
|
||||
outerRadius: 67,
|
||||
cx: pieSize.width / 2,
|
||||
},
|
||||
...pieData,
|
||||
]}
|
||||
width={pieSize.width}
|
||||
height={pieSize.height}
|
||||
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
|
||||
onHighlightChange={setHighLightedItem}
|
||||
slotProps={{
|
||||
legend: { hidden: true },
|
||||
}}
|
||||
tooltip={{ trigger: "none" }}
|
||||
sx={{
|
||||
"&:has(.MuiPieArcLabel-faded) .pie-label": {
|
||||
fill: "rgba(0,0,0,0) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PieCenterLabel
|
||||
value={performance}
|
||||
color={colorMap.text}
|
||||
setExpand={setExpand}
|
||||
/>
|
||||
{pieData?.map((pie) => (
|
||||
<PieValueLabel
|
||||
key={pie.id}
|
||||
value={pie.data[0].value}
|
||||
startAngle={pie.startAngle}
|
||||
endAngle={pie.endAngle}
|
||||
color={pie.data[0].color}
|
||||
highlighted={highlightedItem?.seriesId === pie.id}
|
||||
/>
|
||||
))}
|
||||
</PieChart>
|
||||
) : (
|
||||
<PieChart
|
||||
series={[
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: 100,
|
||||
color: colorMap.bg,
|
||||
},
|
||||
],
|
||||
outerRadius: 67,
|
||||
cx: pieSize.width / 2,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{
|
||||
value: performance,
|
||||
color: colorMap.stroke,
|
||||
},
|
||||
],
|
||||
innerRadius: 63,
|
||||
outerRadius: 70,
|
||||
paddingAngle: 5,
|
||||
cornerRadius: 2,
|
||||
startAngle: 0,
|
||||
endAngle: (performance / 100) * 360,
|
||||
cx: pieSize.width / 2,
|
||||
},
|
||||
]}
|
||||
width={pieSize.width}
|
||||
height={pieSize.height}
|
||||
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
|
||||
tooltip={{ trigger: "none" }}
|
||||
>
|
||||
<PieCenterLabel
|
||||
value={performance}
|
||||
color={colorMap.text}
|
||||
setExpand={setExpand}
|
||||
/>
|
||||
</PieChart>
|
||||
)}
|
||||
</Box>
|
||||
<Typography mt={theme.gap.medium}>
|
||||
Values are estimated and may vary.{" "}
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 500,
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
See calculator
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box
|
||||
px={theme.gap.xl}
|
||||
py={theme.gap.ml}
|
||||
height="100%"
|
||||
flex={1}
|
||||
sx={{
|
||||
borderLeft: `solid 1px ${theme.palette.otherColors.graishWhite}`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
mb={theme.gap.medium}
|
||||
pb={theme.gap.ml}
|
||||
color={theme.palette.secondary.main}
|
||||
textAlign="center"
|
||||
sx={{
|
||||
borderBottom: `solid 1px ${theme.palette.otherColors.graishWhite}`,
|
||||
borderBottomStyle: "dashed",
|
||||
}}
|
||||
>
|
||||
The{" "}
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 500,
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
performance score is calculated
|
||||
</Typography>{" "}
|
||||
directly from these{" "}
|
||||
<Typography component="span" fontWeight={600}>
|
||||
metrics
|
||||
</Typography>
|
||||
.
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
pt={theme.gap.ml}
|
||||
gap={theme.gap.ml}
|
||||
>
|
||||
{Object.keys(audits).map((key) => {
|
||||
if (key === "_id") return;
|
||||
|
||||
let audit = audits[key];
|
||||
let metricParams = getColors(audit.score * 100);
|
||||
|
||||
let shape = (
|
||||
<Box
|
||||
sx={{
|
||||
width: theme.gap.medium,
|
||||
height: theme.gap.medium,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: metricParams.stroke,
|
||||
}}
|
||||
></Box>
|
||||
);
|
||||
if (metricParams.shape === "square")
|
||||
shape = (
|
||||
<Box
|
||||
sx={{
|
||||
width: theme.gap.medium,
|
||||
height: theme.gap.medium,
|
||||
backgroundColor: metricParams.stroke,
|
||||
}}
|
||||
></Box>
|
||||
);
|
||||
else if (metricParams.shape === "triangle")
|
||||
shape = (
|
||||
<Box
|
||||
sx={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
ml: `calc((${theme.gap.medium} - ${theme.gap.small}) / -2)`,
|
||||
borderLeft: `${theme.gap.small} solid transparent`,
|
||||
borderRight: `${theme.gap.small} solid transparent`,
|
||||
borderBottom: `${theme.gap.medium} solid ${metricParams.stroke}`,
|
||||
}}
|
||||
></Box>
|
||||
);
|
||||
|
||||
// Find the position where the number ends and the unit begins
|
||||
const match = audit.displayValue.match(/(\d+\.?\d*)\s*([a-zA-Z]+)/);
|
||||
let value;
|
||||
let unit;
|
||||
if (match) {
|
||||
value = match[1];
|
||||
unit = match[2];
|
||||
} else {
|
||||
value = audit.displayValue;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="metric"
|
||||
key={`${key}-box`}
|
||||
direction="row"
|
||||
gap={theme.gap.small}
|
||||
>
|
||||
{shape}
|
||||
<Box>
|
||||
<Typography sx={{ lineHeight: 1 }}>
|
||||
{audit.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
color: metricParams.text,
|
||||
fontSize: "16px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
<Typography
|
||||
component="span"
|
||||
ml="2px"
|
||||
sx={{
|
||||
color: theme.palette.secondary.main,
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{unit}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageSpeedDetails;
|
||||
@@ -10,31 +10,26 @@ import Fallback from "../../Components/Fallback";
|
||||
import "./index.css";
|
||||
import Button from "../../Components/Button";
|
||||
import { useNavigate } from "react-router";
|
||||
import { getLastChecked } from "../../Utils/monitorUtils";
|
||||
|
||||
const Card = ({ data }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
/**
|
||||
* Helper function to get duration since last check or the last date checked
|
||||
* @param {Array} checks Array of check objects.
|
||||
* @param {boolean} duration Whether the function should return the duration since last checked or the date itself
|
||||
* @returns {number} Timestamp of the most recent check.
|
||||
*/
|
||||
const getLastChecked = (checks, duration = true) => {
|
||||
if (!checks || checks.length === 0) {
|
||||
return 0; // Handle case when no checks are available
|
||||
}
|
||||
|
||||
// Data is sorted newest -> oldest, so newest check is the most recent
|
||||
if (!duration) {
|
||||
return new Date(checks[0].createdAt);
|
||||
}
|
||||
return new Date() - new Date(checks[0].createdAt);
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Grid item lg={6} flexGrow={1}>
|
||||
<Stack direction="row" gap={theme.gap.medium} p={theme.gap.ml}>
|
||||
<Grid
|
||||
item
|
||||
lg={6}
|
||||
flexGrow={1}
|
||||
sx={{ "&:hover > .MuiStack-root": { backgroundColor: "#f9fafb" } }}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.gap.medium}
|
||||
p={theme.gap.ml}
|
||||
onClick={() => navigate(`/pagespeed/${data._id}`)}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<PageSpeedIcon style={{ width: theme.gap.ml, height: theme.gap.ml }} />
|
||||
<Box flex={1}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
@@ -91,7 +86,7 @@ const PageSpeed = () => {
|
||||
<Button
|
||||
level="primary"
|
||||
label="Create new"
|
||||
onClick={() => navigate("/page-speed/create")}
|
||||
onClick={() => navigate("/pagespeed/create")}
|
||||
/>
|
||||
</Stack>
|
||||
<Grid container spacing={theme.gap.large}>
|
||||
@@ -108,7 +103,7 @@ const PageSpeed = () => {
|
||||
"Help analyze webpage speed",
|
||||
"Give suggestions on how the page can be improved",
|
||||
]}
|
||||
link="/page-speed/create"
|
||||
link="/pagespeed/create"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -94,6 +94,7 @@ const theme = createTheme({
|
||||
small: "8px",
|
||||
medium: "12px",
|
||||
ml: "16px",
|
||||
mlplus: "20px",
|
||||
large: "24px",
|
||||
xl: "40px",
|
||||
xxl: "60px",
|
||||
@@ -133,6 +134,36 @@ const theme = createTheme({
|
||||
dotColor: "#4e5ba6",
|
||||
},
|
||||
},
|
||||
pie: {
|
||||
green: {
|
||||
stroke: "#17b26a",
|
||||
strokeBg: "#d4f4e1",
|
||||
text: "#079455",
|
||||
bg: "#ecfdf3",
|
||||
shape: "circle",
|
||||
},
|
||||
yellow: {
|
||||
stroke: "#fdb022",
|
||||
strokeBg: "rgba(255, 192, 34, 0.3)",
|
||||
text: "#dc6803",
|
||||
bg: "#fffcf5",
|
||||
shape: "square",
|
||||
},
|
||||
red: {
|
||||
stroke: "#f04438",
|
||||
strokeBg: "#ffecea",
|
||||
text: "#f04438",
|
||||
bg: "#ffeaea",
|
||||
shape: "triangle",
|
||||
},
|
||||
default: {
|
||||
stroke: "#4e5ba6",
|
||||
strokeBg: "#f2f4f7",
|
||||
text: "#4e5ba6",
|
||||
bg: "#f2f4f7",
|
||||
shape: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Helper function to get duration since last check or the last date checked
|
||||
* @param {Array} checks Array of check objects.
|
||||
* @param {boolean} duration Whether the function should return the duration since last checked or the date itself
|
||||
* @returns {number} Timestamp of the most recent check.
|
||||
*/
|
||||
export const getLastChecked = (checks, duration = true) => {
|
||||
if (!checks || checks.length === 0) {
|
||||
return 0; // Handle case when no checks are available
|
||||
}
|
||||
|
||||
// Data is sorted newest -> oldest, so newest check is the most recent
|
||||
if (!duration) {
|
||||
return new Date(checks[0].createdAt);
|
||||
}
|
||||
return new Date() - new Date(checks[0].createdAt);
|
||||
};
|
||||
@@ -50,7 +50,6 @@ export const formatDate = (date) => {
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="22" viewBox="0 0 20 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 10.5V7.8C19 6.11984 19 5.27976 18.673 4.63803C18.3854 4.07354 17.9265 3.6146 17.362 3.32698C16.7202 3 15.8802 3 14.2 3H5.8C4.11984 3 3.27976 3 2.63803 3.32698C2.07354 3.6146 1.6146 4.07354 1.32698 4.63803C1 5.27976 1 6.11984 1 7.8V16.2C1 17.8802 1 18.7202 1.32698 19.362C1.6146 19.9265 2.07354 20.3854 2.63803 20.673C3.27976 21 4.11984 21 5.8 21H10.5M19 9H1M14 1V5M6 1V5M16 20V14M13 17H19" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.9118 14.2882C14.7835 13.0993 16 11.1735 16 9C16 5.38891 12.6421 2.46154 8.5 2.46154H8.05882M8.5 15.5385C4.35786 15.5385 1 12.6111 1 9C1 6.82651 2.21647 4.90072 4.08824 3.71185M7.61765 17L9.38235 15.4615L7.61765 13.9231M9.38235 4.07692L7.61765 2.53846L9.38235 1" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 463 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 7H1M1 7L7 13M1 7L7 1" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 223 B |
@@ -50,6 +50,8 @@
|
||||
--env-var-color-28: #f79009;
|
||||
--env-var-color-29: #d0d5dd;
|
||||
--env-var-color-30: #fcfcfd;
|
||||
--env-var-color-31: #1a1919;
|
||||
--env-var-color-32: #f5f5f5;
|
||||
|
||||
--env-var-radius-1: 4px;
|
||||
--env-var-radius-2: 8px;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
uptime.bluewavelabs.ca
|
||||
@@ -0,0 +1 @@
|
||||
<h1>BlueWave Uptime web page</h1>
|
||||
Reference in new issue
Block a user