Merge pull request #810 from bluewave-labs/develop

Develop -> Master
This commit is contained in:
Alexander Holliday authored and GitHub committed 2024-09-05 14:47:16 -07:00
commit d1fe5c52ec
60 files changed
+4811 -1100

No files matched your search

+2 -2
View File
@@ -31,7 +31,7 @@ const icons = {
const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
const theme = useTheme();
const { text, light, border } = theme.palette[variant];
const { text, bg, border } = theme.palette[variant];
const icon = icons[variant];
return (
@@ -45,7 +45,7 @@ const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
padding: hasIcon
? theme.spacing(8)
: `${theme.spacing(4)} ${theme.spacing(8)}`,
backgroundColor: light,
backgroundColor: bg,
border: `solid 1px ${border}`,
borderRadius: theme.shape.borderRadius,
}}
+2 -2
View File
@@ -41,8 +41,8 @@ const PulseDot = ({ color }) => {
"&::after": {
content: `""`,
position: "absolute",
width: "6px",
height: "6px",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: "white",
top: "50%",
@@ -1,53 +0,0 @@
import { BarChart, Bar, Cell, ReferenceLine, Label } from "recharts";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
const MonitorDetails60MinChart = ({ data }) => {
const theme = useTheme();
const labelStyle = {
fontSize: "10px",
fill: theme.palette.text.tertiary,
};
const color = {
true: theme.palette.success.main,
false: theme.palette.error.text,
undefined: theme.palette.unresolved.main,
};
return (
<BarChart
width={data.length * 10 + 30}
height={35}
data={data}
margin={{ top: 14, left: 15, right: 15, bottom: 2 }}
style={{ alignSelf: "baseline" }}
>
<Bar dataKey="value" barSize={10}>
{data.map((check, index) => (
<Cell key={`cell-${index}`} fill={color[check.status]} />
))}
</Bar>
<ReferenceLine x={0} stroke="black" strokeDasharray="3 3">
<Label value="60 mins" position="top" style={labelStyle} />
</ReferenceLine>
<ReferenceLine
x={Math.floor(data.length * (2 / 3))}
stroke="black"
strokeDasharray="3 3"
>
<Label value="20 mins" position="top" style={labelStyle} />
</ReferenceLine>
<ReferenceLine x={data.length - 1} stroke="black" strokeDasharray="3 3">
<Label value="now" position="top" style={labelStyle} />
</ReferenceLine>
</BarChart>
);
};
MonitorDetails60MinChart.propTypes = {
data: PropTypes.array.isRequired,
};
export default MonitorDetails60MinChart;
@@ -1,8 +1,16 @@
import PropTypes from "prop-types";
import { AreaChart, Area, XAxis, Tooltip, ResponsiveContainer } from "recharts";
import { Box, Typography } from "@mui/material";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import "./index.css";
import React, { useMemo } from "react";
const CustomToolTip = ({ active, payload, label }) => {
const theme = useTheme();
@@ -16,14 +24,15 @@ const CustomToolTip = ({ active, payload, label }) => {
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(6),
px: theme.spacing(8),
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.primary.main,
fontSize: 13,
color: theme.palette.text.tertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{new Date(label).toLocaleDateString("en-US", {
@@ -38,15 +47,39 @@ const CustomToolTip = ({ active, payload, label }) => {
hour12: true, // AM/PM format
})}
</Typography>
<Typography
mt={theme.spacing(2.5)}
sx={{
color: theme.palette.text.secondary,
fontSize: 13,
}}
>
Response Time (ms): {payload[0].payload.originalResponseTime}
</Typography>{" "}
<Box mt={theme.spacing(1)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={theme.palette.primary.main}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography component="span" sx={{ opacity: 0.8 }}>
Response Time
</Typography>{" "}
<Typography component="span">
{payload[0].payload.originalResponseTime}
<Typography component="span" sx={{ opacity: 0.8 }}>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
@@ -64,12 +97,16 @@ const MonitorDetailsAreaChart = ({ checks }) => {
});
};
const memoizedChecks = useMemo(() => checks, []);
const theme = useTheme();
return (
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" minWidth={25} height={220}>
<AreaChart
width={500}
height={400}
data={checks}
width="100%"
height="100%"
data={memoizedChecks}
margin={{
top: 10,
right: 0,
@@ -77,18 +114,41 @@ const MonitorDetailsAreaChart = ({ checks }) => {
bottom: 0,
}}
>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={theme.palette.primary.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.primary.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
stroke={theme.palette.border.dark}
dataKey="createdAt"
tickFormatter={formatDate}
tick={{ fontSize: "13px" }}
tickLine={false}
height={18}
/>
<Tooltip content={<CustomToolTip />} />
<Area
type="monotone"
dataKey="responseTime"
stroke="#29afee"
fill="#eaf2fd"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
Whitespace-only changes.
@@ -1,43 +0,0 @@
import PropTypes from "prop-types";
import { Stack } from "@mui/material";
import { BarChart, Bar, ResponsiveContainer, Cell } from "recharts";
import "./index.css";
const ResponseTimeChart = ({ checks = [] }) => {
return (
<Stack
flexDirection="row"
justifyContent="space-around"
alignItems="flex-end"
height="50px"
width="300px"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={150}
height={40}
data={checks}
style={{ cursor: "pointer" }}
>
<Bar maxBarSize={10} dataKey="responseTime">
{checks.map((check, index) => (
<Cell
key={`cell-${index}`}
fill={
check.status === true
? "var(--success-color)"
: "var(--error-color)"
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Stack>
);
};
ResponseTimeChart.propTypes = {
checks: PropTypes.array,
};
export default ResponseTimeChart;
@@ -0,0 +1,116 @@
import { Autocomplete, TextField } from "@mui/material";
import React from "react";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
/**
* @example
*
* const options = [
* { _id: "66d6119ef959cbc681e034f0", name: "Googler" },
* { _id: "66d6119ef959cbc681e034f0", name: "CNN" },
* { _id: "66d61a1bf959cbc681e0353f", name: "X Corp." },
* ];
*
* <AutoCompleteField options={options} />
*
*/
/**
* AutoCompleteField component.
*
* @component
* @param {Object} props - The component props.
* @param {string} props.id - The ID of the autocomplete field.
* @param {string} props.type - The type of the input field (text or number).
* @param {Array} props.options - The options for the autocomplete field.
* @param {string} props.placeholder - The placeholder text for the input field.
* @param {boolean} props.disabled - Indicates if the field is disabled.
* @returns {JSX.Element} The AutoCompleteField component.
*/
const AutoCompleteField = ({
id,
type,
options,
placeholder = "Type to search",
disabled,
}) => {
const [value, setValue] = React.useState();
const [inputValue, setInputValue] = React.useState("");
const theme = useTheme();
return (
<Autocomplete
freeSolo
className="auto-complete-field"
id={id}
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
options={options}
getOptionLabel={(option) => option.name}
renderInput={(params) => (
<TextField
{...params}
type={type}
placeholder={placeholder}
disabled={disabled}
InputProps={{
...params.InputProps,
sx: {
width: 360,
height: 34,
fontSize: 13,
p: 0,
borderRadius: theme.shape.borderRadius,
"& input": {
p: 0,
},
},
}}
/>
)}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
return (
<li key={option._id} {...optionProps}>
<div>{<span>{option.name}</span>}</div>
</li>
);
}}
slotProps={{
popper: {
sx: {
"& ul": { p: 0 },
"& li": { borderRadius: theme.shape.borderRadius },
},
},
paper: {
sx: {
p: 2,
fontSize: 13,
},
},
}}
/>
);
};
AutoCompleteField.displayName = "AutoCompleteField";
AutoCompleteField.propTypes = {
id: PropTypes.string,
type: PropTypes.oneOf(["text", "number"]),
options: PropTypes.array,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
setWidth: PropTypes.string,
};
export default AutoCompleteField;
+6 -1
View File
@@ -138,6 +138,11 @@ const StatusLabel = ({ status, text, customStyles }) => {
bgColor: theme.palette.error.bg,
borderColor: theme.palette.error.light,
},
pending: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
borderColor: theme.palette.warning.light,
},
"cannot resolve": {
dotColor: theme.palette.unresolved.main,
bgColor: theme.palette.unresolved.bg,
@@ -170,7 +175,7 @@ const StatusLabel = ({ status, text, customStyles }) => {
};
StatusLabel.propTypes = {
status: PropTypes.oneOf(["up", "down", "cannot resolve"]),
status: PropTypes.oneOf(["up", "down", "pending", "cannot resolve"]),
text: PropTypes.string,
customStyles: PropTypes.object,
};
@@ -1,25 +1,13 @@
import { useTheme } from "@emotion/react";
import TabPanel from "@mui/lab/TabPanel";
import {
Box,
Button,
ButtonGroup,
Divider,
IconButton,
Modal,
Stack,
TextField,
Typography,
} from "@mui/material";
import { Button, ButtonGroup, Modal, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import EditSvg from "../../../assets/icons/edit.svg?react";
import Field from "../../Inputs/Field";
import { credentials } from "../../../Validation/validation";
import { networkService } from "../../../main";
import { createToast } from "../../../Utils/toastUtils";
import { useSelector } from "react-redux";
import BasicTable from "../../BasicTable";
import Remove from "../../../assets/icons/trash-bin.svg?react";
import Select from "../../Inputs/Select";
import LoadingButton from "@mui/lab/LoadingButton";
@@ -54,6 +42,7 @@ const TeamPanel = () => {
const [members, setMembers] = useState([]);
const [filter, setFilter] = useState("all");
const [errors, setErrors] = useState({});
const [isSendingInvite, setIsSendingInvite] = useState(false);
useEffect(() => {
const fetchTeam = async () => {
@@ -173,6 +162,7 @@ const TeamPanel = () => {
};
const handleInviteMember = async () => {
setIsSendingInvite(true);
if (!toInvite.role.includes("user") || !toInvite.role.includes("admin"))
setToInvite((prev) => ({ ...prev, role: ["user"] }));
@@ -185,24 +175,28 @@ const TeamPanel = () => {
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
} else
try {
await networkService.requestInvitationToken(
authToken,
toInvite.email,
toInvite.role
);
return;
}
closeInviteModal();
createToast({
body: "Member invited. They will receive an email with details on how to create their account.",
});
} catch (error) {
createToast({
body: error.message || "Unknown error.",
});
}
try {
await networkService.requestInvitationToken(
authToken,
toInvite.email,
toInvite.role
);
closeInviteModal();
createToast({
body: "Member invited. They will receive an email with details on how to create their account.",
});
} catch (error) {
createToast({
body: error.message || "Unknown error.",
});
} finally {
setIsSendingInvite(false);
}
};
const closeInviteModal = () => {
setIsOpen(false);
setToInvite({ email: "", role: ["0"] });
@@ -307,13 +301,14 @@ const TeamPanel = () => {
</Button>
</ButtonGroup>
</Stack>
<Button
<LoadingButton
loading={isSendingInvite}
variant="contained"
color="primary"
onClick={() => setIsOpen(true)}
>
Invite a team member
</Button>
</LoadingButton>
</Stack>
<BasicTable
data={tableData}
@@ -389,14 +384,19 @@ const TeamPanel = () => {
mt={theme.spacing(8)}
justifyContent="flex-end"
>
<Button variant="text" color="info" onClick={closeInviteModal}>
<LoadingButton
loading={isSendingInvite}
variant="text"
color="info"
onClick={closeInviteModal}
>
Cancel
</Button>
</LoadingButton>
<LoadingButton
variant="contained"
color="primary"
onClick={handleInviteMember}
loading={false}
loading={isSendingInvite}
disabled={Object.keys(errors).length !== 0}
>
Send invite
@@ -3,7 +3,7 @@ import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitors: [],
monitorsSummary: [],
success: null,
msg: null,
};
@@ -28,19 +28,35 @@ export const createPageSpeed = createAsyncThunk(
}
);
export const getPagespeedMonitorById = createAsyncThunk(
"monitors/getMonitorById",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.getMonitorByid(authToken, monitorId);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const getPageSpeedByTeamId = createAsyncThunk(
"pageSpeedMonitors/getPageSpeedByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsByTeamId(
const res = await networkService.getMonitorsAndSummaryByTeamId(
token,
user.teamId,
25,
["pagespeed"],
null,
"desc",
false
["pagespeed"]
);
return res.data;
@@ -109,6 +125,25 @@ export const deletePageSpeed = createAsyncThunk(
}
}
);
export const pausePageSpeed = createAsyncThunk(
"pageSpeedMonitors/pausePageSpeed",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.pauseMonitorById(authToken, monitorId);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
const pageSpeedMonitorSlice = createSlice({
name: "pageSpeedMonitor",
@@ -116,7 +151,7 @@ const pageSpeedMonitorSlice = createSlice({
reducers: {
clearMonitorState: (state) => {
state.isLoading = false;
state.monitors = [];
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
@@ -124,7 +159,7 @@ const pageSpeedMonitorSlice = createSlice({
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by userId
// Monitors by teamId
// *****************************************************
.addCase(getPageSpeedByTeamId.pending, (state) => {
@@ -133,7 +168,7 @@ const pageSpeedMonitorSlice = createSlice({
.addCase(getPageSpeedByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitors = action.payload.data;
state.monitorsSummary = action.payload.data;
})
.addCase(getPageSpeedByTeamId.rejected, (state, action) => {
state.isLoading = false;
@@ -143,6 +178,23 @@ const pageSpeedMonitorSlice = createSlice({
: "Getting page speed monitors failed";
})
// *****************************************************
.addCase(getPagespeedMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getPagespeedMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getPagespeedMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to get pagespeed monitor";
})
// *****************************************************
// Create Monitor
// *****************************************************
@@ -163,7 +215,7 @@ const pageSpeedMonitorSlice = createSlice({
})
// *****************************************************
// Create Monitor
// Update Monitor
// *****************************************************
.addCase(updatePageSpeed.pending, (state) => {
state.isLoading = true;
@@ -198,6 +250,24 @@ const pageSpeedMonitorSlice = createSlice({
state.msg = action.payload
? action.payload.msg
: "Failed to delete page speed monitor";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pausePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(pausePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pausePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause page speed monitor";
});
},
});
@@ -3,7 +3,7 @@ import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitors: [],
monitorsSummary: [],
success: null,
msg: null,
};
@@ -53,14 +53,10 @@ export const getUptimeMonitorsByTeamId = createAsyncThunk(
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsByTeamId(
const res = await networkService.getMonitorsAndSummaryByTeamId(
token,
user.teamId,
25,
["http", "ping"],
null,
"desc",
true
["http", "ping"]
);
return res.data;
} catch (error) {
@@ -175,7 +171,7 @@ const uptimeMonitorsSlice = createSlice({
reducers: {
clearUptimeMonitorState: (state) => {
state.isLoading = false;
state.monitors = [];
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
@@ -183,7 +179,7 @@ const uptimeMonitorsSlice = createSlice({
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by userId
// Monitors by teamId
// *****************************************************
.addCase(getUptimeMonitorsByTeamId.pending, (state) => {
@@ -192,7 +188,7 @@ const uptimeMonitorsSlice = createSlice({
.addCase(getUptimeMonitorsByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitors = action.payload.data;
state.monitorsSummary = action.payload.data;
})
.addCase(getUptimeMonitorsByTeamId.rejected, (state, action) => {
state.isLoading = false;
@@ -236,7 +232,7 @@ const uptimeMonitorsSlice = createSlice({
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause uptime monitor";
: "Failed to get uptime monitor";
})
// *****************************************************
// update Monitor
-2
View File
@@ -3,9 +3,7 @@
}
.home-layout {
display: flex;
position: relative;
gap: var(--env-var-spacing-2);
min-height: 100vh;
max-width: 1400px;
margin: 0 auto;
+7 -3
View File
@@ -1,6 +1,6 @@
import Sidebar from "../../Components/Sidebar";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { Box, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
import "./index.css";
@@ -10,10 +10,14 @@ const HomeLayout = () => {
return (
<Box backgroundColor={theme.palette.background.alt}>
<Box className="home-layout">
<Stack
className="home-layout"
flexDirection="row"
gap={theme.spacing(14)}
>
<Sidebar />
<Outlet />
</Box>
</Stack>
</Box>
);
};
@@ -15,14 +15,12 @@ import {
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { networkService } from "../../../main";
import { StatusLabel } from "../../../Components/Label";
import { logger } from "../../../Utils/Logger";
import { useTheme } from "@emotion/react";
const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
const theme = useTheme();
const { authToken, user } = useSelector((state) => state.auth);
+7 -36
View File
@@ -1,43 +1,12 @@
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import {
ButtonGroup,
Stack,
Skeleton,
Typography,
Button,
} from "@mui/material";
import { ButtonGroup, Stack, Typography, Button } from "@mui/material";
import { networkService } from "../../main";
import { useTheme } from "@emotion/react";
import Select from "../../Components/Inputs/Select";
import IncidentTable from "./IncidentTable";
import "./index.css";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<Skeleton variant="rounded" width={150} height={34} />
<Skeleton variant="rounded" width="15%" height={34} />
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ ml: "auto" }}
/>
</Stack>
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</>
);
};
import SkeletonLayout from "./skeleton";
const Incidents = () => {
const theme = useTheme();
@@ -60,11 +29,13 @@ const Incidents = () => {
null,
null,
null,
null,
null,
null
);
// Reduce to a lookup object for 0(1) lookup
if (res.data && res.data.data.length > 0) {
const monitorLookup = res.data.data.reduce((acc, monitor) => {
if (res?.data?.data?.monitors?.length > 0) {
const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => {
acc[monitor._id] = monitor;
return acc;
}, {});
@@ -83,7 +54,7 @@ const Incidents = () => {
};
return (
<Stack className="incidents" pt={theme.spacing(21)} gap={theme.spacing(12)}>
<Stack className="incidents" pt={theme.spacing(20)} gap={theme.spacing(12)}>
{loading ? (
<SkeletonLayout />
) : (
+30
View File
@@ -0,0 +1,30 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<Skeleton variant="rounded" width={150} height={34} />
<Skeleton variant="rounded" width="15%" height={34} />
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ ml: "auto" }}
/>
</Stack>
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</>
);
};
export default SkeletonLayout;
+19 -16
View File
@@ -53,7 +53,6 @@ const Configure = () => {
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const { monitorId } = useParams();
console.log(monitor);
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
@@ -190,6 +189,18 @@ const Configure = () => {
const parsedUrl = parseUrl(monitor?.url);
const protocol = parsedUrl?.protocol?.replace(":", "") || "";
const statusColor = {
true: theme.palette.success.main,
false: theme.palette.error.main,
undefined: theme.palette.warning.main,
};
const statusMsg = {
true: "Your site is up.",
false: "Your site is down.",
undefined: "Pending...",
};
return (
<Stack className="configure-monitor" gap={theme.spacing(12)}>
{Object.keys(monitor).length === 0 ? (
@@ -211,13 +222,7 @@ const Configure = () => {
flex={1}
>
<Stack direction="row" gap={theme.spacing(2)}>
<PulseDot
color={
monitor?.status
? theme.palette.success.main
: theme.palette.error.main
}
/>
<PulseDot color={statusColor[monitor?.status ?? undefined]} />
<Box>
{parsedUrl?.host ? (
<Typography
@@ -234,13 +239,9 @@ const Configure = () => {
<Typography
component="span"
lineHeight={theme.spacing(12)}
sx={{
color: monitor?.status
? theme.palette.success.main
: theme.palette.error.text,
}}
sx={{ color: statusColor[monitor?.status ?? undefined] }}
>
Your site is {monitor?.status ? "up" : "down"}.
{statusMsg[monitor?.status ?? undefined]}
</Typography>
</Box>
<Box
@@ -254,6 +255,7 @@ const Configure = () => {
color="secondary"
loading={isLoading}
sx={{
border: "none",
backgroundColor: theme.palette.background.main,
px: theme.spacing(5),
mr: theme.spacing(6),
@@ -275,14 +277,15 @@ const Configure = () => {
</>
)}
</LoadingButton>
<Button
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
sx={{ px: theme.spacing(8) }}
onClick={() => setIsOpen(true)}
>
Remove
</Button>
</LoadingButton>
</Box>
</Stack>
<ConfigBox>
@@ -0,0 +1,283 @@
import { useTheme } from "@emotion/react";
import {
BarChart,
Bar,
XAxis,
ResponsiveContainer,
Cell,
RadialBarChart,
RadialBar,
} from "recharts";
import { formatDate } from "../../../../Utils/timeUtils";
import { memo, useMemo, useState } from "react";
const CustomLabels = ({
x,
width,
height,
firstDataPoint,
lastDataPoint,
type,
}) => {
let options = {
month: "short",
year: undefined,
hour: undefined,
minute: undefined,
};
if (type === "day") delete options.hour;
return (
<>
<text x={x} y={height} dy={-3} textAnchor="start" fontSize={11}>
{formatDate(new Date(firstDataPoint.time), options)}
</text>
<text x={width} y={height} dy={-3} textAnchor="end" fontSize={11}>
{formatDate(new Date(lastDataPoint.time), options)}
</text>
</>
);
};
export const UpBarChart = memo(({ data, type, onBarHover }) => {
const theme = useTheme();
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
const getColorRange = (uptime) => {
return uptime > 80
? { main: theme.palette.success.main, light: theme.palette.success.light }
: uptime > 50
? { main: theme.palette.warning.main, light: theme.palette.warning.light }
: { main: theme.palette.error.text, light: theme.palette.error.light };
};
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
return (
<ResponsiveContainer width="100%" minWidth={210} height={155}>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalChecks: 0, uptimePercentage: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalChecks"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => {
let { main, light } = getColorRange(entry.uptimePercentage);
return (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index ? main : chartHovered ? light : main
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({
time: null,
totalChecks: 0,
uptimePercentage: 0,
});
}}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
export const DownBarChart = memo(({ data, type, onBarHover }) => {
const theme = useTheme();
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
return (
<ResponsiveContainer width="100%" minWidth={250} height={155}>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalIncidents: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalIncidents"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index
? theme.palette.error.text
: chartHovered
? theme.palette.error.light
: theme.palette.error.text
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({ time: null, totalIncidents: 0 });
}}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
export const ResponseGaugeChart = ({ data }) => {
const theme = useTheme();
let max = 1000; // max ms
const memoizedData = useMemo(
() => [{ response: max, fill: "transparent", background: false }, ...data],
[data[0].response]
);
let responseTime = Math.floor(memoizedData[1].response);
let responseProps =
responseTime <= 200
? {
category: "Excellent",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 500
? {
category: "Fair",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 600
? {
category: "Acceptable",
main: theme.palette.warning.main,
bg: theme.palette.warning.bg,
}
: {
category: "Poor",
main: theme.palette.error.text,
bg: theme.palette.error.bg,
};
return (
<ResponsiveContainer width="100%" minWidth={210} height={155}>
<RadialBarChart
width="100%"
height="100%"
cy="89%"
data={memoizedData}
startAngle={180}
endAngle={0}
innerRadius={100}
outerRadius={150}
>
<text x={0} y="100%" dx="5%" dy={-2} textAnchor="start" fontSize={11}>
low
</text>
<text x="100%" y="100%" dx="-3%" dy={-2} textAnchor="end" fontSize={11}>
high
</text>
<text
x="50%"
y="45%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={18}
fontWeight={400}
>
{responseProps.category}
</text>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="hanging"
fontSize={25}
>
<tspan fontWeight={600}>{responseTime}</tspan>{" "}
<tspan opacity={0.8}>ms</tspan>
</text>
<RadialBar
background={{ fill: responseProps.bg }}
clockWise
dataKey="response"
stroke="none"
>
<Cell fill="transparent" background={false} barSize={0} />
<Cell fill={responseProps.main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
);
};
+1 -18
View File
@@ -1,18 +1 @@
.monitor-details h1.MuiTypography-root {
font-size: var(--env-var-font-size-large-plus);
font-weight: 600;
}
.monitor-details h2.MuiTypography-root {
font-size: var(--env-var-font-size-large);
}
.monitor-details h2.MuiTypography-root {
font-weight: 600;
}
.monitor-details button.MuiButtonBase-root {
height: var(--env-var-height-2);
line-height: 1;
}
.monitor-details p.MuiTypography-root,
.monitor-details p.MuiTypography-root span.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
+378 -232
View File
@@ -3,8 +3,9 @@ import { useEffect, useState, useCallback } from "react";
import {
Box,
Button,
Skeleton,
Popover,
Stack,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
@@ -13,134 +14,28 @@ import { useNavigate, useParams } from "react-router-dom";
import { networkService } from "../../../main";
import { logger } from "../../../Utils/Logger";
import {
formatDate,
formatDuration,
formatDurationRounded,
formatDurationSplit,
} from "../../../Utils/timeUtils";
import MonitorDetailsAreaChart from "../../../Components/Charts/MonitorDetailsAreaChart";
import ButtonGroup from "@mui/material/ButtonGroup";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import CertificateIcon from "../../../assets/icons/certificate.svg?react";
import UptimeIcon from "../../../assets/icons/uptime-icon.svg?react";
import ResponseTimeIcon from "../../../assets/icons/response-time-icon.svg?react";
import AverageResponseIcon from "../../../assets/icons/average-response-icon.svg?react";
import IncidentsIcon from "../../../assets/icons/incidents.svg?react";
import HistoryIcon from "../../../assets/icons/history-icon.svg?react";
import PaginationTable from "./PaginationTable";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import PulseDot from "../../../Components/Animated/PulseDot";
import { StatBox, ChartBox, IconBox } from "./styled";
import { DownBarChart, ResponseGaugeChart, UpBarChart } from "./Charts";
import SkeletonLayout from "./skeleton";
import "./index.css";
const StatBox = ({ title, value }) => {
const theme = useTheme();
return (
<Box
className="stat-box"
flex="20%"
minWidth="100px"
px={theme.spacing(8)}
py={theme.spacing(4)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
<Typography
variant="h6"
mb={theme.spacing(2)}
fontSize={14}
fontWeight={500}
color={theme.palette.primary.main}
sx={{
"& span": {
color: theme.palette.text.accent,
fontSize: 13,
fontStyle: "italic",
},
}}
>
{title}
</Typography>
<Typography
variant="h4"
fontWeight={500}
fontSize={13}
color={theme.palette.text.secondary}
>
{value}
</Typography>
</Box>
);
};
StatBox.propTypes = {
title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="20%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)}>
<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} />
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.spacing(4) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
>
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(8)}
>
<Skeleton
variant="rounded"
width="20%"
height={24}
sx={{ alignSelf: "flex-end" }}
/>
<Skeleton variant="rounded" width="20%" height={34} />
</Stack>
<Box sx={{ height: "200px" }}>
<Skeleton variant="rounded" width="100%" height="100%" />
</Box>
</Box>
<Stack gap={theme.spacing(8)}>
<Skeleton variant="rounded" width="20%" height={24} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={50} />
</Stack>
</Stack>
</>
);
};
/**
* Details page component displaying monitor details and related information.
* @component
@@ -154,6 +49,14 @@ const DetailsPage = ({ isAdmin }) => {
const [certificateExpiry, setCertificateExpiry] = useState("N/A");
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState(null);
const openCertificate = (event) => {
setAnchorEl(event.currentTarget);
};
const closeCertificate = () => {
setAnchorEl(null);
};
const fetchMonitor = useCallback(async () => {
try {
const res = await networkService.getStatsByMonitorId(
@@ -186,7 +89,16 @@ const DetailsPage = ({ isAdmin }) => {
authToken,
monitorId
);
setCertificateExpiry(res?.data?.data?.certificateDate ?? "N/A");
let [month, day, year] = res?.data?.data?.certificateDate.split("/");
const date = new Date(year, month - 1, day);
setCertificateExpiry(
formatDate(date, {
hour: undefined,
minute: undefined,
}) ?? "N/A"
);
} catch (error) {
console.error(error);
}
@@ -194,7 +106,33 @@ const DetailsPage = ({ isAdmin }) => {
fetchCertificate();
}, [authToken, monitorId, monitor]);
const splitDuration = (duration) => {
const { time, format } = formatDurationSplit(duration);
return (
<>
{time}
<Typography component="span">{format}</Typography>
</>
);
};
let loading = Object.keys(monitor).length === 0;
const [hoveredUptimeData, setHoveredUptimeData] = useState(null);
const [hoveredIncidentsData, setHoveredIncidentsData] = useState(null);
const statusColor = {
true: theme.palette.success.main,
false: theme.palette.error.main,
undefined: theme.palette.warning.main,
};
const statusMsg = {
true: "Your site is up.",
false: "Your site is down.",
undefined: "Pending...",
};
return (
<Box className="monitor-details">
{loading ? (
@@ -207,127 +145,209 @@ const DetailsPage = ({ isAdmin }) => {
{ name: "details", path: `/monitors/${monitorId}` },
]}
/>
<Stack gap={theme.spacing(12)} mt={theme.spacing(12)}>
<Stack gap={theme.spacing(10)} mt={theme.spacing(10)}>
<Stack direction="row" gap={theme.spacing(2)}>
<PulseDot
color={
monitor?.status
? theme.palette.success.main
: theme.palette.error.main
}
/>
<Box>
<Typography
component="h1"
fontSize={22}
fontWeight={500}
color={theme.palette.text.primary}
lineHeight={1}
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
{monitor.name}
</Typography>
<Typography
mt={theme.spacing(4)}
color={theme.palette.text.tertiary}
<Stack
direction="row"
alignItems="flex-end"
gap={theme.spacing(2)}
>
<Typography
component="span"
sx={{
color: monitor?.status
? theme.palette.success.main
: theme.palette.success.text,
<Tooltip
title={statusMsg[monitor?.status ?? undefined]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
Your site is {monitor?.status ? "up" : "down"}.
</Typography>{" "}
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
<Box>
<PulseDot
color={statusColor[monitor?.status ?? undefined]}
/>
</Box>
</Tooltip>
<Typography
component="h2"
color={theme.palette.text.secondary}
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
ml={theme.spacing(6)}
lineHeight="20px"
fontSize={12}
position="relative"
color={theme.palette.text.tertiary}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -9,
top: "42%",
},
}}
>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Box>
{isAdmin && (
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
<Stack
direction="row"
height={34}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<IconBox
mr={theme.spacing(4)}
onClick={openCertificate}
sx={{
ml: "auto",
alignSelf: "flex-end",
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.other.icon,
cursor: "pointer",
}}
>
<CertificateIcon />
</IconBox>
<Popover
id="certificate-dropdown"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={closeCertificate}
disableScrollLock
marginThreshold={null}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
slotProps={{
paper: {
sx: {
mt: theme.spacing(4),
py: theme.spacing(2),
px: theme.spacing(4),
width: 140,
backgroundColor: theme.palette.background.accent,
},
},
}}
>
<SettingsIcon /> Configure
</Button>
)}
<Typography fontSize={12} color={theme.palette.text.tertiary}>
Certificate Expiry
</Typography>
<Typography
component="span"
fontSize={13}
color={theme.palette.text.primary}
>
{certificateExpiry}
</Typography>
</Popover>
{isAdmin && (
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
sx={{
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.text.tertiary,
},
},
}}
>
<SettingsIcon /> Configure
</Button>
)}
</Stack>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
flexWrap="wrap"
>
<Stack direction="row" gap={theme.spacing(8)}>
<StatBox
title="Currently up for"
value={formatDuration(monitor?.uptimeDuration)}
/>
<StatBox
title="Last check"
value={`${formatDurationRounded(monitor?.lastChecked)} ago`}
/>
<StatBox title="Incidents" value={monitor?.incidents} />
<StatBox title="Certificate Expiry" value={certificateExpiry} />
<StatBox
title="Latest response time"
value={monitor?.latestResponseTime}
/>
<StatBox
title={
<>
Avg. Response Time{" "}
<Typography component="span">(24-hr)</Typography>
</>
sx={
monitor?.status === undefined
? {
backgroundColor: theme.palette.warning.light,
borderColor: theme.palette.warning.border,
"& h2": { color: theme.palette.warning.main },
}
: monitor?.status
? {
backgroundColor: theme.palette.success.bg,
borderColor: theme.palette.success.light,
"& h2": { color: theme.palette.success.main },
}
: {
backgroundColor: theme.palette.error.bg,
borderColor: theme.palette.error.light,
"& h2": { color: theme.palette.error.main },
}
}
value={parseFloat(monitor?.avgResponseTime24hours)
.toFixed(2)
.replace(/\.?0+$/, "")}
/>
<StatBox
title={
<>
Uptime <Typography component="span">(24-hr)</Typography>
</>
}
value={`${parseFloat(monitor?.uptime24Hours)
.toFixed(2)
.replace(/\.?0+$/, "")}%`}
/>
<StatBox
title={
<>
Uptime <Typography component="span">(30-day)</Typography>
</>
}
value={`${parseFloat(monitor?.uptime30Days)
.toFixed(2)
.replace(/\.?0+$/, "")}%`}
/>
>
<Typography component="h2">active for</Typography>
<Typography>
{splitDuration(monitor?.uptimeDuration)}
</Typography>
</StatBox>
<StatBox>
<Typography component="h2">last check</Typography>
<Typography>
{splitDuration(monitor?.lastChecked)}
<Typography component="span">ago</Typography>
</Typography>
</StatBox>
<StatBox>
<Typography component="h2">last response time</Typography>
<Typography>
{monitor?.latestResponseTime}
<Typography component="span">ms</Typography>
</Typography>
</StatBox>
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
gap={theme.spacing(4)}
mb={theme.spacing(8)}
>
<Typography
component="h2"
alignSelf="flex-end"
color={theme.palette.text.secondary}
>
Response Times
<Typography fontSize={12} color={theme.palette.text.tertiary}>
Showing statistics for past{" "}
{dateRange === "day"
? "24 hours"
: dateRange === "week"
? "7 days"
: "30 days"}
.
</Typography>
<ButtonGroup>
<ButtonGroup sx={{ height: 32 }}>
<Button
variant="group"
filled={(dateRange === "day").toString()}
@@ -351,26 +371,152 @@ const DetailsPage = ({ isAdmin }) => {
</Button>
</ButtonGroup>
</Stack>
<Box
p={theme.spacing(10)}
pb={theme.spacing(2)}
backgroundColor={theme.palette.background.main}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
sx={{ height: "250px" }}
>
<MonitorDetailsAreaChart
checks={[...monitor.checks].reverse()}
/>
</Box>
<Stack direction="row" flexWrap="wrap" gap={theme.spacing(8)}>
<ChartBox>
<Stack>
<IconBox>
<UptimeIcon />
</IconBox>
<Typography component="h2">Uptime</Typography>
</Stack>
<Stack justifyContent="space-between">
<Box position="relative">
<Typography>Total Checks</Typography>
<Typography component="span">
{hoveredUptimeData !== null
? hoveredUptimeData.totalChecks
: monitor?.periodTotalChecks}
</Typography>
{hoveredUptimeData !== null &&
hoveredUptimeData.time !== null && (
<Typography
component="h5"
position="absolute"
top="100%"
fontSize={11}
color={theme.palette.text.tertiary}
>
{formatDate(new Date(hoveredUptimeData.time), {
month: "short",
year: undefined,
minute: undefined,
hour: dateRange === "day" ? "numeric" : undefined,
})}
</Typography>
)}
</Box>
<Box>
<Typography>Uptime Percentage</Typography>
<Typography component="span">
{hoveredUptimeData !== null
? Math.floor(
hoveredUptimeData.uptimePercentage * 10
) / 10
: Math.floor(monitor?.periodUptime * 10) / 10}
<Typography component="span">%</Typography>
</Typography>
</Box>
</Stack>
<UpBarChart
data={monitor?.aggregateData}
type={dateRange}
onBarHover={setHoveredUptimeData}
/>
</ChartBox>
<ChartBox>
<Stack>
<IconBox>
<IncidentsIcon />
</IconBox>
<Typography component="h2">Incidents</Typography>
</Stack>
<Box position="relative">
<Typography>Total Incidents</Typography>
<Typography component="span">
{hoveredIncidentsData !== null
? hoveredIncidentsData.totalIncidents
: monitor?.periodIncidents}
</Typography>
{hoveredIncidentsData !== null &&
hoveredIncidentsData.time !== null && (
<Typography
component="h5"
position="absolute"
top="100%"
fontSize={11}
color={theme.palette.text.tertiary}
>
{formatDate(new Date(hoveredIncidentsData.time), {
month: "short",
year: undefined,
minute: undefined,
hour: dateRange === "day" ? "numeric" : undefined,
})}
</Typography>
)}
</Box>
<DownBarChart
data={monitor?.aggregateData}
type={dateRange}
onBarHover={setHoveredIncidentsData}
/>
</ChartBox>
<ChartBox justifyContent="space-between">
<Stack>
<IconBox>
<AverageResponseIcon />
</IconBox>
<Typography component="h2">
Average Response Time
</Typography>
</Stack>
<ResponseGaugeChart
data={[{ response: monitor?.periodAvgResponseTime }]}
/>
</ChartBox>
<ChartBox
sx={{
"& tspan": {
fontSize: 11,
},
}}
>
<Stack>
<IconBox>
<ResponseTimeIcon />
</IconBox>
<Typography component="h2">Response Times</Typography>
</Stack>
<MonitorDetailsAreaChart
checks={[...monitor.checks].reverse()}
/>
</ChartBox>
<ChartBox
gap={theme.spacing(8)}
sx={{
flex: "100%",
height: "fit-content",
"& nav": { mt: theme.spacing(12) },
}}
>
<Stack mb={theme.spacing(8)}>
<IconBox>
<HistoryIcon />
</IconBox>
<Typography
component="h2"
color={theme.palette.text.secondary}
>
History
</Typography>
</Stack>
<PaginationTable
monitorId={monitorId}
dateRange={dateRange}
/>
</ChartBox>
</Stack>
</Box>
<Stack gap={theme.spacing(8)}>
<Typography component="h2" color={theme.palette.text.secondary}>
History
</Typography>
<PaginationTable monitorId={monitorId} dateRange={dateRange} />
</Stack>
</Stack>
</>
)}
@@ -0,0 +1,73 @@
import { Box, Skeleton, Stack, useTheme } from "@mui/material";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="20%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)}>
<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} />
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.spacing(4) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
>
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(8)}
>
<Skeleton
variant="rounded"
width="20%"
height={24}
sx={{ alignSelf: "flex-end" }}
/>
<Skeleton variant="rounded" width="20%" height={34} />
</Stack>
<Box sx={{ height: "200px" }}>
<Skeleton variant="rounded" width="100%" height="100%" />
</Box>
</Box>
<Stack gap={theme.spacing(8)}>
<Skeleton variant="rounded" width="20%" height={24} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={50} />
</Stack>
</Stack>
</>
);
};
export default SkeletonLayout;
@@ -0,0 +1,95 @@
import { Box, Stack, styled } from "@mui/material";
export const ChartBox = styled(Stack)(({ theme }) => ({
flex: "1 30%",
gap: theme.spacing(8),
height: 300,
minWidth: 250,
padding: theme.spacing(8),
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
backgroundColor: theme.palette.background.main,
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 500,
},
"& .MuiBox-root:not(.area-tooltip) p": {
color: theme.palette.text.tertiary,
fontSize: 13,
},
"& .MuiBox-root > span": {
color: theme.palette.text.primary,
fontSize: 20,
"& span": {
opacity: 0.8,
marginLeft: 2,
fontSize: 15,
},
},
"& .MuiStack-root": {
flexDirection: "row",
gap: theme.spacing(6),
},
"& .MuiStack-root:first-of-type": {
alignItems: "center",
},
"& tspan, & text": {
fill: theme.palette.text.tertiary,
},
"& path": {
transition: "fill 300ms 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,
},
},
}));
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,
"& 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,
},
},
}));
@@ -0,0 +1,352 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
} from "@mui/material";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import { setRowsPerPage } from "../../../../Features/UI/uiSlice";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import { logger } from "../../../../Utils/Logger";
import Host from "../host";
import { StatusLabel } from "../../../../Components/Label";
import { jwtDecode } from "jwt-decode";
import { useDispatch, useSelector } from "react-redux";
import { networkService } from "../../../../main";
import { useTheme } from "@emotion/react";
import BarChart from "../../../../Components/Charts/BarChart";
import LeftArrowDouble from "../../../../assets/icons/left-arrow-double.svg?react";
import RightArrowDouble from "../../../../assets/icons/right-arrow-double.svg?react";
import LeftArrow from "../../../../assets/icons/left-arrow.svg?react";
import RightArrow from "../../../../assets/icons/right-arrow.svg?react";
import SelectorVertical from "../../../../assets/icons/selector-vertical.svg?react";
import ActionsMenu from "../actionsMenu";
/**
* Component for pagination actions (first, previous, next, last).
*
* @component
* @param {Object} props
* @param {number} props.count - Total number of items.
* @param {number} props.page - Current page number.
* @param {number} props.rowsPerPage - Number of rows per page.
* @param {function} props.onPageChange - Callback function to handle page change.
*
* @returns {JSX.Element} Pagination actions component.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
};
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
const MonitorTable = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const { rowsPerPage } = useSelector((state) => state.ui.monitors);
const [page, setPage] = useState(0);
const [monitors, setMonitors] = useState([]);
const [monitorCount, setMonitorCount] = useState(0);
const authState = useSelector((state) => state.auth);
const [updateTrigger, setUpdateTrigger] = useState(false);
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "monitors",
})
);
setPage(0);
};
useEffect(() => {
const fetchPage = async () => {
try {
const { authToken } = authState;
const user = jwtDecode(authToken);
const res = await networkService.getMonitorsByTeamId(
authToken,
user.teamId,
25,
["http", "ping"],
null,
"desc",
true,
page,
rowsPerPage
);
setMonitors(res?.data?.data?.monitors ?? []);
setMonitorCount(res?.data?.data?.monitorCount ?? 0);
} catch (error) {
logger.error(error);
}
};
fetchPage();
}, [updateTrigger, authState, page, rowsPerPage]);
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, monitorCount);
return `${start} - ${end}`;
};
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Host</TableCell>
<TableCell>
{" "}
<Box width="max-content">
Status
<span>
<ArrowDownwardRoundedIcon />
</span>
</Box>
</TableCell>
<TableCell>Response Time</TableCell>
<TableCell>Type</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{monitors.map((monitor) => {
let uptimePercentage = "";
let percentageColor = theme.palette.percentage.uptimeExcellent;
// Determine uptime percentage and color based on the monitor's uptimePercentage value
if (monitor.uptimePercentage !== undefined) {
uptimePercentage =
monitor.uptimePercentage === 0
? "0"
: (monitor.uptimePercentage * 100).toFixed(2);
percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.percentage.uptimePoor
: monitor.uptimePercentage < 0.5
? theme.palette.percentage.uptimeFair
: monitor.uptimePercentage < 0.75
? theme.palette.percentage.uptimeGood
: theme.palette.percentage.uptimeExcellent;
}
const params = {
url: monitor.url,
title: monitor.name,
percentage: uptimePercentage,
percentageColor,
status:
monitor.status === undefined
? "pending"
: monitor.status === true
? "up"
: "down",
};
return (
<TableRow
key={monitor._id}
sx={{
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
onClick={() => {
navigate(`/monitors/${monitor._id}`);
}}
>
<TableCell>
<Host key={monitor._id} params={params} />
</TableCell>
<TableCell>
<StatusLabel
status={params.status}
text={params.status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
<BarChart checks={monitor.checks.slice().reverse()} />
</TableCell>
<TableCell>
<span style={{ textTransform: "uppercase" }}>
{monitor.type}
</span>
</TableCell>
<TableCell>
<ActionsMenu
monitor={monitor}
isAdmin={isAdmin}
updateCallback={handleActionMenuDelete}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
sx={{
"& p": {
color: theme.palette.text.tertiary,
},
}}
>
<Typography px={theme.spacing(2)} fontSize={12} sx={{ opacity: 0.7 }}>
Showing {getRange()} of {monitorCount} monitor(s)
</Typography>
<TablePagination
component="div"
count={monitorCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
</>
);
};
MonitorTable.propTypes = {
isAdmin: PropTypes.bool,
};
export default MonitorTable;
+1 -2
View File
@@ -48,8 +48,7 @@ const StatusBox = ({ title, value }) => {
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
px={theme.spacing(12)}
py={theme.spacing(8)}
p={theme.spacing(8)}
overflow="hidden"
sx={{
"&:hover": {
@@ -19,7 +19,7 @@ import {
import Settings from "../../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
const ActionsMenu = ({ monitor, isAdmin }) => {
const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
@@ -37,6 +37,7 @@ const ActionsMenu = ({ monitor, isAdmin }) => {
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
updateCallback();
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
@@ -222,6 +223,7 @@ ActionsMenu.propTypes = {
type: PropTypes.string,
}).isRequired,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
};
export default ActionsMenu;
+2 -1
View File
@@ -53,7 +53,8 @@ Host.propTypes = {
params: PropTypes.shape({
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.number,
percentage: PropTypes.string,
url: PropTypes.string,
}).isRequired,
};
+30 -50
View File
@@ -4,15 +4,14 @@ import { useSelector, useDispatch } from "react-redux";
import { getUptimeMonitorsByTeamId } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import BasicTable from "../../../Components/BasicTable";
import { Box, Button, Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import SkeletonLayout from "./skeleton";
import Fallback from "./fallback";
import StatusBox from "./StatusBox";
import { buildData } from "./monitorData";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import Greeting from "../../../Utils/greeting";
import MonitorTable from "./MonitorTable";
const Monitors = ({ isAdmin }) => {
const theme = useTheme();
@@ -24,43 +23,11 @@ const Monitors = ({ isAdmin }) => {
useEffect(() => {
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
}, [authState.authToken, dispatch]);
const monitorStats = monitorState.monitors.reduce(
(acc, monitor) => {
if (monitor.isActive === false) {
acc["paused"] += 1;
} else if (monitor.status === true) {
acc["up"] += 1;
} else {
acc["down"] += 1;
}
return acc;
},
{ paused: 0, up: 0, down: 0 }
);
const data = buildData(monitorState.monitors, isAdmin, navigate);
let loading = monitorState.isLoading && monitorState.monitors.length === 0;
const now = new Date();
const hour = now.getHours();
let greeting = "";
let emoji = "";
if (hour < 12) {
greeting = "morning";
emoji = "🌅";
} else if (hour < 18) {
greeting = "afternoon";
emoji = "🌞";
} else {
greeting = "evening";
emoji = "🌙";
}
let loading =
monitorState?.isLoading &&
monitorState?.monitorsSummary?.monitors?.length === 0;
return (
<Stack className="monitors" gap={theme.spacing(12)}>
<Stack className="monitors" gap={theme.spacing(8)}>
{loading ? (
<SkeletonLayout />
) : (
@@ -74,7 +41,7 @@ const Monitors = ({ isAdmin }) => {
mt={theme.spacing(5)}
>
<Greeting type="uptime" />
{monitorState.monitors?.length !== 0 && (
{monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<Button
variant="contained"
color="primary"
@@ -88,25 +55,37 @@ const Monitors = ({ isAdmin }) => {
)}
</Stack>
</Box>
{isAdmin && monitorState.monitors?.length === 0 && (
{isAdmin && monitorState?.monitorsSummary?.monitors?.length === 0 && (
<Fallback isAdmin={isAdmin} />
)}
{monitorState.monitors?.length !== 0 && (
{monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<>
<Stack
gap={theme.spacing(12)}
gap={theme.spacing(8)}
direction="row"
justifyContent="space-between"
>
<StatusBox title="up" value={monitorStats.up} />
<StatusBox title="down" value={monitorStats.down} />
<StatusBox title="paused" value={monitorStats.paused} />
<StatusBox
title="up"
value={monitorState?.monitorsSummary?.monitorCounts?.up ?? 0}
/>
<StatusBox
title="down"
value={
monitorState?.monitorsSummary?.monitorCounts?.down ?? 0
}
/>
<StatusBox
title="paused"
value={
monitorState?.monitorsSummary?.monitorCounts?.paused ?? 0
}
/>
</Stack>
<Box
flex={1}
px={theme.spacing(16)}
py={theme.spacing(12)}
p={theme.spacing(10)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
@@ -115,7 +94,7 @@ const Monitors = ({ isAdmin }) => {
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(12)}
mb={theme.spacing(8)}
>
<Typography
component="h2"
@@ -131,11 +110,12 @@ const Monitors = ({ isAdmin }) => {
borderColor={theme.palette.border.light}
backgroundColor={theme.palette.background.accent}
>
{monitorState.monitors.length}
{monitorState?.monitorsSummary?.monitors?.monitors
?.length || 0}
</Box>
{/* TODO - add search bar */}
</Stack>
<BasicTable data={data} paginated={true} table={"monitors"} />
<MonitorTable isAdmin={isAdmin} />
</Box>
</>
)}
+30 -11
View File
@@ -27,30 +27,52 @@ const data = {
rows: [],
};
/**
* Builds table data for a list of monitors.
*
* @param {Array} monitors - An array of monitor objects containing information about each monitor.
* @param {boolean} isAdmin - Flag indicating if the current user is an admin.
* @param {Function} navigate - A function to navigate to the monitor detail page.
* @returns {Object} The data structure containing columns and rows for the table.
*/
export const buildData = (monitors, isAdmin, navigate) => {
const theme = useTheme();
data.rows = monitors.map((monitor, idx) => {
let uptimePercentage = "";
let percentageColor = theme.palette.percentage.uptimeExcellent;
// Determine uptime percentage and color based on the monitor's uptimePercentage value
if (monitor.uptimePercentage !== undefined) {
uptimePercentage =
monitor.uptimePercentage === 0
? "0"
: (monitor.uptimePercentage * 100).toFixed(2);
? "0"
: (monitor.uptimePercentage * 100).toFixed(2);
percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.percentage.uptimePoor
: monitor.uptimePercentage < 0.5
? theme.palette.percentage.uptimeFair
: monitor.uptimePercentage < 0.75
? theme.palette.percentage.uptimeGood
: theme.palette.percentage.uptimeExcellent;
}
const params = {
url: monitor.url,
title: monitor.name,
percentage: uptimePercentage,
percentageColor:
monitor.status === true
? theme.palette.success.main
: theme.palette.error.text,
status: monitor.status === true ? "up" : "down",
percentageColor,
status:
monitor.status === undefined
? "pending"
: monitor.status === true
? "up"
: "down",
};
// Reverse checks so latest check is on the right
// Reverse checks so the latest check is on the right
const reversedChecks = monitor.checks.slice().reverse();
return {
@@ -86,6 +108,3 @@ export const buildData = (monitors, isAdmin, navigate) => {
});
return data;
};
+63 -70
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { Box, Button, Modal, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Button, Modal, Stack, Typography } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router";
import {
deletePageSpeed,
getPagespeedMonitorById,
getPageSpeedByTeamId,
updatePageSpeed,
pausePageSpeed,
} from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
@@ -17,63 +19,18 @@ import Checkbox from "../../../Components/Inputs/Checkbox";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import PulseDot from "../../../Components/Animated/PulseDot";
import LoadingButton from "@mui/lab/LoadingButton";
import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded";
import SkeletonLayout from "./skeleton";
import "./index.css";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
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>
</>
);
};
const PageSpeedConfigure = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const MS_PER_MINUTE = 60000;
const { authToken } = useSelector((state) => state.auth);
const { monitors } = useSelector((state) => state.pageSpeedMonitors);
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
@@ -89,15 +46,25 @@ const PageSpeedConfigure = () => {
];
useEffect(() => {
const data = monitors.find((monitor) => monitor._id === monitorId);
if (!data) {
logger.error("Error fetching pagespeed monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
setMonitor({
...data,
});
}, [monitorId, monitors, navigate]);
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]);
const handleChange = (event, id) => {
let { value } = event.target;
@@ -119,6 +86,21 @@ const PageSpeedConfigure = () => {
});
};
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(
@@ -143,11 +125,9 @@ const PageSpeedConfigure = () => {
}
};
let loading = Object.keys(monitor).length === 0;
return (
<Stack className="configure-pagespeed" gap={theme.spacing(12)}>
{loading ? (
{Object.keys(monitor).length === 0 ? (
<SkeletonLayout />
) : (
<>
@@ -195,7 +175,9 @@ const PageSpeedConfigure = () => {
</Typography>
</Box>
<Box alignSelf="flex-end" ml="auto">
<Button
<LoadingButton
onClick={handlePause}
loading={isLoading}
variant="contained"
color="secondary"
sx={{
@@ -210,10 +192,20 @@ const PageSpeedConfigure = () => {
},
}}
>
<PauseCircleOutlineIcon />
Pause
</Button>
<Button
{monitor?.isActive ? (
<>
<PauseCircleOutlineIcon />
Pause
</>
) : (
<>
<PlayCircleOutlineRoundedIcon />
Resume
</>
)}
</LoadingButton>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
@@ -222,7 +214,7 @@ const PageSpeedConfigure = () => {
}}
>
Remove
</Button>
</LoadingButton>
</Box>
</Stack>
<Stack
@@ -322,7 +314,8 @@ const PageSpeedConfigure = () => {
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" mt="auto">
<Button
<LoadingButton
loading={isLoading}
type="submit"
variant="contained"
color="primary"
@@ -330,7 +323,7 @@ const PageSpeedConfigure = () => {
sx={{ px: theme.spacing(12) }}
>
Save
</Button>
</LoadingButton>
</Stack>
</Stack>
</>
@@ -0,0 +1,51 @@
import { Box, Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
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>
</>
);
};
export default SkeletonLayout;
@@ -1,26 +1,136 @@
import { Box, Button, Stack, Typography } from "@mui/material";
import { useState } from "react";
import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material";
import { useSelector, useDispatch } from "react-redux";
import { monitorValidation } from "../../../Validation/validation";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router";
import { createPageSpeed } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../../Monitors/styled";
import Radio from "../../../Components/Inputs/Radio";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { createPageSpeed } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import "./index.css";
import { logger } from "../../../Utils/Logger";
const CreatePageSpeed = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
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 [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];
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 }
);
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 { error } = monitorValidation.validate(form, {
abortEarly: false,
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(
createPageSpeed({ authToken, monitor: form })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/pagespeed");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
@@ -30,75 +140,15 @@ const CreatePageSpeed = () => {
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
const [form, setForm] = useState({
name: "",
url: "",
interval: 3,
});
const [errors, setErrors] = useState({});
const handleChange = (event, id) => {
const { value } = event.target;
setForm((prev) => ({ ...prev, [id]: value }));
const { error } = monitorValidation.validate(
{ [id]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const newErrors = { ...prev };
if (error) newErrors[id] = error.details[0].message;
else delete newErrors[id];
return newErrors;
});
};
const handleCreate = async (event) => {
event.preventDefault();
let monitor = {
url: "http://" + form.url,
name: form.name === "" ? form.url : form.name,
};
const { error } = monitorValidation.validate(form, { abortEarly: false });
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validating data." });
} else {
monitor = {
...monitor,
description: monitor.name,
userId: user._id,
teamId: user.teamId,
interval: form.interval * MS_PER_MINUTE,
type: "pagespeed",
};
try {
const action = await dispatch(createPageSpeed({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/pagespeed");
}
} catch (error) {
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Unknown error.",
});
}
}
};
return (
<Stack className="create-page-speed" gap={theme.spacing(6)}>
<Box
className="create-monitor"
sx={{
"& h1": {
color: theme.palette.text.primary,
},
}}
>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
@@ -107,18 +157,14 @@ const CreatePageSpeed = () => {
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
onSubmit={handleCreate}
gap={theme.spacing(12)}
flex={1}
mt={theme.spacing(6)}
>
<Typography
component="h1"
lineHeight={1}
fontSize={21}
color={theme.palette.text.primary}
>
<Typography component="h1">
<Typography
component="span"
fontSize="inherit"
@@ -127,117 +173,170 @@ const CreatePageSpeed = () => {
Create your{" "}
</Typography>
<Typography component="span" fontSize="inherit" fontWeight="inherit">
pagespeed{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.text.secondary}
>
monitor
Pagespeed monitor
</Typography>
</Typography>
<Stack
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
p={theme.spacing(20)}
pl={theme.spacing(15)}
gap={theme.spacing(20)}
sx={{
"& h3, & p": {
color: theme.palette.text.secondary,
},
}}
>
<Stack direction="row">
<Typography component="h3">Monitor display name</Typography>
<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(15)}>
<Field
type={"url"}
id="monitor-url"
label="URL to monitor"
https={https}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
placeholder="Example monitor"
value={form.name}
onChange={(event) => handleChange(event, "name")}
error={errors.name}
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">URL</Typography>
<Field
type="url"
id="monitor-url"
placeholder="random.website.com"
value={form.url}
onChange={(event) => handleChange(event, "url")}
error={errors.url}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">Check frequency</Typography>
<Select
id="monitor-frequency"
items={frequencies}
value={form.interval}
onChange={(event) => handleChange(event, "interval")}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">
Incidents notifications{" "}
<Typography component="span">(coming soon)</Typography>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
<Stack
className="section-disabled"
backgroundColor={theme.palette.background.fill}
>
<Typography mb={theme.spacing(4)}>
When there is a new incident,
</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
isDisabled={true}
/>
<Checkbox
id="notify-email"
label="Notify via email (to gorkem.cetin@bluewavelabs.ca)"
isChecked={false}
/>
<Checkbox
id="notify-emails"
label="Notify via email to following emails"
isChecked={false}
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "pagespeed"}
onChange={(event) => handleChange(event)}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
</Stack>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</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"
)}
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-emails-list"
placeholder="notifications@gmail.com"
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
error=""
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
</Stack>
) : (
""
)}
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" mt="auto">
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 3}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
onClick={handleCreate}
sx={{ px: theme.spacing(12), mt: theme.spacing(12) }}
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
>
Create
Create monitor
</Button>
</Stack>
</Stack>
</Stack>
</Box>
);
};
+1 -1
View File
@@ -213,7 +213,7 @@ const PageSpeedDetails = () => {
monitorId,
"desc",
50,
null,
"day",
null,
null
);
+78
View File
@@ -0,0 +1,78 @@
import { getLastChecked } from "../../Utils/monitorUtils";
import { formatDate, formatDurationRounded } from "../../Utils/timeUtils";
import PageSpeedIcon from "../../assets/icons/page-speed.svg?react";
import { StatusLabel } from "../../Components/Label";
import { Box, Grid, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const Card = ({ data }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<Grid
item
lg={6}
flexGrow={1}
sx={{
"&:hover > .MuiStack-root": {
backgroundColor: theme.palette.background.accent,
},
}}
>
<Stack
direction="row"
gap={theme.spacing(6)}
p={theme.spacing(8)}
onClick={() => navigate(`/pagespeed/${data._id}`)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
sx={{
cursor: "pointer",
"& svg path": { stroke: theme.palette.other.icon, strokeWidth: 0.8 },
}}
>
<PageSpeedIcon
style={{ width: theme.spacing(8), height: theme.spacing(8) }}
/>
<Box flex={1}>
<Stack direction="row" justifyContent="space-between">
<Typography
component="h2"
mb={theme.spacing(2)}
color={theme.palette.primary.main}
>
{data.name}
</Typography>
<StatusLabel
status={data.status ? "up" : "cannot resolve"}
text={data.status ? "Live (collecting data)" : "Inactive"}
/>
</Stack>
<Typography fontSize={13}>
{data.url.replace(/^https?:\/\//, "")}
</Typography>
<Typography mt={theme.spacing(12)}>
<Typography component="span" fontWeight={600}>
Last checked:{" "}
</Typography>
{formatDate(getLastChecked(data.checks, false))}{" "}
<Typography component="span" fontStyle="italic">
({formatDurationRounded(getLastChecked(data.checks))} ago)
</Typography>
</Typography>
</Box>
</Stack>
</Grid>
);
};
Card.propTypes = {
data: PropTypes.object.isRequired,
};
export default Card;
+9 -140
View File
@@ -1,155 +1,24 @@
import { Box, Button, Grid, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Button, Grid, Stack } from "@mui/material";
import { useEffect } from "react";
import { useTheme } from "@emotion/react";
import { formatDate, formatDurationRounded } from "../../Utils/timeUtils";
import { StatusLabel } from "../../Components/Label";
import { useDispatch, useSelector } from "react-redux";
import { getPageSpeedByTeamId } from "../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import PageSpeedIcon from "../../assets/icons/page-speed.svg?react";
import Fallback from "../../Components/Fallback";
import "./index.css";
import { useNavigate } from "react-router";
import { getLastChecked } from "../../Utils/monitorUtils";
import PropTypes from "prop-types";
import Breadcrumbs from "../../Components/Breadcrumbs";
import Greeting from "../../Utils/greeting";
const Card = ({ data }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<Grid
item
lg={6}
flexGrow={1}
sx={{
"&:hover > .MuiStack-root": {
backgroundColor: theme.palette.background.accent,
},
}}
>
<Stack
direction="row"
gap={theme.spacing(6)}
p={theme.spacing(8)}
onClick={() => navigate(`/pagespeed/${data._id}`)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
sx={{
cursor: "pointer",
"& svg path": { stroke: theme.palette.other.icon, strokeWidth: 0.8 },
}}
>
<PageSpeedIcon
style={{ width: theme.spacing(8), height: theme.spacing(8) }}
/>
<Box flex={1}>
<Stack direction="row" justifyContent="space-between">
<Typography
component="h2"
mb={theme.spacing(2)}
color={theme.palette.primary.main}
>
{data.name}
</Typography>
<StatusLabel
status={data.status ? "up" : "cannot resolve"}
text={data.status ? "Live (collecting data)" : "Inactive"}
/>
</Stack>
<Typography fontSize={13}>
{data.url.replace(/^https?:\/\//, "")}
</Typography>
<Typography mt={theme.spacing(12)}>
<Typography component="span" fontWeight={600}>
Last checked:{" "}
</Typography>
{formatDate(getLastChecked(data.checks, false))}{" "}
<Typography component="span" fontStyle="italic">
({formatDurationRounded(getLastChecked(data.checks))} ago)
</Typography>
</Typography>
</Box>
</Stack>
</Grid>
);
};
Card.propTypes = {
data: PropTypes.object.isRequired,
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
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>
);
};
import SkeletonLayout from "./skeleton";
import Card from "./card";
const PageSpeed = ({ isAdmin }) => {
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
const { authToken, user } = useSelector((state) => state.auth);
const { monitors, isLoading } = useSelector(
const { authToken } = useSelector((state) => state.auth);
const { monitorsSummary, isLoading } = useSelector(
(state) => state.pageSpeedMonitors
);
useEffect(() => {
@@ -158,7 +27,7 @@ const PageSpeed = ({ isAdmin }) => {
// 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;
let isActuallyLoading = isLoading && monitorsSummary?.monitors?.length === 0;
return (
<Box
@@ -177,7 +46,7 @@ const PageSpeed = ({ isAdmin }) => {
>
{isActuallyLoading ? (
<SkeletonLayout />
) : monitors?.length !== 0 ? (
) : monitorsSummary?.monitors?.length !== 0 ? (
<Box
sx={{
"& p": {
@@ -204,8 +73,8 @@ const PageSpeed = ({ isAdmin }) => {
</Stack>
</Box>
<Grid container spacing={theme.spacing(12)}>
{monitors?.map((monitor) => (
<Card data={monitor} key={`monitor-${monitor._id}`} />
{monitorsSummary?.monitors?.map((monitor) => (
<Card data={monitor} key={monitor._id} />
))}
</Grid>
</Box>
+65
View File
@@ -0,0 +1,65 @@
import { Box, Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
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>
);
};
export default SkeletonLayout;
+6 -2
View File
@@ -16,6 +16,9 @@ const Settings = ({ isAdmin }) => {
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const dispatch = useDispatch();
// TODO Handle saving
const handleClearStats = async () => {
try {
const action = await dispatch(
@@ -156,13 +159,14 @@ const Settings = ({ isAdmin }) => {
</Box>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<Button
<LoadingButton
loading={false}
variant="contained"
color="primary"
sx={{ px: theme.spacing(12), mt: theme.spacing(20) }}
>
Save
</Button>
</LoadingButton>
</Stack>
</Stack>
</Box>
+51 -3
View File
@@ -58,9 +58,28 @@ class NetworkService {
});
}
async getMonitorsAndSummaryByTeamId(authToken, teamId, types) {
const params = new URLSearchParams();
if (types) {
types.forEach((type) => {
params.append("type", type);
});
}
return this.axiosInstance.get(
`/monitors/team/summary/${teamId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
}
/**
* ************************************
* Get all uptime monitors for a user
* Get all uptime monitors for a Team
* ************************************
*
* @async
@@ -80,7 +99,9 @@ class NetworkService {
types,
status,
sortOrder,
normalize
normalize,
page,
rowsPerPage
) {
const params = new URLSearchParams();
@@ -93,6 +114,8 @@ class NetworkService {
if (status) params.append("status", status);
if (sortOrder) params.append("sortOrder", sortOrder);
if (normalize) params.append("normalize", normalize);
if (page) params.append("page", page);
if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
return this.axiosInstance.get(
`/monitors/team/${teamId}?${params.toString()}`,
@@ -116,7 +139,7 @@ class NetworkService {
* @param {string} [sortOrder] - The order in which to sort the retrieved statistics.
* @param {number} [limit] - The maximum number of statistics to retrieve.
* @param {string} [dateRange] - The date range for which to retrieve statistics.
* @param {number} [numToDisplay] - The number of statistics to display.
* @param {number} [numToDisplay] - The number of checks to display.
* @param {boolean} [normalize] - Whether to normalize the retrieved statistics.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*/
@@ -146,6 +169,31 @@ class NetworkService {
);
}
/**
* ************************************
* Gets aggregate stats by monitor ID
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor whose certificate expiry is to be retrieved.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getAggregateStatsById(authToken, monitorId, dateRange) {
const params = new URLSearchParams();
if (dateRange) params.append("dateRange", dateRange);
return this.axiosInstance.get(
`/monitors/aggregate/${monitorId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
}
/**
* ************************************
* Updates a single monitor
+32 -10
View File
@@ -4,16 +4,16 @@ const text = {
primary: "#fafafa",
secondary: "#e6e6e6",
tertiary: "#a1a1aa",
accent: "#e6e6e6",
accent: "#8e8e8f",
disabled: "rgba(172, 172, 172, 0.3)",
};
const background = {
main: "#151518",
alt: "#09090b",
fill: "#2e2e2e",
fill: "#2D2D33",
accent: "#18181a",
};
const border = { light: "#27272a", dark: "#2c2c2c" };
const border = { light: "#27272a", dark: "#36363e" };
const fontFamilyDefault =
'"Inter","system-ui", "Avenir", "Helvetica", "Arial", sans-serif';
@@ -25,7 +25,7 @@ const darkTheme = createTheme({
palette: {
mode: "dark",
primary: { main: "#1570ef" },
secondary: { main: "#2e2e2e" },
secondary: { main: "#2D2D33" },
text: text,
background: background,
border: border,
@@ -39,24 +39,30 @@ const darkTheme = createTheme({
success: {
text: "#079455",
main: "#45bb7a",
light: "#1e1e1e",
bg: "#27272a",
light: "#1c4428",
bg: "#12261e",
},
error: {
text: "#f04438",
main: "#d32f2f",
light: "#1e1e1e",
bg: "#27272a",
light: "#542426",
bg: "#301a1f",
dark: "#932020",
border: "#f04438",
},
warning: {
text: "#e88c30",
main: "#FF9F00",
light: "#27272a",
bg: "#1E1E1E",
light: "#272115",
bg: "#624711",
border: "#e88c30",
},
percentage: {
uptimePoor: "#d32f2f",
uptimeFair: "#e88c30",
uptimeGood: "#ffd600",
uptimeExcellent: "#079455",
},
unresolved: { main: "#4e5ba6", light: "#e2eaf7", bg: "#f2f4f7" },
divider: border.light,
other: {
@@ -92,6 +98,15 @@ const darkTheme = createTheme({
backgroundColor: theme.palette.secondary.main,
},
},
{
props: (props) =>
props.variant === "contained" && props.color === "secondary",
style: {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
},
},
],
fontWeight: 400,
borderRadius: 4,
@@ -205,6 +220,13 @@ const darkTheme = createTheme({
},
},
},
MuiSkeleton: {
styleOverrides: {
root: {
backgroundColor: "#151518",
},
},
},
},
shape: {
borderRadius: 2,
+22
View File
@@ -54,6 +54,12 @@ const lightTheme = createTheme({
bg: "#ffecbc",
border: "#fec84b",
},
percentage: {
uptimePoor: "#d32f2f",
uptimeFair: "#ec8013",
uptimeGood: "#ffb800",
uptimeExcellent: "#079455",
},
unresolved: { main: "#4e5ba6", light: "#e2eaf7", bg: "#f2f4f7" },
divider: border.light,
other: {
@@ -89,6 +95,15 @@ const lightTheme = createTheme({
backgroundColor: theme.palette.secondary.main,
},
},
{
props: (props) =>
props.variant === "contained" && props.color === "secondary",
style: {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
],
fontWeight: 400,
borderRadius: 4,
@@ -201,6 +216,13 @@ const lightTheme = createTheme({
},
},
},
MuiSkeleton: {
styleOverrides: {
root: {
backgroundColor: "#f2f4f7",
},
},
},
},
shape: {
borderRadius: 2,
+3 -3
View File
@@ -160,6 +160,7 @@ const Greeting = ({ type = "" }) => {
lineHeight={1}
fontWeight={500}
color={theme.palette.text.primary}
mb={theme.spacing(1)}
>
<Typography
component="span"
@@ -173,11 +174,10 @@ const Greeting = ({ type = "" }) => {
</Typography>
</Typography>
<Typography
sx={{ opacity: 0.8 }}
lineHeight={1}
fontSize={14}
fontWeight={300}
color={theme.palette.text.secondary}
fontWeight={400}
color={theme.palette.text.accent}
>
{append} — Here’s an overview of your {type} monitors.
</Typography>
+21 -2
View File
@@ -43,6 +43,23 @@ export const formatDurationRounded = (ms) => {
return time;
};
export const formatDurationSplit = (ms) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
return days > 0
? { time: days, format: days === 1 ? "day" : "days" }
: hours > 0
? { time: hours, format: hours === 1 ? "hour" : "hours" }
: minutes > 0
? { time: minutes, format: minutes === 1 ? "minute" : "minutes" }
: seconds > 0
? { time: seconds, format: seconds === 1 ? "second" : "seconds" }
: { time: 0, format: "seconds" };
};
export const formatDate = (date, customOptions) => {
const options = {
year: "numeric",
@@ -51,9 +68,11 @@ export const formatDate = (date, customOptions) => {
hour: "numeric",
minute: "numeric",
hour12: true,
...customOptions
...customOptions,
};
// Return the date using the specified options
return date.toLocaleString("en-US", options);
return date
.toLocaleString("en-US", options)
.replace(/\b(AM|PM)\b/g, (match) => match.toLowerCase());
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 20V13M12 20V10M4 20L4 16M13.4067 5.0275L18.5751 6.96567M10.7988 5.40092L5.20023 9.59983M21.0607 6.43934C21.6464 7.02513 21.6464 7.97487 21.0607 8.56066C20.4749 9.14645 19.5251 9.14645 18.9393 8.56066C18.3536 7.97487 18.3536 7.02513 18.9393 6.43934C19.5251 5.85355 20.4749 5.85355 21.0607 6.43934ZM5.06066 9.43934C5.64645 10.0251 5.64645 10.9749 5.06066 11.5607C4.47487 12.1464 3.52513 12.1464 2.93934 11.5607C2.35355 10.9749 2.35355 10.0251 2.93934 9.43934C3.52513 8.85355 4.47487 8.85355 5.06066 9.43934ZM13.0607 3.43934C13.6464 4.02513 13.6464 4.97487 13.0607 5.56066C12.4749 6.14645 11.5251 6.14645 10.9393 5.56066C10.3536 4.97487 10.3536 4.02513 10.9393 3.43934C11.5251 2.85355 12.4749 2.85355 13.0607 3.43934Z" stroke="black" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 915 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 10H3M21 12.5V8.8C21 7.11984 21 6.27976 20.673 5.63803C20.3854 5.07354 19.9265 4.6146 19.362 4.32698C18.7202 4 17.8802 4 16.2 4H7.8C6.11984 4 5.27976 4 4.63803 4.32698C4.07354 4.6146 3.6146 5.07354 3.32698 5.63803C3 6.27976 3 7.11984 3 8.8V17.2C3 18.8802 3 19.7202 3.32698 20.362C3.6146 20.9265 4.07354 21.3854 4.63803 21.673C5.27976 22 6.11984 22 7.8 22H12M16 2V6M8 2V6M14.5 19L16.5 21L21 16.5" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.07598 7.48282L7.36402 10.5457C7.58715 10.705 7.69872 10.7847 7.81548 10.8031C7.91821 10.8192 8.02343 10.8029 8.11648 10.7565C8.22223 10.7037 8.30449 10.594 8.46901 10.3747L9.37511 9.16652C9.42164 9.10448 9.4449 9.07347 9.47224 9.04671C9.49652 9.02295 9.52315 9.00173 9.55173 8.98338C9.58392 8.9627 9.61935 8.94696 9.6902 8.91546L13.5588 7.19609C13.7192 7.12482 13.7993 7.08918 13.8598 7.03352C13.9133 6.9843 13.9554 6.924 13.9832 6.85684C14.0146 6.78091 14.0204 6.69336 14.0321 6.51826L14.3154 2.2694M13.5 13.5L16.116 14.6211C16.4195 14.7512 16.5713 14.8163 16.6517 14.9243C16.7222 15.0191 16.7569 15.1358 16.7496 15.2537C16.7413 15.3881 16.6497 15.5255 16.4665 15.8002L15.2375 17.6438C15.1507 17.774 15.1072 17.8391 15.0499 17.8863C14.9991 17.928 14.9406 17.9593 14.8777 17.9784C14.8067 18 14.7284 18 14.5719 18H12.5766C12.3693 18 12.2656 18 12.1774 17.9653C12.0995 17.9347 12.0305 17.885 11.9768 17.8208C11.916 17.7481 11.8832 17.6497 11.8177 17.453L11.1048 15.3144C11.0661 15.1983 11.0468 15.1403 11.0417 15.0814C11.0372 15.0291 11.0409 14.9764 11.0528 14.9253C11.0662 14.8677 11.0935 14.813 11.1482 14.7036L11.6897 13.6206C11.7997 13.4005 11.8547 13.2905 11.9395 13.2222C12.0141 13.162 12.1046 13.1246 12.1999 13.1143C12.3081 13.1027 12.4248 13.1416 12.6582 13.2194L13.5 13.5ZM22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="black" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 7L14.1314 14.8686C13.7354 15.2646 13.5373 15.4627 13.309 15.5368C13.1082 15.6021 12.8918 15.6021 12.691 15.5368C12.4627 15.4627 12.2646 15.2646 11.8686 14.8686L9.13137 12.1314C8.73535 11.7354 8.53735 11.5373 8.30902 11.4632C8.10817 11.3979 7.89183 11.3979 7.69098 11.4632C7.46265 11.5373 7.26465 11.7354 6.86863 12.1314L2 17M22 7H15M22 7V14" stroke="black" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 17L17 7M17 7H7M17 7V17" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 17L17 7M17 7H7M17 7V17" stroke="#667085" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 224 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 7H4.6C4.03995 7 3.75992 7 3.54601 7.10899C3.35785 7.20487 3.20487 7.35785 3.10899 7.54601C3 7.75992 3 8.03995 3 8.6V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H9M9 21H15M9 21L9 4.6C9 4.03995 9 3.75992 9.10899 3.54601C9.20487 3.35785 9.35785 3.20487 9.54601 3.10899C9.75992 3 10.0399 3 10.6 3L13.4 3C13.9601 3 14.2401 3 14.454 3.10899C14.6422 3.20487 14.7951 3.35785 14.891 3.54601C15 3.75992 15 4.03995 15 4.6V21M15 11H19.4C19.9601 11 20.2401 11 20.454 11.109C20.6422 11.2049 20.7951 11.3578 20.891 11.546C21 11.7599 21 12.0399 21 12.6V19.4C21 19.9601 21 20.2401 20.891 20.454C20.7951 20.6422 20.6422 20.7951 20.454 20.891C20.2401 21 19.9601 21 19.4 21H15" stroke="black" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 927 B

+4 -4
View File
@@ -4,6 +4,10 @@
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
font-weight: 400;
@@ -86,10 +90,6 @@ body {
display: none;
}
body .MuiSkeleton-root {
background-color: #f2f4f7;
}
@keyframes ripple {
from {
opacity: 1;
+2 -3
View File
@@ -11,9 +11,8 @@
<p align="center"><strong>An open source server monitoring application</strong></p>
https://github.com/user-attachments/assets/c8234cce-edf8-4d7e-8f6b-f18b06669fcc
![Dashboard-dark](https://github.com/user-attachments/assets/db875138-164f-453c-a75e-889f88747578)
(yes, we have a light theme as well, but this looks better on readme.md)
BlueWave Uptime is an open source server monitoring application used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
+99 -5
View File
@@ -4,15 +4,20 @@ const {
getMonitorsByTeamIdValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
getMonitorsAndSummaryByTeamIdParamValidation,
getMonitorsAndSummaryByTeamIdQueryValidation,
getMonitorsByTeamIdQueryValidation,
pauseMonitorParamValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getCertificateParamValidation,
getMonitorAggregateStatsParamValidation,
getMonitorAggregateStatsQueryValidation,
} = require("../validation/joi");
const sslChecker = require("ssl-checker");
const SERVICE_NAME = "monitorController";
const { errorMessages, successMessages } = require("../utils/messages");
const { runInNewContext } = require("vm");
/**
* Returns all monitors
* @async
@@ -35,6 +40,44 @@ const getAllMonitors = async (req, res, next) => {
}
};
/**
* Returns agregate stats for a monitor
* @async
* @param {Express.Request} req
* @param {Express.Response} res
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
const getMonitorAggregateStats = async (req, res, next) => {
try {
await getMonitorAggregateStatsParamValidation.validateAsync(req.params);
await getMonitorAggregateStatsQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const { monitorId } = req.params;
const dateRange = req.query.dateRange;
const aggregateStats = await req.db.getMonitorAggregateStats(
monitorId,
dateRange
);
return res.json({
success: true,
msg: successMessages.MONTIOR_STATS_BY_ID,
data: aggregateStats,
});
} catch (error) {
next(error);
}
};
/**
* Returns monitor stats for monitor with matching ID
* @async
@@ -45,7 +88,8 @@ const getAllMonitors = async (req, res, next) => {
*/
const getMonitorStatsById = async (req, res, next) => {
try {
//Validation
await getMonitorStatsByIdParamValidation.validateAsync(req.params);
await getMonitorStatsByIdQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.message =
@@ -69,7 +113,7 @@ const getMonitorStatsById = async (req, res, next) => {
const getMonitorCertificate = async (req, res, next) => {
try {
//validation
await getCertificateParamValidation.validateAsync(req.params);
} catch (error) {
error.status = 422;
error.message =
@@ -143,7 +187,54 @@ const getMonitorById = async (req, res, next) => {
};
/**
* Returns all monitors belong to User with UserID
* Returns all monitors and a sumamry for a team with TeamID
* @async
* @param {Express.Request} req
* @param {Express.Response} res
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
const getMonitorsAndSummaryByTeamId = async (req, res, next) => {
try {
await getMonitorsAndSummaryByTeamIdParamValidation.validateAsync(
req.params
);
await getMonitorsAndSummaryByTeamIdQueryValidation.validateAsync(req.query);
//validation
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.method === undefined &&
error.method === "getMonitorsAndSummaryByTeamId";
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const { teamId } = req.params;
const { type } = req.query;
const monitorsSummary = await req.db.getMonitorsAndSummaryByTeamId(
teamId,
type
);
return res.status(200).json({
success: true,
msg: successMessages.MONITOR_GET_BY_USER_ID(teamId),
data: monitorsSummary,
});
} catch (error) {
error.service = SERVICE_NAME;
error.method === undefined &&
error.method === "getMonitorsAndSummaryByTeamId";
next(error);
}
};
/**
* Returns all monitors belong to team with TeamID
* @async
* @param {Express.Request} req
* @param {Express.Response} res
@@ -359,6 +450,7 @@ const pauseMonitor = async (req, res, next) => {
await req.jobQueue.addJob(monitor._id, monitor);
}
monitor.isActive = !monitor.isActive;
monitor.status = undefined;
monitor.save();
return res.status(200).json({
success: true,
@@ -376,9 +468,11 @@ const pauseMonitor = async (req, res, next) => {
module.exports = {
getAllMonitors,
getMonitorAggregateStats,
getMonitorStatsById,
getMonitorCertificate,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
+2
View File
@@ -66,6 +66,7 @@ const {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
@@ -146,6 +147,7 @@ module.exports = {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
+141 -93
View File
@@ -35,7 +35,7 @@ const calculateUptimeDuration = (checks) => {
const latestCheck = new Date(checks[0].createdAt);
let latestDownCheck = 0;
for (let i = 0; i < checks.length; i++) {
for (let i = checks.length; i <= 0; i--) {
if (checks[i].status === false) {
latestDownCheck = new Date(checks[i].createdAt);
break;
@@ -77,11 +77,11 @@ const getLatestResponseTime = (checks) => {
};
/**
* Helper function to get average 24h response time
* Helper function to get average response time
* @param {Array} checks Array of check objects.
* @returns {number} Timestamp of the most recent check.
*/
const getAverageResponseTime24Hours = (checks) => {
const getAverageResponseTime = (checks) => {
if (!checks || checks.length === 0) {
return 0;
}
@@ -121,28 +121,7 @@ const getIncidents = (checks) => {
return check.status === false ? (acc += 1) : acc;
}, 0);
};
/**
* Helper function to get all incidents
* @param {Array} checks Array of check objects.
* @returns {Array<Boolean>} Array of booleans representing up/down.
*/
const getStatusBarValues = (monitor, checks) => {
const checksIn60Mins = Math.floor((60 * 60 * 1000) / monitor.interval);
const noBlankChecks = checksIn60Mins - checks.length;
const statusBarValues = checks.map((check) => {
return {
status: check.status,
responseTime: check.responseTime,
value: 75,
};
});
for (let i = 0; i < noBlankChecks; i++) {
statusBarValues.push({ status: undefined, responseTime: 0, value: 75 });
}
return statusBarValues.reverse();
};
/**
* Get stats by monitor ID
* @async
@@ -152,42 +131,35 @@ const getStatusBarValues = (monitor, checks) => {
* @throws {Error}
*/
const getMonitorStatsById = async (req) => {
const filterLookup = {
hour: new Date(new Date().getTime() - 60 * 60 * 1000),
const startDates = {
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
};
const endDate = new Date();
try {
const { monitorId } = req.params;
let { status, limit, sortOrder, dateRange, numToDisplay, normalize } =
req.query;
// This effectively removes limit, returning all checks
if (limit === undefined) limit = 0;
// Default sort order is newest -> oldest
sortOrder = sortOrder === "asc" ? 1 : -1;
// Get monitor
const { monitorId } = req.params;
let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query;
const monitor = await Monitor.findById(monitorId);
if (monitor === null || monitor === undefined) {
throw new Error(errorMessages.DB_FIND_MONTIOR_BY_ID(monitorId));
}
// This effectively removes limit, returning all checks
if (limit === undefined) limit = 0;
// Default sort order is newest -> oldest
sortOrder = sortOrder === "asc" ? 1 : -1;
// Determine if this is a pagespeed monitor or an http/ping monitor
let model =
monitor.type === "http" || monitor.type === "ping"
? Check
: PageSpeedCheck;
// Build monitor stats object
const monitorStats = {
...monitor.toObject(),
};
// Start building query
// Build checks query
const checksQuery = { monitorId: monitor._id };
// Get all checks
@@ -195,76 +167,101 @@ const getMonitorStatsById = async (req) => {
createdAt: sortOrder,
});
const checksQueryForDateRange = {
...checksQuery,
createdAt: {
$gte: startDates[dateRange],
$lte: endDate,
},
};
const checksForDateRange = await model
.find(checksQueryForDateRange)
.sort({ createdAt: sortOrder });
if (monitor.type === "http" || monitor.type === "ping") {
const checksQuery24Hours = {
...checksQuery,
createdAt: { $gte: filterLookup.day },
};
const checksQuery30Days = {
...checksQuery,
createdAt: { $gte: filterLookup.month },
};
const checksQuery60Mins = {
...checksQuery,
createdAt: { $gte: filterLookup.hour },
};
const [checks24Hours, checks30Days, checks60Mins] = await Promise.all([
model.find(checksQuery24Hours).sort({ createdAt: sortOrder }),
model.find(checksQuery30Days).sort({ createdAt: sortOrder }),
model.find(checksQuery60Mins).sort({ createdAt: sortOrder }),
]);
// HTTP/PING Specific stats
monitorStats.avgResponseTime24hours =
getAverageResponseTime24Hours(checks24Hours);
monitorStats.uptime24Hours = getUptimePercentage(checks24Hours);
monitorStats.uptime30Days = getUptimePercentage(checks30Days);
monitorStats.statusBar = getStatusBarValues(monitor, checks60Mins);
monitorStats.periodAvgResponseTime =
getAverageResponseTime(checksForDateRange);
monitorStats.periodUptime = getUptimePercentage(checksForDateRange);
// Aggregate data
let groupedChecks;
// Group checks by hour if range is day
if (dateRange === "day") {
groupedChecks = checksForDateRange.reduce((acc, check) => {
const time = new Date(check.createdAt);
time.setMinutes(0, 0, 0);
if (!acc[time]) {
acc[time] = { time, checks: [] };
}
acc[time].checks.push(check);
return acc;
}, {});
} else {
groupedChecks = checksForDateRange.reduce((acc, check) => {
const time = new Date(check.createdAt).toISOString().split("T")[0]; // Extract the date part
if (!acc[time]) {
acc[time] = { time, checks: [] };
}
acc[time].checks.push(check);
return acc;
}, {});
}
// Map grouped checks to stats
const aggregateData = Object.values(groupedChecks).map((group) => {
const totalChecks = group.checks.length;
const uptimePercentage = getUptimePercentage(group.checks);
const totalIncidents = group.checks.filter(
(check) => check.status === false
).length;
const avgResponseTime =
group.checks.reduce((sum, check) => sum + check.responseTime, 0) /
totalChecks;
return {
time: group.time,
uptimePercentage,
totalChecks,
totalIncidents,
avgResponseTime,
};
});
monitorStats.aggregateData = aggregateData;
}
//Get checks for dateRange
if (status !== undefined) {
checksQuery.status = status;
}
// Filter checks by "day", "week", or "month"
if (dateRange !== undefined) {
checksQuery.createdAt = { $gte: filterLookup[dateRange] };
}
let dateRangeChecks = await model
.find(checksQuery)
.sort({
createdAt: sortOrder,
})
.limit(limit);
const incidents = getIncidents(dateRangeChecks);
monitorStats.periodIncidents = getIncidents(checksForDateRange);
monitorStats.periodTotalChecks = checksForDateRange.length;
// If more than numToDisplay checks, pick every nth check
let nthChecks = checksForDateRange;
if (
numToDisplay !== undefined &&
dateRangeChecks &&
dateRangeChecks.length > numToDisplay
checksForDateRange &&
checksForDateRange.length > numToDisplay
) {
const n = Math.ceil(dateRangeChecks.length / numToDisplay);
dateRangeChecks = dateRangeChecks.filter((_, index) => index % n === 0);
const n = Math.ceil(checksForDateRange.length / numToDisplay);
nthChecks = checksForDateRange.filter((_, index) => index % n === 0);
}
// Normalize checks if requested
if (normalize !== undefined) {
dateRangeChecks = NormalizeData(dateRangeChecks, 1, 100);
const normailzedChecks = NormalizeData(nthChecks, 1, 100);
monitorStats.checks = normailzedChecks;
} else {
monitorStats.checks = nthChecks;
}
// Add common stats and stats that depend on the dateRange
monitorStats.uptimeDuration = calculateUptimeDuration(checksAll);
monitorStats.lastChecked = getLastChecked(checksAll);
monitorStats.latestResponseTime = getLatestResponseTime(checksAll);
monitorStats.incidents = incidents;
monitorStats.checks = dateRangeChecks;
return monitorStats;
} catch (error) {
error.methodName = "getMonitorStatsById";
throw error;
}
};
@@ -295,6 +292,43 @@ const getMonitorById = async (monitorId) => {
}
};
/**
* Get monitors and Summary by TeamID
* @async
* @param {Express.Request} req
* @param {Express.Response} res
* @returns {Promise<Array<Monitor>>}
* @throws {Error}
*/
const getMonitorsAndSummaryByTeamId = async (teamId, type) => {
try {
const monitors = await Monitor.find({ teamId, type });
const monitorCounts = monitors.reduce(
(acc, monitor) => {
if (monitor.status === true) {
acc.up += 1;
}
if (monitor.status === false) {
acc.down += 1;
}
if (monitor.isActive === false) {
acc.paused += 1;
}
return acc;
},
{ up: 0, down: 0, paused: 0 }
);
monitorCounts.total = monitors.length;
return { monitors, monitorCounts };
} catch (error) {
error.method = "getMonitorsAndSummaryByTeamId";
throw error;
}
};
/**
* Get monitors by TeamID
* @async
@@ -305,8 +339,19 @@ const getMonitorById = async (monitorId) => {
*/
const getMonitorsByTeamId = async (req, res) => {
try {
let { limit, type, status, sortOrder, normalize } = req.query || {};
let { limit, type, status, sortOrder, normalize, page, rowsPerPage } =
req.query || {};
const monitorQuery = { teamId: req.params.teamId };
const monitorsCount = await Monitor.countDocuments({
teamId: req.params.teamId,
type,
});
// Pagination
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
if (type !== undefined) {
const types = Array.isArray(type) ? type : [type];
@@ -323,7 +368,9 @@ const getMonitorsByTeamId = async (req, res) => {
// This effectively removes limit, returning all checks
if (limit === undefined) limit = 0;
const monitors = await Monitor.find(monitorQuery);
const monitors = await Monitor.find(monitorQuery)
.skip(skip)
.limit(rowsPerPage);
// Map each monitor to include its associated checks
const monitorsWithChecks = await Promise.all(
monitors.map(async (monitor) => {
@@ -352,7 +399,7 @@ const getMonitorsByTeamId = async (req, res) => {
return { ...monitor.toObject(), checks };
})
);
return monitorsWithChecks;
return { monitors: monitorsWithChecks, monitorCount: monitorsCount };
} catch (error) {
throw error;
}
@@ -454,6 +501,7 @@ module.exports = {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
+4 -1
View File
@@ -5,7 +5,10 @@ const handleErrors = (error, req, res, next) => {
const status = error.status || 500;
const message = error.message || errorMessages.FRIENDLY_ERROR;
const service = error.service || errorMessages.UNKNOWN_SERVICE;
logger.error(error.message, { service: service });
logger.error(error.message, {
service: service,
methodName: error.methodName,
});
res.status(status).json({ success: false, msg: message });
};
+2 -2
View File
@@ -24,7 +24,7 @@ const MonitorSchema = mongoose.Schema(
},
status: {
type: Boolean,
default: true,
default: undefined,
},
type: {
type: String,
@@ -46,7 +46,7 @@ const MonitorSchema = mongoose.Schema(
},
uptimePercentage: {
type: Number,
default: undefined,
default: undefined,
},
notifications: [
{
+835
View File
@@ -0,0 +1,835 @@
{
"openapi": "3.1.0",
"info": {
"title": "BlueWave Uptime",
"summary": "BlueWave Uptime OpenAPI Specifications",
"description": "BlueWave Uptime is an open source server monitoring application used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.",
"contact": {
"name": "API Support",
"url": "mailto:support@bluewavelabs.ca",
"email": "support@bluewavelabs.ca"
},
"license": {
"name": "AGPLv3",
"url": "https://github.com/bluewave-labs/bluewave-uptime/tree/HEAD/LICENSE"
},
"version": "1.0"
},
"servers": [
{
"url": "http://localhost:{PORT}/{API_PATH}",
"description": "Local Development Server",
"variables": {
"PORT": {
"description": "API Port",
"enum": ["5000", "3000", "8080"],
"default": "5000"
},
"API_PATH": {
"description": "API Base Path",
"enum": ["api/v1", "api/v1.1", "api/v2"],
"default": "api/v1"
}
}
}
],
"tags": [
{
"name": "auth",
"description": "Authentication"
},
{
"name": "invite",
"description": "Invite"
},
{
"name": "monitors",
"description": "Monitors"
},
{
"name": "checks",
"description": "Checks"
},
{
"name": "alerts",
"description": "Alerts"
},
{
"name": "pagespeed",
"description": "Page Speed"
},
{
"name": "maintenance-window",
"description": "Maintenance window"
},
{
"name": "healthy",
"description": "Health check"
},
{
"name": "mail",
"description": "Mail service"
}
],
"paths": {
"/auth/register": {
"post": {
"tags": ["auth"],
"description": "Register a new user",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["firstName", "lastName", "email", "password", "role", "teamId"],
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"format": "password"
},
"profileImage": {
"type": "file",
"format": "file"
},
"role": {
"type": "array",
"enum": [["user"], ["admin"], ["superadmin"]],
"default": ["superadmin"]
},
"teamId": {
"type": "string",
"format": "uuid"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/login": {
"post": {
"tags": ["auth"],
"description": "Login with credentials",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"format": "password"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/user/:userId": {
"put": {
"tags": ["auth"],
"description": "Change user informations",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"delete": {
"tags": ["auth"],
"description": "Delete user",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/users/admin": {
"get": {
"tags": ["auth"],
"description": "Checks to see if an admin account exists",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/users": {
"get": {
"tags": ["auth"],
"description": "Get all users",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/invite": {
"post": {
"tags": ["auth"],
"description": "Invite people to application",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/invite/verify": {
"post": {
"tags": ["auth"],
"description": "Verify the invite",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/recovery/request": {
"post": {
"tags": ["auth"],
"description": "Request a recovery token",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/recovery/validate": {
"post": {
"tags": ["auth"],
"description": "Validate recovery token",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/recovery/reset": {
"post": {
"tags": ["auth"],
"description": "Password reset",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/healthy": {
"get": {
"tags": ["healthy"],
"description": "Health check",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ErrorResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
}
}
},
"SuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object"
}
}
},
"UserSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/UserDataType"
}
}
},
"MonitorSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/MonitorDataType"
}
}
},
"CheckDataSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/CheckDataType"
}
}
},
"AlertDataSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/AlertDataType"
}
}
},
"UserDataType": {
"type": "object",
"required": [
"firstname",
"lastname",
"email",
"profilePicUrl",
"isActive",
"isVerified",
"updatedAt",
"createdAt"
],
"properties": {
"firstname": {
"type": "string"
},
"lastname": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"profilePicUrl": {
"type": "string"
},
"isActive": {
"type": "boolean"
},
"isVerified": {
"type": "boolean"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
},
"MonitorDataType": {
"type": "object",
"required": [
"userId",
"name",
"description",
"url",
"isActive",
"interval",
"updatedAt",
"createdAt"
],
"properties": {
"userId": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"url": {
"type": "string"
},
"isActive": {
"type": "boolean"
},
"interval": {
"type": "integer"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
},
"CheckDataType": {
"type": "object",
"required": [
"monitorId",
"status",
"responseTime",
"statusCode",
"message",
"updatedAt",
"createdAt"
],
"properties": {
"monitorId": {
"type": "string"
},
"status": {
"type": "string"
},
"responseTime": {
"type": "integer"
},
"statusCode": {
"type": "integer"
},
"message": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
},
"AlertDataType": {
"type": "object",
"required": [
"checkId",
"monitorId",
"userId",
"status",
"message",
"notifiedStatus",
"acknowledgedStatus",
"updatedAt",
"createdAt"
],
"properties": {
"checkId": {
"type": "string"
},
"monitorId": {
"type": "string"
},
"userId": {
"type": "string"
},
"status": {
"type": "string"
},
"message": {
"type": "string"
},
"notifiedStatus": {
"type": "boolean"
},
"acknowledgedStatus": {
"type": "boolean"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
}
}
+5
View File
@@ -3,9 +3,14 @@ const monitorController = require("../controllers/monitorController");
const { isAllowed } = require("../middleware/isAllowed");
router.get("/", monitorController.getAllMonitors);
router.get("/aggregate/:monitorId", monitorController.getMonitorAggregateStats);
router.get("/stats/:monitorId", monitorController.getMonitorStatsById);
router.get("/certificate/:monitorId", monitorController.getMonitorCertificate);
router.get("/:monitorId", monitorController.getMonitorById);
router.get(
"/team/summary/:teamId",
monitorController.getMonitorsAndSummaryByTeamId
);
router.get("/team/:teamId", monitorController.getMonitorsByTeamId);
router.post(
+3 -2
View File
@@ -39,7 +39,9 @@ class JobQueue {
queue.networkService = networkService;
const monitors = await db.getAllMonitors();
for (const monitor of monitors) {
await queue.addJob(monitor.id, monitor);
if (monitor.active) {
await queue.addJob(monitor.id, monitor);
}
}
const workerStats = await queue.getWorkerStats();
await queue.scaleWorkers(workerStats);
@@ -238,7 +240,6 @@ class JobQueue {
console.log("Adding job", payload.url);
// Execute job immediately
await this.queue.add(jobName, payload);
await this.queue.add(jobName, payload, {
repeat: {
every: payload.interval,
+49 -34
View File
@@ -15,44 +15,60 @@ class NetworkService {
this.NETWORK_ERROR = 5000;
}
async handleNotification(job, isAlive) {
async handleNotification(monitor, isAlive) {
try {
const { _id } = job.data;
const monitor = await this.db.getMonitorById(_id);
if (monitor === null || monitor === undefined) {
logger.error(`Null Monitor: ${_id}`, {
method: "handleNotification",
service: this.SERVICE_NAME,
jobId: job.id,
});
return;
}
let template =
isAlive === true ? "serverIsUpTemplate" : "serverIsDownTemplate";
let status = isAlive === true ? "up" : "down";
// If monitor status changes, update monitor status and send notification
if (monitor.status !== isAlive) {
monitor.status = !monitor.status;
await monitor.save();
let template =
isAlive === true ? "serverIsUpTemplate" : "serverIsDownTemplate";
let status = isAlive === true ? "up" : "down";
const notifications = await this.db.getNotificationsByMonitorId(_id);
for (const notification of notifications) {
if (notification.type === "email") {
await this.emailService.buildAndSendEmail(
template,
{ monitorName: monitor.name, monitorUrl: monitor.url },
notification.address,
`Monitor ${monitor.name} is ${status}`
);
}
const notifications = await this.db.getNotificationsByMonitorId(
monitor._id
);
for (const notification of notifications) {
if (notification.type === "email") {
await this.emailService.buildAndSendEmail(
template,
{ monitorName: monitor.name, monitorUrl: monitor.url },
notification.address,
`Monitor ${monitor.name} is ${status}`
);
}
}
} catch (error) {
logger.error(error.message, {
method: "handleNotification",
service: this.SERVICE_NAME,
monitorId: monitor._id,
});
}
}
async handleStatusUpdate(job, isAlive) {
try {
const { _id } = job.data;
const monitor = await this.db.getMonitorById(_id);
if (monitor === null || monitor === undefined) {
logger.error(`Null Monitor: ${_id}`, {
method: "handleStatusUpdate",
service: this.SERVICE_NAME,
jobId: job.id,
});
return;
}
if (monitor.status === undefined || monitor.status !== isAlive) {
const oldStatus = monitor.status;
monitor.status = isAlive;
await monitor.save();
if (oldStatus !== undefined && oldStatus !== isAlive) {
this.handleNotification(monitor, isAlive);
}
}
} catch (error) {
logger.error(error.message, {
method: "handleStatusUpdate",
service: this.SERVICE_NAME,
jobId: job.id,
});
}
@@ -113,7 +129,7 @@ class NetworkService {
};
return await this.logAndStoreCheck(checkData, this.db.createCheck);
} finally {
this.handleNotification(job, isAlive);
this.handleStatusUpdate(job, isAlive);
}
}
@@ -161,7 +177,7 @@ class NetworkService {
return await this.logAndStoreCheck(checkData, this.db.createCheck);
} finally {
this.handleNotification(job, isAlive);
this.handleStatusUpdate(job, isAlive);
}
}
@@ -181,7 +197,6 @@ class NetworkService {
*/
async handlePagespeed(job) {
let isAlive;
try {
const url = job.data.url;
const response = await axios.get(
@@ -241,7 +256,7 @@ class NetworkService {
};
this.logAndStoreCheck(checkData, this.db.createPageSpeedCheck);
} finally {
this.handleNotification(job, isAlive);
this.handleStatusUpdate(job, isAlive);
}
}
+46
View File
@@ -1,4 +1,5 @@
const joi = require("joi");
const { normalize } = require("path");
//****************************************
// Custom Validators
@@ -160,6 +161,19 @@ const getMonitorByIdQueryValidation = joi.object({
normalize: joi.boolean(),
});
const getMonitorsAndSummaryByTeamIdParamValidation = joi.object({
teamId: joi.string().required(),
});
const getMonitorsAndSummaryByTeamIdQueryValidation = joi.object({
type: joi
.alternatives()
.try(
joi.string().valid("http", "ping", "pagespeed"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed"))
),
});
const getMonitorsByTeamIdValidation = joi.object({
teamId: joi.string().required(),
});
@@ -175,6 +189,24 @@ const getMonitorsByTeamIdQueryValidation = joi.object({
joi.string().valid("http", "ping", "pagespeed"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed"))
),
page: joi.number(),
rowsPerPage: joi.number(),
});
const getMonitorStatsByIdParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorStatsByIdQueryValidation = joi.object({
status: joi.string(),
limit: joi.number(),
sortOrder: joi.string().valid("asc", "desc"),
dateRange: joi.string().valid("day", "week", "month"),
numToDisplay: joi.number(),
normalize: joi.boolean(),
});
const getCertificateParamValidation = joi.object({
monitorId: joi.string().required(),
});
const createMonitorBodyValidation = joi.object({
@@ -201,6 +233,13 @@ const pauseMonitorParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorAggregateStatsParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorAggregateStatsQueryValidation = joi.object({
dateRange: joi.string().valid("day", "week", "month"),
});
//****************************************
// Alerts
//****************************************
@@ -355,8 +394,15 @@ module.exports = {
createMonitorBodyValidation,
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsAndSummaryByTeamIdParamValidation,
getMonitorsAndSummaryByTeamIdQueryValidation,
getMonitorsByTeamIdValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getCertificateParamValidation,
getMonitorAggregateStatsParamValidation,
getMonitorAggregateStatsQueryValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
editUserParamValidation,