chore: merge changes

This commit is contained in:
Caio Cabral
2024-11-25 18:57:23 -05:00
67 changed files with 4033 additions and 728 deletions

View File

@@ -15,8 +15,8 @@
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.16.7",
"@mui/x-charts": "^7.5.1",
"@mui/x-data-grid": "7.22.2",
"@mui/x-date-pickers": "7.22.2",
"@mui/x-data-grid": "7.22.3",
"@mui/x-date-pickers": "7.22.3",
"@reduxjs/toolkit": "2.3.0",
"axios": "^1.7.4",
"chart.js": "^4.4.3",
@@ -362,16 +362,16 @@
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz",
"integrity": "sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==",
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/serialize": "^1.2.0",
"@emotion/serialize": "^1.3.3",
"babel-plugin-macros": "^3.1.0",
"convert-source-map": "^1.5.0",
"escape-string-regexp": "^4.0.0",
@@ -381,14 +381,14 @@
}
},
"node_modules/@emotion/cache": {
"version": "11.13.1",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz",
"integrity": "sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==",
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.5.tgz",
"integrity": "sha512-Z3xbtJ+UcK76eWkagZ1onvn/wAVb1GOMuR15s30Fm2wrMgC7jzpnO2JZXr4eujTTqoQFUrZIw/rT0c6Zzjca1g==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"stylis": "4.2.0"
}
@@ -415,17 +415,17 @@
"license": "MIT"
},
"node_modules/@emotion/react": {
"version": "11.13.3",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.3.tgz",
"integrity": "sha512-lIsdU6JNrmYfJ5EbUCf4xW1ovy5wKQ2CkPRM4xogziOxH1nXxBSjpC9YqbFAP7circxMfYp+6x676BqWcEiixg==",
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.5.tgz",
"integrity": "sha512-6zeCUxUH+EPF1s+YF/2hPVODeV/7V07YU5x+2tfuRL8MdW6rv5vb2+CBEGTGwBdux0OIERcOS+RzxeK80k2DsQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.12.0",
"@emotion/cache": "^11.13.0",
"@emotion/serialize": "^1.3.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/cache": "^11.13.5",
"@emotion/serialize": "^1.3.3",
"@emotion/use-insertion-effect-with-fallbacks": "^1.1.0",
"@emotion/utils": "^1.4.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"hoist-non-react-statics": "^3.3.1"
},
@@ -439,15 +439,15 @@
}
},
"node_modules/@emotion/serialize": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz",
"integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/unitless": "^0.10.0",
"@emotion/utils": "^1.4.1",
"@emotion/utils": "^1.4.2",
"csstype": "^3.0.2"
}
},
@@ -458,17 +458,17 @@
"license": "MIT"
},
"node_modules/@emotion/styled": {
"version": "11.13.0",
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.0.tgz",
"integrity": "sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA==",
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.5.tgz",
"integrity": "sha512-gnOQ+nGLPvDXgIx119JqGalys64lhMdnNQA9TMxhDA4K0Hq5+++OE20Zs5GxiCV9r814xQ2K5WmtofSpHVW6BQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.12.0",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/is-prop-valid": "^1.3.0",
"@emotion/serialize": "^1.3.0",
"@emotion/serialize": "^1.3.3",
"@emotion/use-insertion-effect-with-fallbacks": "^1.1.0",
"@emotion/utils": "^1.4.0"
"@emotion/utils": "^1.4.2"
},
"peerDependencies": {
"@emotion/react": "^11.0.0-rc.0",
@@ -496,9 +496,9 @@
}
},
"node_modules/@emotion/utils": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz",
"integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
},
"node_modules/@emotion/weak-memoize": {
@@ -1400,9 +1400,9 @@
}
},
"node_modules/@mui/x-charts": {
"version": "7.22.2",
"resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.2.tgz",
"integrity": "sha512-0Y2du4Ed7gOT53l8vVJ4vKT+Jz4Dh/iHnLy8TtL3+XhbPH9Ndu9Q30WwyyzOn84yt37hSUru/njQ1BWaSvVPHw==",
"version": "7.22.3",
"resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.3.tgz",
"integrity": "sha512-w23+AwIK86bpNWkuHewyQwOKi1wYbLDzrvUEqvZ9KVYzZvnqpJmbTKideX1pLVgSNt0On8NDXytzCntV48Nobw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.7",
@@ -1458,9 +1458,9 @@
}
},
"node_modules/@mui/x-data-grid": {
"version": "7.22.2",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.2.tgz",
"integrity": "sha512-yfy2s5A6tbajQZiEdsba49T4FYb9F0WPrzbbG30dl1+sIiX4ZRX7ma44UIDGPZrsZv8xkkE+p8qeJxZ7OaMteA==",
"version": "7.22.3",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.3.tgz",
"integrity": "sha512-O6kBf6yt/GkOcWjHca5xWN10qBQ/MkITvJmBuIOtX+LH7YtOAriMgD2zkhNbXxHChi7QdEud3bNC3jw5RLRVCA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.7",
@@ -1495,9 +1495,9 @@
}
},
"node_modules/@mui/x-date-pickers": {
"version": "7.22.2",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.2.tgz",
"integrity": "sha512-1KHSlIlnSoY3oHm820By8X344pIdGYqPvCCvfVHrEeeIQ/pHdxDD8tjZFWkFl4Jgm9oVFK90fMcqNZAzc+WaCw==",
"version": "7.22.3",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.3.tgz",
"integrity": "sha512-shNp92IrST5BiVy2f4jbrmRaD32QhyUthjh1Oexvpcn0v6INyuWgxfodoTi5ZCnE5Ue5UVFSs4R9Xre0UbJ5DQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.7",

View File

@@ -18,8 +18,8 @@
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.16.7",
"@mui/x-charts": "^7.5.1",
"@mui/x-data-grid": "7.22.2",
"@mui/x-date-pickers": "7.22.2",
"@mui/x-data-grid": "7.22.3",
"@mui/x-date-pickers": "7.22.3",
"@reduxjs/toolkit": "2.3.0",
"axios": "^1.7.4",
"chart.js": "^4.4.3",

View File

@@ -10,6 +10,7 @@ import Register from "./Pages/Auth/Register/Register";
import Account from "./Pages/Account";
import Monitors from "./Pages/Monitors/Home";
import CreateMonitor from "./Pages/Monitors/CreateMonitor";
import CreateInfrastructureMonitor from "./Pages/Infrastructure/CreateMonitor";
import Incidents from "./Pages/Incidents";
import Status from "./Pages/Status";
import Integrations from "./Pages/Integrations";
@@ -39,6 +40,7 @@ import { getAppSettings } from "./Features/Settings/settingsSlice";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Infrastructure } from "./Pages/Infrastructure";
import InfrastructureDetails from "./Pages/Infrastructure/Details";
function App() {
const AdminCheckedRegister = withAdminCheck(Register);
const MonitorsWithAdminProp = withAdminProp(Monitors);
@@ -48,6 +50,7 @@ function App() {
const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const SettingsWithAdminProp = withAdminProp(Settings);
const AdvancedSettingsWithAdminProp = withAdminProp(AdvancedSettings);
const InfrastructureDetailsWithAdminProp = withAdminProp(InfrastructureDetails);
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
@@ -127,6 +130,14 @@ function App() {
path="infrastructure"
element={<ProtectedRoute Component={Infrastructure} />}
/>
<Route
path="infrastructure/create"
element={<ProtectedRoute Component={CreateInfrastructureMonitor} />}
/>
<Route
path="infrastructure/:monitorId"
element={<ProtectedRoute Component={InfrastructureDetailsWithAdminProp} />}
/>
<Route
path="incidents/:monitorId?"

View File

@@ -0,0 +1,157 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { createGradient } from "../Utils/gradientUtils";
import PropTypes from "prop-types";
import { useTheme } from "@mui/material";
import { useId } from "react";
/**
* CustomAreaChart component for rendering an area chart with optional gradient and custom ticks.
*
* @param {Object} props - The properties object.
* @param {Array} props.data - The data array for the chart.
* @param {string} props.xKey - The key for the x-axis data.
* @param {string} props.yKey - The key for the y-axis data.
* @param {Object} [props.xTick] - Custom tick component for the x-axis.
* @param {Object} [props.yTick] - Custom tick component for the y-axis.
* @param {string} [props.strokeColor] - The stroke color for the area.
* @param {string} [props.fillColor] - The fill color for the area.
* @param {boolean} [props.gradient=false] - Whether to apply a gradient fill.
* @param {string} [props.gradientDirection="vertical"] - The direction of the gradient.
* @param {string} [props.gradientStartColor] - The start color of the gradient.
* @param {string} [props.gradientEndColor] - The end color of the gradient.
* @param {Object} [props.customTooltip] - Custom tooltip component.
* @returns {JSX.Element} The rendered area chart component.
*
* @example
* // Example usage of CustomAreaChart
* import React from 'react';
* import CustomAreaChart from './CustomAreaChart';
* import { TzTick, PercentTick, InfrastructureTooltip } from './chartUtils';
*
* const data = [
* { createdAt: '2023-01-01T00:00:00Z', cpu: { usage_percent: 0.5 } },
* { createdAt: '2023-01-01T01:00:00Z', cpu: { usage_percent: 0.6 } },
* // more data points...
* ];
*
* const MyChartComponent = () => {
* return (
* <CustomAreaChart
* data={data}
* xKey="createdAt"
* yKey="cpu.usage_percent"
* xTick={<TzTick />}
* yTick={<PercentTick />}
* strokeColor="#8884d8"
* fillColor="#8884d8"
* gradient={true}
* gradientStartColor="#8884d8"
* gradientEndColor="#82ca9d"
* customTooltip={({ active, payload, label }) => (
* <InfrastructureTooltip
* label={label?.toString() ?? ""}
* yKey="cpu.usage_percent"
* yLabel="CPU Usage"
* active={active}
* payload={payload}
* />
* )}
* />
* );
* };
*
* export default MyChartComponent;
*/
const CustomAreaChart = ({
data,
dataKey,
xKey,
yKey,
xTick,
yTick,
strokeColor,
fillColor,
gradient = false,
gradientDirection = "vertical",
gradientStartColor,
gradientEndColor,
customTooltip,
height = "100%",
}) => {
const theme = useTheme();
const uniqueId = useId();
const gradientId = `gradient-${uniqueId}`;
return (
<ResponsiveContainer
width="100%"
height={height}
// FE team HELP! Why does this overflow if set to 100%?
>
<AreaChart data={data}>
<XAxis
dataKey={xKey}
{...(xTick && { tick: xTick })}
/>
<YAxis
dataKey={yKey}
{...(yTick && { tick: yTick })}
/>
{gradient === true &&
createGradient({
id: gradientId,
startColor: gradientStartColor,
endColor: gradientEndColor,
direction: gradientDirection,
})}
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={strokeColor}
fill={gradient === true ? `url(#${gradientId})` : fillColor}
/>
{customTooltip ? (
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={customTooltip}
wrapperStyle={{ pointerEvents: "none" }}
/>
) : (
<Tooltip />
)}
</AreaChart>
</ResponsiveContainer>
);
};
CustomAreaChart.propTypes = {
data: PropTypes.array.isRequired,
dataKey: PropTypes.string.isRequired,
xTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
yTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
xKey: PropTypes.string.isRequired,
yKey: PropTypes.string.isRequired,
fillColor: PropTypes.string,
strokeColor: PropTypes.string,
gradient: PropTypes.bool,
gradientDirection: PropTypes.string,
gradientStartColor: PropTypes.string,
gradientEndColor: PropTypes.string,
customTooltip: PropTypes.func,
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default CustomAreaChart;

View File

@@ -0,0 +1,14 @@
.radial-chart {
position: relative;
display: inline-block;
}
.radial-chart-base {
opacity: 0.3;
}
.radial-chart-progress {
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 1.5s ease-in-out;
}

View File

@@ -0,0 +1,113 @@
import { useTheme } from "@emotion/react";
import { useEffect, useState, useMemo } from "react";
import { Box, Typography } from "@mui/material";
import PropTypes from "prop-types";
import "./index.css";
/**
* A Performant SVG based circular gauge
*
* @component
* @param {Object} props - Component properties
* @param {number} [props.progress=0] - Progress percentage (0-100)
* @param {number} [props.radius=60] - Radius of the gauge circle
* @param {string} [props.color="#000000"] - Color of the progress stroke
* @param {number} [props.strokeWidth=15] - Width of the gauge stroke
*
* @example
* <CustomGauge
* progress={75}
* radius={50}
* color="#00ff00"
* strokeWidth={10}
* />
*
* @returns {React.ReactElement} Rendered CustomGauge component
*/
const CustomGauge = ({
progress = 0,
radius = 60,
color = "#000000",
strokeWidth = 15,
}) => {
// Calculate the length of the stroke for the circle
const { circumference, totalSize, strokeLength } = useMemo(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progress / 100) * (2 * Math.PI * radius),
}),
[radius, strokeWidth, progress]
);
const [offset, setOffset] = useState(circumference);
const theme = useTheme();
// Handle initial animation
useEffect(() => {
setOffset(circumference);
const timer = setTimeout(() => {
setOffset(circumference - strokeLength);
}, 100);
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
return (
<Box
className="radial-chart"
width={radius}
height={radius}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
width={radius}
height={radius}
>
<circle
className="radial-chart-base"
stroke={theme.palette.background.fill}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2} // Center the circle
cy={totalSize / 2} // Center the circle
r={radius}
/>
<circle
className="radial-chart-progress"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
strokeLinecap="round"
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
/>
</svg>
<Typography
className="radial-chart-text"
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
...theme.typography.body2,
fill: theme.typography.body2.color,
}}
>
{`${progress.toFixed(2)}%`}
</Typography>
</Box>
);
};
export default CustomGauge;
CustomGauge.propTypes = {
progress: PropTypes.number,
radius: PropTypes.number,
color: PropTypes.string,
strokeWidth: PropTypes.number,
};

View File

@@ -0,0 +1,190 @@
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useTheme } from "@mui/material";
import { Text } from "recharts";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import { Box, Stack, Typography } from "@mui/material";
/**
* Custom tick component for rendering time with timezone.
*
* @param {Object} props - The properties object.
* @param {number} props.x - The x-coordinate for the tick.
* @param {number} props.y - The y-coordinate for the tick.
* @param {Object} props.payload - The payload object containing tick data.
* @param {number} props.index - The index of the tick.
* @returns {JSX.Element} The rendered tick component.
*/
export const TzTick = ({ x, y, payload, index }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.tertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
</Text>
);
};
TzTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
};
/**
* Custom tick component for rendering percentage values.
*
* @param {Object} props - The properties object.
* @param {number} props.x - The x-coordinate for the tick.
* @param {number} props.y - The y-coordinate for the tick.
* @param {Object} props.payload - The payload object containing tick data.
* @param {number} props.index - The index of the tick.
* @returns {JSX.Element|null} The rendered tick component or null for the first tick.
*/
export const PercentTick = ({ x, y, payload, index }) => {
const theme = useTheme();
if (index === 0) return null;
return (
<Text
x={x - 20}
y={y}
textAnchor="middle"
fill={theme.palette.text.tertiary}
fontSize={11}
fontWeight={400}
>
{`${payload?.value * 100}%`}
</Text>
);
};
PercentTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
};
/**
* Converts a decimal value to a formatted percentage string.
*
* @param {number} value - The decimal value to convert (e.g., 0.75)
* @returns {string} Formatted percentage string (e.g., "75.00%") or original input if not a number
*
* @example
* getFormattedPercentage(0.7543) // Returns "75.43%"
* getFormattedPercentage(1) // Returns "100.00%"
* getFormattedPercentage("test") // Returns "test"
*/
const getFormattedPercentage = (value) => {
if (typeof value !== "number") return value;
return `${(value * 100).toFixed(2)}.%`;
};
/**
* Custom tooltip component for displaying infrastructure data.
*
* @param {Object} props - The properties object.
* @param {boolean} props.active - Indicates if the tooltip is active.
* @param {Array} props.payload - The payload array containing tooltip data.
* @param {string} props.label - The label for the tooltip.
* @param {string} props.yKey - The key for the y-axis data.
* @param {string} props.yLabel - The label for the y-axis data.
* @param {string} props.dotColor - The color of the dot in the tooltip.
* @returns {JSX.Element|null} The rendered tooltip component or null if inactive.
*/
export const InfrastructureTooltip = ({
active,
payload,
label,
yKey,
yIdx = -1,
yLabel,
dotColor,
}) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
if (active && payload && payload.length) {
const [hardwareType, metric] = yKey.split(".");
return (
<Box
className="area-tooltip"
sx={{
backgroundColor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.text.tertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
</Typography>
<Box mt={theme.spacing(1)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{yIdx >= 0
? `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][yIdx][metric])}`
: `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][metric])}`}
</Typography>
<Typography component="span"></Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
}
return null;
};
InfrastructureTooltip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.array,
label: PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string,
PropTypes.number,
]),
yKey: PropTypes.string,
yIdx: PropTypes.number,
yLabel: PropTypes.string,
dotColor: PropTypes.string,
};

View File

@@ -0,0 +1,47 @@
/**
* Creates an SVG gradient definition for use in charts
* @param {Object} params - The gradient parameters
* @param {string} [params.id="colorUv"] - Unique identifier for the gradient
* @param {string} params.startColor - Starting color of the gradient (hex, rgb, or color name)
* @param {string} params.endColor - Ending color of the gradient (hex, rgb, or color name)
* @param {number} [params.startOpacity=0.8] - Starting opacity (0-1)
* @param {number} [params.endOpacity=0] - Ending opacity (0-1)
* @param {('vertical'|'horizontal')} [params.direction="vertical"] - Direction of the gradient
* @returns {JSX.Element} SVG gradient definition element
* @example
* createCustomGradient({
* startColor: "#1976D2",
* endColor: "#42A5F5",
* direction: "horizontal"
* })
*/
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical", // or "horizontal"
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);

View File

