chore: merge changes
This commit is contained in:
commit
a088e83ce5
67 files changed
+4033
-728
No files matched your search
@@ -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?"
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -90,6 +90,7 @@ const URL_MAP = {
|
||||
const PATH_MAP = {
|
||||
monitors: "Dashboard",
|
||||
pagespeed: "Dashboard",
|
||||
infrastructure: "Dashboard",
|
||||
account: "Account",
|
||||
settings: "Other",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -548,23 +548,6 @@ const Login = () => {
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
<Box
|
||||
textAlign="center"
|
||||
p={theme.spacing(12)}
|
||||
>
|
||||
<Typography display="inline-block">Don'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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user