diff --git a/Client/src/Components/Alert/index.jsx b/Client/src/Components/Alert/index.jsx index eb5fbd13b..cafd58451 100644 --- a/Client/src/Components/Alert/index.jsx +++ b/Client/src/Components/Alert/index.jsx @@ -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, }} diff --git a/Client/src/Components/Animated/PulseDot.jsx b/Client/src/Components/Animated/PulseDot.jsx index 38e956b50..9ac536fe6 100644 --- a/Client/src/Components/Animated/PulseDot.jsx +++ b/Client/src/Components/Animated/PulseDot.jsx @@ -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%", diff --git a/Client/src/Components/Charts/MonitorDetails60MinChart/index.jsx b/Client/src/Components/Charts/MonitorDetails60MinChart/index.jsx deleted file mode 100644 index 57cb344e7..000000000 --- a/Client/src/Components/Charts/MonitorDetails60MinChart/index.jsx +++ /dev/null @@ -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 ( - - - {data.map((check, index) => ( - - ))} - - - - - - - - - ); -}; - -MonitorDetails60MinChart.propTypes = { - data: PropTypes.array.isRequired, -}; - -export default MonitorDetails60MinChart; diff --git a/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx b/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx index 4e5d1d7b0..cb01734fd 100644 --- a/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx +++ b/Client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx @@ -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), }} > {new Date(label).toLocaleDateString("en-US", { @@ -38,15 +47,39 @@ const CustomToolTip = ({ active, payload, label }) => { hour12: true, // AM/PM format })} - - Response Time (ms): {payload[0].payload.originalResponseTime} - {" "} + + + + + Response Time + {" "} + + {payload[0].payload.originalResponseTime} + + {" "} + ms + + + + {/* Display original value */} ); @@ -64,12 +97,16 @@ const MonitorDetailsAreaChart = ({ checks }) => { }); }; + const memoizedChecks = useMemo(() => checks, []); + + const theme = useTheme(); + return ( - + { bottom: 0, }} > + + + + + + + } /> diff --git a/Client/src/Components/Charts/ResponseTimeChart/index.css b/Client/src/Components/Charts/ResponseTimeChart/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/Client/src/Components/Charts/ResponseTimeChart/index.jsx b/Client/src/Components/Charts/ResponseTimeChart/index.jsx deleted file mode 100644 index fd6c0f41e..000000000 --- a/Client/src/Components/Charts/ResponseTimeChart/index.jsx +++ /dev/null @@ -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 ( - - - - - {checks.map((check, index) => ( - - ))} - - - - - ); -}; - -ResponseTimeChart.propTypes = { - checks: PropTypes.array, -}; -export default ResponseTimeChart; diff --git a/Client/src/Components/Inputs/Autocomplete/index.jsx b/Client/src/Components/Inputs/Autocomplete/index.jsx new file mode 100644 index 000000000..5256efccc --- /dev/null +++ b/Client/src/Components/Inputs/Autocomplete/index.jsx @@ -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 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 ( + { + setValue(newValue); + }} + inputValue={inputValue} + onInputChange={(event, newInputValue) => { + setInputValue(newInputValue); + }} + options={options} + getOptionLabel={(option) => option.name} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const { key, ...optionProps } = props; + return ( +
  • +
    {{option.name}}
    +
  • + ); + }} + 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; diff --git a/Client/src/Components/Label/index.jsx b/Client/src/Components/Label/index.jsx index 0799a87d7..886f66fb9 100644 --- a/Client/src/Components/Label/index.jsx +++ b/Client/src/Components/Label/index.jsx @@ -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, }; diff --git a/Client/src/Components/TabPanels/Account/TeamPanel.jsx b/Client/src/Components/TabPanels/Account/TeamPanel.jsx index 8c4630b19..9809ac710 100644 --- a/Client/src/Components/TabPanels/Account/TeamPanel.jsx +++ b/Client/src/Components/TabPanels/Account/TeamPanel.jsx @@ -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 = () => { - + { mt={theme.spacing(8)} justifyContent="flex-end" > - + Send invite diff --git a/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js b/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js index e32d673cc..2e107e7e7 100644 --- a/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js +++ b/Client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js @@ -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"; }); }, }); diff --git a/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js b/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js index 2c381812c..ac3ce1d6f 100644 --- a/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js +++ b/Client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js @@ -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 diff --git a/Client/src/Layouts/HomeLayout/index.css b/Client/src/Layouts/HomeLayout/index.css index 1434ff9d9..855bf33fc 100644 --- a/Client/src/Layouts/HomeLayout/index.css +++ b/Client/src/Layouts/HomeLayout/index.css @@ -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; diff --git a/Client/src/Layouts/HomeLayout/index.jsx b/Client/src/Layouts/HomeLayout/index.jsx index 92d763f0c..fd92c59fa 100644 --- a/Client/src/Layouts/HomeLayout/index.jsx +++ b/Client/src/Layouts/HomeLayout/index.jsx @@ -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 ( - + - + ); }; diff --git a/Client/src/Pages/Incidents/IncidentTable/index.jsx b/Client/src/Pages/Incidents/IncidentTable/index.jsx index aa80b35ce..d10c79c68 100644 --- a/Client/src/Pages/Incidents/IncidentTable/index.jsx +++ b/Client/src/Pages/Incidents/IncidentTable/index.jsx @@ -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); diff --git a/Client/src/Pages/Incidents/index.jsx b/Client/src/Pages/Incidents/index.jsx index 5d756298e..1fd91c10a 100644 --- a/Client/src/Pages/Incidents/index.jsx +++ b/Client/src/Pages/Incidents/index.jsx @@ -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 ( - <> - - - - - - - - - ); -}; +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 ( - + {loading ? ( ) : ( diff --git a/Client/src/Pages/Incidents/skeleton.jsx b/Client/src/Pages/Incidents/skeleton.jsx new file mode 100644 index 000000000..d1571ad3a --- /dev/null +++ b/Client/src/Pages/Incidents/skeleton.jsx @@ -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 ( + <> + + + + + + + + + ); +}; + +export default SkeletonLayout; diff --git a/Client/src/Pages/Monitors/Configure/index.jsx b/Client/src/Pages/Monitors/Configure/index.jsx index 483a2fd79..1ee171034 100644 --- a/Client/src/Pages/Monitors/Configure/index.jsx +++ b/Client/src/Pages/Monitors/Configure/index.jsx @@ -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 ( {Object.keys(monitor).length === 0 ? ( @@ -211,13 +222,7 @@ const Configure = () => { flex={1} > - + {parsedUrl?.host ? ( { - Your site is {monitor?.status ? "up" : "down"}. + {statusMsg[monitor?.status ?? undefined]} { 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 = () => { )} - + diff --git a/Client/src/Pages/Monitors/Details/Charts/index.jsx b/Client/src/Pages/Monitors/Details/Charts/index.jsx new file mode 100644 index 000000000..cb0afb55b --- /dev/null +++ b/Client/src/Pages/Monitors/Details/Charts/index.jsx @@ -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 ( + <> + + {formatDate(new Date(firstDataPoint.time), options)} + + + {formatDate(new Date(lastDataPoint.time), options)} + + + ); +}; + +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 ( + + { + setChartHovered(true); + onBarHover({ time: null, totalChecks: 0, uptimePercentage: 0 }); + }} + onMouseLeave={() => { + setChartHovered(false); + setHoveredBarIndex(null); + onBarHover(null); + }} + > + + } + /> + + {reversedData.map((entry, index) => { + let { main, light } = getColorRange(entry.uptimePercentage); + return ( + { + setHoveredBarIndex(index); + onBarHover(entry); + }} + onMouseLeave={() => { + setHoveredBarIndex(null); + onBarHover({ + time: null, + totalChecks: 0, + uptimePercentage: 0, + }); + }} + /> + ); + })} + + + + ); +}); + +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 ( + + { + setChartHovered(true); + onBarHover({ time: null, totalIncidents: 0 }); + }} + onMouseLeave={() => { + setChartHovered(false); + setHoveredBarIndex(null); + onBarHover(null); + }} + > + + } + /> + + {reversedData.map((entry, index) => ( + { + setHoveredBarIndex(index); + onBarHover(entry); + }} + onMouseLeave={() => { + setHoveredBarIndex(null); + onBarHover({ time: null, totalIncidents: 0 }); + }} + /> + ))} + + + + ); +}); + +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 ( + + + + low + + + high + + + {responseProps.category} + + + {responseTime}{" "} + ms + + + + + + + + ); +}; diff --git a/Client/src/Pages/Monitors/Details/index.css b/Client/src/Pages/Monitors/Details/index.css index 59ffc5d85..8b1378917 100644 --- a/Client/src/Pages/Monitors/Details/index.css +++ b/Client/src/Pages/Monitors/Details/index.css @@ -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); -} + diff --git a/Client/src/Pages/Monitors/Details/index.jsx b/Client/src/Pages/Monitors/Details/index.jsx index 616f18657..e31de38d7 100644 --- a/Client/src/Pages/Monitors/Details/index.jsx +++ b/Client/src/Pages/Monitors/Details/index.jsx @@ -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 ( - - - {title} - - - {value} - - - ); -}; - -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 ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - /** * 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} + {format} + + ); + }; + 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 ( {loading ? ( @@ -207,127 +145,209 @@ const DetailsPage = ({ isAdmin }) => { { name: "details", path: `/monitors/${monitorId}` }, ]} /> - + - - {monitor.url?.replace(/^https?:\/\//, "") || "..."} + {monitor.name} - - - Your site is {monitor?.status ? "up" : "down"}. - {" "} - Checking every {formatDurationRounded(monitor?.interval)}. - + + + + + + {monitor.url?.replace(/^https?:\/\//, "") || "..."} + + + Checking every {formatDurationRounded(monitor?.interval)}. + + - {isAdmin && ( - - )} + + Certificate Expiry + + + {certificateExpiry} + + + {isAdmin && ( + + )} + - + - - - - - - Avg. Response Time{" "} - (24-hr) - + 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+$/, "")} - /> - - Uptime (24-hr) - - } - value={`${parseFloat(monitor?.uptime24Hours) - .toFixed(2) - .replace(/\.?0+$/, "")}%`} - /> - - Uptime (30-day) - - } - value={`${parseFloat(monitor?.uptime30Days) - .toFixed(2) - .replace(/\.?0+$/, "")}%`} - /> + > + active for + + {splitDuration(monitor?.uptimeDuration)} + + + + last check + + {splitDuration(monitor?.lastChecked)} + ago + + + + last response time + + {monitor?.latestResponseTime} + ms + + - - Response Times + + Showing statistics for past{" "} + {dateRange === "day" + ? "24 hours" + : dateRange === "week" + ? "7 days" + : "30 days"} + . - + - - - + + + + + + + Uptime + + + + Total Checks + + {hoveredUptimeData !== null + ? hoveredUptimeData.totalChecks + : monitor?.periodTotalChecks} + + {hoveredUptimeData !== null && + hoveredUptimeData.time !== null && ( + + {formatDate(new Date(hoveredUptimeData.time), { + month: "short", + year: undefined, + minute: undefined, + hour: dateRange === "day" ? "numeric" : undefined, + })} + + )} + + + Uptime Percentage + + {hoveredUptimeData !== null + ? Math.floor( + hoveredUptimeData.uptimePercentage * 10 + ) / 10 + : Math.floor(monitor?.periodUptime * 10) / 10} + % + + + + + + + + + + + Incidents + + + Total Incidents + + {hoveredIncidentsData !== null + ? hoveredIncidentsData.totalIncidents + : monitor?.periodIncidents} + + {hoveredIncidentsData !== null && + hoveredIncidentsData.time !== null && ( + + {formatDate(new Date(hoveredIncidentsData.time), { + month: "short", + year: undefined, + minute: undefined, + hour: dateRange === "day" ? "numeric" : undefined, + })} + + )} + + + + + + + + + + Average Response Time + + + + + + + + + + Response Times + + + + + + + + + + History + + + + + - - - History - - - )} diff --git a/Client/src/Pages/Monitors/Details/skeleton.jsx b/Client/src/Pages/Monitors/Details/skeleton.jsx new file mode 100644 index 000000000..b267f413e --- /dev/null +++ b/Client/src/Pages/Monitors/Details/skeleton.jsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default SkeletonLayout; diff --git a/Client/src/Pages/Monitors/Details/styled.jsx b/Client/src/Pages/Monitors/Details/styled.jsx new file mode 100644 index 000000000..99112802d --- /dev/null +++ b/Client/src/Pages/Monitors/Details/styled.jsx @@ -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, + }, + }, +})); diff --git a/Client/src/Pages/Monitors/Home/MonitorTable/index.jsx b/Client/src/Pages/Monitors/Home/MonitorTable/index.jsx new file mode 100644 index 000000000..4a0325246 --- /dev/null +++ b/Client/src/Pages/Monitors/Home/MonitorTable/index.jsx @@ -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 ( + + + + + + + ); +}; + +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 ( + <> + + + + + Host + + {" "} + + Status + + + + + + Response Time + Type + Actions + + + + {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 ( + { + navigate(`/monitors/${monitor._id}`); + }} + > + + + + + + + + + + + + {monitor.type} + + + + + + + ); + })} + +
    +
    + + + Showing {getRange()} of {monitorCount} monitor(s) + + + `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, + }, + }} + /> + + + ); +}; + +MonitorTable.propTypes = { + isAdmin: PropTypes.bool, +}; + +export default MonitorTable; diff --git a/Client/src/Pages/Monitors/Home/StatusBox.jsx b/Client/src/Pages/Monitors/Home/StatusBox.jsx index 08b2e88d9..ece8cde16 100644 --- a/Client/src/Pages/Monitors/Home/StatusBox.jsx +++ b/Client/src/Pages/Monitors/Home/StatusBox.jsx @@ -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": { diff --git a/Client/src/Pages/Monitors/Home/actionsMenu.jsx b/Client/src/Pages/Monitors/Home/actionsMenu.jsx index 7d342f5d7..efb29402d 100644 --- a/Client/src/Pages/Monitors/Home/actionsMenu.jsx +++ b/Client/src/Pages/Monitors/Home/actionsMenu.jsx @@ -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; diff --git a/Client/src/Pages/Monitors/Home/host.jsx b/Client/src/Pages/Monitors/Home/host.jsx index d450a53f4..9e6a71a05 100644 --- a/Client/src/Pages/Monitors/Home/host.jsx +++ b/Client/src/Pages/Monitors/Home/host.jsx @@ -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, }; diff --git a/Client/src/Pages/Monitors/Home/index.jsx b/Client/src/Pages/Monitors/Home/index.jsx index 318a9da20..9bc4496db 100644 --- a/Client/src/Pages/Monitors/Home/index.jsx +++ b/Client/src/Pages/Monitors/Home/index.jsx @@ -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 ( - + {loading ? ( ) : ( @@ -74,7 +41,7 @@ const Monitors = ({ isAdmin }) => { mt={theme.spacing(5)} > - {monitorState.monitors?.length !== 0 && ( + {monitorState?.monitorsSummary?.monitors?.length !== 0 && ( - + { - + diff --git a/Client/src/Pages/PageSpeed/Configure/skeleton.jsx b/Client/src/Pages/PageSpeed/Configure/skeleton.jsx new file mode 100644 index 000000000..301a00e52 --- /dev/null +++ b/Client/src/Pages/PageSpeed/Configure/skeleton.jsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + ); +}; + +export default SkeletonLayout; diff --git a/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx b/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx index 00c02a362..ecf7a2eb5 100644 --- a/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx +++ b/Client/src/Pages/PageSpeed/CreatePageSpeed/index.jsx @@ -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 ( - + { /> - + { Create your{" "} - pagespeed{" "} - - - monitor + Pagespeed monitor - - - Monitor display name + + + General settings + + Here you can select the URL of the host, together with the type of + monitor. + + + + handleChange(event, "name")} - error={errors.name} + label="Display name" + isOptional={true} + placeholder="Google" + value={monitor.name} + onChange={handleChange} + error={errors["name"]} /> - - URL - handleChange(event, "url")} - error={errors.url} - /> - - - Check frequency - handleChange(event, "interval")} + items={frequencies} + /> + + + - + ); }; diff --git a/Client/src/Pages/PageSpeed/Details/index.jsx b/Client/src/Pages/PageSpeed/Details/index.jsx index 29c0b72e0..4004e1715 100644 --- a/Client/src/Pages/PageSpeed/Details/index.jsx +++ b/Client/src/Pages/PageSpeed/Details/index.jsx @@ -213,7 +213,7 @@ const PageSpeedDetails = () => { monitorId, "desc", 50, - null, + "day", null, null ); diff --git a/Client/src/Pages/PageSpeed/card.jsx b/Client/src/Pages/PageSpeed/card.jsx new file mode 100644 index 000000000..22406d714 --- /dev/null +++ b/Client/src/Pages/PageSpeed/card.jsx @@ -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 ( + .MuiStack-root": { + backgroundColor: theme.palette.background.accent, + }, + }} + > + 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 }, + }} + > + + + + + {data.name} + + + + + {data.url.replace(/^https?:\/\//, "")} + + + + Last checked:{" "} + + {formatDate(getLastChecked(data.checks, false))}{" "} + + ({formatDurationRounded(getLastChecked(data.checks))} ago) + + + + + + ); +}; + +Card.propTypes = { + data: PropTypes.object.isRequired, +}; + +export default Card; diff --git a/Client/src/Pages/PageSpeed/index.jsx b/Client/src/Pages/PageSpeed/index.jsx index eaaa3bc3e..ba65bf436 100644 --- a/Client/src/Pages/PageSpeed/index.jsx +++ b/Client/src/Pages/PageSpeed/index.jsx @@ -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 ( - .MuiStack-root": { - backgroundColor: theme.palette.background.accent, - }, - }} - > - 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 }, - }} - > - - - - - {data.name} - - - - - {data.url.replace(/^https?:\/\//, "")} - - - - Last checked:{" "} - - {formatDate(getLastChecked(data.checks, false))}{" "} - - ({formatDurationRounded(getLastChecked(data.checks))} ago) - - - - - - ); -}; - -Card.propTypes = { - data: PropTypes.object.isRequired, -}; - -/** - * Renders a skeleton layout. - * - * @returns {JSX.Element} - */ -const SkeletonLayout = () => { - const theme = useTheme(); - - return ( - - - - - - - - - - - - - - - - ); -}; +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 ( { > {isActuallyLoading ? ( - ) : monitors?.length !== 0 ? ( + ) : monitorsSummary?.monitors?.length !== 0 ? ( { - {monitors?.map((monitor) => ( - + {monitorsSummary?.monitors?.map((monitor) => ( + ))} diff --git a/Client/src/Pages/PageSpeed/skeleton.jsx b/Client/src/Pages/PageSpeed/skeleton.jsx new file mode 100644 index 000000000..2338ab636 --- /dev/null +++ b/Client/src/Pages/PageSpeed/skeleton.jsx @@ -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 ( + + + + + + + + + + + + + + + + ); +}; + +export default SkeletonLayout; diff --git a/Client/src/Pages/Settings/index.jsx b/Client/src/Pages/Settings/index.jsx index fc615d848..d1f15ea8c 100644 --- a/Client/src/Pages/Settings/index.jsx +++ b/Client/src/Pages/Settings/index.jsx @@ -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 }) => {
    - + diff --git a/Client/src/Utils/NetworkService.js b/Client/src/Utils/NetworkService.js index 31a23b5b1..e4b7be526 100644 --- a/Client/src/Utils/NetworkService.js +++ b/Client/src/Utils/NetworkService.js @@ -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} 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} 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 diff --git a/Client/src/Utils/Theme/darkTheme.js b/Client/src/Utils/Theme/darkTheme.js index 15df7c1b8..e54d9eaf4 100644 --- a/Client/src/Utils/Theme/darkTheme.js +++ b/Client/src/Utils/Theme/darkTheme.js @@ -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, diff --git a/Client/src/Utils/Theme/lightTheme.js b/Client/src/Utils/Theme/lightTheme.js index bb83d09b2..60a8e0b2d 100644 --- a/Client/src/Utils/Theme/lightTheme.js +++ b/Client/src/Utils/Theme/lightTheme.js @@ -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, diff --git a/Client/src/Utils/greeting.jsx b/Client/src/Utils/greeting.jsx index cb878c991..7da08b340 100644 --- a/Client/src/Utils/greeting.jsx +++ b/Client/src/Utils/greeting.jsx @@ -160,6 +160,7 @@ const Greeting = ({ type = "" }) => { lineHeight={1} fontWeight={500} color={theme.palette.text.primary} + mb={theme.spacing(1)} > { {append} — Here’s an overview of your {type} monitors. diff --git a/Client/src/Utils/timeUtils.js b/Client/src/Utils/timeUtils.js index 2d1a2f180..f9f916d04 100644 --- a/Client/src/Utils/timeUtils.js +++ b/Client/src/Utils/timeUtils.js @@ -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()); }; diff --git a/Client/src/assets/data/hostnames.json b/Client/src/assets/data/hostnames.json new file mode 100644 index 000000000..b66818c3a --- /dev/null +++ b/Client/src/assets/data/hostnames.json @@ -0,0 +1,1290 @@ +[ + { + "Company Name": "0to255", + "Company Link ": "https://0to255.com" + }, + { + "Company Name": "10015.io", + "Company Link ": "https://10015.io" + }, + { + "Company Name": "3DIcons", + "Company Link ": "https://3dicons.co" + }, + { + "Company Name": "About.me", + "Company Link ": "https://about.me" + }, + { + "Company Name": "Alias", + "Company Link ": "https://alias.co" + }, + { + "Company Name": "All About Berlin", + "Company Link ": "https://allaboutberlin.com" + }, + { + "Company Name": "All Acronyms", + "Company Link ": "https://allacronyms.com" + }, + { + "Company Name": "All You Can Read ", + "Company Link ": "https://allyoucanread.com" + }, + { + "Company Name": "AllTrails", + "Company Link ": "https://alltrails.com" + }, + { + "Company Name": "Anotepad", + "Company Link ": "https://anotepad.com" + }, + { + "Company Name": "AnswerSocrates", + "Company Link ": "https://answersocrates.com" + }, + { + "Company Name": "AnswerThePublic ", + "Company Link ": "https://answerthepublic.com" + }, + { + "Company Name": "Apollo ", + "Company Link ": "https://apollo.io" + }, + { + "Company Name": "ArrayList", + "Company Link ": "https://arraylist.org" + }, + { + "Company Name": "Ask Difference", + "Company Link ": "https://askdifference.com" + }, + { + "Company Name": "Audd.io", + "Company Link ": "https://audd.io" + }, + { + "Company Name": "Audiocheck", + "Company Link ": "https://audiocheck.net" + }, + { + "Company Name": "Audionautix", + "Company Link ": "https://audionautix.com" + }, + { + "Company Name": "Authentic Jobs", + "Company Link ": "https://authenticjobs.com" + }, + { + "Company Name": "Behind the Name", + "Company Link ": "https://behindthename.com" + }, + { + "Company Name": "Bilim Terimleri", + "Company Link ": "https://terimler.org" + }, + { + "Company Name": "BitBof", + "Company Link ": "https://bitbof.com" + }, + { + "Company Name": "Blank Page", + "Company Link ": "https://blank.page" + }, + { + "Company Name": "Bonanza", + "Company Link ": "https://bonanza.com" + }, + { + "Company Name": "BookCrossing", + "Company Link ": "https://bookcrossing.com" + }, + { + "Company Name": "Browse AI", + "Company Link ": "https://browse.ai" + }, + { + "Company Name": "Bubbl.us", + "Company Link ": "https://bubbl.us" + }, + { + "Company Name": "Business Model Toolbox", + "Company Link ": "https://bmtoolbox.net" + }, + { + "Company Name": "ByClickDownloader", + "Company Link ": "https://byclickdownloader.com" + }, + { + "Company Name": "Calligraphr", + "Company Link ": "https://calligraphr.com" + }, + { + "Company Name": "CertificateClaim", + "Company Link ": "https://certificateclaim.com" + }, + { + "Company Name": "Chosic", + "Company Link ": "https://chosic.com" + }, + { + "Company Name": "ClipDrop", + "Company Link ": "https://clipdrop.co" + }, + { + "Company Name": "CloudConvert", + "Company Link ": "https://cloudconvert.com" + }, + { + "Company Name": "CodingFont", + "Company Link ": "https://codingfont.com" + }, + { + "Company Name": "Color Hunt", + "Company Link ": "https://colorhunt.co" + }, + { + "Company Name": "ColorHexa", + "Company Link ": "https://colorhexa.com" + }, + { + "Company Name": "Conversion-Tool", + "Company Link ": "https://conversion-tool.com" + }, + { + "Company Name": "Cool Startup Jobs", + "Company Link ": "https://coolstartupjobs.com" + }, + { + "Company Name": "Coroflot", + "Company Link ": "https://coroflot.com" + }, + { + "Company Name": "Corrupt-a-File", + "Company Link ": "https://corrupt-a-file.net" + }, + { + "Company Name": "Couchsurfing", + "Company Link ": "https://couchsurfing.com" + }, + { + "Company Name": "Countries Been", + "Company Link ": "https://countriesbeen.com" + }, + { + "Company Name": "Country Code", + "Company Link ": "https://countrycode.org" + }, + { + "Company Name": "Creately", + "Company Link ": "https://creately.com" + }, + { + "Company Name": "Creately ", + "Company Link ": "https://creately.com" + }, + { + "Company Name": "Crossfade.io", + "Company Link ": "https://crossfade.io" + }, + { + "Company Name": "Crunchbase", + "Company Link ": "https://crunchbase.com" + }, + { + "Company Name": "CVmkr", + "Company Link ": "https://cvwizard.com" + }, + { + "Company Name": "Daily Remote", + "Company Link ": "https://dailyremote.com" + }, + { + "Company Name": "David Li", + "Company Link ": "https://david.li" + }, + { + "Company Name": "DemandHunt", + "Company Link ": "https://demandhunt.com" + }, + { + "Company Name": "Designify", + "Company Link ": "https://designify.com" + }, + { + "Company Name": "Diff Checker", + "Company Link ": "https://diffchecker.com" + }, + { + "Company Name": "DifferenceBetween.info", + "Company Link ": "https://differencebetween.info" + }, + { + "Company Name": "Digital Glossary", + "Company Link ": "https://digital-glossary.com" + }, + { + "Company Name": "Dimensions", + "Company Link ": "https://dimensions.com" + }, + { + "Company Name": "Discoverify Music", + "Company Link ": "https://discoverifymusic.com" + }, + { + "Company Name": "discu.eu", + "Company Link ": "https://discu.eu" + }, + { + "Company Name": "Do It Yourself", + "Company Link ": "https://doityourself.com" + }, + { + "Company Name": "Donkey Car", + "Company Link ": "https://donkeycar.com" + }, + { + "Company Name": "draw.io", + "Company Link ": "https://drawio.com" + }, + { + "Company Name": "Drumeo", + "Company Link ": "https://drumeo.com" + }, + { + "Company Name": "Dummies", + "Company Link ": "https://dummies.com" + }, + { + "Company Name": "Easel.ly", + "Company Link ": "https://easel.ly" + }, + { + "Company Name": "Educalingo", + "Company Link ": "https://educalingo.com" + }, + { + "Company Name": "Emoji Combos", + "Company Link ": "https://emojicombos.com" + }, + { + "Company Name": "EquityBee", + "Company Link ": "https://equitybee.com" + }, + { + "Company Name": "EquityZen", + "Company Link ": "https://equityzen.com" + }, + { + "Company Name": "Escape Room Tips", + "Company Link ": "https://escaperoomtips.com" + }, + { + "Company Name": "Every Noise", + "Company Link ": "https://everynoise.com" + }, + { + "Company Name": "Every Time Zone", + "Company Link ": "https://everytimezone.com" + }, + { + "Company Name": "Excalideck", + "Company Link ": "https://excalideck.com" + }, + { + "Company Name": "Excalidraw", + "Company Link ": "https://excalidraw.com" + }, + { + "Company Name": "Extract pics", + "Company Link ": "https://extract.pics" + }, + { + "Company Name": "EZGIF", + "Company Link ": "https://ezgif.com" + }, + { + "Company Name": "FactSlides", + "Company Link ": "https://factslides.com" + }, + { + "Company Name": "FIGR ", + "Company Link ": "https://figr.app" + }, + { + "Company Name": "Fine Dictionary", + "Company Link ": "https://finedictionary.com" + }, + { + "Company Name": "Fiverr", + "Company Link ": "https://fiverr.com" + }, + { + "Company Name": "Fix It Club", + "Company Link ": "https://fixitclub.com" + }, + { + "Company Name": "FlexJobs", + "Company Link ": "https://flexjobs.com" + }, + { + "Company Name": "Flightradar24", + "Company Link ": "https://flightradar24.com" + }, + { + "Company Name": "FlowCV ", + "Company Link ": "https://flowcv.com" + }, + { + "Company Name": "Foco Clipping", + "Company Link ": "https://fococlipping.com" + }, + { + "Company Name": "Font Squirrel", + "Company Link ": "https://fontsquirrel.com" + }, + { + "Company Name": "FontAwesome", + "Company Link ": "https://fontawesome.com" + }, + { + "Company Name": "Fontello ", + "Company Link ": "https://fontello.com" + }, + { + "Company Name": "Form to Chatbot", + "Company Link ": "https://formtochatbot.com" + }, + { + "Company Name": "Founder Resources", + "Company Link ": "https://founderresources.io" + }, + { + "Company Name": "Franz", + "Company Link ": "https://meetfranz.com" + }, + { + "Company Name": "Fraze It", + "Company Link ": "https://fraze.it" + }, + { + "Company Name": "Freecycle", + "Company Link ": "https://freecycle.org" + }, + { + "Company Name": "FreeType", + "Company Link ": "https://freetype.org" + }, + { + "Company Name": "FutureM", + "Company Link ": "https://futureme.org" + }, + { + "Company Name": "Generated.Photos", + "Company Link ": "https://generated.photos" + }, + { + "Company Name": "Get Human", + "Company Link ": "https://gethuman.com" + }, + { + "Company Name": "Go Bento", + "Company Link ": "https://gobento.com" + }, + { + "Company Name": "Good CV", + "Company Link ": "https://goodcv.com" + }, + { + "Company Name": "Grammar Monster", + "Company Link ": "https://grammar-monster.com" + }, + { + "Company Name": "Grammar Book", + "Company Link ": "https://grammarbook.com" + }, + { + "Company Name": "Gummy Search", + "Company Link ": "https://gummysearch.com" + }, + { + "Company Name": "Gumroad", + "Company Link ": "https://gumroad.com" + }, + { + "Company Name": "HealthIcons", + "Company Link ": "https://healthicons.org" + }, + { + "Company Name": "HexColor", + "Company Link ": "https://hexcolor.co" + }, + { + "Company Name": "Hidden Life Radio", + "Company Link ": "https://hiddenliferadio.com" + }, + { + "Company Name": "Hired", + "Company Link ": "https://lhh.com" + }, + { + "Company Name": "Honey", + "Company Link ": "https://joinhoney.com" + }, + { + "Company Name": "HowStuffWorks", + "Company Link ": "https://howstuffworks.com" + }, + { + "Company Name": "HugeIcons Pro", + "Company Link ": "https://hugeicons.com" + }, + { + "Company Name": "Humble Bundle", + "Company Link ": "https://humblebundle.com" + }, + { + "Company Name": "I Have No TV", + "Company Link ": "https://ihavenotv.com" + }, + { + "Company Name": "I Miss the Office", + "Company Link ": "https://imisstheoffice.eu" + }, + { + "Company Name": "IcoMoon", + "Company Link ": "https://icomoon.io" + }, + { + "Company Name": "Iconfinder", + "Company Link ": "https://iconfinder.com" + }, + { + "Company Name": "Icon Packs", + "Company Link ": "https://iconpacks.net" + }, + { + "Company Name": "Iconshock", + "Company Link ": "https://iconshock.com" + }, + { + "Company Name": "Iconz Design", + "Company Link ": "https://iconz.design" + }, + { + "Company Name": "iFixit", + "Company Link ": "https://ifixit.com" + }, + { + "Company Name": "IFTTT", + "Company Link ": "https://ifttt.com" + }, + { + "Company Name": "Illlustrations", + "Company Link ": "https://illlustrations.co" + }, + { + "Company Name": "Illustration Kit", + "Company Link ": "https://illustrationkit.com" + }, + { + "Company Name": "IMSDB", + "Company Link ": "https://imsdb.com" + }, + { + "Company Name": "Incompetech", + "Company Link ": "https://incompetech.com" + }, + { + "Company Name": "Incredibox", + "Company Link ": "https://incredibox.com" + }, + { + "Company Name": "InnerBod", + "Company Link ": "https://innerbody.com" + }, + { + "Company Name": "Instructables", + "Company Link ": "https://instructables.com" + }, + { + "Company Name": "Integromat", + "Company Link ": "https://make.com" + }, + { + "Company Name": "Investopedia", + "Company Link ": "https://investopedia.com" + }, + { + "Company Name": "Japanese Wiki Corpus", + "Company Link ": "https://japanesewiki.com" + }, + { + "Company Name": "Jitter.Video", + "Company Link ": "https://jitter.video" + }, + { + "Company Name": "Jobspresso", + "Company Link ": "https://jobspresso.co" + }, + { + "Company Name": "JPEG-Optimizer", + "Company Link ": "https://jpeg-optimizer.com" + }, + { + "Company Name": "JS Remotely", + "Company Link ": "https://jsremotely.com" + }, + { + "Company Name": "JScreenFix", + "Company Link ": "https://jscreenfix.com" + }, + { + "Company Name": "JSON Resume", + "Company Link ": "https://jsonresume.io" + }, + { + "Company Name": "Just Join", + "Company Link ": "https://justjoin.it" + }, + { + "Company Name": "Just the Recipe", + "Company Link ": "https://justtherecipe.com" + }, + { + "Company Name": "JustRemote", + "Company Link ": "https://justremote.co" + }, + { + "Company Name": "JustWatch", + "Company Link ": "https://justwatch.com" + }, + { + "Company Name": "Kanopy", + "Company Link ": "https://kanopy.com" + }, + { + "Company Name": "Kassellabs", + "Company Link ": "https://kassellabs.io" + }, + { + "Company Name": "Key Differences", + "Company Link ": "https://keydifferences.com" + }, + { + "Company Name": "Keybase", + "Company Link ": "https://keybase.io" + }, + { + "Company Name": "KeyValues", + "Company Link ": "https://keyvalues.com" + }, + { + "Company Name": "KHInsider", + "Company Link ": "https://khinsider.com" + }, + { + "Company Name": "Killed by Google", + "Company Link ": "https://killedbygoogle.com" + }, + { + "Company Name": "Kimovil", + "Company Link ": "https://kimovil.com" + }, + { + "Company Name": "Lalal.ai", + "Company Link ": "https://www.lalal.ai" + }, + { + "Company Name": "Learn Anything", + "Company Link ": "https://learn-anything.xyz" + }, + { + "Company Name": "LendingTree", + "Company Link ": "https://lendingtree.com" + }, + { + "Company Name": "Lightyear.fm", + "Company Link ": "https://lightyear.fm" + }, + { + "Company Name": "LittleSis", + "Company Link ": "https://littlesis.org" + }, + { + "Company Name": "Looria", + "Company Link ": "https://looria.com" + }, + { + "Company Name": "Lucidchart", + "Company Link ": "https://lucidchart.com" + }, + { + "Company Name": "Lunar", + "Company Link ": "https://lunar.fyi" + }, + { + "Company Name": "Manuals Lib", + "Company Link ": "https://manualslib.com" + }, + { + "Company Name": "Map Crunch", + "Company Link ": "https://mapcrunch.com" + }, + { + "Company Name": "Masterworks", + "Company Link ": "https://masterworks.com" + }, + { + "Company Name": "MediaFire", + "Company Link ": "https://mediafire.com" + }, + { + "Company Name": "Mind Mup", + "Company Link ": "https://mindmup.com" + }, + { + "Company Name": "Mixlr", + "Company Link ": "https://mixlr.com" + }, + { + "Company Name": "Moises AI", + "Company Link ": "https://moises.ai" + }, + { + "Company Name": "Money", + "Company Link ": "https://money.com" + }, + { + "Company Name": "Mountain Project", + "Company Link ": "https://mountainproject.com" + }, + { + "Company Name": "Movie Map", + "Company Link ": "https://movie-map.com" + }, + { + "Company Name": "Movie Sounds", + "Company Link ": "https://movie-sounds.org" + }, + { + "Company Name": "MP3Cut", + "Company Link ": "https://mp3cut.net" + }, + { + "Company Name": "Murmel", + "Company Link ": "https://murmel.social" + }, + { + "Company Name": "Muscle Wiki", + "Company Link ": "https://musclewiki.com" + }, + { + "Company Name": "Music-Map", + "Company Link ": "https://music-map.com" + }, + { + "Company Name": "MusicTheory.net", + "Company Link ": "https://musictheory.net" + }, + { + "Company Name": "MyFonts", + "Company Link ": "https://myfonts.com" + }, + { + "Company Name": "MyFridgeFood", + "Company Link ": "https://myfridgefood.com" + }, + { + "Company Name": "Nameberry", + "Company Link ": "https://nameberry.com" + }, + { + "Company Name": "Namechk", + "Company Link ": "https://namechk.com" + }, + { + "Company Name": "Ncase", + "Company Link ": "https://ncase.me" + }, + { + "Company Name": "News in Levels", + "Company Link ": "https://newsinlevels.com" + }, + { + "Company Name": "Noisli", + "Company Link ": "https://noisli.com" + }, + { + "Company Name": "Notes.io", + "Company Link ": "https://notes.io" + }, + { + "Company Name": "Novoresume", + "Company Link ": "https://novoresume.com" + }, + { + "Company Name": "Ocoya", + "Company Link ": "https://ocoya.com" + }, + { + "Company Name": "Old Computers Museum", + "Company Link ": "https://oldcomputers.net" + }, + { + "Company Name": "Online Tone Generator", + "Company Link ": "https://onlinetonegenerator.com" + }, + { + "Company Name": "Online-Convert", + "Company Link ": "https://online-convert.com" + }, + { + "Company Name": "OnlineConversion", + "Company Link ": "https://onlineconversion.com" + }, + { + "Company Name": "Online OCR", + "Company Link ": "https://onlineocr.net" + }, + { + "Company Name": "OpenWeatherMap", + "Company Link ": "https://openweathermap.org" + }, + { + "Company Name": "OrgPad", + "Company Link ": "https://orgpad.com" + }, + { + "Company Name": "Passport Index", + "Company Link ": "https://passportindex.org" + }, + { + "Company Name": "PDF Candy", + "Company Link ": "https://pdfcandy.com" + }, + { + "Company Name": "PDF2DOC", + "Company Link ": "https://pdf2doc.com" + }, + { + "Company Name": "PDFescape", + "Company Link ": "https://pdfescape.com" + }, + { + "Company Name": "PfpMaker", + "Company Link ": "https://pfpmaker.com" + }, + { + "Company Name": "PIDGI Wiki ", + "Company Link ": "https://pidgi.net" + }, + { + "Company Name": "PimEyes", + "Company Link ": "https://pimeyes.com" + }, + { + "Company Name": "Pipl ", + "Company Link ": "https://pipl.com" + }, + { + "Company Name": "PixelBazaar", + "Company Link ": "https://pixelbazaar.com" + }, + { + "Company Name": "PixelPaper", + "Company Link ": "https://pixelpaper.io" + }, + { + "Company Name": "Ponly", + "Company Link ": "https://ponly.com" + }, + { + "Company Name": "PowerToFly", + "Company Link ": "https://powertofly.com" + }, + { + "Company Name": "Pretzel Rocks", + "Company Link ": "https://pretzel.rocks" + }, + { + "Company Name": "PrintIt", + "Company Link ": "https://printit.work" + }, + { + "Company Name": "Prismatext", + "Company Link ": "https://prismatext.com" + }, + { + "Company Name": "Puffin Maps", + "Company Link ": "https://puffinmaps.com" + }, + { + "Company Name": "Puzzle Loop ", + "Company Link ": "https://puzzle-loop.com" + }, + { + "Company Name": "QuoteMaster", + "Company Link ": "https://quotemaster.org" + }, + { + "Company Name": "Radio Garden", + "Company Link ": "https://radio.garden" + }, + { + "Company Name": "Radiooooo", + "Company Link ": "https://radiooooo.com" + }, + { + "Company Name": "Radiosondy", + "Company Link ": "https://radiosondy.info" + }, + { + "Company Name": "Rainy Mood", + "Company Link ": "https://rainymood.com" + }, + { + "Company Name": "Random Street View", + "Company Link ": "https://randomstreetview.com" + }, + { + "Company Name": "Rap4Ever", + "Company Link ": "https://rap4all.com" + }, + { + "Company Name": "RareFilm", + "Company Link ": "https://rarefilm.net" + }, + { + "Company Name": "Rattibha", + "Company Link ": "https://rattibha.com" + }, + { + "Company Name": "Reddit List ", + "Company Link ": "https://redditlist.com" + }, + { + "Company Name": "RedditSearch.io", + "Company Link ": "https://redditsearch.io" + }, + { + "Company Name": "Reelgood", + "Company Link ": "https://reelgood.com" + }, + { + "Company Name": "Reface", + "Company Link ": "https://reface.ai" + }, + { + "Company Name": "Rejected.us", + "Company Link ": "https://rejected.us" + }, + { + "Company Name": "Relanote", + "Company Link ": "https://relanote.com" + }, + { + "Company Name": "Remote Leaf", + "Company Link ": "https://remoteleaf.com" + }, + { + "Company Name": "Remote OK", + "Company Link ": "https://remoteok.com" + }, + { + "Company Name": "Remote Starter Kit ", + "Company Link ": "https://remotestarterkit.com" + }, + { + "Company Name": "Remote.co", + "Company Link ": "https://remote.co" + }, + { + "Company Name": "Remote Base ", + "Company Link ": "https://remotebase.com" + }, + { + "Company Name": "Remote Bear", + "Company Link ": "https://remotebear.io" + }, + { + "Company Name": "Remove.bg", + "Company Link ": "https://remove.bg" + }, + { + "Company Name": "Respresso", + "Company Link ": "https://respresso.io" + }, + { + "Company Name": "Reveddit", + "Company Link ": "https://reveddit.com" + }, + { + "Company Name": "Rhymer", + "Company Link ": "https://rhymer.com" + }, + { + "Company Name": "RhymeZone", + "Company Link ": "https://rhymezone.com" + }, + { + "Company Name": "Ribbet", + "Company Link ": "https://ribbet.com" + }, + { + "Company Name": "Roadmap.sh", + "Company Link ": "https://roadmap.sh" + }, + { + "Company Name": "Roadtrippers", + "Company Link ": "https://roadtrippers.com" + }, + { + "Company Name": "RxResu.me", + "Company Link ": "https://rxresu.me" + }, + { + "Company Name": "SchemeColor", + "Company Link ": "https://schemecolor.com" + }, + { + "Company Name": "Screenshot.Guru", + "Company Link ": "https://screenshot.guru" + }, + { + "Company Name": "SeatGuru", + "Company Link ": "https://seatguru.com" + }, + { + "Company Name": "Sessions", + "Company Link ": "https://sessions.us" + }, + { + "Company Name": "Shottr", + "Company Link ": "https://shottr.cc" + }, + { + "Company Name": "Signature Maker", + "Company Link ": "https://signature-maker.net" + }, + { + "Company Name": "Skip The Drive", + "Company Link ": "https://skipthedrive.com" + }, + { + "Company Name": "Slowby", + "Company Link ": "https://slowby.travel" + }, + { + "Company Name": "Small World", + "Company Link ": "https://smallworld.kiwi" + }, + { + "Company Name": "SmallPDF", + "Company Link ": "https://smallpdf.com" + }, + { + "Company Name": "Social Image Maker", + "Company Link ": "https://socialimagemaker.io" + }, + { + "Company Name": "Social Sizes", + "Company Link ": "https://socialsizes.io" + }, + { + "Company Name": "SoundLove", + "Company Link ": "https://soundlove.se" + }, + { + "Company Name": "Spline", + "Company Link ": "https://spline.design" + }, + { + "Company Name": "Starkey Comics", + "Company Link ": "https://starkeycomics.com" + }, + { + "Company Name": "Startups List", + "Company Link ": "https://startups-list.com" + }, + { + "Company Name": "Statista", + "Company Link ": "https://statista.com" + }, + { + "Company Name": "Stolen Camera Finder", + "Company Link ": "https://stolencamerafinder.com" + }, + { + "Company Name": "Strobe.Cool", + "Company Link ": "https://strobe.cool" + }, + { + "Company Name": "Sumo", + "Company Link ": "https://sumo.app" + }, + { + "Company Name": "SuperMeme AI", + "Company Link ": "https://supermeme.ai" + }, + { + "Company Name": "Synthesia", + "Company Link ": "https://synthesia.io" + }, + { + "Company Name": "TablerIcons", + "Company Link ": "https://tablericons.com" + }, + { + "Company Name": "Tango", + "Company Link ": "https://tango.us" + }, + { + "Company Name": "TasteDive", + "Company Link ": "https://tastedive.com" + }, + { + "Company Name": "TechSpecs", + "Company Link ": "https://techspecs.io" + }, + { + "Company Name": "Teoria", + "Company Link ": "https://teoria.com" + }, + { + "Company Name": "Text Faces", + "Company Link ": "https://textfac.es" + }, + { + "Company Name": "The Balance Money", + "Company Link ": "https://thebalancemoney.com" + }, + { + "Company Name": "The Punctuation Guide", + "Company Link ": "https://thepunctuationguide.com" + }, + { + "Company Name": "This to That", + "Company Link ": "https://thistothat.com" + }, + { + "Company Name": "This vs That", + "Company Link ": "https://thisvsthat.io" + }, + { + "Company Name": "ThreadReaderApp ", + "Company Link ": "https://threadreaderapp.com" + }, + { + "Company Name": "Thumbly", + "Company Link ": "https://tokee.ai" + }, + { + "Company Name": "Tiii.me", + "Company Link ": "https://tiii.me" + }, + { + "Company Name": "TikTok Video Downloader", + "Company Link ": "https://ttvdl.com" + }, + { + "Company Name": "Time and Date", + "Company Link ": "https://timeanddate.com" + }, + { + "Company Name": "Time.is", + "Company Link ": "https://time.is" + }, + { + "Company Name": "Title Case", + "Company Link ": "https://titlecase.com" + }, + { + "Company Name": "Toaster Central", + "Company Link ": "https://toastercentral.com" + }, + { + "Company Name": "Tongue-Twister ", + "Company Link ": "https://tongue-twister.net" + }, + { + "Company Name": "TradingView", + "Company Link ": "https://tradingview.com" + }, + { + "Company Name": "Transparent Textures", + "Company Link ": "https://transparenttextures.com" + }, + { + "Company Name": "Tubi TV", + "Company Link ": "https://tubitv.com" + }, + { + "Company Name": "Tunefind", + "Company Link ": "https://tunefind.com" + }, + { + "Company Name": "TuneMyMusic", + "Company Link ": "https://tunemymusic.com" + }, + { + "Company Name": "Tweepsmap", + "Company Link ": "https://fedica.com" + }, + { + "Company Name": "Two Peas and Their Pod", + "Company Link ": "https://twopeasandtheirpod.com" + }, + { + "Company Name": "Typatone", + "Company Link ": "https://typatone.com" + }, + { + "Company Name": "Under Glass", + "Company Link ": "https://underglass.io" + }, + { + "Company Name": "UniCorner", + "Company Link ": "https://unicorner.news" + }, + { + "Company Name": "Unita", + "Company Link ": "https://unita.co" + }, + { + "Company Name": "UnitConverters", + "Company Link ": "https://unitconverters.net" + }, + { + "Company Name": "Unreadit", + "Company Link ": "https://unreadit.com" + }, + { + "Company Name": "Unscreen", + "Company Link ": "https://unscreen.com" + }, + { + "Company Name": "UnTools ", + "Company Link ": "https://untools.co" + }, + { + "Company Name": "Upwork", + "Company Link ": "https://upwork.com" + }, + { + "Company Name": "UTF8 Icons", + "Company Link ": "https://utf8icons.com" + }, + { + "Company Name": "Vector Magic", + "Company Link ": "https://vectormagic.com" + }, + { + "Company Name": "Virtual Vacation", + "Company Link ": "https://virtualvacation.us" + }, + { + "Company Name": "Virtual Vocations", + "Company Link ": "https://virtualvocations.com" + }, + { + "Company Name": "Visiwig", + "Company Link ": "https://visiwig.com" + }, + { + "Company Name": "Visual CV", + "Company Link ": "https://visualcv.com" + }, + { + "Company Name": "Vocus.io", + "Company Link ": "https://vocus.io" + }, + { + "Company Name": "Voscreen", + "Company Link ": "https://voscreen.com" + }, + { + "Company Name": "Wanderprep", + "Company Link ": "https://wanderprep.com" + }, + { + "Company Name": "Warmshowers", + "Company Link ": "https:/warmshowers.org" + }, + { + "Company Name": "Watch Documentaries", + "Company Link ": "https://watchdocumentaries.com" + }, + { + "Company Name": "We Work Remotely", + "Company Link ": "https://weworkremotely.com" + }, + { + "Company Name": "Web2PDFConvert", + "Company Link ": "https://web2pdfconvert.com" + }, + { + "Company Name": "Welcome to My Garden", + "Company Link ": "https://welcometomygarden.org" + }, + { + "Company Name": "When2meet ", + "Company Link ": "https://when2meet.com" + }, + { + "Company Name": "Where's George", + "Company Link ": "https://wheresgeorge.com" + }, + { + "Company Name": "Where's Willy", + "Company Link ": "https://whereswilly.com" + }, + { + "Company Name": "WikiHow", + "Company Link ": "https://wikihow.com" + }, + { + "Company Name": "Windy", + "Company Link ": "https://www.windy.com" + }, + { + "Company Name": "WonderHowTo", + "Company Link ": "https://wonderhowto.com" + }, + { + "Company Name": "Working Nomads", + "Company Link ": "https://workingnomads.com" + }, + { + "Company Name": "Wormhole", + "Company Link ": "https://wormhole.app" + }, + { + "Company Name": "Y Combinator Jobs", + "Company Link ": "https://ycombinator.com" + }, + { + "Company Name": "Yes Promo", + "Company Link ": "https://yespromo.me" + }, + { + "Company Name": "YouGlish", + "Company Link ": "https://youglish.com" + }, + { + "Company Name": "Zamzar", + "Company Link ": "https://zamzar.com" + }, + { + "Company Name": "Zippyshare", + "Company Link ": "https://zippyshare.com" + }, + { + "Company Name": "Zoom Earth", + "Company Link ": "https://zoom.earth" + }, + { + "Company Name": "Zoom.it", + "Company Link ": "https://zoom.it" + } +] \ No newline at end of file diff --git a/Client/src/assets/icons/average-response-icon.svg b/Client/src/assets/icons/average-response-icon.svg new file mode 100644 index 000000000..572e2002f --- /dev/null +++ b/Client/src/assets/icons/average-response-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Client/src/assets/icons/certificate.svg b/Client/src/assets/icons/certificate.svg new file mode 100644 index 000000000..a985bef9f --- /dev/null +++ b/Client/src/assets/icons/certificate.svg @@ -0,0 +1,3 @@ + + + diff --git a/Client/src/assets/icons/history-icon.svg b/Client/src/assets/icons/history-icon.svg new file mode 100644 index 000000000..7343455ec --- /dev/null +++ b/Client/src/assets/icons/history-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Client/src/assets/icons/response-time-icon.svg b/Client/src/assets/icons/response-time-icon.svg new file mode 100644 index 000000000..db5b2af38 --- /dev/null +++ b/Client/src/assets/icons/response-time-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Client/src/assets/icons/top-right-arrow.svg b/Client/src/assets/icons/top-right-arrow.svg index 471b9454c..d569cdd7e 100644 --- a/Client/src/assets/icons/top-right-arrow.svg +++ b/Client/src/assets/icons/top-right-arrow.svg @@ -1,3 +1,3 @@ - + diff --git a/Client/src/assets/icons/uptime-icon.svg b/Client/src/assets/icons/uptime-icon.svg new file mode 100644 index 000000000..55706ae71 --- /dev/null +++ b/Client/src/assets/icons/uptime-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Client/src/index.css b/Client/src/index.css index 105776bd8..1f3b9bee2 100644 --- a/Client/src/index.css +++ b/Client/src/index.css @@ -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; diff --git a/README.md b/README.md index 9cbd37355..e050b221e 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,8 @@

    An open source server monitoring application

    -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. diff --git a/Server/controllers/monitorController.js b/Server/controllers/monitorController.js index f33ddcf10..4571a9a65 100644 --- a/Server/controllers/monitorController.js +++ b/Server/controllers/monitorController.js @@ -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} + * @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} + * @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, diff --git a/Server/db/mongo/MongoDB.js b/Server/db/mongo/MongoDB.js index 912bfb438..d6a4a243f 100644 --- a/Server/db/mongo/MongoDB.js +++ b/Server/db/mongo/MongoDB.js @@ -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, diff --git a/Server/db/mongo/modules/monitorModule.js b/Server/db/mongo/modules/monitorModule.js index 79e8917ea..e77ccc696 100644 --- a/Server/db/mongo/modules/monitorModule.js +++ b/Server/db/mongo/modules/monitorModule.js @@ -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} 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>} + * @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, diff --git a/Server/middleware/handleErrors.js b/Server/middleware/handleErrors.js index 1e1e955c5..db51c1af6 100644 --- a/Server/middleware/handleErrors.js +++ b/Server/middleware/handleErrors.js @@ -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 }); }; diff --git a/Server/models/Monitor.js b/Server/models/Monitor.js index fdc147d1b..d91275e23 100644 --- a/Server/models/Monitor.js +++ b/Server/models/Monitor.js @@ -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: [ { diff --git a/Server/openapi.json b/Server/openapi.json new file mode 100644 index 000000000..23c16521d --- /dev/null +++ b/Server/openapi.json @@ -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" + } + } + } + } + } +} diff --git a/Server/routes/monitorRoute.js b/Server/routes/monitorRoute.js index ee1c08488..b29b7130a 100644 --- a/Server/routes/monitorRoute.js +++ b/Server/routes/monitorRoute.js @@ -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( diff --git a/Server/service/jobQueue.js b/Server/service/jobQueue.js index ed15e0f94..1c44c4062 100644 --- a/Server/service/jobQueue.js +++ b/Server/service/jobQueue.js @@ -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, diff --git a/Server/service/networkService.js b/Server/service/networkService.js index f5c333a60..e25a63ce5 100644 --- a/Server/service/networkService.js +++ b/Server/service/networkService.js @@ -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); } } diff --git a/Server/validation/joi.js b/Server/validation/joi.js index ccc8da9f6..417965b95 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -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,