@@ -37,9 +37,9 @@ const Checkbox = ({
onChange,
isDisabled,
}) => {
/* TODO move sizes to theme */
const sizes = { small: "14px", medium: "16px", large: "18px" };
const theme = useTheme();
return (
<FormControlLabel
className="checkbox-wrapper"
@@ -54,9 +54,10 @@ const Checkbox = ({
"aria-label": "controlled checkbox",
id: id,
}}
sx={{
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: sizes[size], height: sizes[size] },
alignSelf: "flex-start",
}}
/>
}
@@ -65,7 +66,6 @@ const Checkbox = ({
sx={{
borderRadius: theme.shape.borderRadius,
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
"& .MuiButtonBase-root": {
width: theme.spacing(10),
p: 0,
@@ -78,6 +78,10 @@ const Checkbox = ({
fontSize: 13,
color: theme.palette.text.tertiary,
},
".MuiFormControlLabel-label.Mui-disabled": {
color: theme.palette.text.tertiary,
opacity: 0.25,
},
}}
/>
);
@@ -85,7 +89,7 @@ const Checkbox = ({
Checkbox.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
size: PropTypes.oneOf(["small", "medium", "large"]),
isChecked: PropTypes.bool.isRequired,
value: PropTypes.string,

View File

@@ -1,6 +1,12 @@
.field {
min-width: 250px;
min-width: var(--env-var-width-3);
}
.field-infrastructure-alert{
max-width: var(--env-var-width-4);
min-width: unset;
}
.field h3.MuiTypography-root,
.field h5.MuiTypography-root,
.field input,
@@ -14,7 +20,7 @@
padding-right: var(--env-var-spacing-1-minus);
}
.field .MuiInputBase-root:has(input) {
height: 34px;
height: var(--env-var-height-2);
}
.field .MuiInputBase-root:has(.MuiInputAdornment-root) {
padding-right: var(--env-var-spacing-1-minus);

View File

@@ -7,23 +7,31 @@ import Visibility from "@mui/icons-material/Visibility";
import "./index.css";
/**
* Field component for rendering various types of input fields with customizable properties
*
* @param {Object} props
* @param {string} [props.type] - Type of input field (e.g., 'text', 'password').
* @param {string} props.id - ID of the input field.
* @param {string} props.name - Name of the input field.
* @param {string} [props.label] - Label for the input field.
* @param {boolean} [props.https] - Indicates if it should display http or https.
* @param {boolean} [props.isRequired] - Indicates if the field is required, will display a red asterisk.
* @param {boolean} [props.isOptional] - Indicates if the field is optional, will display optional text.
* @param {string} [props.optionalLabel] - Optional label for the input field.
* @param {string} [props.autoComplete] - Autocomplete value for the input field.
* @param {string} [props.type='text'] - Type of input field (text, password, url, email, description, number).
* @param {string} props.id - Unique identifier for the input field.
* @param {string} props.name - Name attribute for the input field.
* @param {string} [props.label] - Label text displayed above the input field.
* @param {boolean} [props.https=true] - For URL type, determines whether to show https:// or http://.
* @param {boolean} [props.isRequired=false] - Displays a red asterisk if the field is required.
* @param {boolean} [props.isOptional=false] - Displays an optional label next to the field.
* @param {string} [props.optionalLabel='(optional)'] - Custom text for optional label.
* @param {string} [props.autoComplete] - Autocomplete attribute for the input.
* @param {string} [props.placeholder] - Placeholder text for the input field.
* @param {string} props.value - Value of the input field.
* @param {function} props.onChange - Function called on input change.
* @param {string} [props.error] - Error message to display for the input field.
* @param {boolean} [props.disabled] - Indicates if the input field is disabled.
* @param {boolean} [props.hidden] - Indicates if the input field is hidden.
* @param {React.Ref} [ref] - Ref forwarded to the underlying `TextField` component. Allows for direct interactions such as focusing.
* @param {string} props.value - Current value of the input field.
* @param {function} props.onChange - Callback function triggered on input value change.
* @param {function} [props.onBlur] - Callback function triggered when input loses focus.
* @param {function} [props.onInput] - Callback function triggered on input event.
* @param {string} [props.error] - Error message to display below the input field.
* @param {boolean} [props.disabled=false] - Disables the input field if true.
* @param {boolean} [props.hidden=false] - Hides the entire input field if true.
* @param {string} [props.className] - Additional CSS class names for the input container.
* @param {boolean} [props.hideErrorText=false] - Hides the error message if true.
* @param {React.Ref} [ref] - Ref forwarded to the underlying TextField component.
*
* @returns {React.ReactElement} Rendered input field component
*/
const Field = forwardRef(
@@ -46,6 +54,8 @@ const Field = forwardRef(
error,
disabled,
hidden,
className,
hideErrorText = false,
},
ref
) => {
@@ -56,7 +66,7 @@ const Field = forwardRef(
return (
<Stack
gap={theme.spacing(2)}
className={`field field-${type}`}
className={`field field-${type} ${className}`}
sx={{
"& fieldset": {
borderColor: theme.palette.border.dark,
@@ -67,7 +77,7 @@ const Field = forwardRef(
borderColor: theme.palette.border.dark,
},
"&:has(.input-error) .MuiOutlinedInput-root fieldset": {
borderColor: theme.palette.error.contrastText,
borderColor: theme.palette.error.main,
},
display: hidden ? "none" : "",
}}
@@ -83,7 +93,7 @@ const Field = forwardRef(
<Typography
component="span"
ml={theme.spacing(1)}
color={theme.palette.error.contrastText}
color={theme.palette.error.main}
>
*
</Typography>
@@ -189,7 +199,8 @@ const Field = forwardRef(
<Typography
component="span"
className="input-error"
color={theme.palette.error.contrastText}
hidden={hideErrorText}
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
@@ -223,6 +234,8 @@ Field.propTypes = {
error: PropTypes.string,
disabled: PropTypes.bool,
hidden: PropTypes.bool,
className: PropTypes.string,
hideErrorText: PropTypes.bool,
};
export default Field;

View File

@@ -90,6 +90,7 @@ const URL_MAP = {
const PATH_MAP = {
monitors: "Dashboard",
pagespeed: "Dashboard",
infrastructure: "Dashboard",
account: "Account",
settings: "Other",
};

View File

@@ -0,0 +1,406 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitorsSummary: [],
success: null,
msg: null,
};
export const createInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/createMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await networkService.createMonitor({
authToken: authToken,
monitor: monitor,
});
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 checkInfrastructureEndpointResolution = createAsyncThunk(
"infrastructureMonitors/CheckEndpoint",
async (data, thunkApi) => {
try {
const { authToken, monitorURL } = data;
const res = await networkService.checkEndpointResolution({
authToken: authToken,
monitorURL: monitorURL,
})
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 getInfrastructureMonitorById = createAsyncThunk(
"infrastructureMonitors/getMonitorById",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.getMonitorById({
authToken: authToken,
monitorId: 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 getInfrastructureMonitorsByTeamId = createAsyncThunk(
"infrastructureMonitors/getMonitorsByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["hardware"],
});
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 updateInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/updateMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const updatedFields = {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications,
threshold: monitor.threshold
};
const res = await networkService.updateMonitor({
authToken: authToken,
monitorId: monitor._id,
updatedFields: updatedFields,
});
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 deleteInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/deleteMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const res = await networkService.deleteMonitorById({
authToken: authToken,
monitorId: monitor._id,
});
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 pauseInfrastructureMonitor = createAsyncThunk(
"infrastructureMonitors/pauseMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.pauseMonitorById({
authToken: authToken,
monitorId: 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 deleteInfrastructureMonitorChecksByTeamId = createAsyncThunk(
"infrastructureMonitors/deleteChecksByTeamId",
async (data, thunkApi) => {
try {
const { authToken, teamId } = data;
const res = await networkService.deleteChecksByTeamId({
authToken: authToken,
teamId: teamId,
});
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 deleteAllInfrastructureMonitors = createAsyncThunk(
"infrastructureMonitors/deleteAllMonitors",
async (data, thunkApi) => {
try {
const { authToken } = data;
const res = await networkService.deleteAllMonitors({
authToken: authToken,
});
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 infrastructureMonitorsSlice = createSlice({
name: "infrastructureMonitors",
initialState,
reducers: {
clearInfrastructureMonitorState: (state) => {
state.isLoading = false;
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
},
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by teamId
// *****************************************************
.addCase(getInfrastructureMonitorsByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getInfrastructureMonitorsByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitorsSummary = action.payload.data;
})
.addCase(getInfrastructureMonitorsByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting infrastructure monitors failed";
})
// *****************************************************
// Create Monitor
// *****************************************************
.addCase(createInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(createInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(createInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to create infrastructure monitor";
})
// *****************************************************
// Resolve Endpoint
// *****************************************************
.addCase(checkInfrastructureEndpointResolution.pending, (state) => {
state.isLoading = true;
})
.addCase(checkInfrastructureEndpointResolution.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(checkInfrastructureEndpointResolution.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to check endpoint resolution";
})
// *****************************************************
// Get Monitor By Id
// *****************************************************
.addCase(getInfrastructureMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getInfrastructureMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getInfrastructureMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to get infrastructure monitor";
})
// *****************************************************
// update Monitor
// *****************************************************
.addCase(updateInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(updateInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(updateInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update infrastructure monitor";
})
// *****************************************************
// Delete Monitor
// *****************************************************
.addCase(deleteInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete infrastructure monitor";
})
// *****************************************************
// Delete Monitor checks by Team ID
// *****************************************************
.addCase(deleteInfrastructureMonitorChecksByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteInfrastructureMonitorChecksByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteInfrastructureMonitorChecksByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete monitor checks";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pauseInfrastructureMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(pauseInfrastructureMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pauseInfrastructureMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause infrastructure monitor";
})
// *****************************************************
// Delete all Monitors
// *****************************************************
.addCase(deleteAllInfrastructureMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteAllInfrastructureMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteAllInfrastructureMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to delete all monitors";
});
},
});
export const { setInfrastructureMonitors, clearInfrastructureMonitorState } = infrastructureMonitorsSlice.actions;
export default infrastructureMonitorsSlice.reducer;

View File

@@ -40,7 +40,7 @@ export const checkEndpointResolution = createAsyncThunk(
const res = await networkService.checkEndpointResolution({
authToken: authToken,
monitorURL: monitorURL,
})
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
@@ -53,7 +53,7 @@ export const checkEndpointResolution = createAsyncThunk(
return thunkApi.rejectWithValue(payload);
}
}
)
);
export const getUptimeMonitorById = createAsyncThunk(
"monitors/getMonitorById",
@@ -86,7 +86,7 @@ export const getUptimeMonitorsByTeamId = createAsyncThunk(
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["http", "ping"],
types: ["http", "ping", "docker"],
});
return res.data;
} catch (error) {

View File

@@ -548,23 +548,6 @@ const Login = () => {
)
)}
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Don&apos;t have an account? </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => {
navigate("/register");
}}
sx={{ userSelect: "none" }}
>
Sign Up
</Typography>
</Box>
</Stack>
);
};

View File

@@ -39,7 +39,7 @@ const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
page: 0,
rowsPerPage: 14,
});
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setPaginationController((prevPaginationController) => ({

View File

@@ -0,0 +1,92 @@
import { Box, Stack, Typography } from "@mui/material";
import Field from "../../../../Components/Inputs/Field";
import Checkbox from "../../../../Components/Inputs/Checkbox";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
/**
* `CustomThreshold` is a functional React component that displays a
* group of CheckBox with a label and its correspondant threshold input field.
*
* @param {{ checkboxId: any; checkboxLabel: any; onCheckboxChange: any; fieldId: any; onFieldChange: any; onFieldBlur: any; alertUnit: any; infrastructureMonitor: any; errors: any; }} param0
* @param {string} param0.checkboxId - The text is the id of the checkbox.
* @param {string} param0.checkboxLabel - The text to be displayed as the label next to the check icon.
* @param {func} param0.onCheckboxChange - The function to invoke when checkbox is checked or unchecked.
* @param {string} param0.fieldId - The text is the id of the input field.
* @param {func} param0.onFieldChange - The function to invoke when input field is changed.
* @param {func} param0.onFieldBlur - The function to invoke when input field is losing focus.
* @param {string} param0.alertUnit the threshold unit such as usage percentage '%' etc
* @param {object} param0.infrastructureMonitor the form object of the create infrastrcuture monitor page
* @param {object} param0.errors the object that holds all the errors of the form page
* @returns A compound React component that renders the custom threshold alert section
*
*/
export const CustomThreshold = ({
checkboxId,
checkboxLabel,
onCheckboxChange,
fieldId,
onFieldChange,
onFieldBlur,
alertUnit,
infrastructureMonitor,
errors,
}) => {
const theme = useTheme();
return (
<Stack
direction={"row"}
sx={{
width: "50%",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Checkbox
id={checkboxId}
label={checkboxLabel}
isChecked={infrastructureMonitor[checkboxId]}
onChange={onCheckboxChange}
/>
</Box>
<Stack
direction={"row"}
sx={{
justifyContent: "flex-end",
}}
>
<Field
type="number"
className="field-infrastructure-alert"
id={fieldId}
value={infrastructureMonitor[fieldId]}
onBlur={onFieldBlur}
onChange={onFieldChange}
error={errors[fieldId]}
disabled={!infrastructureMonitor[checkboxId]}
hideErrorText={true}
></Field>
<Typography
component="p"
m={theme.spacing(3)}
>
{alertUnit}
</Typography>
</Stack>
</Stack>
);
};
CustomThreshold.propTypes = {
checkboxId: PropTypes.string.isRequired,
checkboxLabel: PropTypes.string.isRequired,
onCheckboxChange: PropTypes.func.isRequired,
fieldId: PropTypes.string.isRequired,
onFieldChange: PropTypes.func.isRequired,
onFieldBlur: PropTypes.func.isRequired,
alertUnit: PropTypes.string.isRequired,
infrastructureMonitor: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
};

View File

@@ -0,0 +1,386 @@
import { useState } from "react";
import { Box, Stack, Typography } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import { useSelector, useDispatch } from "react-redux";
import { infrastructureMonitorValidation } from "../../../Validation/validation";
import {
createInfrastructureMonitor,
checkInfrastructureEndpointResolution,
} from "../../../Features/InfrastructureMonitors/infrastructureMonitorsSlice";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../../Monitors/styled";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { buildErrors, hasValidationErrors } from "../../../Validation/error";
import { capitalizeFirstLetter } from "../../../Utils/stringUtils";
import { CustomThreshold } from "../CreateMonitor/CustomThreshold";
const CreateInfrastructureMonitor = () => {
const [infrastructureMonitor, setInfrastructureMonitor] = useState({
url: "",
name: "",
notifications: [],
interval: 0.25,
cpu: false,
usage_cpu: "",
memory: false,
usage_memory: "",
disk: false,
usage_disk: "",
secret: "",
});
const MS_PER_MINUTE = 60000;
const THRESHOLD_FIELD_PREFIX = "usage_";
const HARDWARE_MONITOR_TYPES = ["cpu", "memory", "disk"];
const { user, authToken } = useSelector((state) => state.auth);
const monitorState = useSelector((state) => state.infrastructureMonitor);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"notify-email-default": "notification-email",
};
const [errors, setErrors] = useState({});
const alertErrKeyLen = Object.keys(errors).filter((k) =>
k.startsWith(THRESHOLD_FIELD_PREFIX)
).length;
const handleCustomAlertCheckChange = (event) => {
const { value, id } = event.target;
setInfrastructureMonitor((prev) => {
const newState = {
[id]: prev[id] == undefined && value == "on" ? true : !prev[id],
};
return {
...prev,
...newState,
[THRESHOLD_FIELD_PREFIX + id]: newState[id]
? prev[THRESHOLD_FIELD_PREFIX + id]
: "",
};
});
// Remove the error if unchecked
setErrors((prev) => {
return buildErrors(prev, [THRESHOLD_FIELD_PREFIX + id]);
});
};
const handleBlur = (event, appenedID) => {
event.preventDefault();
const { value, id } = event.target;
if (id?.startsWith("notify-email-")) return;
const { error } = infrastructureMonitorValidation.validate(
{ [id ?? appenedID]: value },
{
abortEarly: false,
}
);
setErrors((prev) => {
return buildErrors(prev, id ?? appenedID, error);
});
};
const handleChange = (event, appendedId) => {
event.preventDefault();
const { value, id } = event.target;
let name = appendedId ?? idMap[id] ?? id;
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = infrastructureMonitor.notifications.some(
(notification) => notification.type === name
);
setInfrastructureMonitor((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 {
setInfrastructureMonitor((prev) => ({
...prev,
[name]: value,
}));
}
};
const generatePayload = (form) => {
let thresholds = {};
Object.keys(form)
.filter((k) => k.startsWith(THRESHOLD_FIELD_PREFIX))
.map((k) => {
if (form[k]) thresholds[k] = form[k] / 100;
delete form[k];
delete form[k.substring(THRESHOLD_FIELD_PREFIX.length)];
});
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
type: "hardware",
notifications: infrastructureMonitor.notifications,
thresholds,
};
return form;
};
const handleCreateInfrastructureMonitor = async (event) => {
event.preventDefault();
let form = {
...infrastructureMonitor,
name:
infrastructureMonitor.name === ""
? infrastructureMonitor.url
: infrastructureMonitor.name,
interval: infrastructureMonitor.interval * MS_PER_MINUTE,
};
delete form.notifications;
if (hasValidationErrors(form, infrastructureMonitorValidation, setErrors)) {
return;
} else {
const checkEndpointAction = await dispatch(
checkInfrastructureEndpointResolution({ authToken, monitorURL: form.url })
);
if (checkEndpointAction.meta.requestStatus === "rejected") {
createToast({
body: "The endpoint you entered doesn't resolve. Check the URL again.",
});
setErrors({ url: "The entered URL is not reachable." });
return;
}
const action = await dispatch(
createInfrastructureMonitor({ authToken, monitor: generatePayload(form) })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Infrastructure monitor created successfully!" });
navigate("/infrastructure");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 0.25, name: "15 seconds" },
{ _id: 0.5, name: "30 seconds" },
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
];
return (
<Box className="create-infrastructure-monitor">
<Breadcrumbs
list={[
{ name: "Infrastructure monitors", path: "infrastructure" },
{ name: "create", path: `infrastructure/create` },
]}
/>
<Stack
component="form"
className="create-infrastructure-monitor-form"
onSubmit={handleCreateInfrastructureMonitor}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography
component="h1"
variant="h1"
>
<Typography
component="span"
fontSize="inherit"
>
Create your{" "}
</Typography>
<Typography
component="span"
variant="h2"
fontSize="inherit"
fontWeight="inherit"
>
infrastructure monitor
</Typography>
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the friendly name and
authorization secret to connect to the server agent.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
type="text"
id="url"
label="Server URL"
placeholder="https://"
value={infrastructureMonitor.url}
onBlur={handleBlur}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="name"
label="Friendly name"
isOptional={true}
value={infrastructureMonitor.name}
onBlur={handleBlur}
onChange={handleChange}
error={errors["name"]}
/>
<Field
type="text"
id="secret"
label="Authorization secret"
value={infrastructureMonitor.secret}
onBlur={handleBlur}
onChange={handleChange}
error={errors["secret"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={infrastructureMonitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(e) => handleChange(e)}
onBlur={handleBlur}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Customize alerts</Typography>
<Typography component="p">
Send a notification to user(s) when thresholds exceed a specified percentage.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
{HARDWARE_MONITOR_TYPES.map((type, idx) => (
<CustomThreshold
key={idx}
checkboxId={type}
checkboxLabel={
type !== "cpu" ? capitalizeFirstLetter(type) : type.toUpperCase()
}
onCheckboxChange={handleCustomAlertCheckChange}
fieldId={THRESHOLD_FIELD_PREFIX + type}
fieldValue={infrastructureMonitor[THRESHOLD_FIELD_PREFIX + type] ?? ""}
onFieldChange={handleChange}
onFieldBlur={handleBlur}
// TODO: need BE, maybe in another PR
alertUnit={type == "temperature" ? "°C" : "%"}
infrastructureMonitor={infrastructureMonitor}
errors={errors}
/>
))}
{alertErrKeyLen > 0 && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{
errors[
THRESHOLD_FIELD_PREFIX +
HARDWARE_MONITOR_TYPES.filter(
(type) => errors[THRESHOLD_FIELD_PREFIX + type]
)[0]
]
}
</Typography>
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="interval"
label="Check frequency"
value={infrastructureMonitor.interval || 15}
onChange={(e) => handleChange(e, "interval")}
onBlur={(e) => handleBlur(e, "interval")}
items={frequencies}
/>
{/* <Field
type={"number"}
id="monitor-retries"
label="Maximum retries before the service is marked as down"
value={infrastructureMonitor.url}
onChange={handleChange}
onBlur={handleBlur}
error={errors["url"]}
/> */}
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateInfrastructureMonitor}
loading={monitorState?.isLoading}
>
Create infrastructure monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
export default CreateInfrastructureMonitor;

View File

@@ -0,0 +1,37 @@
import { useTheme } from "@emotion/react";
import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react";
import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react";
import { Box, Typography, Stack } from "@mui/material";
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
const Empty = ({ styles }) => {
const theme = useTheme();
const mode = useSelector((state) => state.ui.mode);
return (
<Box sx={{ ...styles, marginTop: theme.spacing(24) }}>
<Stack
direction="column"
gap={theme.spacing(8)}
alignItems="center"
>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
<Typography variant="h2">Your infrastructure dashboard will show here</Typography>
<Typography
textAlign="center"
color={theme.palette.text.secondary}
>
Hang tight! When we receive data, we'll show it here. Please check back in a few
minutes.
</Typography>
</Stack>
</Box>
);
};
Empty.propTypes = {
styles: PropTypes.object,
mode: PropTypes.string,
};
export default Empty;

View File

@@ -0,0 +1,435 @@
import { useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { Stack, Box, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import CustomGauge from "../../../Components/Charts/CustomGauge";
import AreaChart from "../../../Components/Charts/AreaChart";
import { useSelector } from "react-redux";
import { networkService } from "../../../main";
import PulseDot from "../../../Components/Animated/PulseDot";
import useUtils from "../../Monitors/utils";
import { useNavigate } from "react-router-dom";
import Empty from "./empty";
import { logger } from "../../../Utils/Logger";
import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils";
import {
TzTick,
PercentTick,
InfrastructureTooltip,
} from "../../../Components/Charts/Utils/chartUtils";
import PropTypes from "prop-types";
const BASE_BOX_PADDING_VERTICAL = 4;
const BASE_BOX_PADDING_HORIZONTAL = 8;
const TYPOGRAPHY_PADDING = 8;
/**
* Converts bytes to gigabytes
* @param {number} bytes - Number of bytes to convert
* @returns {number} Converted value in gigabytes
*/
const formatBytes = (bytes) => {
if (bytes === undefined || bytes === null) return "0 GB";
if (typeof bytes !== "number") return "0 GB";
if (bytes === 0) return "0 GB";
const GB = bytes / (1024 * 1024 * 1024);
const MB = bytes / (1024 * 1024);
if (GB >= 1) {
return `${Number(GB.toFixed(0))} GB`;
} else {
return `${Number(MB.toFixed(0))} MB`;
}
};
/**
* Converts a decimal value to a percentage
*
* @function decimalToPercentage
* @param {number} value - Decimal value to convert
* @returns {number} Percentage representation
*
* @example
* decimalToPercentage(0.75) // Returns 75
* decimalToPercentage(null) // Returns 0
*/
const decimalToPercentage = (value) => {
if (value === null || value === undefined) return 0;
return value * 100;
};
/**
* Renders a base box with consistent styling
* @param {Object} props - Component properties
* @param {React.ReactNode} props.children - Child components to render inside the box
* @param {Object} props.sx - Additional styling for the box
* @returns {React.ReactElement} Styled box component
*/
const BaseBox = ({ children, sx = {} }) => {
const theme = useTheme();
return (
<Box
sx={{
height: "100%",
padding: `${theme.spacing(BASE_BOX_PADDING_VERTICAL)} ${theme.spacing(BASE_BOX_PADDING_HORIZONTAL)}`,
minWidth: 200,
width: 225,
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
...sx,
}}
>
{children}
</Box>
);
};
BaseBox.propTypes = {
children: PropTypes.node.isRequired,
sx: PropTypes.object,
};
/**
* Renders a statistic box with a heading and subheading
* @param {Object} props - Component properties
* @param {string} props.heading - Primary heading text
* @param {string} props.subHeading - Secondary heading text
* @returns {React.ReactElement} Stat box component
*/
const StatBox = ({ heading, subHeading }) => {
return (
<BaseBox>
<Typography component="h2">{heading}</Typography>
<Typography>{subHeading}</Typography>
</BaseBox>
);
};
StatBox.propTypes = {
heading: PropTypes.string.isRequired,
subHeading: PropTypes.string.isRequired,
};
/**
* Renders a gauge box with usage visualization
* @param {Object} props - Component properties
* @param {number} props.value - Percentage value for gauge
* @param {string} props.heading - Box heading
* @param {string} props.metricOne - First metric label
* @param {string} props.valueOne - First metric value
* @param {string} props.metricTwo - Second metric label
* @param {string} props.valueTwo - Second metric value
* @returns {React.ReactElement} Gauge box component
*/
const GaugeBox = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => {
const theme = useTheme();
return (
<BaseBox>
<Stack
direction="column"
gap={theme.spacing(2)}
alignItems="center"
>
<CustomGauge
progress={value}
radius={100}
color={theme.palette.primary.main}
/>
<Typography component="h2">{heading}</Typography>
<Box
sx={{
width: "100%",
borderTop: `1px solid ${theme.palette.border.light}`,
}}
>
<Stack
justifyContent={"space-between"}
direction="row"
gap={theme.spacing(2)}
>
<Typography>{metricOne}</Typography>
<Typography>{valueOne}</Typography>
</Stack>
<Stack
justifyContent={"space-between"}
direction="row"
gap={theme.spacing(2)}
>
<Typography>{metricTwo}</Typography>
<Typography>{valueTwo}</Typography>
</Stack>
</Box>
</Stack>
</BaseBox>
);
};
GaugeBox.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
heading: PropTypes.string.isRequired,
metricOne: PropTypes.string.isRequired,
valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
metricTwo: PropTypes.string.isRequired,
valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
};
/**
* Renders the infrastructure details page
* @returns {React.ReactElement} Infrastructure details page component
*/
const InfrastructureDetails = () => {
const navigate = useNavigate();
const theme = useTheme();
const { monitorId } = useParams();
const navList = [
{ name: "infrastructure monitors", path: "/infrastructure" },
{ name: "details", path: `/infrastructure/${monitorId}` },
];
const [monitor, setMonitor] = useState(null);
const { authToken } = useSelector((state) => state.auth);
const [dateRange, setDateRange] = useState("all");
const { statusColor, determineState } = useUtils();
// These calculations are needed because ResponsiveContainer
// doesn't take padding of parent/siblings into account
// when calculating height.
const chartContainerHeight = 300;
const totalChartContainerPadding =
parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2;
const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2;
const areaChartHeight =
(chartContainerHeight - totalChartContainerPadding - totalTypographyPadding) * 0.95;
// end height calculations
// Fetch data
useEffect(() => {
const fetchData = async () => {
try {
const response = await networkService.getStatsByMonitorId({
authToken: authToken,
monitorId: monitorId,
sortOrder: "asc",
limit: null,
dateRange: dateRange,
numToDisplay: 50,
normalize: false,
});
setMonitor(response.data.data);
} catch (error) {
navigate("/not-found", { replace: true });
logger.error(error);
}
};
fetchData();
}, [authToken, monitorId, dateRange]);
const statBoxConfigs = [
{
id: 0,
heading: "CPU",
subHeading: `${monitor?.checks[0]?.cpu?.physical_core ?? 0} cores`,
},
{
id: 1,
heading: "Memory",
subHeading: formatBytes(monitor?.checks[0]?.memory?.total_bytes),
},
{
id: 2,
heading: "Disk",
subHeading: formatBytes(monitor?.checks[0]?.disk[0]?.total_bytes),
},
{ id: 3, heading: "Uptime", subHeading: "100%" },
{
id: 4,
heading: "Status",
subHeading: monitor?.status === true ? "Active" : "Inactive",
},
];
const gaugeBoxConfigs = [
{
type: "memory",
value: decimalToPercentage(monitor?.checks[0]?.memory?.usage_percent),
heading: "Memory Usage",
metricOne: "Used",
valueOne: formatBytes(monitor?.checks[0]?.memory?.used_bytes),
metricTwo: "Total",
valueTwo: formatBytes(monitor?.checks[0]?.memory?.total_bytes),
},
{
type: "cpu",
value: decimalToPercentage(monitor?.checks[0]?.cpu?.usage_percent),
heading: "CPU Usage",
metricOne: "Cores",
valueOne: monitor?.checks[0]?.cpu?.physical_core ?? 0,
metricTwo: "Frequency",
valueTwo: `${(monitor?.checks[0]?.cpu?.frequency ?? 0 / 1000).toFixed(2)} Ghz`,
},
...(monitor?.checks?.[0]?.disk ?? []).map((disk, idx) => ({
type: "disk",
diskIndex: idx,
value: decimalToPercentage(disk.usage_percent),
heading: `Disk${idx} usage`,
metricOne: "Used",
valueOne: formatBytes(disk.total_bytes - disk.free_bytes),
metricTwo: "Total",
valueTwo: formatBytes(disk.total_bytes),
})),
];
const areaChartConfigs = [
{
type: "memory",
dataKey: "memory.usage_percent",
heading: "Memory usage",
strokeColor: theme.palette.primary.main,
yLabel: "Memory Usage",
},
{
type: "cpu",
dataKey: "cpu.usage_percent",
heading: "CPU usage",
strokeColor: theme.palette.success.main,
yLabel: "CPU Usage",
},
...(monitor?.checks?.[0]?.disk?.map((disk, idx) => ({
type: "disk",
diskIndex: idx,
dataKey: `disk[${idx}].usage_percent`,
heading: `Disk${idx} usage`,
strokeColor: theme.palette.warning.main,
yLabel: "Disk Usage",
})) || []),
];
return (
<Box>
<Breadcrumbs list={navList} />
{monitor?.checks?.length > 0 ? (
<Stack
direction="column"
gap={theme.spacing(10)}
mt={theme.spacing(10)}
>
<Stack
direction="row"
gap={theme.spacing(8)}
>
<Box>
<PulseDot color={statusColor[determineState(monitor)]} />
</Box>
<Typography
alignSelf="end"
component="h1"
variant="h1"
>
{monitor.name}
</Typography>
<Typography alignSelf="end">{monitor.url || "..."}</Typography>
<Box sx={{ flexGrow: 1 }} />
<Typography alignSelf="end">
Checking every {formatDurationRounded(monitor?.interval)}
</Typography>
<Typography alignSelf="end">
Last checked {formatDurationSplit(monitor?.lastChecked).time}{" "}
{formatDurationSplit(monitor?.lastChecked).format} ago
</Typography>
</Stack>
<Stack
direction="row"
gap={theme.spacing(8)}
>
{statBoxConfigs.map((statBox) => (
<StatBox
key={statBox.id}
{...statBox}
/>
))}
</Stack>
<Stack
direction="row"
gap={theme.spacing(8)}
>
{gaugeBoxConfigs.map((config) => (
<GaugeBox
key={`${config.type}-${config.diskIndex ?? ""}`}
value={config.value}
heading={config.heading}
metricOne={config.metricOne}
valueOne={config.valueOne}
metricTwo={config.metricTwo}
valueTwo={config.valueTwo}
/>
))}
</Stack>
<Stack
direction={"row"}
height={chartContainerHeight} // FE team HELP!
gap={theme.spacing(8)} // FE team HELP!
flexWrap="wrap" // //FE team HELP! Better way to do this?
sx={{
"& > *": {
flexBasis: `calc(50% - ${theme.spacing(8)})`,
maxWidth: `calc(50% - ${theme.spacing(8)})`,
},
}}
>
{areaChartConfigs.map((config) => (
<BaseBox key={`${config.type}-${config.diskIndex ?? ""}`}>
<Typography
component="h2"
padding={theme.spacing(8)}
>
{config.heading}
</Typography>
<AreaChart
height={areaChartHeight}
data={monitor?.checks ?? []}
dataKey={config.dataKey}
xKey="createdAt"
yKey={config.dataKey}
customTooltip={({ active, payload, label }) => (
<InfrastructureTooltip
label={label}
yKey={
config.type === "disk" ? "disk.usage_percent" : config.dataKey
}
yLabel={config.yLabel}
yIdx={config.diskIndex}
active={active}
payload={payload}
/>
)}
xTick={<TzTick />}
yTick={<PercentTick />}
strokeColor={config.strokeColor}
gradient={true}
gradientStartColor={config.strokeColor}
gradientEndColor="#ffffff"
/>
</BaseBox>
))}
</Stack>
</Stack>
) : (
<Empty
styles={{
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.main,
p: theme.spacing(30),
}}
/>
)}
</Box>
);
};
export default InfrastructureDetails;

View File

@@ -14,6 +14,7 @@ import {
// Typography,
} from "@mui/material";
import { Heading } from "../../Components/Heading";
import { useTheme } from "@emotion/react";
import Greeting from "../../Utils/greeting";
import Breadcrumbs from "../../Components/Breadcrumbs";
@@ -68,6 +69,8 @@ Apply to Monitor Table, and Account/Team.
Analyze existing BasicTable
*/
import { useNavigate } from "react-router-dom";
/**
* This is the Infrastructure monitoring page. This is a work in progress
*
@@ -77,6 +80,7 @@ Analyze existing BasicTable
function Infrastructure() {
const theme = useTheme();
const navigate = useNavigate();
return (
<Stack
component="main"
@@ -100,7 +104,7 @@ function Infrastructure() {
variant="contained"
color="primary"
onClick={() => {
// navigate("/monitors/create");
navigate("/infrastructure/create");
}}
sx={{ fontWeight: 500 }}
>

View File

@@ -31,9 +31,28 @@ const CreateMonitor = () => {
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"monitor-checks-docker": "type",
"notify-email-default": "notification-email",
};
const monitorTypeMaps = {
http: {
label: "URL to monitor",
placeholder: "google.com",
namePlaceholder: "Google",
},
ping: {
label: "IP address to monitor",
placeholder: "1.1.1.1",
namePlaceholder: "Google",
},
docker: {
label: "Container ID",
placeholder: "abc123",
namePlaceholder: "My Container",
},
};
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({
url: "",
@@ -112,7 +131,6 @@ const CreateMonitor = () => {
{ [name]: value },
{ abortEarly: false }
);
console.log(error);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;
@@ -234,9 +252,9 @@ const CreateMonitor = () => {
<Field
type={monitor.type === "http" ? "url" : "text"}
id="monitor-url"
label="URL to monitor"
label={monitorTypeMaps[monitor.type].label || "URL to monitor"}
https={https}
placeholder="google.com"
placeholder={monitorTypeMaps[monitor.type].placeholder || ""}
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
@@ -246,7 +264,7 @@ const CreateMonitor = () => {
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
placeholder={monitorTypeMaps[monitor.type].namePlaceholder || ""}
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
@@ -301,6 +319,15 @@ const CreateMonitor = () => {
checked={monitor.type === "ping"}
onChange={(event) => handleChange(event)}
/>
<Radio
id="monitor-checks-docker"
title="Docker container monitoring"
desc="Check whether your container is running or not."
size="small"
value="docker"
checked={monitor.type === "docker"}
onChange={(event) => handleChange(event)}
/>
{errors["type"] ? (
<Box className="error-container">
<Typography

View File

@@ -150,7 +150,7 @@ const MonitorTable = ({ isAdmin, filter, setIsSearching, isSearching }) => {
authToken,
teamId: user.teamId,
limit: 25,
types: ["http", "ping"],
types: ["http", "ping", "docker"],
status: null,
checkOrder: "desc",
normalize: true,

View File

@@ -44,6 +44,14 @@ class Logger {
this.log = NO_OP;
return;
}
if (logLevel === "debug") {
this.error = console.error.bind(console);
this.warn = console.warn.bind(console);
this.info = console.info.bind(console);
this.log = console.log.bind(console);
return;
}
}
cleanup() {

View File

@@ -0,0 +1,17 @@
/**
* Helper function to get first letter capitalized string
* @param {string} str String whose first letter is to be capitalized
* @returns A string with first letter capitalized
*/
export const capitalizeFirstLetter = (str) => {
if (str === null || str === undefined) {
return "";
}
if (typeof str !== "string") {
throw new TypeError("Input must be a string");
}
if (str.length === 0) {
return "";
}
return str.charAt(0).toUpperCase() + str.slice(1);
};

View File

@@ -1,7 +1,7 @@
const buildErrors = (prev, id, error) => {
const updatedErrors = { ...prev };
if (error) {
updatedErrors[id] = error.details[0].message?? "Validation error";
updatedErrors[id] = error.details[0].message ?? "Validation error";
} else {
delete updatedErrors[id];
}
@@ -22,15 +22,29 @@ const hasValidationErrors = (form, validation, setErrors) => {
"dbConnectionString",
"refreshTokenTTL",
"jwtTTL",
"notify-email-list",
].includes(err.path[0])
) {
newErrors[err.path[0]] = err.message ?? "Validation error";
}
// Handle conditionally usage number required cases
if (!form.cpu || form.usage_cpu) {
newErrors["usage_cpu"] = null;
}
if (!form.memory || form.usage_memory) {
newErrors["usage_memory"] = null;
}
if (!form.disk || form.usage_disk) {
newErrors["usage_disk"] = null;
}
});
if (Object.keys(newErrors).length > 0) {
if (Object.values(newErrors).some(v=> v)) {
setErrors(newErrors);
return true;
} else return false;
} else {
setErrors({});
return false;
}
}
return false;
};

View File

@@ -1,6 +1,8 @@
import joi from "joi";
import dayjs from "dayjs";
const THRESHOLD_COMMON_BASE_MSG = "Threshold must be a number.";
const nameSchema = joi
.string()
.max(50)
@@ -173,6 +175,42 @@ const advancedSettingsValidation = joi.object({
pagespeedApiKey: joi.string().allow(""),
});
const infrastructureMonitorValidation = joi.object({
url: joi.string().uri({ allowRelative: true }).trim().messages({
"string.empty": "This field is required.",
"string.uri": "The URL you provided is not valid.",
}),
name: joi.string().trim().max(50).allow("").messages({
"string.max": "This field should not exceed the 50 characters limit.",
}),
secret: joi.string().trim().messages({ "string.empty": "This field is required." }),
usage_cpu: joi.number().messages({
"number.base": THRESHOLD_COMMON_BASE_MSG,
}),
cpu: joi.boolean(),
memory: joi.boolean(),
disk: joi.boolean(),
usage_memory: joi.number().messages({
"number.base": THRESHOLD_COMMON_BASE_MSG,
}),
usage_disk: joi.number().messages({
"number.base": THRESHOLD_COMMON_BASE_MSG,
}),
// usage_temperature: joi.number().messages({
// "number.base": "Temperature must be a number.",
// }),
// usage_system: joi.number().messages({
// "number.base": "System load must be a number.",
// }),
// usage_swap: joi.number().messages({
// "number.base": "Swap used must be a number.",
// }),
interval: joi.number().messages({
"number.base": "Frequency must be a number.",
"any.required": "Frequency is required.",
}),
});
export {
credentials,
imageValidation,
@@ -180,4 +218,5 @@ export {
settingsValidation,
maintenanceWindowValidation,
advancedSettingsValidation,
infrastructureMonitorValidation,
};

View File

@@ -20,6 +20,8 @@ html {
--env-var-width-1: 100vw;
--env-var-width-2: 360px;
--env-var-width-3: 250px;
--env-var-width-4: 100px;
--env-var-height-1: 100vh;
--env-var-height-2: 34px;

View File

@@ -1,6 +1,7 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit";
import uptimeMonitorsReducer from "./Features/UptimeMonitors/uptimeMonitorsSlice";
import infrastructureMonitorsReducer from "./Features/InfrastructureMonitors/infrastructureMonitorsSlice";
import pageSpeedMonitorReducer from "./Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import authReducer from "./Features/Auth/authSlice";
import uiReducer from "./Features/UI/uiSlice";
@@ -28,6 +29,7 @@ const persistConfig = {
const rootReducer = combineReducers({
uptimeMonitors: uptimeMonitorsReducer,
infrastructureMonitors: infrastructureMonitorsReducer,
auth: authReducer,
pageSpeedMonitors: pageSpeedMonitorReducer,
ui: uiReducer,

View File

@@ -18,6 +18,8 @@ services:
environment:
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
- REDIS_HOST=redis
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock:ro
redis:
image: bluewaveuptime/uptime_redis:latest
ports:

View File

@@ -19,7 +19,7 @@ import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
import logger from "../utils/logger.js";
import { handleError, handleValidationError } from "./controllerUtils.js";
import dns from "dns";
import axios from "axios";
const SERVICE_NAME = "monitorController";
@@ -288,18 +288,16 @@ const checkEndpointResolution = async (req, res, next) => {
}
try {
let { monitorURL } = req.query;
monitorURL = new URL(monitorURL);
await new Promise((resolve, reject) => {
dns.resolve(monitorURL.hostname, (error) => {
if (error) {
reject(error);
}
resolve();
});
const { monitorURL } = req.query;
const parsedUrl = new URL(monitorURL);
const response = await axios.get(parsedUrl, {
timeout: 5000,
validateStatus: () => true,
});
return res.status(200).json({
success: true,
code: response.status,
statusText: response.statusText,
msg: `URL resolved successfully`,
});
} catch (error) {

View File

@@ -28,7 +28,7 @@ const MonitorSchema = mongoose.Schema(
type: {
type: String,
required: true,
enum: ["http", "ping", "pagespeed", "hardware"],
enum: ["http", "ping", "pagespeed", "hardware", "docker"],
},
url: {
type: String,

View File

@@ -16,6 +16,28 @@ const NotificationSchema = mongoose.Schema(
phone: {
type: String,
},
alertThreshold: {
type: Number,
default: 5,
},
cpuAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
memoryAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
diskAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
},
{
timestamps: true,

View File

@@ -118,7 +118,6 @@ import {
import {
createPageSpeedCheck,
getPageSpeedChecks,
deletePageSpeedChecksByMonitorId,
} from "./modules/pageSpeedCheckModule.js";
@@ -212,7 +211,6 @@ export default {
updateChecksTTL,
deleteMonitorsByUserId,
createPageSpeedCheck,
getPageSpeedChecks,
deletePageSpeedChecksByMonitorId,
createHardwareCheck,
createMaintenanceWindow,

View File

@@ -20,6 +20,7 @@ const SERVICE_NAME = "monitorModule";
const CHECK_MODEL_LOOKUP = {
http: Check,
ping: Check,
docker: Check,
pagespeed: PageSpeedCheck,
hardware: HardwareCheck,
};
@@ -197,7 +198,7 @@ const getIncidents = (checks) => {
/**
* Get date range parameters
* @param {string} dateRange - 'day' | 'week' | 'month'
* @param {string} dateRange - 'day' | 'week' | 'month' | 'all'
* @returns {Object} Start and end dates
*/
const getDateRange = (dateRange) => {
@@ -205,6 +206,7 @@ const getDateRange = (dateRange) => {
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)),
all: new Date(0),
};
return {
start: startDates[dateRange],
@@ -349,7 +351,7 @@ const getMonitorStatsById = async (req) => {
),
};
if (monitor.type === "http" || monitor.type === "ping") {
if (monitor.type === "http" || monitor.type === "ping" || monitor.type === "docker") {
// HTTP/PING Specific stats
monitorStats.periodAvgResponseTime = getAverageResponseTime(checksForDateRange);
monitorStats.periodUptime = getUptimePercentage(checksForDateRange);

View File

@@ -25,25 +25,6 @@ const createPageSpeedCheck = async (pageSpeedCheckData) => {
}
};
/**
* Get all PageSpeed checks for a monitor
* @async
* @param {string} monitorId
* @returns {Promise<Array<PageSpeedCheck>>}
* @throws {Error}
*/
const getPageSpeedChecks = async (monitorId) => {
try {
const pageSpeedChecks = await PageSpeedCheck.find({ monitorId });
return pageSpeedChecks;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getPageSpeedChecks";
throw error;
}
};
/**
* Delete all PageSpeed checks for a monitor
* @async
@@ -63,4 +44,4 @@ const deletePageSpeedChecksByMonitorId = async (monitorId) => {
}
};
export { createPageSpeedCheck, getPageSpeedChecks, deletePageSpeedChecksByMonitorId };
export { createPageSpeedCheck, deletePageSpeedChecksByMonitorId };

View File

@@ -15,7 +15,11 @@ const SERVICE_NAME = "userModule";
* @returns {Promise<UserModel>}
* @throws {Error}
*/
const insertUser = async (userData, imageFile) => {
const insertUser = async (
userData,
imageFile,
generateAvatarImage = GenerateAvatarImage
) => {
try {
if (imageFile) {
// 1. Save the full size image
@@ -25,7 +29,7 @@ const insertUser = async (userData, imageFile) => {
};
// 2. Get the avatar sized image
const avatar = await GenerateAvatarImage(imageFile);
const avatar = await generateAvatarImage(imageFile);
userData.avatarImage = avatar;
}
@@ -90,7 +94,12 @@ const getUserByEmail = async (email) => {
* @throws {Error}
*/
const updateUser = async (req, res) => {
const updateUser = async (
req,
res,
parseBoolean = ParseBoolean,
generateAvatarImage = GenerateAvatarImage
) => {
const candidateUserId = req.params.userId;
try {
const candidateUser = { ...req.body };
@@ -98,7 +107,7 @@ const updateUser = async (req, res) => {
// Handle profile image
// ******************************************
if (ParseBoolean(candidateUser.deleteProfileImage) === true) {
if (parseBoolean(candidateUser.deleteProfileImage) === true) {
candidateUser.profileImage = null;
candidateUser.avatarImage = null;
} else if (req.file) {
@@ -108,8 +117,8 @@ const updateUser = async (req, res) => {
contentType: req.file.mimetype,
};
// 2. Get the avaatar sized image
const avatar = await GenerateAvatarImage(req.file);
// 2. Get the avatar sized image
const avatar = await generateAvatarImage(req.file);
candidateUser.avatarImage = avatar;
}
@@ -164,6 +173,7 @@ const deleteUser = async (userId) => {
const deleteTeam = async (teamId) => {
try {
await TeamModel.findByIdAndDelete(teamId);
return true;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "deleteTeam";
@@ -174,6 +184,7 @@ const deleteTeam = async (teamId) => {
const deleteAllOtherUsers = async () => {
try {
await UserModel.deleteMany({ role: { $ne: "superadmin" } });
return true;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "deleteAllOtherUsers";

View File

@@ -28,6 +28,7 @@ import NetworkService from "./service/networkService.js";
import axios from "axios";
import ping from "ping";
import http from "http";
import Docker from "dockerode";
// Email service and dependencies
import EmailService from "./service/emailService.js";
@@ -45,7 +46,7 @@ import NotificationService from "./service/notificationService.js";
import db from "./db/mongo/MongoDB.js";
const SERVICE_NAME = "Server";
const SHUTDOWN_TIMEOUT = 0;
const SHUTDOWN_TIMEOUT = 1000;
let isShuttingDown = false;
const __filename = fileURLToPath(import.meta.url);
@@ -91,6 +92,23 @@ const startApp = async () => {
app.use("/api/v1/queue", verifyJWT, queueRouter);
app.use("/api/v1/status-page", statusPageRouter);
app.use("/api/v1/dummy-data", async (req, res) => {
try {
const response = await axios.get(
"https://gist.githubusercontent.com/ajhollid/9afa39410c7bbf52cc905f285a2225bf/raw/429a231a3559ebc95f6f488ed2c766bd7d6f46e5/dummyData.json",
{
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
}
);
return res.status(200).json(response.data);
} catch (error) {
return res.status(500).json({ message: error.message });
}
});
//health check
app.use("/api/v1/healthy", (req, res) => {
try {
@@ -130,7 +148,7 @@ const startApp = async () => {
nodemailer,
logger
);
const networkService = new NetworkService(axios, ping, logger, http);
const networkService = new NetworkService(axios, ping, logger, http, Docker);
const statusService = new StatusService(db, logger);
const notificationService = new NotificationService(emailService, db, logger);
const jobQueue = await JobQueue.createJobQueue(

334
Server/package-lock.json generated
View File

@@ -9,11 +9,11 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@sendgrid/mail": "^8.1.3",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bullmq": "5.25.6",
"bullmq": "5.29.1",
"cors": "^2.8.5",
"dockerode": "4.0.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"handlebars": "^4.7.8",
@@ -89,6 +89,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@balena/dockerignore": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
@@ -738,41 +744,6 @@
"node": ">=14"
}
},
"node_modules/@sendgrid/client": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.3.tgz",
"integrity": "sha512-mRwTticRZIdUTsnyzvlK6dMu3jni9ci9J+dW/6fMMFpGRAJdCJlivFVYQvqk8kRS3RnFzS7sf6BSmhLl1ldDhA==",
"dependencies": {
"@sendgrid/helpers": "^8.0.0",
"axios": "^1.6.8"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sendgrid/helpers": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz",
"integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==",
"dependencies": {
"deepmerge": "^4.2.2"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/@sendgrid/mail": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.3.tgz",
"integrity": "sha512-Wg5iKSUOER83/cfY6rbPa+o3ChnYzWwv1OcsR8gCV8SKi+sUPIMroildimlnb72DBkQxcbylxng1W7f0RIX7MQ==",
"dependencies": {
"@sendgrid/client": "^8.1.3",
"@sendgrid/helpers": "^8.0.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -996,6 +967,15 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1032,6 +1012,26 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@@ -1045,6 +1045,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1056,6 +1065,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -1152,6 +1172,30 @@
"node": ">=16.20.1"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1163,10 +1207,19 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/bullmq": {
"version": "5.25.6",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.25.6.tgz",
"integrity": "sha512-jxpa/DB02V20CqBAgyqpQazT630CJm0r4fky8EchH3mcJAomRtKXLS6tRA0J8tb29BDGlr/LXhlUuZwdBJBSdA==",
"version": "5.29.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.29.1.tgz",
"integrity": "sha512-TZWiwRlPnpaN+Qwh4D8IQf2cYLpkiDX1LbaaWEabc6y37ojIttWOSynxDewpVHyW233LssSIC4+aLMSvAjtpmg==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.6.0",
@@ -1708,6 +1761,20 @@
}
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
@@ -1927,14 +1994,6 @@
"node": ">=6"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -2014,6 +2073,58 @@
"node": ">=0.3.1"
}
},
"node_modules/docker-modem": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz",
"integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.1",
"readable-stream": "^3.5.0",
"split-ca": "^1.0.1",
"ssh2": "^1.15.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/docker-modem/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/docker-modem/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/dockerode": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz",
"integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==",
"license": "Apache-2.0",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"docker-modem": "^5.0.3",
"tar-fs": "~2.0.1"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -2145,6 +2256,15 @@
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -2439,6 +2559,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@@ -2822,6 +2948,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -4129,6 +4275,12 @@
"node": ">=10"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mocha": {
"version": "10.8.2",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz",
@@ -4370,9 +4522,9 @@
}
},
"node_modules/mongoose": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.8.1.tgz",
"integrity": "sha512-l7DgeY1szT98+EKU8GYnga5WnyatAu+kOQ2VlVX1Mxif6A0Umt0YkSiksCiyGxzx8SPhGe9a53ND1GD4yVDrPA==",
"version": "8.8.2",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.8.2.tgz",
"integrity": "sha512-jCTSqDANfRzk909v4YoZQi7jlGRB2MTvgG+spVBc/BA4tOs1oWJr//V6yYujqNq9UybpOtsSfBqxI0dSOEFJHQ==",
"license": "MIT",
"dependencies": {
"bson": "^6.7.0",
@@ -4503,6 +4655,13 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/nan": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz",
"integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@@ -5553,6 +5712,16 @@
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true
},
"node_modules/pump": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
"integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6046,6 +6215,29 @@
"memory-pager": "^1.0.2"
}
},
"node_modules/split-ca": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
"license": "ISC"
},
"node_modules/ssh2": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz",
"integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.20.0"
}
},
"node_modules/ssl-checker": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/ssl-checker/-/ssl-checker-2.0.10.tgz",
@@ -6248,6 +6440,40 @@
"node": ">=10"
}
},
"node_modules/tar-fs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz",
"integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.0.0"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -6311,6 +6537,12 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View File

@@ -12,11 +12,11 @@
"author": "",
"license": "ISC",
"dependencies": {
"@sendgrid/mail": "^8.1.3",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bullmq": "5.25.6",
"bullmq": "5.29.1",
"cors": "^2.8.5",
"dockerode": "4.0.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"handlebars": "^4.7.8",

View File

@@ -65,7 +65,7 @@ class EmailService {
serverIsDownTemplate: this.loadTemplate("serverIsDown"),
serverIsUpTemplate: this.loadTemplate("serverIsUp"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
thresholdViolatedTemplate: this.loadTemplate("thresholdViolated"),
hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"),
};
/**

View File

@@ -165,23 +165,18 @@ class JobQueue {
// Handle status change
const { monitor, statusChanged, prevStatus } =
await this.statusService.updateStatus(networkResponse);
//If status hasn't changed, we're done
if (statusChanged === false) return;
// if prevStatus is undefined, monitor is resuming, we're done
if (prevStatus === undefined) return;
// Handle notifications
this.notificationService.handleNotifications({
...networkResponse,
monitor,
prevStatus,
statusChanged,
});
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "createWorker",
service: error.service ?? SERVICE_NAME,
method: error.method ?? "createJobHandler",
details: `Error processing job ${job.id}: ${error.message}`,
stack: error.stack,
});

View File

@@ -8,11 +8,12 @@ import { errorMessages, successMessages } from "../utils/messages.js";
* @param {Object} http - The HTTP utility for network operations.
*/
class NetworkService {
constructor(axios, ping, logger, http) {
constructor(axios, ping, logger, http, Docker) {
this.TYPE_PING = "ping";
this.TYPE_HTTP = "http";
this.TYPE_PAGESPEED = "pagespeed";
this.TYPE_HARDWARE = "hardware";
this.TYPE_DOCKER = "docker";
this.SERVICE_NAME = "NetworkService";
this.NETWORK_ERROR = 5000;
this.PING_ERROR = 5001;
@@ -20,6 +21,7 @@ class NetworkService {
this.ping = ping;
this.logger = logger;
this.http = http;
this.Docker = Docker;
}
/**
@@ -62,28 +64,34 @@ class NetworkService {
* @property {string} message - The message indicating the result of the ping request.
*/
async requestPing(job) {
const url = job.data.url;
const { response, responseTime, error } = await this.timeRequest(() =>
this.ping.promise.probe(url)
);
try {
const url = job.data.url;
const { response, responseTime, error } = await this.timeRequest(() =>
this.ping.promise.probe(url)
);
const pingResponse = {
monitorId: job.data._id,
type: "ping",
responseTime,
payload: response,
};
if (error) {
pingResponse.status = false;
pingResponse.code = this.PING_ERROR;
pingResponse.message = errorMessages.PING_CANNOT_RESOLVE;
const pingResponse = {
monitorId: job.data._id,
type: "ping",
responseTime,
payload: response,
};
if (error) {
pingResponse.status = false;
pingResponse.code = this.PING_ERROR;
pingResponse.message = errorMessages.PING_CANNOT_RESOLVE;
return pingResponse;
}
pingResponse.code = 200;
pingResponse.status = response.alive;
pingResponse.message = successMessages.PING_SUCCESS;
return pingResponse;
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestPing";
throw error;
}
pingResponse.code = 200;
pingResponse.status = response.alive;
pingResponse.message = successMessages.PING_SUCCESS;
return pingResponse;
}
/**
@@ -104,34 +112,40 @@ class NetworkService {
* @property {string} message - The message indicating the result of the HTTP request.
*/
async requestHttp(job) {
const url = job.data.url;
const config = {};
try {
const url = job.data.url;
const config = {};
job.data.secret !== undefined &&
(config.headers = { Authorization: `Bearer ${job.data.secret}` });
job.data.secret !== undefined &&
(config.headers = { Authorization: `Bearer ${job.data.secret}` });
const { response, responseTime, error } = await this.timeRequest(() =>
this.axios.get(url, config)
);
const { response, responseTime, error } = await this.timeRequest(() =>
this.axios.get(url, config)
);
const httpResponse = {
monitorId: job.data._id,
type: job.data.type,
responseTime,
payload: response?.data,
};
const httpResponse = {
monitorId: job.data._id,
type: job.data.type,
responseTime,
payload: response?.data,
};
if (error) {
const code = error.response?.status || this.NETWORK_ERROR;
httpResponse.code = code;
httpResponse.status = false;
httpResponse.message = this.http.STATUS_CODES[code] || "Network Error";
if (error) {
const code = error.response?.status || this.NETWORK_ERROR;
httpResponse.code = code;
httpResponse.status = false;
httpResponse.message = this.http.STATUS_CODES[code] || "Network Error";
return httpResponse;
}
httpResponse.status = true;
httpResponse.code = response.status;
httpResponse.message = this.http.STATUS_CODES[response.status];
return httpResponse;
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestHttp";
throw error;
}
httpResponse.status = true;
httpResponse.code = response.status;
httpResponse.message = this.http.STATUS_CODES[response.status];
return httpResponse;
}
/**
@@ -151,15 +165,114 @@ class NetworkService {
* @property {string} message - The message indicating the result of the PageSpeed request.
*/
async requestPagespeed(job) {
const url = job.data.url;
const updatedJob = { ...job };
const pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`;
updatedJob.data.url = pagespeedUrl;
return this.requestHttp(updatedJob);
try {
const url = job.data.url;
const updatedJob = { ...job };
const pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`;
updatedJob.data.url = pagespeedUrl;
return await this.requestHttp(updatedJob);
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestPagespeed";
throw error;
}
}
/**
* Sends an HTTP request to check hardware status and returns the response.
*
* @param {Object} job - The job object containing the data for the hardware request.
* @param {Object} job.data - The data object within the job.
* @param {string} job.data.url - The URL to send the hardware status request to.
* @param {string} job.data._id - The monitor ID for the hardware request.
* @param {string} job.data.type - The type of request, which is "hardware".
* @returns {Promise<Object>} An object containing the hardware status response details.
* @property {string} monitorId - The monitor ID for the hardware request.
* @property {string} type - The type of request ("hardware").
* @property {number} responseTime - The time taken for the request to complete, in milliseconds.
* @property {Object} payload - The response payload from the hardware status request.
* @property {boolean} status - The status of the request (true if successful, false otherwise).
* @property {number} code - The response code (200 if successful, error code otherwise).
* @property {string} message - The message indicating the result of the hardware status request.
*/
async requestHardware(job) {
return this.requestHttp(job);
try {
return await this.requestHttp(job);
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestHardware";
throw error;
}
}
/**
* Sends a request to inspect a Docker container and returns its status.
*
* @param {Object} job - The job object containing the data for the Docker request.
* @param {Object} job.data - The data object within the job.
* @param {string} job.data.url - The container ID or name to inspect.
* @param {string} job.data._id - The monitor ID for the Docker request.
* @param {string} job.data.type - The type of request, which is "docker".
* @returns {Promise<Object>} An object containing the Docker container status details.
* @property {string} monitorId - The monitor ID for the Docker request.
* @property {string} type - The type of request ("docker").
* @property {number} responseTime - The time taken for the Docker inspection to complete, in milliseconds.
* @property {boolean} status - The status of the container (true if running, false otherwise).
* @property {number} code - The response code (200 if successful, error code otherwise).
* @property {string} message - The message indicating the result of the Docker inspection.
*/
async requestDocker(job) {
try {
const docker = new this.Docker({
socketPath: "/var/run/docker.sock",
handleError: true, // Enable error handling
});
const containers = await docker.listContainers({ all: true });
const containerExists = containers.some((c) => c.Id.startsWith(job.data.url));
if (!containerExists) {
throw new Error(errorMessages.DOCKER_NOT_FOUND);
}
const container = docker.getContainer(job.data.url);
const { response, responseTime, error } = await this.timeRequest(() =>
container.inspect()
);
const dockerResponse = {
monitorId: job.data._id,
type: job.data.type,
responseTime,
};
if (error) {
dockerResponse.status = false;
dockerResponse.code = error.statusCode || this.NETWORK_ERROR;
dockerResponse.message = error.reason || errorMessages.DOCKER_FAIL;
return dockerResponse;
}
dockerResponse.status = response?.State?.Status === "running" ? true : false;
dockerResponse.code = 200;
dockerResponse.message = successMessages.DOCKER_SUCCESS;
return dockerResponse;
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestDocker";
throw error;
}
}
/**
* Handles unsupported job types by throwing an error with details.
*
* @param {string} type - The unsupported job type that was provided
* @throws {Error} An error with service name, method name and unsupported type message
*/
handleUnsupportedType(type) {
const err = new Error(`Unsupported type: ${type}`);
err.service = this.SERVICE_NAME;
err.method = "getStatus";
throw err;
}
/**
@@ -172,7 +285,8 @@ class NetworkService {
* @throws {Error} Throws an error if the job type is unsupported.
*/
async getStatus(job) {
switch (job.data.type) {
const type = job?.data?.type ?? "unknown";
switch (type) {
case this.TYPE_PING:
return await this.requestPing(job);
case this.TYPE_HTTP:
@@ -181,13 +295,10 @@ class NetworkService {
return await this.requestPagespeed(job);
case this.TYPE_HARDWARE:
return await this.requestHardware(job);
case this.TYPE_DOCKER:
return await this.requestDocker(job);
default:
this.logger.error({
message: `Unsupported type: ${job.data.type}`,
service: this.SERVICE_NAME,
method: "getStatus",
});
return {};
return this.handleUnsupportedType(type);
}
}
}

View File

@@ -14,15 +14,34 @@ class NotificationService {
}
/**
* Sends an email notification based on the network response.
* Sends an email notification for hardware infrastructure alerts
*
* @param {Object} networkResponse - The response from the network monitor.
* @param {Object} networkResponse.monitor - The monitor object containing details about the monitored service.
* @param {string} networkResponse.monitor.name - The name of the monitor.
* @param {string} networkResponse.monitor.url - The URL of the monitor.
* @param {boolean} networkResponse.status - The current status of the monitor (true for up, false for down).
* @param {boolean} networkResponse.prevStatus - The previous status of the monitor (true for up, false for down).
* @param {string} address - The email address to send the notification to.
* @async
* @function sendHardwareEmail
* @param {Object} networkResponse - Response object containing monitor information
* @param {string} address - Email address to send the notification to
* @param {Array} [alerts=[]] - List of hardware alerts to include in the email
* @returns {Promise<boolean>} - Indicates whether email was sent successfully
* @throws {Error}
*/
async sendHardwareEmail(networkResponse, address, alerts = []) {
if (alerts.length === 0) return false;
const { monitor, status, prevStatus } = networkResponse;
const template = "hardwareIncidentTemplate";
const context = { monitor: monitor.name, url: monitor.url, alerts };
const subject = `Monitor ${monitor.name} infrastructure alerts`;
this.emailService.buildAndSendEmail(template, context, address, subject);
return true;
}
/**
* Sends an email notification about monitor status change
*
* @async
* @function sendEmail
* @param {Object} networkResponse - Response object containing monitor status information
* @param {string} address - Email address to send the notification to
* @returns {Promise<boolean>} - Indicates email was sent successfully
*/
async sendEmail(networkResponse, address) {
const { monitor, status, prevStatus } = networkResponse;
@@ -30,25 +49,133 @@ class NotificationService {
const context = { monitor: monitor.name, url: monitor.url };
const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`;
this.emailService.buildAndSendEmail(template, context, address, subject);
return true;
}
/**
* Handles notifications based on the network response.
*
* @param {Object} networkResponse - The response from the network monitor.
* @param {string} networkResponse.monitorId - The ID of the monitor.
*/
async handleNotifications(networkResponse) {
async handleStatusNotifications(networkResponse) {
try {
//If status hasn't changed, we're done
if (networkResponse.statusChanged === false) return false;
// if prevStatus is undefined, monitor is resuming, we're done
if (networkResponse.prevStatus === undefined) return false;
const notifications = await this.db.getNotificationsByMonitorId(
networkResponse.monitorId
);
for (const notification of notifications) {
if (notification.type === "email") {
this.sendEmail(networkResponse, notification.address);
}
// Handle other types of notifications here
}
return true;
} catch (error) {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
method: "handleNotifications",
stack: error.stack,
});
}
}
/**
* Handles status change notifications for a monitor
*
* @async
* @function handleStatusNotifications
* @param {Object} networkResponse - Response object containing monitor status information
* @returns {Promise<boolean>} - Indicates whether notifications were processed
* @throws {Error}
*/
async handleHardwareNotifications(networkResponse) {
const thresholds = networkResponse?.monitor?.thresholds;
if (thresholds === undefined) return false; // No thresholds set, we're done
// Get thresholds from monitor
const {
usage_cpu: cpuThreshold = -1,
usage_memory: memoryThreshold = -1,
usage_disk: diskThreshold = -1,
} = thresholds;
// Get metrics from response
const metrics = networkResponse?.payload?.data ?? null;
if (metrics === null) return false;
const {
cpu: { usage_percent: cpuUsage = -1 } = {},
memory: { usage_percent: memoryUsage = -1 } = {},
disk = [],
} = metrics;
const alerts = {
cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false,
memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false,
disk: disk.some((d) => diskThreshold !== -1 && d.usage_percent > diskThreshold)
? true
: false,
};
const notifications = await this.db.getNotificationsByMonitorId(
networkResponse.monitorId
);
for (const notification of notifications) {
const alertsToSend = [];
const alertTypes = ["cpu", "memory", "disk"];
for (const type of alertTypes) {
// Iterate over each alert type to see if any need to be decremented
if (alerts[type] === true) {
notification[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered
if (notification[`${type}AlertThreshold`] <= 0) {
// If threshold drops below 0, reset and send notification
notification[`${type}AlertThreshold`] = notification.alertThreshold;
const formatAlert = {
cpu: () =>
`Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`,
memory: () =>
`Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`,
disk: () =>
`Your current disk usage: ${disk
.map((d, idx) => `(Disk${idx}: ${(d.usage_percent * 100).toFixed(0)}%)`)
.join(
", "
)} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`,
};
alertsToSend.push(formatAlert[type]());
}
}
}
await notification.save();
if (alertsToSend.length === 0) continue; // No alerts to send, we're done
if (notification.type === "email") {
this.sendHardwareEmail(networkResponse, notification.address, alertsToSend);
}
}
return true;
}
/**
* Handles notifications for different monitor types
*
* @async
* @function handleNotifications
* @param {Object} networkResponse - Response object containing monitor information
* @returns {Promise<boolean>} - Indicates whether notifications were processed successfully
*/
async handleNotifications(networkResponse) {
try {
if (networkResponse.monitor.type === "hardware") {
this.handleHardwareNotifications(networkResponse);
}
this.handleStatusNotifications(networkResponse);
return true;
} catch (error) {
this.logger.warn({
message: error.message,

View File

@@ -11,6 +11,11 @@ class StatusService {
this.SERVICE_NAME = "StatusService";
}
getStatusString = (status) => {
if (status === true) return "up";
if (status === false) return "down";
return "unknown";
};
/**
* Updates the status of a monitor based on the network response.
*
@@ -29,12 +34,13 @@ class StatusService {
const { monitorId, status } = networkResponse;
const monitor = await this.db.getMonitorById(monitorId);
// No change in monitor status, return early
if (monitor.status === status) return { statusChanged: false };
if (monitor.status === status)
return { monitor, statusChanged: false, prevStatus: monitor.status };
// Monitor status changed, save prev status and update monitor
this.logger.info({
service: this.SERVICE_NAME,
message: `${monitor.name} went from ${monitor.status === true ? "up" : "down"} to ${status === true ? "up" : "down"}`,
message: `${monitor.name} went from ${this.getStatusString(monitor.status)} to ${this.getStatusString(status)}`,
prevStatus: monitor.status,
newStatus: status,
});
@@ -103,7 +109,7 @@ class StatusService {
if (type === "hardware") {
const { cpu, memory, disk, host } = payload?.data ?? {};
const { errors } = payload;
const { errors } = payload?.errors ?? [];
check.cpu = cpu ?? {};
check.memory = memory ?? {};
check.disk = disk ?? {};
@@ -133,6 +139,7 @@ class StatusService {
ping: this.db.createCheck,
pagespeed: this.db.createPageSpeedCheck,
hardware: this.db.createHardwareCheck,
docker: this.db.createCheck,
};
const operation = operationMap[networkResponse.type];

View File

@@ -0,0 +1,43 @@
<mjml>
<mj-head>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text align="left" font-size="10px">
Message from BlueWave Infrastructure Monitoring
</mj-text>
</mj-column>
<mj-column width="45%" padding-top="20px">
<mj-text align="center" font-weight="500" padding="0px" font-size="18px" color="red">
Infrastructure Alerts
</mj-text>
<mj-divider border-width="2px" border-color="#616161"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{name}}!</p>
<p>{{monitor}} at {{url}} has the following infrastructure alerts:</p>
{{#each alerts}}
<p>• {{this}}</p>
{{/each}}
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider border-width="1px" border-color="#E0E0E0"></mj-divider>
<mj-button background-color="#1570EF"> View Infrastructure Details </mj-button>
<mj-text font-size="12px">
<p>This email was sent by BlueWave Infrastructure Monitoring.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

View File

@@ -1,40 +0,0 @@
<mjml>
<mj-head>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text align="left" font-size="10px">
Message from BlueWave Uptime Service
</mj-text>
</mj-column>
<mj-column width="45%" padding-top="20px">
<mj-text font-weight="500" padding="0px" font-size="18px">
{{message}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if cpu}}
{{cpu}}
{{/if}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if disk}}
{{disk}}
{{/if}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if memory}}
{{memory}}
{{/if}}
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

View File

@@ -18,7 +18,7 @@ import jwt from "jsonwebtoken";
import sinon from "sinon";
import { successMessages } from "../../utils/messages.js";
import logger from "../../utils/logger.js";
import dns from "dns";
import axios from "axios";
const SERVICE_NAME = "monitorController";
describe("Monitor Controller - getAllMonitors", () => {
@@ -476,38 +476,40 @@ describe("Monitor Controller - createMonitor", () => {
});
});
describe("Monitor Controllor - checkEndpointResolution", () => {
let req, res, next, dnsResolveStub;
describe("Monitor Controller - checkEndpointResolution", () => {
let req, res, next, axiosGetStub;
beforeEach(() => {
req = { query: { monitorURL: "https://example.com" } };
res = { status: sinon.stub().returnsThis(), json: sinon.stub() };
next = sinon.stub();
dnsResolveStub = sinon.stub(dns, "resolve");
axiosGetStub = sinon.stub(axios, "get");
});
afterEach(() => {
dnsResolveStub.restore();
sinon.restore();
});
it("should resolve the URL successfully", async () => {
dnsResolveStub.callsFake((hostname, callback) => callback(null));
axiosGetStub.resolves({ status: 200, statusText: "OK" });
await checkEndpointResolution(req, res, next);
expect(res.status.calledWith(200)).to.be.true;
expect(
res.json.calledWith({
success: true,
code: 200,
statusText: "OK",
msg: "URL resolved successfully",
})
).to.be.true;
expect(next.called).to.be.false;
});
it("should return an error if DNS resolution fails", async () => {
const dnsError = new Error("DNS resolution failed");
dnsError.code = "ENOTFOUND";
dnsResolveStub.callsFake((hostname, callback) => callback(dnsError));
it("should return an error if endpoint resolution fails", async () => {
const axiosError = new Error("resolution failed");
axiosError.code = "ENOTFOUND";
axiosGetStub.rejects(axiosError);
await checkEndpointResolution(req, res, next);
expect(next.calledOnce).to.be.true;
const errorPassedToNext = next.getCall(0).args[0];
expect(errorPassedToNext).to.be.an.instanceOf(Error);
expect(errorPassedToNext.message).to.include("DNS resolution failed");
expect(errorPassedToNext.message).to.include("resolution failed");
expect(errorPassedToNext.code).to.equal("ENOTFOUND");
expect(errorPassedToNext.status).to.equal(500);
});

View File

@@ -0,0 +1,73 @@
import sinon from "sinon";
import HardwareCheck from "../../db/models/HardwareCheck.js";
import { createHardwareCheck } from "../../db/mongo/modules/hardwareCheckModule.js";
const mockHardwareCheck = {
data: {
cpu: {
physical_core: 4,
logical_core: 8,
frequency: 4800,
current_frequency: 1411,
temperature: [45, 50, 46, 47, 45, 50, 46, 47],
free_percent: 0.8552990910595134,
usage_percent: 0.14470090894048657,
},
memory: {
total_bytes: 16467628032,
available_bytes: 7895044096,
used_bytes: 6599561216,
usage_percent: 0.4008,
},
disk: [
{
read_speed_bytes: null,
write_speed_bytes: null,
total_bytes: 931258499072,
free_bytes: 737097256960,
usage_percent: 0.1661,
},
],
host: {
os: "linux",
platform: "ubuntu",
kernel_version: "6.8.0-48-generic",
},
},
errors: [
{
metric: ["cpu.temperature"],
err: "unable to read CPU temperature",
},
],
};
describe("HardwareCheckModule", () => {
let hardwareCheckSaveStub;
beforeEach(() => {
hardwareCheckSaveStub = sinon.stub(HardwareCheck.prototype, "save");
});
afterEach(() => {
sinon.restore();
});
describe("createHardwareCheck", () => {
it("should return a hardware check", async () => {
hardwareCheckSaveStub.resolves(mockHardwareCheck);
const hardwareCheck = await createHardwareCheck({});
expect(hardwareCheck).to.exist;
expect(hardwareCheck).to.deep.equal(mockHardwareCheck);
});
it("should handle an error", async () => {
const err = new Error("test error");
hardwareCheckSaveStub.rejects(err);
try {
await createHardwareCheck({});
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
});

View File

@@ -891,6 +891,18 @@ describe("monitorModule", () => {
url: "https://test.com",
}),
};
const mockMonitorDocker = {
_id: "monitor123",
type: "docker",
name: "Test Monitor",
url: "https://test.com",
toObject: () => ({
_id: "monitor123",
type: "http",
name: "Test Monitor",
url: "https://test.com",
}),
};
const checkDocs = [
{
@@ -1004,6 +1016,30 @@ describe("monitorModule", () => {
expect(result.periodUptime).to.be.a("number");
expect(result.aggregateData).to.be.an("array");
});
it("should return monitor stats with calculated values, docker type", async () => {
monitorFindByIdStub.returns(mockMonitorDocker);
req.query.sortOrder = "desc";
const result = await getMonitorStatsById(req);
expect(result).to.include.keys([
"_id",
"type",
"name",
"url",
"uptimeDuration",
"lastChecked",
"latestResponseTime",
"periodIncidents",
"periodTotalChecks",
"periodAvgResponseTime",
"periodUptime",
"aggregateData",
]);
expect(result.latestResponseTime).to.equal(100);
expect(result.periodTotalChecks).to.equal(3);
expect(result.periodIncidents).to.equal(1);
expect(result.periodUptime).to.be.a("number");
expect(result.aggregateData).to.be.an("array");
});
it("should return monitor stats with calculated values", async () => {
req.query.sortOrder = "asc";
const result = await getMonitorStatsById(req);

View File

@@ -0,0 +1,65 @@
import sinon from "sinon";
import PageSpeedCheck from "../../db/models/PageSpeedCheck.js";
import {
createPageSpeedCheck,
deletePageSpeedChecksByMonitorId,
} from "../../db/mongo/modules/pageSpeedCheckModule.js";
const mockPageSpeedCheck = {
monitorId: "monitorId",
bestPractices: 1,
seo: 1,
performance: 1,
};
const mockDeletedResult = { deletedCount: 1 };
describe("pageSpeedCheckModule", () => {
let pageSpeedCheckSaveStub, pageSpeedCheckDeleteManyStub;
beforeEach(() => {
pageSpeedCheckSaveStub = sinon.stub(PageSpeedCheck.prototype, "save");
pageSpeedCheckDeleteManyStub = sinon.stub(PageSpeedCheck, "deleteMany");
});
afterEach(() => {
sinon.restore();
});
describe("createPageSpeedCheck", () => {
it("should return a page speed check", async () => {
pageSpeedCheckSaveStub.resolves(mockPageSpeedCheck);
const pageSpeedCheck = await createPageSpeedCheck(mockPageSpeedCheck);
expect(pageSpeedCheck).to.deep.equal(mockPageSpeedCheck);
});
it("should handle an error", async () => {
const err = new Error("test error");
pageSpeedCheckSaveStub.rejects(err);
try {
await expect(createPageSpeedCheck(mockPageSpeedCheck));
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("deletePageSpeedChecksByMonitorId", () => {
it("should return the number of deleted checks", async () => {
pageSpeedCheckDeleteManyStub.resolves(mockDeletedResult);
const result = await deletePageSpeedChecksByMonitorId("monitorId");
expect(result).to.deep.equal(mockDeletedResult.deletedCount);
});
it("should handle an error", async () => {
const err = new Error("test error");
pageSpeedCheckDeleteManyStub.rejects(err);
try {
await expect(deletePageSpeedChecksByMonitorId("monitorId"));
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
});

View File

@@ -0,0 +1,171 @@
import sinon from "sinon";
import RecoveryToken from "../../db/models/RecoveryToken.js";
import User from "../../db/models/User.js";
import {
requestRecoveryToken,
validateRecoveryToken,
resetPassword,
} from "../../db/mongo/modules/recoveryModule.js";
import { errorMessages } from "../../utils/messages.js";
const mockRecoveryToken = {
email: "test@test.com",
token: "1234567890",
};
const mockUser = {
email: "test@test.com",
password: "oldPassword",
};
const mockUserWithoutPassword = {
email: "test@test.com",
};
// Create a query builder that logs
const createQueryChain = (finalResult, comparePasswordResult = false) => ({
select: () => ({
select: async () => {
if (finalResult === mockUser) {
// Return a new object with all required methods
return {
email: "test@test.com",
password: "oldPassword",
comparePassword: sinon.stub().resolves(comparePasswordResult),
save: sinon.stub().resolves(),
};
}
return finalResult;
},
}),
// Add methods to the top level too
comparePassword: sinon.stub().resolves(comparePasswordResult),
save: sinon.stub().resolves(),
});
describe("recoveryModule", () => {
let deleteManyStub,
saveStub,
findOneStub,
userCompareStub,
userSaveStub,
userFindOneStub;
let req, res;
beforeEach(() => {
req = {
body: { email: "test@test.com" },
};
deleteManyStub = sinon.stub(RecoveryToken, "deleteMany");
saveStub = sinon.stub(RecoveryToken.prototype, "save");
findOneStub = sinon.stub(RecoveryToken, "findOne");
userCompareStub = sinon.stub(User.prototype, "comparePassword");
userSaveStub = sinon.stub(User.prototype, "save");
userFindOneStub = sinon.stub().resolves();
});
afterEach(() => {
sinon.restore();
});
describe("requestRecoveryToken", () => {
it("should return a recovery token", async () => {
deleteManyStub.resolves();
saveStub.resolves(mockRecoveryToken);
const result = await requestRecoveryToken(req, res);
expect(result.email).to.equal(mockRecoveryToken.email);
});
it("should handle an error", async () => {
const err = new Error("Test error");
deleteManyStub.rejects(err);
try {
await requestRecoveryToken(req, res);
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("validateRecoveryToken", () => {
it("should return a recovery token if found", async () => {
findOneStub.resolves(mockRecoveryToken);
const result = await validateRecoveryToken(req, res);
expect(result).to.deep.equal(mockRecoveryToken);
});
it("should thrown an error if a token is not found", async () => {
findOneStub.resolves(null);
try {
await validateRecoveryToken(req, res);
} catch (error) {
expect(error).to.exist;
expect(error.message).to.equal(errorMessages.DB_TOKEN_NOT_FOUND);
}
});
it("should handle DB errors", async () => {
const err = new Error("Test error");
findOneStub.rejects(err);
try {
await validateRecoveryToken(req, res);
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("resetPassword", () => {
beforeEach(() => {
req.body = {
password: "test",
newPassword: "test1",
};
});
afterEach(() => {
sinon.restore();
});
it("should thrown an error if a recovery token is not found", async () => {
findOneStub.resolves(null);
try {
await resetPassword(req, res);
} catch (error) {
expect(error).to.exist;
expect(error.message).to.equal(errorMessages.DB_TOKEN_NOT_FOUND);
}
});
it("should throw an error if a user is not found", async () => {
findOneStub.resolves(mockRecoveryToken);
userFindOneStub = sinon.stub(User, "findOne").resolves(null);
try {
await resetPassword(req, res);
} catch (error) {
expect(error).to.exist;
expect(error.message).to.equal(errorMessages.DB_USER_NOT_FOUND);
}
});
it("should throw an error if the passwords match", async () => {
findOneStub.resolves(mockRecoveryToken);
saveStub.resolves();
userFindOneStub = sinon
.stub(User, "findOne")
.returns(createQueryChain(mockUser, true));
try {
await resetPassword(req, res);
} catch (error) {
expect(error).to.exist;
expect(error.message).to.equal(errorMessages.DB_RESET_PASSWORD_BAD_MATCH);
}
});
it("should return a user without password if successful", async () => {
findOneStub.resolves(mockRecoveryToken);
saveStub.resolves();
userFindOneStub = sinon
.stub(User, "findOne")
.returns(createQueryChain(mockUser)) // First call will resolve to mockUser
.onSecondCall()
.returns(createQueryChain(mockUserWithoutPassword));
const result = await resetPassword(req, res);
expect(result).to.deep.equal(mockUserWithoutPassword);
});
});
});

View File

@@ -0,0 +1,54 @@
import sinon from "sinon";
import {
getAppSettings,
updateAppSettings,
} from "../../db/mongo/modules/settingsModule.js";
import AppSettings from "../../db/models/AppSettings.js";
const mockAppSettings = {
appName: "Test App",
};
describe("SettingsModule", () => {
let appSettingsFindOneStub, appSettingsFindOneAndUpdateStub;
beforeEach(() => {
appSettingsFindOneStub = sinon.stub(AppSettings, "findOne");
appSettingsFindOneAndUpdateStub = sinon.stub(AppSettings, "findOneAndUpdate");
});
afterEach(() => {
sinon.restore();
});
describe("getAppSettings", () => {
it("should return app settings", async () => {
appSettingsFindOneStub.resolves(mockAppSettings);
const result = await getAppSettings();
expect(result).to.deep.equal(mockAppSettings);
});
it("should handle an error", async () => {
const err = new Error("Test error");
appSettingsFindOneStub.throws(err);
try {
await getAppSettings();
} catch (error) {
expect(error).to.deep.equal(err);
}
});
});
describe("updateAppSettings", () => {
it("should update app settings", async () => {
appSettingsFindOneAndUpdateStub.resolves(mockAppSettings);
const result = await updateAppSettings(mockAppSettings);
expect(result).to.deep.equal(mockAppSettings);
});
it("should handle an error", async () => {
const err = new Error("Test error");
appSettingsFindOneAndUpdateStub.throws(err);
try {
await updateAppSettings(mockAppSettings);
} catch (error) {
expect(error).to.deep.equal(err);
}
});
});
});

View File

@@ -0,0 +1,286 @@
import sinon from "sinon";
import UserModel from "../../db/models/User.js";
import TeamModel from "../../db/models/Team.js";
import {
insertUser,
getUserByEmail,
updateUser,
deleteUser,
deleteTeam,
deleteAllOtherUsers,
getAllUsers,
logoutUser,
} from "../../db/mongo/modules/userModule.js";
import { errorMessages } from "../../utils/messages.js";
const mockUser = {
email: "test@test.com",
password: "password",
role: ["user"],
};
const mockSuperUser = {
email: "test@test.com",
password: "password",
role: ["superadmin"],
};
const imageFile = {
image: 1,
};
describe("userModule", () => {
let teamSaveStub,
teamFindByIdAndDeleteStub,
userSaveStub,
userFindStub,
userFindOneStub,
userFindByIdAndUpdateStub,
userFindByIdAndDeleteStub,
userDeleteManyStub,
userUpdateOneStub,
generateAvatarImageStub,
parseBooleanStub;
beforeEach(() => {
teamSaveStub = sinon.stub(TeamModel.prototype, "save");
teamFindByIdAndDeleteStub = sinon.stub(TeamModel, "findByIdAndDelete");
userSaveStub = sinon.stub(UserModel.prototype, "save");
userFindStub = sinon.stub(UserModel, "find");
userFindOneStub = sinon.stub(UserModel, "findOne");
userFindByIdAndUpdateStub = sinon.stub(UserModel, "findByIdAndUpdate");
userFindByIdAndDeleteStub = sinon.stub(UserModel, "findByIdAndDelete");
userDeleteManyStub = sinon.stub(UserModel, "deleteMany");
userUpdateOneStub = sinon.stub(UserModel, "updateOne");
generateAvatarImageStub = sinon.stub().resolves({ image: 2 });
parseBooleanStub = sinon.stub().returns(true);
});
afterEach(() => {
sinon.restore();
});
describe("insertUser", () => {
it("should insert a regular user", async () => {
userSaveStub.resolves(mockUser);
userFindOneStub.returns({
select: sinon.stub().returns({
select: sinon.stub().resolves(mockUser),
}),
});
const result = await insertUser(mockUser, imageFile, generateAvatarImageStub);
expect(result).to.deep.equal(mockUser);
});
it("should insert a superadmin user", async () => {
userSaveStub.resolves(mockSuperUser);
userFindOneStub.returns({
select: sinon.stub().returns({
select: sinon.stub().resolves(mockSuperUser),
}),
});
const result = await insertUser(mockSuperUser, imageFile, generateAvatarImageStub);
expect(result).to.deep.equal(mockSuperUser);
});
it("should handle an error", async () => {
const err = new Error("test error");
userSaveStub.rejects(err);
try {
await insertUser(mockUser, imageFile, generateAvatarImageStub);
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
it("should handle a duplicate key error", async () => {
const err = new Error("test error");
err.code = 11000;
userSaveStub.rejects(err);
try {
await insertUser(mockUser, imageFile, generateAvatarImageStub);
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("getUserByEmail", () => {
it("should return a user", async () => {
userFindOneStub.returns({
select: sinon.stub().resolves(mockUser),
});
const result = await getUserByEmail(mockUser.email);
expect(result).to.deep.equal(mockUser);
});
});
describe("getUserByEmail", () => {
it("throw an error if a user is not found", async () => {
userFindOneStub.returns({
select: sinon.stub().resolves(null),
});
try {
await getUserByEmail(mockUser.email);
} catch (error) {
expect(error.message).to.equal(errorMessages.DB_USER_NOT_FOUND);
}
});
});
describe("updateUser", () => {
let req, res;
beforeEach(() => {
req = {
params: {
userId: "testId",
},
body: {
deleteProfileImage: "false",
email: "test@test.com",
},
file: {
buffer: "test",
mimetype: "test",
},
};
res = {};
});
afterEach(() => {});
it("should update a user", async () => {
parseBooleanStub.returns(false);
userFindByIdAndUpdateStub.returns({
select: sinon.stub().returns({
select: sinon.stub().resolves(mockUser),
}),
});
const result = await updateUser(
req,
res,
parseBooleanStub,
generateAvatarImageStub
);
expect(result).to.deep.equal(mockUser);
});
it("should delete a user profile image", async () => {
req.body.deleteProfileImage = "true";
userFindByIdAndUpdateStub.returns({
select: sinon.stub().returns({
select: sinon.stub().resolves(mockUser),
}),
});
const result = await updateUser(
req,
res,
parseBooleanStub,
generateAvatarImageStub
);
expect(result).to.deep.equal(mockUser);
});
it("should handle an error", async () => {
const err = new Error("test error");
userFindByIdAndUpdateStub.throws(err);
try {
await updateUser(req, res, parseBooleanStub, generateAvatarImageStub);
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("deleteUser", async () => {
it("should return a deleted user", async () => {
userFindByIdAndDeleteStub.resolves(mockUser);
const result = await deleteUser("testId");
expect(result).to.deep.equal(mockUser);
});
it("should throw an error if a user is not found", async () => {
try {
await deleteUser("testId");
} catch (error) {
expect(error).to.exist;
expect(error.message).to.equal(errorMessages.DB_USER_NOT_FOUND);
}
});
it("should handle an error", async () => {
const err = new Error("test error");
userFindByIdAndDeleteStub.throws(err);
try {
await deleteUser("testId");
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("deleteTeam", () => {
it("should return true if team deleted", async () => {
teamFindByIdAndDeleteStub.resolves();
const result = await deleteTeam("testId");
expect(result).to.equal(true);
});
it("should handle an error", async () => {
const err = new Error("test error");
teamFindByIdAndDeleteStub.throws(err);
try {
await deleteTeam("testId");
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("deleteAllOtherUsers", () => {
it("should return true if all other users deleted", async () => {
userDeleteManyStub.resolves(true);
const result = await deleteAllOtherUsers();
expect(result).to.equal(true);
});
it("should handle an error", async () => {
const err = new Error("test error");
userDeleteManyStub.throws(err);
try {
await deleteAllOtherUsers();
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("getAllUsers", () => {
it("should return all users", async () => {
userFindStub.returns({
select: sinon.stub().returns({
select: sinon.stub().resolves([mockUser]),
}),
});
const result = await getAllUsers();
expect(result).to.deep.equal([mockUser]);
});
it("should handle an error", async () => {
const err = new Error("test error");
userFindStub.throws(err);
try {
await getAllUsers();
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
describe("logoutUser", async () => {
it("should return true if user logged out", async () => {
userUpdateOneStub.resolves(true);
const result = await logoutUser("testId");
expect(result).to.equal(true);
});
it("should handle an error", async () => {
const err = new Error("test error");
userUpdateOneStub.throws(err);
try {
await logoutUser("testId");
} catch (error) {
expect(error).to.exist;
expect(error).to.deep.equal(err);
}
});
});
});

View File

@@ -30,6 +30,14 @@ class QueueStub {
async getJobs() {
return this.jobs;
}
async pause() {
return true;
}
async obliterate() {
return true;
}
}
class WorkerStub {

View File

@@ -2,8 +2,9 @@ import sinon from "sinon";
import NetworkService from "../../service/networkService.js";
import { expect } from "chai";
import http from "http";
import { errorMessages } from "../../utils/messages.js";
describe("Network Service", () => {
let axios, ping, logger, networkService;
let axios, ping, Docker, logger, networkService;
beforeEach(() => {
axios = {
@@ -12,6 +13,17 @@ describe("Network Service", () => {
status: 200,
}),
};
Docker = class {
listContainers = sinon.stub().resolves([
{
Names: ["http://test.com"],
Id: "http://test.com",
},
]);
getContainer = sinon.stub().returns({
inspect: sinon.stub().resolves({ State: { Status: "running" } }),
});
};
ping = {
promise: {
probe: sinon
@@ -20,7 +32,7 @@ describe("Network Service", () => {
},
};
logger = { error: sinon.stub() };
networkService = new NetworkService(axios, ping, logger, http);
networkService = new NetworkService(axios, ping, logger, http, Docker);
});
describe("constructor", () => {
it("should create a new NetworkService instance", () => {
@@ -70,6 +82,18 @@ describe("Network Service", () => {
expect(pingResult.status).to.be.false;
expect(pingResult.code).to.equal(networkService.PING_ERROR);
});
it("should throw an error if ping cannot resolve", async () => {
const error = new Error("test error");
networkService.timeRequest = sinon.stub().throws(error);
try {
await networkService.requestPing({
data: { url: "http://test.com", _id: "123" },
});
} catch (error) {
expect(error).to.exist;
expect(error.method).to.equal("requestPing");
}
});
});
describe("requestHttp", () => {
it("should return a response object if http successful", async () => {
@@ -108,6 +132,18 @@ describe("Network Service", () => {
expect(httpResult.status).to.be.false;
expect(httpResult.code).to.equal(networkService.NETWORK_ERROR);
});
it("should throw an error if an error occurs", async () => {
const error = new Error("test error");
networkService.timeRequest = sinon.stub().throws(error);
try {
await networkService.requestHttp({
data: { url: "http://test.com", _id: "123" },
});
} catch (error) {
expect(error).to.exist;
expect(error.method).to.equal("requestHttp");
}
});
});
describe("requestPagespeed", () => {
@@ -147,6 +183,18 @@ describe("Network Service", () => {
expect(pagespeedResult.status).to.be.false;
expect(pagespeedResult.code).to.equal(networkService.NETWORK_ERROR);
});
it("should throw an error if pagespeed cannot resolve", async () => {
const error = new Error("test error");
networkService.timeRequest = sinon.stub().throws(error);
try {
await networkService.requestPagespeed({
data: { url: "http://test.com", _id: "123" },
});
} catch (error) {
expect(error).to.exist;
expect(error.method).to.equal("requestPagespeed");
}
});
});
describe("requestHardware", () => {
@@ -201,6 +249,99 @@ describe("Network Service", () => {
expect(httpResult.status).to.be.false;
expect(httpResult.code).to.equal(networkService.NETWORK_ERROR);
});
it("should throw an error if hardware cannot resolve", async () => {
const error = new Error("test error");
networkService.timeRequest = sinon.stub().throws(error);
try {
await networkService.requestHardware({
data: { url: "http://test.com", _id: "123" },
});
} catch (error) {
expect(error).to.exist;
expect(error.method).to.equal("requestHardware");
}
});
});
describe("requestDocker", () => {
it("should return a response object if docker successful", async () => {
const job = { data: { url: "http://test.com", _id: "123", type: "docker" } };
const dockerResult = await networkService.requestDocker(job);
expect(dockerResult.monitorId).to.equal("123");
expect(dockerResult.type).to.equal("docker");
expect(dockerResult.responseTime).to.be.a("number");
expect(dockerResult.status).to.be.true;
});
it("should return a response object with status false if container not running", async () => {
Docker = class {
listContainers = sinon.stub().resolves([
{
Names: ["/my_container"],
Id: "abc123",
},
]);
getContainer = sinon.stub().returns({
inspect: sinon.stub().resolves({ State: { Status: "stopped" } }),
});
};
networkService = new NetworkService(axios, ping, logger, http, Docker);
const job = { data: { url: "abc123", _id: "123", type: "docker" } };
const dockerResult = await networkService.requestDocker(job);
expect(dockerResult.status).to.be.false;
expect(dockerResult.code).to.equal(200);
});
it("should handle an error when fetching the container", async () => {
Docker = class {
listContainers = sinon.stub().resolves([
{
Names: ["/my_container"],
Id: "abc123",
},
]);
getContainer = sinon.stub().returns({
inspect: sinon.stub().throws(new Error("test error")),
});
};
networkService = new NetworkService(axios, ping, logger, http, Docker);
const job = { data: { url: "abc123", _id: "123", type: "docker" } };
const dockerResult = await networkService.requestDocker(job);
expect(dockerResult.status).to.be.false;
expect(dockerResult.code).to.equal(networkService.NETWORK_ERROR);
});
it("should throw an error if operations fail", async () => {
Docker = class {
listContainers = sinon.stub().resolves([
{
Names: ["/my_container"],
Id: "abc123",
},
]);
getContainer = sinon.stub().throws(new Error("test error"));
};
networkService = new NetworkService(axios, ping, logger, http, Docker);
const job = { data: { url: "abc123", _id: "123", type: "docker" } };
try {
await networkService.requestDocker(job);
} catch (error) {
expect(error.message).to.equal("test error");
}
});
it("should throw an error if no matching images found", async () => {
Docker = class {
listContainers = sinon.stub().resolves([]);
getContainer = sinon.stub().throws(new Error("test error"));
};
networkService = new NetworkService(axios, ping, logger, http, Docker);
const job = { data: { url: "abc123", _id: "123", type: "docker" } };
try {
await networkService.requestDocker(job);
} catch (error) {
expect(error.message).to.equal(errorMessages.DOCKER_NOT_FOUND);
}
});
});
describe("getStatus", () => {
@@ -209,39 +350,81 @@ describe("Network Service", () => {
networkService.requestHttp = sinon.stub();
networkService.requestPagespeed = sinon.stub();
networkService.requestHardware = sinon.stub();
networkService.requestDocker = sinon.stub();
});
afterEach(() => {
sinon.restore();
});
it("should call requestPing if type is ping", () => {
networkService.getStatus({ data: { type: "ping" } });
it("should call requestPing if type is ping", async () => {
await networkService.getStatus({ data: { type: "ping" } });
expect(networkService.requestPing.calledOnce).to.be.true;
expect(networkService.requestDocker.notCalled).to.be.true;
expect(networkService.requestHttp.notCalled).to.be.true;
expect(networkService.requestPagespeed.notCalled).to.be.true;
});
it("should call requestHttp if type is http", () => {
networkService.getStatus({ data: { type: "http" } });
it("should call requestHttp if type is http", async () => {
await networkService.getStatus({ data: { type: "http" } });
expect(networkService.requestPing.notCalled).to.be.true;
expect(networkService.requestDocker.notCalled).to.be.true;
expect(networkService.requestHttp.calledOnce).to.be.true;
expect(networkService.requestPagespeed.notCalled).to.be.true;
});
it("should call requestPagespeed if type is pagespeed", () => {
networkService.getStatus({ data: { type: "pagespeed" } });
it("should call requestPagespeed if type is pagespeed", async () => {
await networkService.getStatus({ data: { type: "pagespeed" } });
expect(networkService.requestPing.notCalled).to.be.true;
expect(networkService.requestDocker.notCalled).to.be.true;
expect(networkService.requestHttp.notCalled).to.be.true;
expect(networkService.requestPagespeed.calledOnce).to.be.true;
});
it("should call requestHardware if type is hardware", () => {
networkService.getStatus({ data: { type: "hardware" } });
it("should call requestHardware if type is hardware", async () => {
await networkService.getStatus({ data: { type: "hardware" } });
expect(networkService.requestHardware.calledOnce).to.be.true;
expect(networkService.requestDocker.notCalled).to.be.true;
expect(networkService.requestPing.notCalled).to.be.true;
expect(networkService.requestPagespeed.notCalled).to.be.true;
});
it("should log an error if an unknown type is provided", () => {
networkService.getStatus({ data: { type: "unknown" } });
expect(logger.error.calledOnce).to.be.true;
expect(logger.error.args[0][0].message).to.equal("Unsupported type: unknown");
it("should call requestDocker if type is Docker", async () => {
await networkService.getStatus({ data: { type: "docker" } });
expect(networkService.requestDocker.calledOnce).to.be.true;
expect(networkService.requestHardware.notCalled).to.be.true;
expect(networkService.requestPing.notCalled).to.be.true;
expect(networkService.requestPagespeed.notCalled).to.be.true;
});
it("should throw an error if an unknown type is provided", async () => {
try {
await networkService.getStatus({ data: { type: "unknown" } });
} catch (error) {
expect(error.service).to.equal("NetworkService");
expect(error.method).to.equal("getStatus");
expect(error.message).to.equal("Unsupported type: unknown");
}
});
it("should throw an error if job type is undefined", async () => {
try {
await networkService.getStatus({ data: { type: undefined } });
} catch (error) {
expect(error.service).to.equal("NetworkService");
expect(error.method).to.equal("getStatus");
expect(error.message).to.equal("Unsupported type: unknown");
}
});
it("should throw an error if job is empty", async () => {
try {
await networkService.getStatus({});
} catch (error) {
expect(error.method).to.equal("getStatus");
expect(error.message).to.equal("Unsupported type: unknown");
}
});
it("should throw an error if job is null", async () => {
try {
await networkService.getStatus(null);
} catch (error) {
expect(error.service).to.equal("NetworkService");
expect(error.method).to.equal("getStatus");
expect(error.message).to.equal("Unsupported type: unknown");
}
});
});
});

View File

@@ -78,11 +78,23 @@ describe("NotificationService", () => {
describe("handleNotifications", async () => {
it("should handle notifications based on the network response", async () => {
notificationService.sendEmail = sinon.stub();
notificationService.db.getNotificationsByMonitorId.resolves([
{ type: "email", address: "www.google.com" },
]);
await notificationService.handleNotifications({ monitorId: "123" });
expect(notificationService.sendEmail.calledOnce).to.be.true;
const res = await notificationService.handleNotifications({
monitor: {
type: "email",
address: "www.google.com",
},
});
expect(res).to.be.true;
});
it("should handle hardware notifications", async () => {
notificationService.sendEmail = sinon.stub();
const res = await notificationService.handleNotifications({
monitor: {
type: "hardware",
address: "www.google.com",
},
});
expect(res).to.be.true;
});
it("should handle an error when getting notifications", async () => {
@@ -92,4 +104,184 @@ describe("NotificationService", () => {
expect(notificationService.logger.warn.calledOnce).to.be.true;
});
});
describe("sendHardwareEmail", async () => {
let networkResponse, address, alerts;
beforeEach(() => {
networkResponse = {
monitor: {
name: "Test Monitor",
url: "http://test.com",
},
status: true,
prevStatus: false,
};
address = "test@test.com";
alerts = ["test"];
});
afterEach(() => {
sinon.restore();
});
it("should send an email notification with Hardware Template", async () => {
emailService.buildAndSendEmail.resolves(true);
const res = await notificationService.sendHardwareEmail(
networkResponse,
address,
alerts
);
expect(res).to.be.true;
});
it("should return false if no alerts are provided", async () => {
alerts = [];
emailService.buildAndSendEmail.resolves(true);
const res = await notificationService.sendHardwareEmail(
networkResponse,
address,
alerts
);
expect(res).to.be.false;
});
});
describe("handleStatusNotifications", async () => {
let networkResponse;
beforeEach(() => {
networkResponse = {
monitor: {
name: "Test Monitor",
url: "http://test.com",
},
statusChanged: true,
status: true,
prevStatus: false,
};
});
afterEach(() => {
sinon.restore();
});
it("should handle status notifications", async () => {
db.getNotificationsByMonitorId.resolves([
{ type: "email", address: "test@test.com" },
]);
const res = await notificationService.handleStatusNotifications(networkResponse);
expect(res).to.be.true;
});
it("should return false if status hasn't changed", async () => {
networkResponse.statusChanged = false;
const res = await notificationService.handleStatusNotifications(networkResponse);
expect(res).to.be.false;
});
it("should return false if prevStatus is undefined", async () => {
networkResponse.prevStatus = undefined;
const res = await notificationService.handleStatusNotifications(networkResponse);
expect(res).to.be.false;
});
it("should handle an error", async () => {
const testError = new Error("Test Error");
db.getNotificationsByMonitorId.rejects(testError);
try {
await notificationService.handleStatusNotifications(networkResponse);
} catch (error) {
expect(error).to.be.an.instanceOf(Error);
expect(error.message).to.equal("Test Error");
}
});
});
describe("handleHardwareNotifications", async () => {
let networkResponse;
beforeEach(() => {
networkResponse = {
monitor: {
name: "Test Monitor",
url: "http://test.com",
thresholds: {
usage_cpu: 1,
usage_memory: 1,
usage_disk: 1,
},
},
payload: {
data: {
cpu: {
usage_percent: 0.655,
},
memory: {
usage_percent: 0.783,
},
disk: [
{
name: "/dev/sda1",
usage_percent: 0.452,
},
{
name: "/dev/sdb1",
usage_percent: 0.627,
},
],
},
},
};
});
afterEach(() => {
sinon.restore();
});
describe("it should return false if no thresholds are set", () => {
it("should return false if no thresholds are set", async () => {
networkResponse.monitor.thresholds = undefined;
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.false;
});
it("should return false if metrics are null", async () => {
networkResponse.payload.data = null;
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.false;
});
it("should return true if request is well formed and thresholds > 0", async () => {
db.getNotificationsByMonitorId.resolves([
{
type: "email",
address: "test@test.com",
alertThreshold: 1,
cpuAlertThreshold: 1,
memoryAlertThreshold: 1,
diskAlertThreshold: 1,
save: sinon.stub().resolves(),
},
]);
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.true;
});
it("should return true if thresholds are exceeded", async () => {
db.getNotificationsByMonitorId.resolves([
{
type: "email",
address: "test@test.com",
alertThreshold: 1,
cpuAlertThreshold: 1,
memoryAlertThreshold: 1,
diskAlertThreshold: 1,
save: sinon.stub().resolves(),
},
]);
networkResponse.monitor.thresholds = {
usage_cpu: 0.01,
usage_memory: 0.01,
usage_disk: 0.01,
};
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.true;
});
});
});
});

View File

@@ -27,6 +27,18 @@ describe("StatusService", () => {
});
});
describe("getStatusString", () => {
it("should return 'up' if status is true", () => {
expect(statusService.getStatusString(true)).to.equal("up");
});
it("should return 'down' if status is false", () => {
expect(statusService.getStatusString(false)).to.equal("down");
});
it("should return 'unknown' if status is undefined or null", () => {
expect(statusService.getStatusString(undefined)).to.equal("unknown");
});
});
describe("updateStatus", async () => {
beforeEach(() => {
// statusService.insertCheck = sinon.stub().resolves;
@@ -182,7 +194,7 @@ describe("StatusService", () => {
responseTime: 100,
code: 200,
message: "Test message",
payload: { cpu: "cpu", memory: "memory", disk: "disk", host: "host" },
payload: { data: { cpu: "cpu", memory: "memory", disk: "disk", host: "host" } },
});
expect(check.monitorId).to.equal("test");
expect(check.status).to.be.true;

View File

@@ -46,6 +46,26 @@ describe("Logger", () => {
const logger = new Logger();
logger.logger.info(logMessage);
});
it("should convert details to JSON string if it is an object", function () {
const logDetails = { key: "value" };
const expectedDetails = JSON.stringify(logDetails, null, 2); // Removed .s
createLoggerStub.callsFake((config) => {
const consoleTransport = config.transports[0];
const logEntry = {
level: "info",
message: "", // Add empty message since it's required
details: logDetails,
timestamp: new Date().toISOString(),
};
const formattedMessage = consoleTransport.format.transform(logEntry);
expect(formattedMessage).to.include(expectedDetails);
return { info: sinon.spy() }; // Changed to return info method
});
const logger = new Logger();
logger.logger.info("", { details: logDetails }); // Updated to pass details properly
});
});
describe("info", () => {

View File

@@ -55,6 +55,10 @@ const errorMessages = {
// Status Page Errors
STATUS_PAGE_NOT_FOUND: "Status page not found",
STATUS_PAGE_URL_NOT_UNIQUE: "Status page url must be unique",
// Docker
DOCKER_FAIL: "Failed to fetch Docker container information",
DOCKER_NOT_FOUND: "Docker container not found",
};
const successMessages = {
@@ -123,6 +127,9 @@ const successMessages = {
// Status Page
STATUS_PAGE_BY_URL: "Got status page by url successfully",
STATUS_PAGE_CREATE: "Status page created successfully",
// Docker
DOCKER_SUCCESS: "Docker container status fetched successfully",
};
export { errorMessages, successMessages };

View File

@@ -144,8 +144,8 @@ const getMonitorsAndSummaryByTeamIdQueryValidation = joi.object({
type: joi
.alternatives()
.try(
joi.string().valid("http", "ping", "pagespeed"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed"))
joi.string().valid("http", "ping", "pagespeed", "docker"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker"))
),
});
@@ -161,8 +161,8 @@ const getMonitorsByTeamIdQueryValidation = joi.object({
type: joi
.alternatives()
.try(
joi.string().valid("http", "ping", "pagespeed"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed"))
joi.string().valid("http", "ping", "pagespeed", "docker"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker"))
),
page: joi.number(),
rowsPerPage: joi.number(),
@@ -178,7 +178,7 @@ const getMonitorStatsByIdQueryValidation = joi.object({
status: joi.string(),
limit: joi.number(),
sortOrder: joi.string().valid("asc", "desc"),
dateRange: joi.string().valid("day", "week", "month"),
dateRange: joi.string().valid("day", "week", "month", "all"),
numToDisplay: joi.number(),
normalize: joi.boolean(),
});

View File

@@ -1,181 +0,0 @@
---
icon: sign-posts-wrench
---
# Installing Uptime Manager
## Quickstart for users (quick method) <a href="#user-quickstart" id="user-quickstart"></a>
1. Download our [Docker compose file](https://github.com/bluewave-labs/bluewave-uptime/blob/develop/Docker/dist/docker-compose.yaml)
2. Run `docker compose up` to start the application
3. Now the application is running at `http://localhost`
---
## Quickstart for developers <a href="#dev-quickstart" id="dev-quickstart"></a>
{% hint style="info" %}
Make sure you change the directory to the specified directories, as paths in commands are relative.
{% endhint %}
### Cloning and initial setup
1. Clone this repository.
2. Checkout the `develop` branch `git checkout develop`
### Setting up Docker images
3. Change directory to the `Docker/dev` directory
4. Build the docker images by running the `build_images.sh` script
5. Run `docker run -d -p 6379:6379 -v $(pwd)/redis/data:/data --name uptime_redis uptime_redis`
6. Run `docker run -d -p 27017:27017 -v $(pwd)/mongo/data:/data/db --name uptime_database_mongo uptime_database_mongo`
### Server setup
6. CD to `Server` directory, and run `npm install`
7. While in `Server` directory, create a `.env` file with the [required environmental variables](#env-vars-server)
8. While in the `Server` directory, run `npm run dev`
### Client setup
9. CD to `Client` directory `run npm install`
10. While in the `Client` directory, create a `.env` file with the [required environmental variables](#env-vars-client)
11. While in the `Client` directory run `npm run dev`
### Access the application
12. Client is now running at `localhost:5173`
13. Server is now running at `localhost:5000`
---
## Manual installation <a href="#manual-install" id="manual-install"></a>
### Client installation <a href="#install-client" id="install-client"></a>
1. Change directory to the `Client` directory
2. Install all dependencies by running `npm install`
3. Add a `.env` file to the `Client` directory with the following options:
#### Environment variables <a href="#env-vars-client" id="env-vars-client"></a>
| ENV Variable Name | Required/Optional | Type | Description | Accepted Values |
| --------------------- | ----------------- | --------- | ------------------ | ---------------------------------- |
| VITE_APP_API_BASE_URL | Required | `string` | Base URL of server | {host}/api/v1 |
| VITE_APP_LOG_LEVEL | Optional | `string` | Log level | `"none"`\|`"error"` \| `"warn"` \| |
| VITE_APP_DEMO | Optional | `boolean` | Demo server or not | `true`\|`false` \| |
#### Starting the Client development server <a href="#start-client" id="start-client"></a>
1. Run `npm run dev` to start the development server.
### Server Installation <a href="#install-server" id="install-server"></a>
1. Change the directory to the `Server` directory
2. Install all dependencies by running `npm install`
3. Add a `.env` file to the `Server` directory with the following options:
#### Environment variables <a href="#env-vars-server" id="env-vars-server"></a>
Configure the server with the following environmental variables:
<table><thead><tr><th width="239">ENV Variable Name</th><th width="149">Required/Optional</th><th width="116">Type</th><th>Description</th><th>Accepted Values</th></tr></thead><tbody><tr><td>CLIENT_HOST</td><td>Required</td><td><code>string</code></td><td>Frontend Host</td><td></td></tr><tr><td>JWT_SECRET</td><td>Required</td><td><code>string</code></td><td>JWT secret</td><td></td></tr><tr><td>DB_TYPE</td><td>Optional</td><td><code>string</code></td><td>Specify DB to use</td><td><code>MongoDB | FakeDB</code></td></tr><tr><td>DB_CONNECTION_STRING</td><td>Required</td><td><code>string</code></td><td>Specifies URL for MongoDB Database</td><td></td></tr><tr><td>PORT</td><td>Optional</td><td><code>integer</code></td><td>Specifies Port for Server</td><td></td></tr><tr><td>LOGIN_PAGE_URL</td><td>Required</td><td><code>string</code></td><td>Login url to be used in emailing service</td><td></td></tr><tr><td>REDIS_HOST</td><td>Required</td><td><code>string</code></td><td>Host address for Redis database</td><td></td></tr><tr><td>REDIS_PORT</td><td>Required</td><td><code>integer</code></td><td>Port for Redis database</td><td></td></tr><tr><td>TOKEN_TTL</td><td>Optional</td><td><code>string</code></td><td>Time for token to live</td><td>In vercel/ms format https://github.com/vercel/ms</td></tr><tr><td>PAGESPEED_API_KEY</td><td>Optional</td><td><code>string</code></td><td>API Key for PageSpeed requests</td><td></td></tr><tr><td>SYSTEM_EMAIL_HOST</td><td>Required</td><td><code>string</code></td><td>Host to send System Emails From</td><td></td></tr><tr><td>SYSTEM_EMAIL_PORT</td><td>Required</td><td><code>number</code></td><td>Port for System Email Host</td><td></td></tr><tr><td>SYSTEM_EMAIL_ADDRESS</td><td>Required</td><td><code>string</code></td><td>System Email Address</td><td></td></tr><tr><td>SYSTEM_EMAIL_PASSWORD</td><td>Required</td><td><code>string</code></td><td>System Email Password</td><td></td></tr></tbody></table>
---
#### Databases <a href="#databases" id="databases"></a>
This project requires two databases:
1. **Main application database:** The project uses MongoDB for its primary database, with a MongoDB Docker image provided for easy setup.
2. **Redis for queue management:** A Redis database is used for the PingServices queue system, and a Redis Docker image is included for deployment.
You may use the included Dockerfiles to spin up databases quickly if you wish.
**(Optional) Dockerised databases**
Dockerfiles for the server and databases are located in the `Docker` directory
<details>
<summary>MongoDB Image</summary>
Location: `Docker/mongoDB.Dockerfile`
The `Docker/mongo/data` directory should be mounted to the MongoDB container in order to persist data.
From the `Docker` directory run
1. Build the image: `docker build -f mongoDB.Dockerfile -t uptime_database_mongo .`
2. Run the docker image: `docker run -d -p 27017:27017 -v $(pwd)/mongo/data:/data/db --name uptime_database_mongo uptime_database_mongo`
</details>
<details>
<summary>Redis Image</summary>
Location `Docker/redis.Dockerfile`
the `Docker/redis/data` directory should be mounted to the Redis container in order to persist data.
From the `Docker` directory run
1. Build the image: `docker build -f redis.Dockerfile -t uptime_redis .`
2. Run the image: `docker run -d -p 6379:6379 -v $(pwd)/redis/data:/data --name uptime_redis uptime_redis`
</details>
---
#### Starting the development server <a href="#start-server" id="start-server"></a>
- run `npm run dev` to start the development server
or,
- run `node index.js` to start server
---
### API documentation <a href="#api-documentation" id="api-documentation"></a>
Our API is documented in accordance with the [OpenAPI spec](https://www.openapis.org/).
You can see the documentation on your local development server at http://localhost:{port}/api-docs
You can also view the documentation on our demo server at [https://uptime-demo.bluewavelabs.ca/api-docs](https://uptime-demo.bluewavelabs.ca/api-docs)
### Error handling
Errors are returned in a standard format:
`{"success": false, "msg": "No token provided"}`
Errors are handled by error handling middleware and should be thrown with the following parameters
| Name | Type | Default | Notes |
| ------- | --------- | ---------------------- | ------------------------------------ |
| status | `integer` | 500 | Standard HTTP codes |
| message | `string` | "Something went wrong" | An error message |
| service | `string` | "Unknown Service" | Name of service that threw the error |
Example:
```
const myRoute = async(req, res, next) => {
try{
const result = myRiskyOperationHere();
}
catch(error){
error.status = 404
error.message = "Resource not found"
error.service = service name
next(error)
return;
}
}
```
Errors should not be handled at the controller level and should be left to the middleware to handle.

View File

@@ -1,181 +0,0 @@
---
icon: sign-posts-wrench
---
# Installing Uptime Manager
## Quickstart for users (quick method) <a href="#user-quickstart" id="user-quickstart"></a>
1. Download our [Docker compose file](https://github.com/bluewave-labs/bluewave-uptime/blob/develop/Docker/dist/docker-compose.yaml)
2. Run `docker compose up` to start the application
3. Now the application is running at `http://localhost`
---
## Quickstart for developers <a href="#dev-quickstart" id="dev-quickstart"></a>
{% hint style="info" %}
Make sure you change the directory to the specified directories, as paths in commands are relative.
{% endhint %}
### Cloning and initial setup
1. Clone this repository.
2. Checkout the `develop` branch `git checkout develop`
### Setting up Docker images
3. Change directory to the `Docker/dev` directory
4. Build the docker images by running the `build_images.sh` script
5. Run `docker run -d -p 6379:6379 -v $(pwd)/redis/data:/data --name uptime_redis uptime_redis`
6. Run `docker run -d -p 27017:27017 -v $(pwd)/mongo/data:/data/db --name uptime_database_mongo uptime_database_mongo`
### Server setup
6. CD to `Server` directory, and run `npm install`
7. While in `Server` directory, create a `.env` file with the [required environmental variables](#env-vars-server)
8. While in the `Server` directory, run `npm run dev`
### Client setup
9. CD to `Client` directory `run npm install`
10. While in the `Client` directory, create a `.env` file with the [required environmental variables](#env-vars-client)
11. While in the `Client` directory run `npm run dev`
### Access the application
12. Client is now running at `localhost:5173`
13. Server is now running at `localhost:5000`
---
## Manual installation <a href="#manual-install" id="manual-install"></a>
### Client installation <a href="#install-client" id="install-client"></a>
1. Change directory to the `Client` directory
2. Install all dependencies by running `npm install`
3. Add a `.env` file to the `Client` directory with the following options:
#### Environment variables <a href="#env-vars-client" id="env-vars-client"></a>
| ENV Variable Name | Required/Optional | Type | Description | Accepted Values |
| --------------------- | ----------------- | --------- | ------------------ | ---------------------------------- |
| VITE_APP_API_BASE_URL | Required | `string` | Base URL of server | {host}/api/v1 |
| VITE_APP_LOG_LEVEL | Optional | `string` | Log level | `"none"`\|`"error"` \| `"warn"` \| |
| VITE_APP_DEMO | Optional | `boolean` | Demo server or not | `true`\|`false` \| |
#### Starting the Client development server <a href="#start-client" id="start-client"></a>
1. Run `npm run dev` to start the development server.
### Server Installation <a href="#install-server" id="install-server"></a>
1. Change the directory to the `Server` directory
2. Install all dependencies by running `npm install`
3. Add a `.env` file to the `Server` directory with the following options:
#### Environment variables <a href="#env-vars-server" id="env-vars-server"></a>
Configure the server with the following environmental variables:
<table><thead><tr><th width="239">ENV Variable Name</th><th width="149">Required/Optional</th><th width="116">Type</th><th>Description</th><th>Accepted Values</th></tr></thead><tbody><tr><td>CLIENT_HOST</td><td>Required</td><td><code>string</code></td><td>Frontend Host</td><td></td></tr><tr><td>JWT_SECRET</td><td>Required</td><td><code>string</code></td><td>JWT secret</td><td></td></tr><tr><td>DB_TYPE</td><td>Optional</td><td><code>string</code></td><td>Specify DB to use</td><td><code>MongoDB | FakeDB</code></td></tr><tr><td>DB_CONNECTION_STRING</td><td>Required</td><td><code>string</code></td><td>Specifies URL for MongoDB Database</td><td></td></tr><tr><td>PORT</td><td>Optional</td><td><code>integer</code></td><td>Specifies Port for Server</td><td></td></tr><tr><td>LOGIN_PAGE_URL</td><td>Required</td><td><code>string</code></td><td>Login url to be used in emailing service</td><td></td></tr><tr><td>REDIS_HOST</td><td>Required</td><td><code>string</code></td><td>Host address for Redis database</td><td></td></tr><tr><td>REDIS_PORT</td><td>Required</td><td><code>integer</code></td><td>Port for Redis database</td><td></td></tr><tr><td>TOKEN_TTL</td><td>Optional</td><td><code>string</code></td><td>Time for token to live</td><td>In vercel/ms format https://github.com/vercel/ms</td></tr><tr><td>PAGESPEED_API_KEY</td><td>Optional</td><td><code>string</code></td><td>API Key for PageSpeed requests</td><td></td></tr><tr><td>SYSTEM_EMAIL_HOST</td><td>Required</td><td><code>string</code></td><td>Host to send System Emails From</td><td></td></tr><tr><td>SYSTEM_EMAIL_PORT</td><td>Required</td><td><code>number</code></td><td>Port for System Email Host</td><td></td></tr><tr><td>SYSTEM_EMAIL_ADDRESS</td><td>Required</td><td><code>string</code></td><td>System Email Address</td><td></td></tr><tr><td>SYSTEM_EMAIL_PASSWORD</td><td>Required</td><td><code>string</code></td><td>System Email Password</td><td></td></tr></tbody></table>
---
#### Databases <a href="#databases" id="databases"></a>
This project requires two databases:
1. **Main application database:** The project uses MongoDB for its primary database, with a MongoDB Docker image provided for easy setup.
2. **Redis for queue management:** A Redis database is used for the PingServices queue system, and a Redis Docker image is included for deployment.
You may use the included Dockerfiles to spin up databases quickly if you wish.
**(Optional) Dockerised databases**
Dockerfiles for the server and databases are located in the `Docker` directory
<details>
<summary>MongoDB Image</summary>
Location: `Docker/mongoDB.Dockerfile`
The `Docker/mongo/data` directory should be mounted to the MongoDB container in order to persist data.
From the `Docker` directory run
1. Build the image: `docker build -f mongoDB.Dockerfile -t uptime_database_mongo .`
2. Run the docker image: `docker run -d -p 27017:27017 -v $(pwd)/mongo/data:/data/db --name uptime_database_mongo uptime_database_mongo`
</details>
<details>
<summary>Redis Image</summary>
Location `Docker/redis.Dockerfile`
the `Docker/redis/data` directory should be mounted to the Redis container in order to persist data.
From the `Docker` directory run
1. Build the image: `docker build -f redis.Dockerfile -t uptime_redis .`
2. Run the image: `docker run -d -p 6379:6379 -v $(pwd)/redis/data:/data --name uptime_redis uptime_redis`
</details>
---
#### Starting the development server <a href="#start-server" id="start-server"></a>
- run `npm run dev` to start the development server
or,
- run `node index.js` to start server
---
### API documentation <a href="#api-documentation" id="api-documentation"></a>
Our API is documented in accordance with the [OpenAPI spec](https://www.openapis.org/).
You can see the documentation on your local development server at http://localhost:{port}/api-docs
You can also view the documentation on our demo server at [https://uptime-demo.bluewavelabs.ca/api-docs](https://uptime-demo.bluewavelabs.ca/api-docs)
### Error handling
Errors are returned in a standard format:
`{"success": false, "msg": "No token provided"}`
Errors are handled by error handling middleware and should be thrown with the following parameters
| Name | Type | Default | Notes |
| ------- | --------- | ---------------------- | ------------------------------------ |
| status | `integer` | 500 | Standard HTTP codes |
| message | `string` | "Something went wrong" | An error message |
| service | `string` | "Unknown Service" | Name of service that threw the error |
Example:
```
const myRoute = async(req, res, next) => {
try{
const result = myRiskyOperationHere();
}
catch(error){
error.status = 404
error.message = "Resource not found"
error.service = service name
next(error)
return;
}
}
```
Errors should not be handled at the controller level and should be left to the middleware to handle.

View File

@@ -8,24 +8,25 @@ Creating a new monitor involves a few steps, mentioned below.&#x20;
### General settings
* **URL to monitor:** Enter the full URL of the website or service you want to monitor.
* **Display name:** Optionally, provide a custom name for your monitor. This helps identify it easily in your dashboard.
- **URL to monitor:** Enter the full URL of the website or service you want to monitor.
- **Display name:** Optionally, provide a custom name for your monitor. This helps identify it easily in your dashboard.
### Checks to perform
* **Website monitoring:** This option uses HTTP(s) to monitor your website or API endpoint. You can choose between HTTPS and HTTP protocols.
* **Ping monitoring:** Checks whether your server is available. This option is currently unselected.
- **Website monitoring:** This option uses HTTP(s) to monitor your website or API endpoint. You can choose between HTTPS and HTTP protocols.
- **Ping monitoring:** Checks whether your server is available. This option is currently unselected.
- **Docker monitoring:** Checks whether a Docker container is running
### Incident notifications&#x20;
When there's a new incident, you can choose how to be notified:
* Notify via SMS (coming soon)
* Notify via email (to the email address you logged in with)
* Notify via email to multiple addresses (coming soon)
- Notify via SMS (coming soon)
- Notify via email (to the email address you logged in with)
- Notify via email to multiple addresses (coming soon)
### Advanced settings
* **Check frequency:** Set how often the system should check your monitor. The current setting is 1 minute.
- **Check frequency:** Set how often the system should check your monitor. The current setting is 1 minute.
After configuring all settings, click the "Create monitor" button at the bottom right to set up your new monitor.

View File

@@ -6,10 +6,21 @@ icon: sign-posts-wrench
## Quickstart for users (quick method) <a href="#user-quickstart" id="user-quickstart"></a>
1. Download our [Docker compose file](https://github.com/bluewave-labs/bluewave-uptime/blob/develop/Docker/dist/docker-compose.yaml)
1. Download our [Docker compose file](https://github.com/bluewave-labs/bluewave-uptime/raw/refs/heads/master/Docker/dist/docker-compose.yaml)
2. Run `docker compose up` to start the application
3. Now the application is running at `http://localhost`
##### Optional Config:
- If you want to monitor Docker containers, uncomment this line in `docker-compose.yaml`:
```
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock:ro
```
This gives the app access to your docker daemon via unix socket, please be aware of what you are doing.
---
## Quickstart for users (remote server) <a href="#user-quickstart" id="user-quickstart"></a>
@@ -19,6 +30,17 @@ icon: sign-posts-wrench
3. Run `docker compose up` to start the application
4. Now the application is running at `http://<remote_server_ip>`
##### Optional Config:
- If you want to monitor Docker containers, uncomment this line in `docker-compose.yaml`:
```
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock:ro
```
This gives the app access to your docker daemon via unix socket, please be aware of what you are doing.
---
## Quickstart for developers <a href="#dev-quickstart" id="dev-quickstart"></a>
@@ -74,6 +96,13 @@ Make sure you change the directory to the specified directories, as paths in com
| VITE_APP_LOG_LEVEL | Optional | `string` | Log level | `"none"`\|`"error"` \| `"warn"` \| |
| VITE_APP_DEMO | Optional | `boolean` | Demo server or not | `true`\|`false` \| |
Sample ENV file:
```
VITE_APP_API_BASE_URL="http://localhost:5000/api/v1"
VITE_APP_LOG_LEVEL="debug"
```
#### Starting the Client development server <a href="#start-client" id="start-client"></a>
1. Run `npm run dev` to start the development server.
@@ -90,6 +119,25 @@ Configure the server with the following environmental variables:
<table><thead><tr><th width="239">ENV Variable Name</th><th width="149">Required/Optional</th><th width="116">Type</th><th>Description</th><th>Accepted Values</th></tr></thead><tbody><tr><td>CLIENT_HOST</td><td>Required</td><td><code>string</code></td><td>Frontend Host</td><td></td></tr><tr><td>JWT_SECRET</td><td>Required</td><td><code>string</code></td><td>JWT secret</td><td></td></tr><tr><td>REFRESH_TOKEN_SECRET</td><td>Required</td><td><code>string</code></td><td>Refresh JWT secret</td><td></td></tr><tr><td>DB_TYPE</td><td>Optional</td><td><code>string</code></td><td>Specify DB to use</td><td><code>MongoDB | FakeDB</code></td></tr><tr><td>DB_CONNECTION_STRING</td><td>Required</td><td><code>string</code></td><td>Specifies URL for MongoDB Database</td><td></td></tr><tr><td>PORT</td><td>Optional</td><td><code>integer</code></td><td>Specifies Port for Server</td><td></td></tr><tr><td>LOGIN_PAGE_URL</td><td>Required</td><td><code>string</code></td><td>Login url to be used in emailing service</td><td></td></tr><tr><td>REDIS_HOST</td><td>Required</td><td><code>string</code></td><td>Host address for Redis database</td><td></td></tr><tr><td>REDIS_PORT</td><td>Required</td><td><code>integer</code></td><td>Port for Redis database</td><td></td></tr><tr><td>TOKEN_TTL</td><td>Optional</td><td><code>string</code></td><td>Time for token to live</td><td>In vercel/ms format https://github.com/vercel/ms</td></tr><tr><td>REFRESH_TOKEN_TTL</td><td>Optional</td><td><code>string</code></td><td>Time for refresh token to live</td><td></td></tr><tr><td>PAGESPEED_API_KEY</td><td>Optional</td><td><code>string</code></td><td>API Key for PageSpeed requests</td><td></td></tr><tr><td>SYSTEM_EMAIL_HOST</td><td>Required</td><td><code>string</code></td><td>Host to send System Emails From</td><td></td></tr><tr><td>SYSTEM_EMAIL_PORT</td><td>Required</td><td><code>number</code></td><td>Port for System Email Host</td><td></td></tr><tr><td>SYSTEM_EMAIL_ADDRESS</td><td>Required</td><td><code>string</code></td><td>System Email Address</td><td></td></tr><tr><td>SYSTEM_EMAIL_PASSWORD</td><td>Required</td><td><code>string</code></td><td>System Email Password</td><td></td></tr></tbody></table>
Sample env file
```
CLIENT_HOST="http://localhost:5173"
JWT_SECRET="my_secret"
DB_TYPE="MongoDB"
DB_CONNECTION_STRING="mongodb://localhost:27017/uptime_db"
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
TOKEN_TTL="99d"
PAGESPEED_API_KEY=<api_key>
SYSTEM_EMAIL_HOST="smtp.gmail.com"
SYSTEM_EMAIL_PORT=465
SYSTEM_EMAIL_ADDRESS=<email_address>
SYSTEM_EMAIL_PASSWORD=<password>
REFRESH_TOKEN_SECRET="my_refresh"
REFRESH_TOKEN_TTL="99d"
```
---
#### Databases <a href="#databases" id="databases"></a>

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "bluewave-uptime",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}