Merge branch 'develop' into enhancement/check-if-url-resolves

This commit is contained in:
om-3004
2024-10-18 11:39:07 +05:30
202 changed files with 33101 additions and 32065 deletions

View File

@@ -1,16 +1,16 @@
{
"printWidth": 90,
"useTabs": true,
"tabWidth": 2,
"singleQuote": false,
"bracketSpacing": true,
"proseWrap": "preserve",
"bracketSameLine": false,
"singleAttributePerLine": true,
"semi": true,
"jsx-single-quote": false,
"quoteProps": "as-needed",
"arrowParens": "always",
"trailingComma": "es5",
"htmlWhitespaceSensitivity": "css"
"printWidth": 90,
"useTabs": true,
"tabWidth": 2,
"singleQuote": false,
"bracketSpacing": true,
"proseWrap": "preserve",
"bracketSameLine": false,
"singleAttributePerLine": true,
"semi": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"arrowParens": "always",
"trailingComma": "es5",
"htmlWhitespaceSensitivity": "css"
}

View File

@@ -1,21 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
settings: { react: { version: "18.2" } },
plugins: ["react-refresh"],
rules: {
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
};

View File

@@ -1,14 +1,23 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./bluewave_favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlueWave Uptime</title>
</head>
<head>
<meta charset="UTF-8" />
<link
rel="icon"
href="./bluewave_favicon.ico"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>BlueWave Uptime</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.jsx"
></script>
</body>
</html>

13002
Client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,49 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^5.0.13",
"@mui/icons-material": "^5.15.17",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.16",
"@mui/x-charts": "^7.5.1",
"@mui/x-data-grid": "7.3.2",
"@mui/x-date-pickers": "7.3.2",
"@reduxjs/toolkit": "2.2.5",
"axios": "^1.7.4",
"chart.js": "^4.4.3",
"dayjs": "1.11.11",
"joi": "17.13.1",
"jwt-decode": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "9.1.2",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
"react-toastify": "^10.0.5",
"recharts": "2.13.0-alpha.4",
"redux-persist": "6.0.0",
"vite-plugin-svgr": "^4.2.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"prettier": "^3.3.3",
"vite": "^5.2.0"
}
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^5.0.13",
"@mui/icons-material": "^5.15.17",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.16",
"@mui/x-charts": "^7.5.1",
"@mui/x-data-grid": "7.3.2",
"@mui/x-date-pickers": "7.3.2",
"@reduxjs/toolkit": "2.2.5",
"axios": "^1.7.4",
"chart.js": "^4.4.3",
"dayjs": "1.11.11",
"joi": "17.13.1",
"jwt-decode": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "9.1.2",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
"react-toastify": "^10.0.5",
"recharts": "2.13.0-alpha.4",
"redux-persist": "6.0.0",
"vite-plugin-svgr": "^4.2.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"prettier": "^3.3.3",
"vite": "^5.2.0"
}
}

View File

@@ -40,177 +40,177 @@ import { getAppSettings } from "./Features/Settings/settingsSlice";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
function App() {
const AdminCheckedRegister = withAdminCheck(Register);
const MonitorsWithAdminProp = withAdminProp(Monitors);
const MonitorDetailsWithAdminProp = withAdminProp(Details);
const PageSpeedWithAdminProp = withAdminProp(PageSpeed);
const PageSpeedDetailsWithAdminProp = withAdminProp(PageSpeedDetails);
const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const SettingsWithAdminProp = withAdminProp(Settings);
const AdvancedSettingsWithAdminProp = withAdminProp(AdvancedSettings);
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const AdminCheckedRegister = withAdminCheck(Register);
const MonitorsWithAdminProp = withAdminProp(Monitors);
const MonitorDetailsWithAdminProp = withAdminProp(Details);
const PageSpeedWithAdminProp = withAdminProp(PageSpeed);
const PageSpeedDetailsWithAdminProp = withAdminProp(PageSpeedDetails);
const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const SettingsWithAdminProp = withAdminProp(Settings);
const AdvancedSettingsWithAdminProp = withAdminProp(AdvancedSettings);
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
useEffect(() => {
if (authToken) {
dispatch(getAppSettings({ authToken }));
}
}, [dispatch, authToken]);
useEffect(() => {
if (authToken) {
dispatch(getAppSettings({ authToken }));
}
}, [dispatch, authToken]);
// Cleanup
useEffect(() => {
return () => {
logger.cleanup();
networkService.cleanup();
};
}, []);
// Cleanup
useEffect(() => {
return () => {
logger.cleanup();
networkService.cleanup();
};
}, []);
return (
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<CssBaseline />
<Routes>
<Route
exact
path="/"
element={<HomeLayout />}
>
<Route
exact
path="/"
element={<ProtectedRoute Component={MonitorsWithAdminProp} />}
/>
<Route
path="/monitors"
element={<ProtectedRoute Component={MonitorsWithAdminProp} />}
/>
<Route
path="/monitors/create/:monitorId?"
element={<ProtectedRoute Component={CreateMonitor} />}
/>
<Route
path="/monitors/:monitorId/"
element={<ProtectedRoute Component={MonitorDetailsWithAdminProp} />}
/>
<Route
path="/monitors/configure/:monitorId/"
element={<ProtectedRoute Component={Configure} />}
/>
<Route
path="incidents/:monitorId?"
element={<ProtectedRoute Component={Incidents} />}
/>
return (
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<CssBaseline />
<Routes>
<Route
exact
path="/"
element={<HomeLayout />}
>
<Route
exact
path="/"
element={<ProtectedRoute Component={MonitorsWithAdminProp} />}
/>
<Route
path="/monitors"
element={<ProtectedRoute Component={MonitorsWithAdminProp} />}
/>
<Route
path="/monitors/create/:monitorId?"
element={<ProtectedRoute Component={CreateMonitor} />}
/>
<Route
path="/monitors/:monitorId/"
element={<ProtectedRoute Component={MonitorDetailsWithAdminProp} />}
/>
<Route
path="/monitors/configure/:monitorId/"
element={<ProtectedRoute Component={Configure} />}
/>
<Route
path="incidents/:monitorId?"
element={<ProtectedRoute Component={Incidents} />}
/>
<Route
path="status"
element={<ProtectedRoute Component={Status} />}
/>
<Route
path="integrations"
element={<ProtectedRoute Component={Integrations} />}
/>
<Route
path="maintenance"
element={<ProtectedRoute Component={MaintenanceWithAdminProp} />}
/>
<Route
path="/maintenance/create/:maintenanceWindowId?"
element={<CreateNewMaintenanceWindow />}
/>
<Route
path="settings"
element={<ProtectedRoute Component={SettingsWithAdminProp} />}
/>
<Route
path="advanced-settings"
element={<ProtectedRoute Component={AdvancedSettingsWithAdminProp} />}
/>
<Route
path="account/profile"
element={
<ProtectedRoute
Component={Account}
open="profile"
/>
}
/>
<Route
path="account/password"
element={
<ProtectedRoute
Component={Account}
open="password"
/>
}
/>
<Route
path="account/team"
element={
<ProtectedRoute
Component={Account}
open="team"
/>
}
/>
<Route
path="pagespeed"
element={<ProtectedRoute Component={PageSpeedWithAdminProp} />}
/>
<Route
path="pagespeed/create"
element={<ProtectedRoute Component={CreatePageSpeed} />}
/>
<Route
path="pagespeed/:monitorId"
element={<ProtectedRoute Component={PageSpeedDetailsWithAdminProp} />}
/>
<Route
path="pagespeed/configure/:monitorId"
element={<ProtectedRoute Component={PageSpeedConfigure} />}
/>
</Route>
<Route
path="status"
element={<ProtectedRoute Component={Status} />}
/>
<Route
path="integrations"
element={<ProtectedRoute Component={Integrations} />}
/>
<Route
path="maintenance"
element={<ProtectedRoute Component={MaintenanceWithAdminProp} />}
/>
<Route
path="/maintenance/create/:maintenanceWindowId?"
element={<CreateNewMaintenanceWindow />}
/>
<Route
path="settings"
element={<ProtectedRoute Component={SettingsWithAdminProp} />}
/>
<Route
path="advanced-settings"
element={<ProtectedRoute Component={AdvancedSettingsWithAdminProp} />}
/>
<Route
path="account/profile"
element={
<ProtectedRoute
Component={Account}
open="profile"
/>
}
/>
<Route
path="account/password"
element={
<ProtectedRoute
Component={Account}
open="password"
/>
}
/>
<Route
path="account/team"
element={
<ProtectedRoute
Component={Account}
open="team"
/>
}
/>
<Route
path="pagespeed"
element={<ProtectedRoute Component={PageSpeedWithAdminProp} />}
/>
<Route
path="pagespeed/create"
element={<ProtectedRoute Component={CreatePageSpeed} />}
/>
<Route
path="pagespeed/:monitorId"
element={<ProtectedRoute Component={PageSpeedDetailsWithAdminProp} />}
/>
<Route
path="pagespeed/configure/:monitorId"
element={<ProtectedRoute Component={PageSpeedConfigure} />}
/>
</Route>
<Route
exact
path="/login"
element={<Login />}
/>
<Route
exact
path="/login"
element={<Login />}
/>
<Route
exact
path="/register"
element={<AdminCheckedRegister />}
/>
<Route
exact
path="/register/:token"
element={<Register />}
/>
{/* <Route path="/toast" element={<ToastComponent />} /> */}
<Route
path="*"
element={<NotFound />}
/>
<Route
path="/forgot-password"
element={<ForgotPassword />}
/>
<Route
path="/check-email"
element={<CheckEmail />}
/>
<Route
path="/set-new-password/:token"
element={<SetNewPassword />}
/>
<Route
path="/new-password-confirmed"
element={<NewPasswordConfirmed />}
/>
</Routes>
<ToastContainer />
</ThemeProvider>
);
<Route
exact
path="/register"
element={<AdminCheckedRegister />}
/>
<Route
exact
path="/register/:token"
element={<Register />}
/>
{/* <Route path="/toast" element={<ToastComponent />} /> */}
<Route
path="*"
element={<NotFound />}
/>
<Route
path="/forgot-password"
element={<ForgotPassword />}
/>
<Route
path="/check-email"
element={<CheckEmail />}
/>
<Route
path="/set-new-password/:token"
element={<SetNewPassword />}
/>
<Route
path="/new-password-confirmed"
element={<NewPasswordConfirmed />}
/>
</Routes>
<ToastContainer />
</ThemeProvider>
);
}
export default App;

View File

@@ -1,9 +1,9 @@
.alert {
margin: 0;
width: fit-content;
margin: 0;
width: fit-content;
}
.alert,
.alert button,
.alert .MuiTypography-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}

View File

@@ -13,9 +13,9 @@ import "./index.css";
*/
const icons = {
info: <InfoOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
warning: <WarningAmberOutlinedIcon />,
info: <InfoOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
warning: <WarningAmberOutlinedIcon />,
};
/**
@@ -30,94 +30,92 @@ const icons = {
*/
const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
const theme = useTheme();
const { text, bg, border } = theme.palette[variant];
const icon = icons[variant];
const theme = useTheme();
const { text, bg, border } = theme.palette[variant];
const icon = icons[variant];
return (
<Stack
direction="row"
justifyContent="flex-start"
alignItems={hasIcon ? "" : "center"}
className="alert row-stack"
gap={theme.spacing(8)}
sx={{
padding: hasIcon
? theme.spacing(8)
: `${theme.spacing(4)} ${theme.spacing(8)}`,
backgroundColor: bg,
border: `solid 1px ${border}`,
borderRadius: theme.shape.borderRadius,
}}
>
{hasIcon && <Box sx={{ color: text }}>{icon}</Box>}
<Stack direction="column" gap="2px" sx={{ flex: 1 }}>
{title && (
<Typography sx={{ fontWeight: "700", color: `${text}` }}>
{title}
</Typography>
)}
{body && (
<Typography sx={{ fontWeight: "400", color: `${text}` }}>
{body}
</Typography>
)}
{hasIcon && isToast && (
<Button
variant="text"
color="info"
onClick={onClick}
sx={{
fontWeight: "600",
width: "fit-content",
mt: theme.spacing(4),
padding: 0,
minWidth: 0,
}}
>
Dismiss
</Button>
)}
</Stack>
{isToast && (
<IconButton
onClick={onClick}
sx={{
alignSelf: "flex-start",
ml: "auto",
mr: "-5px",
mt: hasIcon ? "-5px" : 0,
padding: "5px",
"&:focus": {
outline: "none",
},
}}
>
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
)}
</Stack>
);
return (
<Stack
direction="row"
justifyContent="flex-start"
alignItems={hasIcon ? "" : "center"}
className="alert row-stack"
gap={theme.spacing(8)}
sx={{
padding: hasIcon ? theme.spacing(8) : `${theme.spacing(4)} ${theme.spacing(8)}`,
backgroundColor: bg,
border: `solid 1px ${border}`,
borderRadius: theme.shape.borderRadius,
}}
>
{hasIcon && <Box sx={{ color: text }}>{icon}</Box>}
<Stack
direction="column"
gap="2px"
sx={{ flex: 1 }}
>
{title && (
<Typography sx={{ fontWeight: "700", color: `${text}` }}>{title}</Typography>
)}
{body && (
<Typography sx={{ fontWeight: "400", color: `${text}` }}>{body}</Typography>
)}
{hasIcon && isToast && (
<Button
variant="text"
color="info"
onClick={onClick}
sx={{
fontWeight: "600",
width: "fit-content",
mt: theme.spacing(4),
padding: 0,
minWidth: 0,
}}
>
Dismiss
</Button>
)}
</Stack>
{isToast && (
<IconButton
onClick={onClick}
sx={{
alignSelf: "flex-start",
ml: "auto",
mr: "-5px",
mt: hasIcon ? "-5px" : 0,
padding: "5px",
"&:focus": {
outline: "none",
},
}}
>
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
)}
</Stack>
);
};
Alert.propTypes = {
variant: PropTypes.oneOf(["info", "error", "warning"]).isRequired,
title: PropTypes.string,
body: PropTypes.string,
isToast: PropTypes.bool,
hasIcon: PropTypes.bool,
onClick: function (props, propName, componentName) {
if (props.isToast && !props[propName]) {
return new Error(
`Prop '${propName}' is required when 'isToast' is true in '${componentName}'.`
);
}
return null;
},
variant: PropTypes.oneOf(["info", "error", "warning"]).isRequired,
title: PropTypes.string,
body: PropTypes.string,
isToast: PropTypes.bool,
hasIcon: PropTypes.bool,
onClick: function (props, propName, componentName) {
if (props.isToast && !props[propName]) {
return new Error(
`Prop '${propName}' is required when 'isToast' is true in '${componentName}'.`
);
}
return null;
},
};
export default Alert;

View File

@@ -15,48 +15,48 @@ import { Box, Stack } from "@mui/material";
*/
const PulseDot = ({ color }) => {
return (
<Stack
width="26px"
height="24px"
alignItems="center"
justifyContent="center"
>
<Box
minWidth="18px"
minHeight="18px"
sx={{
position: "relative",
backgroundColor: color,
borderRadius: "50%",
"&::before": {
content: `""`,
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "inherit",
borderRadius: "50%",
animation: "ripple 1.8s ease-out infinite",
},
"&::after": {
content: `""`,
position: "absolute",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: "white",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
}}
/>
</Stack>
);
return (
<Stack
width="26px"
height="24px"
alignItems="center"
justifyContent="center"
>
<Box
minWidth="18px"
minHeight="18px"
sx={{
position: "relative",
backgroundColor: color,
borderRadius: "50%",
"&::before": {
content: `""`,
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "inherit",
borderRadius: "50%",
animation: "ripple 1.8s ease-out infinite",
},
"&::after": {
content: `""`,
position: "absolute",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: "white",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
}}
/>
</Stack>
);
};
PulseDot.propTypes = {
color: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
};
export default PulseDot;

View File

@@ -9,19 +9,19 @@ import { useEffect, useState } from "react";
* @returns {string}
*/
const stringToColor = (string) => {
let hash = 0;
let i;
for (i = 0; i < string.length; i += 1) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
}
let hash = 0;
let i;
for (i = 0; i < string.length; i += 1) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (i = 0; i < 3; i += 1) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
}
let color = "#";
for (i = 0; i < 3; i += 1) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
}
return color;
return color;
};
/**
@@ -37,54 +37,52 @@ const stringToColor = (string) => {
*/
const Avatar = ({ src, small, sx }) => {
const { user } = useSelector((state) => state.auth);
const { user } = useSelector((state) => state.auth);
const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 };
const border = small ? 1 : 3;
const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 };
const border = small ? 1 : 3;
const [image, setImage] = useState();
useEffect(() => {
if (user.avatarImage) {
setImage(`data:image/png;base64,${user.avatarImage}`);
}
}, [user?.avatarImage]);
const [image, setImage] = useState();
useEffect(() => {
if (user.avatarImage) {
setImage(`data:image/png;base64,${user.avatarImage}`);
}
}, [user?.avatarImage]);
return (
<MuiAvatar
alt={`${user?.firstName} ${user?.lastName}`}
src={
src ? src : user?.avatarImage ? image : "/static/images/avatar/2.jpg"
}
sx={{
fontSize: small ? "16px" : "22px",
color: "white",
fontWeight: 400,
backgroundColor: stringToColor(`${user?.firstName} ${user?.lastName}`),
display: "inline-flex",
"&::before": {
content: `""`,
position: "absolute",
top: 0,
left: 0,
width: `100%`,
height: `100%`,
border: `${border}px solid rgba(255,255,255,0.2)`,
borderRadius: "50%",
},
...style,
...sx,
}}
>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0)}
</MuiAvatar>
);
return (
<MuiAvatar
alt={`${user?.firstName} ${user?.lastName}`}
src={src ? src : user?.avatarImage ? image : "/static/images/avatar/2.jpg"}
sx={{
fontSize: small ? "16px" : "22px",
color: "white",
fontWeight: 400,
backgroundColor: stringToColor(`${user?.firstName} ${user?.lastName}`),
display: "inline-flex",
"&::before": {
content: `""`,
position: "absolute",
top: 0,
left: 0,
width: `100%`,
height: `100%`,
border: `${border}px solid rgba(255,255,255,0.2)`,
borderRadius: "50%",
},
...style,
...sx,
}}
>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0)}
</MuiAvatar>
);
};
Avatar.propTypes = {
src: PropTypes.string,
small: PropTypes.bool,
sx: PropTypes.object,
src: PropTypes.string,
small: PropTypes.bool,
sx: PropTypes.object,
};
export default Avatar;

View File

@@ -1,133 +1,130 @@
.MuiTable-root .host {
width: fit-content;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: fit-content;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.MuiTable-root .host span {
font-size: 11px;
font-size: 11px;
}
.MuiTable-root .label {
line-height: 1;
border-radius: var(--env-var-radius-2);
padding: 7px;
font-size: var(--env-var-font-size-small-plus);
line-height: 1;
border-radius: var(--env-var-radius-2);
padding: 7px;
font-size: var(--env-var-font-size-small-plus);
}
.MuiPaper-root:has(table.MuiTable-root) {
box-shadow: none;
box-shadow: none;
}
.MuiTable-root
.MuiTableBody-root
.MuiTableRow-root:last-child
.MuiTableCell-root {
border: none;
.MuiTable-root .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root {
border: none;
}
.MuiTable-root .MuiTableHead-root .MuiTableCell-root,
.MuiTable-root .MuiTableBody-root .MuiTableCell-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.MuiTable-root .MuiTableHead-root .MuiTableCell-root {
padding: var(--env-var-spacing-1) var(--env-var-spacing-2);
font-weight: 500;
padding: var(--env-var-spacing-1) var(--env-var-spacing-2);
font-weight: 500;
}
.MuiTable-root .MuiTableHead-root span {
display: inline-block;
height: 17px;
width: 20px;
overflow: hidden;
margin-bottom: -3px;
margin-left: 3px;
display: inline-block;
height: 17px;
width: 20px;
overflow: hidden;
margin-bottom: -3px;
margin-left: 3px;
}
.MuiTable-root .MuiTableHead-root span svg {
width: 20px;
height: 20px;
width: 20px;
height: 20px;
}
.MuiTable-root .MuiTableBody-root .MuiTableCell-root {
padding: 6px var(--env-var-spacing-2);
padding: 6px var(--env-var-spacing-2);
}
.MuiTable-root .MuiTableBody-root .MuiTableRow-root {
height: 50px;
height: 50px;
}
.MuiPaper-root + .MuiPagination-root {
border-radius: var(--env-var-radius-1);
padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2);
border-radius: var(--env-var-radius-1);
padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2);
}
.MuiPaper-root + .MuiPagination-root ul {
justify-content: center;
justify-content: center;
}
.MuiPaper-root + .MuiPagination-root button {
font-size: var(--env-var-font-size-medium);
font-weight: 500;
font-size: var(--env-var-font-size-medium);
font-weight: 500;
}
.MuiPaper-root + .MuiPagination-root ul li:first-child {
margin-right: auto;
margin-right: auto;
}
.MuiPaper-root + .MuiPagination-root ul li:last-child {
margin-left: auto;
margin-left: auto;
}
.MuiPaper-root + .MuiPagination-root ul li:first-child button {
padding: 0 var(--env-var-spacing-1) 0 var(--env-var-spacing-1-plus);
padding: 0 var(--env-var-spacing-1) 0 var(--env-var-spacing-1-plus);
}
.MuiPaper-root + .MuiPagination-root ul li:last-child button {
padding: 0 var(--env-var-spacing-1-plus) 0 var(--env-var-spacing-1);
padding: 0 var(--env-var-spacing-1-plus) 0 var(--env-var-spacing-1);
}
.MuiPaper-root + .MuiPagination-root ul li:first-child button::after,
.MuiPaper-root + .MuiPagination-root ul li:last-child button::before {
position: relative;
display: inline-block;
position: relative;
display: inline-block;
}
.MuiPaper-root + .MuiPagination-root ul li:first-child button::after {
content: "Previous";
margin-left: 15px;
content: "Previous";
margin-left: 15px;
}
.MuiPaper-root + .MuiPagination-root ul li:last-child button::before {
content: "Next";
margin-right: 15px;
content: "Next";
margin-right: 15px;
}
.MuiPaper-root + .MuiPagination-root div.MuiPaginationItem-root {
user-select: none;
user-select: none;
}
.MuiTablePagination-root p {
font-weight: 500;
font-size: var(--env-var-font-size-small-plus);
font-weight: 500;
font-size: var(--env-var-font-size-small-plus);
}
.MuiTablePagination-root .MuiTablePagination-select.MuiSelect-select {
text-align: left;
text-align-last: left;
text-align: left;
text-align-last: left;
}
.MuiTablePagination-root button {
min-width: 0;
padding: 4px;
margin-left: 5px;
min-width: 0;
padding: 4px;
margin-left: 5px;
}
.MuiTablePagination-root svg {
width: 22px;
height: 22px;
width: 22px;
height: 22px;
}
.MuiTablePagination-root .MuiSelect-icon {
width: 16px;
height: 16px;
top: 50%;
right: 8%;
transform: translateY(-50%);
width: 16px;
height: 16px;
top: 50%;
right: 8%;
transform: translateY(-50%);
}
.MuiTablePagination-root button.Mui-disabled {
opacity: 0.4;
opacity: 0.4;
}
.table-container .MuiTable-root .MuiTableHead-root .MuiTableCell-root {
text-transform: uppercase;
opacity: 0.8;
font-size: var(--env-var-font-size-small-plus);
font-weight: 400;
text-transform: uppercase;
opacity: 0.8;
font-size: var(--env-var-font-size-small-plus);
font-weight: 400;
}
.monitors .MuiTableCell-root:not(:first-of-type):not(:last-of-type),
.monitors .MuiTableCell-root:not(:first-of-type):not(:last-of-type) {
padding-left: var(--env-var-spacing-1);
padding-right: var(--env-var-spacing-1);
padding-left: var(--env-var-spacing-1);
padding-right: var(--env-var-spacing-1);
}

View File

@@ -2,18 +2,18 @@ import PropTypes from "prop-types";
import { useState, useEffect } from "react";
import { useTheme } from "@emotion/react";
import {
TableContainer,
Paper,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
TablePagination,
Box,
Typography,
Stack,
Button,
TableContainer,
Paper,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
TablePagination,
Box,
Typography,
Stack,
Button,
} from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { setRowsPerPage } from "../../Features/UI/uiSlice";
@@ -36,64 +36,64 @@ import "./index.css";
* @returns {JSX.Element} Pagination actions component.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
};
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
/**
@@ -150,172 +150,173 @@ TablePaginationActions.propTypes = {
*/
const BasicTable = ({ data, paginated, reversed, table }) => {
const DEFAULT_ROWS_PER_PAGE = 5;
const theme = useTheme();
const dispatch = useDispatch();
const uiState = useSelector((state) => state.ui);
let rowsPerPage = uiState?.[table]?.rowsPerPage ?? DEFAULT_ROWS_PER_PAGE;
const [page, setPage] = useState(0);
const DEFAULT_ROWS_PER_PAGE = 5;
const theme = useTheme();
const dispatch = useDispatch();
const uiState = useSelector((state) => state.ui);
let rowsPerPage = uiState?.[table]?.rowsPerPage ?? DEFAULT_ROWS_PER_PAGE;
const [page, setPage] = useState(0);
useEffect(() => {
setPage(0);
}, [data]);
useEffect(() => {
setPage(0);
}, [data]);
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: table,
})
);
setPage(0);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: table,
})
);
setPage(0);
};
let displayData = [];
let displayData = [];
if (data && data.rows) {
let rows = reversed ? [...data.rows].reverse() : data.rows;
displayData = paginated
? rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
: rows;
}
if (data && data.rows) {
let rows = reversed ? [...data.rows].reverse() : data.rows;
displayData = paginated
? rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
: rows;
}
if (!data || !data.cols || !data.rows) {
return <div>No data</div>;
}
if (!data || !data.cols || !data.rows) {
return <div>No data</div>;
}
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, data.rows.length);
return `${start} - ${end}`;
};
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, data.rows.length);
return `${start} - ${end}`;
};
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
{data.cols.map((col) => (
<TableCell key={col.id}>{col.name}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{displayData.map((row) => {
return (
<TableRow
sx={{
cursor: row.handleClick ? "pointer" : "default",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
key={row.id}
onClick={row.handleClick ? row.handleClick : null}
>
{row.data.map((cell) => {
return <TableCell key={cell.id}>{cell.data}</TableCell>;
})}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginated && (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
sx={{
"& p": {
color: theme.palette.text.tertiary,
},
}}
>
<Typography px={theme.spacing(2)} fontSize={12} sx={{ opacity: 0.7 }}>
Showing {getRange()} of {data.rows.length} monitor(s)
</Typography>
<TablePagination
component="div"
count={data.rows.length}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(
0,
Math.ceil(count / rowsPerPage)
)}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
)}
</>
);
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
{data.cols.map((col) => (
<TableCell key={col.id}>{col.name}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{displayData.map((row) => {
return (
<TableRow
sx={{
cursor: row.handleClick ? "pointer" : "default",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
key={row.id}
onClick={row.handleClick ? row.handleClick : null}
>
{row.data.map((cell) => {
return <TableCell key={cell.id}>{cell.data}</TableCell>;
})}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginated && (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
sx={{
"& p": {
color: theme.palette.text.tertiary,
},
}}
>
<Typography
px={theme.spacing(2)}
fontSize={12}
sx={{ opacity: 0.7 }}
>
Showing {getRange()} of {data.rows.length} monitor(s)
</Typography>
<TablePagination
component="div"
count={data.rows.length}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
)}
</>
);
};
BasicTable.propTypes = {
data: PropTypes.object.isRequired,
paginated: PropTypes.bool,
reversed: PropTypes.bool,
rowPerPage: PropTypes.number,
table: PropTypes.string,
data: PropTypes.object.isRequired,
paginated: PropTypes.bool,
reversed: PropTypes.bool,
rowPerPage: PropTypes.number,
table: PropTypes.string,
};
export default BasicTable;

View File

@@ -1,22 +1,22 @@
.MuiBreadcrumbs-root {
height: 34px;
height: 34px;
}
.MuiBreadcrumbs-root svg {
width: 16px;
height: 16px;
width: 16px;
height: 16px;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li a {
font-size: var(--env-var-font-size-medium);
font-weight: 400;
font-size: var(--env-var-font-size-medium);
font-weight: 400;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:not(:last-child) {
cursor: pointer;
cursor: pointer;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:last-child a {
font-weight: 500;
opacity: 1;
cursor: default;
font-weight: 500;
opacity: 1;
cursor: default;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-separator {
margin: 0;
margin: 0;
}

View File

@@ -18,59 +18,59 @@ import "./index.css";
*/
const Breadcrumbs = ({ list }) => {
const theme = useTheme();
const navigate = useNavigate();
const theme = useTheme();
const navigate = useNavigate();
return (
<MUIBreadcrumbs
separator={<ArrowRight />}
aria-label="breadcrumb"
px={theme.spacing(2)}
py={theme.spacing(3.5)}
width="fit-content"
backgroundColor={theme.palette.background.fill}
borderRadius={theme.shape.borderRadius}
lineHeight="18px"
sx={{
"& .MuiBreadcrumbs-li:not(:last-of-type):hover a": {
backgroundColor: theme.palette.other.fill,
opacity: 1,
},
}}
>
{list.map((item, index) => {
return (
<Box
component="a"
key={`${item.name}-${index}`}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.shape.borderRadius}
onClick={() => navigate(item.path)}
sx={{
opacity: 0.8,
textTransform: "capitalize",
"&, &:hover": {
color: theme.palette.text.tertiary,
},
}}
>
{item.name}
</Box>
);
})}
</MUIBreadcrumbs>
);
return (
<MUIBreadcrumbs
separator={<ArrowRight />}
aria-label="breadcrumb"
px={theme.spacing(2)}
py={theme.spacing(3.5)}
width="fit-content"
backgroundColor={theme.palette.background.fill}
borderRadius={theme.shape.borderRadius}
lineHeight="18px"
sx={{
"& .MuiBreadcrumbs-li:not(:last-of-type):hover a": {
backgroundColor: theme.palette.other.fill,
opacity: 1,
},
}}
>
{list.map((item, index) => {
return (
<Box
component="a"
key={`${item.name}-${index}`}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.shape.borderRadius}
onClick={() => navigate(item.path)}
sx={{
opacity: 0.8,
textTransform: "capitalize",
"&, &:hover": {
color: theme.palette.text.tertiary,
},
}}
>
{item.name}
</Box>
);
})}
</MUIBreadcrumbs>
);
};
Breadcrumbs.propTypes = {
list: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}).isRequired
).isRequired,
list: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}).isRequired
).isRequired,
};
export default Breadcrumbs;

View File

@@ -6,164 +6,166 @@ import "./index.css";
import { useSelector } from "react-redux";
const BarChart = ({ checks = [] }) => {
const theme = useTheme();
const [animate, setAnimate] = useState(false);
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const [animate, setAnimate] = useState(false);
const uiTimezone = useSelector((state) => state.ui.timezone);
useEffect(() => {
setAnimate(true);
});
useEffect(() => {
setAnimate(true);
});
// set responseTime to average if there's only one check
if (checks.length === 1) {
checks[0] = { ...checks[0], responseTime: 50 };
}
// set responseTime to average if there's only one check
if (checks.length === 1) {
checks[0] = { ...checks[0], responseTime: 50 };
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill("placeholder");
checks = [...checks, ...placeholders];
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill("placeholder");
checks = [...checks, ...placeholders];
}
return (
<Stack
direction="row"
flexWrap="nowrap"
gap={theme.spacing(1.5)}
height="50px"
width="fit-content"
onClick={(event) => event.stopPropagation()}
sx={{
cursor: "default",
}}
>
{checks.map((check, index) =>
check === "placeholder" ? (
<Box
key={`${check}-${index}`}
position="relative"
width={theme.spacing(4.5)}
height="100%"
backgroundColor={theme.palette.background.fill}
sx={{
borderRadius: theme.spacing(1.5),
}}
/>
) : (
<Tooltip
title={
<>
<Typography>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
<Box mt={theme.spacing(2)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={
check.status
? theme.palette.success.main
: theme.palette.error.text
}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(2)}
gap={theme.spacing(12)}
>
<Typography component="span" sx={{ opacity: 0.8 }}>
Response Time
</Typography>
<Typography component="span">
{check.originalResponseTime}
<Typography component="span" sx={{ opacity: 0.8 }}>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
</>
}
placement="top"
key={`check-${check?._id}`}
slotProps={{
popper: {
className: "bar-tooltip",
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
sx: {
"& .MuiTooltip-tooltip": {
backgroundColor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
px: theme.spacing(4),
py: theme.spacing(2),
},
"& .MuiTooltip-tooltip p": {
fontSize: 12,
color: theme.palette.text.tertiary,
fontWeight: 500,
},
"& .MuiTooltip-tooltip span": {
fontSize: 11,
color: theme.palette.text.tertiary,
fontWeight: 600,
},
},
},
}}
>
<Box
position="relative"
width="9px"
height="100%"
backgroundColor={
check.status ? theme.palette.success.bg : theme.palette.error.bg
}
sx={{
borderRadius: theme.spacing(1.5),
"&:hover > .MuiBox-root": {
filter: "brightness(0.8)",
},
}}
>
<Box
position="absolute"
bottom={0}
width="100%"
height={`${animate ? check.responseTime : 0}%`}
backgroundColor={
check.status
? theme.palette.success.main
: theme.palette.error.text
}
sx={{
borderRadius: theme.spacing(1.5),
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
</Tooltip>
)
)}
</Stack>
);
return (
<Stack
direction="row"
flexWrap="nowrap"
gap={theme.spacing(1.5)}
height="50px"
width="fit-content"
onClick={(event) => event.stopPropagation()}
sx={{
cursor: "default",
}}
>
{checks.map((check, index) =>
check === "placeholder" ? (
<Box
key={`${check}-${index}`}
position="relative"
width={theme.spacing(4.5)}
height="100%"
backgroundColor={theme.palette.background.fill}
sx={{
borderRadius: theme.spacing(1.5),
}}
/>
) : (
<Tooltip
title={
<>
<Typography>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
<Box mt={theme.spacing(2)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={
check.status ? theme.palette.success.main : theme.palette.error.text
}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(2)}
gap={theme.spacing(12)}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
Response Time
</Typography>
<Typography component="span">
{check.originalResponseTime}
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
</>
}
placement="top"
key={`check-${check?._id}`}
slotProps={{
popper: {
className: "bar-tooltip",
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
sx: {
"& .MuiTooltip-tooltip": {
backgroundColor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
px: theme.spacing(4),
py: theme.spacing(2),
},
"& .MuiTooltip-tooltip p": {
fontSize: 12,
color: theme.palette.text.tertiary,
fontWeight: 500,
},
"& .MuiTooltip-tooltip span": {
fontSize: 11,
color: theme.palette.text.tertiary,
fontWeight: 600,
},
},
},
}}
>
<Box
position="relative"
width="9px"
height="100%"
backgroundColor={
check.status ? theme.palette.success.bg : theme.palette.error.bg
}
sx={{
borderRadius: theme.spacing(1.5),
"&:hover > .MuiBox-root": {
filter: "brightness(0.8)",
},
}}
>
<Box
position="absolute"
bottom={0}
width="100%"
height={`${animate ? check.responseTime : 0}%`}
backgroundColor={
check.status ? theme.palette.success.main : theme.palette.error.text
}
sx={{
borderRadius: theme.spacing(1.5),
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
</Tooltip>
)
)}
</Stack>
);
};
export default BarChart;

View File

@@ -1,12 +1,12 @@
import PropTypes from "prop-types";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
@@ -16,181 +16,197 @@ import { formatDateWithTz } from "../../../Utils/timeUtils";
import "./index.css";
const CustomToolTip = ({ active, payload, label }) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
if (active && payload && payload.length) {
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={theme.palette.primary.main}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography component="span" sx={{ opacity: 0.8 }}>
Response Time
</Typography>{" "}
<Typography component="span">
{payload[0].payload.originalResponseTime}
<Typography component="span" sx={{ opacity: 0.8 }}>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
}
return null;
const theme = useTheme();
if (active && payload && payload.length) {
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={theme.palette.primary.main}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
Response Time
</Typography>{" "}
<Typography component="span">
{payload[0].payload.originalResponseTime}
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
}
return null;
};
const CustomTick = ({ x, y, payload, index }) => {
const theme = useTheme();
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
const uiTimezone = useSelector((state) => state.ui.timezone);
// Render nothing for the first tick
if (index === 0) return null;
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>
);
// Render nothing for the first tick
if (index === 0) return null;
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>
);
};
CustomTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
};
const MonitorDetailsAreaChart = ({ checks }) => {
const theme = useTheme();
const memoizedChecks = useMemo(() => checks, [checks[0]]);
const [isHovered, setIsHovered] = useState(false);
const theme = useTheme();
const memoizedChecks = useMemo(() => checks, [checks[0]]);
const [isHovered, setIsHovered] = useState(false);
return (
<ResponsiveContainer width="100%" minWidth={25} height={220}>
<AreaChart
width="100%"
height="100%"
data={memoizedChecks}
margin={{
top: 10,
right: 0,
left: 0,
bottom: 0,
}}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={theme.palette.primary.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.primary.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
stroke={theme.palette.border.dark}
dataKey="createdAt"
tick={<CustomTick />}
minTickGap={0}
axisLine={false}
tickLine={false}
height={20}
interval="equidistantPreserveStart"
/>
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={<CustomToolTip />}
wrapperStyle={{ pointerEvents: "none" }}
/>
<Area
type="monotone"
dataKey="responseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
strokeWidth={isHovered ? 2.5 : 1.5}
activeDot={{ stroke: theme.palette.background.main, r: 5 }}
/>
</AreaChart>
</ResponsiveContainer>
);
return (
<ResponsiveContainer
width="100%"
minWidth={25}
height={220}
>
<AreaChart
width="100%"
height="100%"
data={memoizedChecks}
margin={{
top: 10,
right: 0,
left: 0,
bottom: 0,
}}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient
id="colorUv"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.primary.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.primary.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
stroke={theme.palette.border.dark}
dataKey="createdAt"
tick={<CustomTick />}
minTickGap={0}
axisLine={false}
tickLine={false}
height={20}
interval="equidistantPreserveStart"
/>
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={<CustomToolTip />}
wrapperStyle={{ pointerEvents: "none" }}
/>
<Area
type="monotone"
dataKey="responseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
strokeWidth={isHovered ? 2.5 : 1.5}
activeDot={{ stroke: theme.palette.background.main, r: 5 }}
/>
</AreaChart>
</ResponsiveContainer>
);
};
MonitorDetailsAreaChart.propTypes = {
checks: PropTypes.array,
checks: PropTypes.array,
};
CustomToolTip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.arrayOf(
PropTypes.shape({
payload: PropTypes.shape({
originalResponseTime: PropTypes.number.isRequired,
}).isRequired,
})
),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
active: PropTypes.bool,
payload: PropTypes.arrayOf(
PropTypes.shape({
payload: PropTypes.shape({
originalResponseTime: PropTypes.number.isRequired,
}).isRequired,
})
),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default MonitorDetailsAreaChart;

View File

@@ -21,50 +21,49 @@ import { useTheme } from "@emotion/react";
* @returns {React.Element} The `Check` component with a check icon and a label, defined by the `text` prop.
*/
const Check = ({ text, variant = "info", outlined = false }) => {
const theme = useTheme();
const colors = {
success: theme.palette.success.main,
error: theme.palette.error.text,
info: theme.palette.info.border,
};
const theme = useTheme();
const colors = {
success: theme.palette.success.main,
error: theme.palette.error.text,
info: theme.palette.info.border,
};
return (
<Stack
direction="row"
className="check"
gap={outlined ? theme.spacing(6) : theme.spacing(4)}
alignItems="center"
>
{outlined ? (
<CheckOutlined alt="check" />
) : (
<Box
lineHeight={0}
sx={{
"& svg > path": { fill: colors[variant] },
}}
>
<CheckGrey alt="form checks" />
</Box>
)}
<Typography
component="span"
sx={{
color:
variant === "info" ? theme.palette.text.tertiary : colors[variant],
opacity: 0.8,
}}
>
{text}
</Typography>
</Stack>
);
return (
<Stack
direction="row"
className="check"
gap={outlined ? theme.spacing(6) : theme.spacing(4)}
alignItems="center"
>
{outlined ? (
<CheckOutlined alt="check" />
) : (
<Box
lineHeight={0}
sx={{
"& svg > path": { fill: colors[variant] },
}}
>
<CheckGrey alt="form checks" />
</Box>
)}
<Typography
component="span"
sx={{
color: variant === "info" ? theme.palette.text.tertiary : colors[variant],
opacity: 0.8,
}}
>
{text}
</Typography>
</Stack>
);
};
Check.propTypes = {
text: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
variant: PropTypes.oneOf(["info", "error", "success"]),
outlined: PropTypes.bool,
text: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
variant: PropTypes.oneOf(["info", "error", "success"]),
outlined: PropTypes.bool,
};
export default Check;

View File

@@ -1 +0,0 @@

View File

@@ -1,80 +1,103 @@
import LoadingButton from "@mui/lab/LoadingButton";
import { Button, Modal, Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
const Dialog = ({ modelTitle, modelDescription,
open, onClose, title, confirmationBtnLbl, confirmationBtnOnClick, cancelBtnLbl, cancelBtnOnClick, theme, isLoading, description }) => {
return <Modal
aria-labelledby={modelTitle}
aria-describedby={modelDescription}
open={open}
onClose={onClose} >
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography id={modelTitle} component="h2"
fontSize={16}
color={theme.palette.text.primary}
fontWeight={600}>
{title}
</Typography>
{description && <Typography id={modelDescription} color={theme.palette.text.tertiary}>
{description}
</Typography>}
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={cancelBtnOnClick}
>
{cancelBtnLbl}
</Button>
<LoadingButton
variant="contained"
color="error"
loading= {isLoading}
onClick={confirmationBtnOnClick}
>
{confirmationBtnLbl}
</LoadingButton>
</Stack>
</Stack>
</Modal>
}
const Dialog = ({
modelTitle,
modelDescription,
open,
onClose,
title,
confirmationBtnLbl,
confirmationBtnOnClick,
cancelBtnLbl,
cancelBtnOnClick,
theme,
isLoading,
description,
}) => {
return (
<Modal
aria-labelledby={modelTitle}
aria-describedby={modelDescription}
open={open}
onClose={onClose}
>
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id={modelTitle}
component="h2"
fontSize={16}
color={theme.palette.text.primary}
fontWeight={600}
>
{title}
</Typography>
{description && (
<Typography
id={modelDescription}
color={theme.palette.text.tertiary}
>
{description}
</Typography>
)}
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={cancelBtnOnClick}
>
{cancelBtnLbl}
</Button>
<LoadingButton
variant="contained"
color="error"
loading={isLoading}
onClick={confirmationBtnOnClick}
>
{confirmationBtnLbl}
</LoadingButton>
</Stack>
</Stack>
</Modal>
);
};
Dialog.propTypes = {
modelTitle: PropTypes.string.isRequired,
modelDescription: PropTypes.string.isRequired,
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
confirmationBtnLbl: PropTypes.string.isRequired,
confirmationBtnOnClick: PropTypes.func.isRequired,
cancelBtnLbl: PropTypes.string.isRequired,
cancelBtnOnClick: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
isLoading: PropTypes.bool.isRequired,
description: PropTypes.string
}
modelTitle: PropTypes.string.isRequired,
modelDescription: PropTypes.string.isRequired,
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
confirmationBtnLbl: PropTypes.string.isRequired,
confirmationBtnOnClick: PropTypes.func.isRequired,
cancelBtnLbl: PropTypes.string.isRequired,
cancelBtnOnClick: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
isLoading: PropTypes.bool.isRequired,
description: PropTypes.string,
};
export default Dialog
export default Dialog;

View File

@@ -1,24 +1,24 @@
[class*="fallback__"] {
width: fit-content;
margin: auto;
margin-top: 100px;
width: fit-content;
margin: auto;
margin-top: 100px;
}
[class*="fallback__"] h1.MuiTypography-root {
font-size: var(--env-var-font-size-large);
font-weight: 600;
font-size: var(--env-var-font-size-large);
font-weight: 600;
}
[class*="fallback__"] button.MuiButtonBase-root,
[class*="fallback__"] .check {
width: max-content;
width: max-content;
}
[class*="fallback__"] button.MuiButtonBase-root {
height: 34px;
height: 34px;
}
[class*="fallback__"] .check span.MuiTypography-root,
[class*="fallback__"] button.MuiButtonBase-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.fallback__status > .MuiStack-root {
margin-left: var(--env-var-spacing-2);
margin-left: var(--env-var-spacing-2);
}

View File

@@ -21,67 +21,71 @@ import "./index.css";
*/
const Fallback = ({ title, checks, link = "/", isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const mode = useSelector((state) => state.ui.mode);
const theme = useTheme();
const navigate = useNavigate();
const mode = useSelector((state) => state.ui.mode);
return (
<Stack
className={`fallback__${title.trim().split(" ")[0]}`}
alignItems="center"
gap={theme.spacing(20)}
>
{mode === "light" ? (
<Skeleton style={{ zIndex: 1 }} />
) : (
<SkeletonDark style={{ zIndex: 1 }} />
)}
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack gap={theme.spacing(4)} maxWidth={"275px"} zIndex={1}>
<Typography
component="h1"
marginY={theme.spacing(4)}
color={theme.palette.text.tertiary}
>
A {title} is used to:
</Typography>
{checks.map((check, index) => (
<Check
text={check}
key={`${title.trim().split(" ")[0]}-${index}`}
outlined={true}
/>
))}
</Stack>
{/* TODO - display a different fallback if user is not an admin*/}
{isAdmin && (
<Button
variant="contained"
color="primary"
sx={{ alignSelf: "center" }}
onClick={() => navigate(link)}
>
Let's create your {title}
</Button>
)}
</Stack>
);
return (
<Stack
className={`fallback__${title.trim().split(" ")[0]}`}
alignItems="center"
gap={theme.spacing(20)}
>
{mode === "light" ? (
<Skeleton style={{ zIndex: 1 }} />
) : (
<SkeletonDark style={{ zIndex: 1 }} />
)}
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
gap={theme.spacing(4)}
maxWidth={"275px"}
zIndex={1}
>
<Typography
component="h1"
marginY={theme.spacing(4)}
color={theme.palette.text.tertiary}
>
A {title} is used to:
</Typography>
{checks.map((check, index) => (
<Check
text={check}
key={`${title.trim().split(" ")[0]}-${index}`}
outlined={true}
/>
))}
</Stack>
{/* TODO - display a different fallback if user is not an admin*/}
{isAdmin && (
<Button
variant="contained"
color="primary"
sx={{ alignSelf: "center" }}
onClick={() => navigate(link)}
>
Let's create your {title}
</Button>
)}
</Stack>
);
};
Fallback.propTypes = {
title: PropTypes.string.isRequired,
checks: PropTypes.arrayOf(PropTypes.string).isRequired,
link: PropTypes.string,
isAdmin: PropTypes.bool,
title: PropTypes.string.isRequired,
checks: PropTypes.arrayOf(PropTypes.string).isRequired,
link: PropTypes.string,
isAdmin: PropTypes.bool,
};
export default Fallback;

View File

@@ -29,68 +29,68 @@ import "./index.css";
*/
const Checkbox = ({
id,
label,
size = "medium",
isChecked,
value,
onChange,
isDisabled,
id,
label,
size = "medium",
isChecked,
value,
onChange,
isDisabled,
}) => {
const sizes = { small: "14px", medium: "16px", large: "18px" };
const theme = useTheme();
const sizes = { small: "14px", medium: "16px", large: "18px" };
const theme = useTheme();
return (
<FormControlLabel
className="checkbox-wrapper"
control={
<MuiCheckbox
checked={isDisabled ? false : isChecked}
value={value}
onChange={onChange}
icon={<CheckboxOutline />}
checkedIcon={<CheckboxFilled />}
inputProps={{
"aria-label": "controlled checkbox",
id: id,
}}
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: sizes[size], height: sizes[size] },
}}
/>
}
label={label}
disabled={isDisabled}
sx={{
borderRadius: theme.shape.borderRadius,
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
"& .MuiButtonBase-root": {
width: theme.spacing(10),
p: 0,
mr: theme.spacing(6),
},
"&:not(:has(.Mui-disabled)):hover": {
backgroundColor: theme.palette.background.accent,
},
"& span.MuiTypography-root": {
fontSize: 13,
color: theme.palette.text.tertiary,
},
}}
/>
);
return (
<FormControlLabel
className="checkbox-wrapper"
control={
<MuiCheckbox
checked={isDisabled ? false : isChecked}
value={value}
onChange={onChange}
icon={<CheckboxOutline />}
checkedIcon={<CheckboxFilled />}
inputProps={{
"aria-label": "controlled checkbox",
id: id,
}}
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: sizes[size], height: sizes[size] },
}}
/>
}
label={label}
disabled={isDisabled}
sx={{
borderRadius: theme.shape.borderRadius,
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
"& .MuiButtonBase-root": {
width: theme.spacing(10),
p: 0,
mr: theme.spacing(6),
},
"&:not(:has(.Mui-disabled)):hover": {
backgroundColor: theme.palette.background.accent,
},
"& span.MuiTypography-root": {
fontSize: 13,
color: theme.palette.text.tertiary,
},
}}
/>
);
};
Checkbox.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
size: PropTypes.oneOf(["small", "medium", "large"]),
isChecked: PropTypes.bool.isRequired,
value: PropTypes.string,
onChange: PropTypes.func,
isDisabled: PropTypes.bool,
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
size: PropTypes.oneOf(["small", "medium", "large"]),
isChecked: PropTypes.bool.isRequired,
value: PropTypes.string,
onChange: PropTypes.func,
isDisabled: PropTypes.bool,
};
export default Checkbox;

View File

@@ -1,31 +1,31 @@
.field {
min-width: 250px;
min-width: 250px;
}
.field h3.MuiTypography-root,
.field h5.MuiTypography-root,
.field input,
.field textarea,
.field .input-error {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.field h5.MuiTypography-root {
position: relative;
opacity: 0.8;
padding-right: var(--env-var-spacing-1-minus);
position: relative;
opacity: 0.8;
padding-right: var(--env-var-spacing-1-minus);
}
.field .MuiInputBase-root:has(input) {
height: 34px;
height: 34px;
}
.field .MuiInputBase-root:has(.MuiInputAdornment-root) {
padding-right: var(--env-var-spacing-1-minus);
padding-right: var(--env-var-spacing-1-minus);
}
.field input {
height: 100%;
padding: 0 var(--env-var-spacing-1-minus);
height: 100%;
padding: 0 var(--env-var-spacing-1-minus);
}
.field .MuiInputBase-root:has(textarea) {
padding: var(--env-var-spacing-1-minus);
padding: var(--env-var-spacing-1-minus);
}
.register-page .field .MuiOutlinedInput-root fieldset,
@@ -36,5 +36,5 @@
.forgot-password-page .field input,
.set-new-password-page .field .MuiOutlinedInput-root fieldset,
.set-new-password-page .field input {
border-radius: var(--env-var-radius-2);
border-radius: var(--env-var-radius-2);
}

View File

@@ -1,13 +1,7 @@
import PropTypes from "prop-types";
import { forwardRef, useState } from "react";
import { useTheme } from "@emotion/react";
import {
IconButton,
InputAdornment,
Stack,
TextField,
Typography,
} from "@mui/material";
import { IconButton, InputAdornment, Stack, TextField, Typography } from "@mui/material";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
import "./index.css";
@@ -33,208 +27,199 @@ import "./index.css";
*/
const Field = forwardRef(
(
{
type = "text",
id,
name,
label,
https,
isRequired,
isOptional,
optionalLabel,
autoComplete,
placeholder,
value,
onChange,
onInput,
error,
disabled,
hidden,
},
ref
) => {
const theme = useTheme();
(
{
type = "text",
id,
name,
label,
https,
isRequired,
isOptional,
optionalLabel,
autoComplete,
placeholder,
value,
onChange,
onInput,
error,
disabled,
hidden,
},
ref
) => {
const theme = useTheme();
const [isVisible, setVisible] = useState(false);
const [isVisible, setVisible] = useState(false);
return (
<Stack
gap={theme.spacing(2)}
className={`field field-${type}`}
sx={{
"& fieldset": {
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.input-error)) .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
borderColor: theme.palette.border.dark,
},
"&:has(.input-error) .MuiOutlinedInput-root fieldset": {
borderColor: theme.palette.error.text,
},
display: hidden ? "none" : "",
}}
>
{label && (
<Typography
component="h3"
color={theme.palette.text.secondary}
fontWeight={500}
>
{label}
{isRequired ? (
<Typography
component="span"
ml={theme.spacing(1)}
color={theme.palette.error.text}
>
*
</Typography>
) : (
""
)}
{isOptional ? (
<Typography
component="span"
fontSize="inherit"
fontWeight={400}
ml={theme.spacing(2)}
sx={{ opacity: 0.6 }}
>
{optionalLabel || "(optional)"}
</Typography>
) : (
""
)}
</Typography>
)}
<TextField
type={type === "password" ? (isVisible ? "text" : type) : type}
name={name}
id={id}
autoComplete={autoComplete}
placeholder={placeholder}
multiline={type === "description"}
rows={type === "description" ? 4 : 1}
value={value}
onInput={onInput}
onChange={onChange}
disabled={disabled}
inputRef={ref}
inputProps={{
sx: {
color: theme.palette.text.secondary,
"&:-webkit-autofill": {
WebkitBoxShadow: `0 0 0 100px ${theme.palette.other.autofill} inset`,
WebkitTextFillColor: theme.palette.text.secondary,
borderRadius: "0 5px 5px 0",
},
},
}}
sx={
type === "url"
? {
"& .MuiInputBase-root": { padding: 0 },
"& .MuiStack-root": {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
}
: {
}
}
InputProps={{
startAdornment: type === "url" && (
<Stack
direction="row"
alignItems="center"
height="100%"
sx={{
borderRight: `solid 1px ${theme.palette.border.dark}`,
backgroundColor: theme.palette.background.accent,
pl: theme.spacing(6),
}}
>
<Typography
component="h5"
color={theme.palette.text.secondary}
sx={{ lineHeight: 1 }}
>
{https ? "https" : "http"}://
</Typography>
</Stack>
),
endAdornment: type === "password" && (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setVisible((show) => !show)}
tabIndex={-1}
sx={{
color: theme.palette.border.dark,
padding: theme.spacing(1),
"&:focus": {
outline: "none",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
>
{!isVisible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.text}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
);
}
return (
<Stack
gap={theme.spacing(2)}
className={`field field-${type}`}
sx={{
"& fieldset": {
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.input-error)) .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
borderColor: theme.palette.border.dark,
},
"&:has(.input-error) .MuiOutlinedInput-root fieldset": {
borderColor: theme.palette.error.text,
},
display: hidden ? "none" : "",
}}
>
{label && (
<Typography
component="h3"
color={theme.palette.text.secondary}
fontWeight={500}
>
{label}
{isRequired ? (
<Typography
component="span"
ml={theme.spacing(1)}
color={theme.palette.error.text}
>
*
</Typography>
) : (
""
)}
{isOptional ? (
<Typography
component="span"
fontSize="inherit"
fontWeight={400}
ml={theme.spacing(2)}
sx={{ opacity: 0.6 }}
>
{optionalLabel || "(optional)"}
</Typography>
) : (
""
)}
</Typography>
)}
<TextField
type={type === "password" ? (isVisible ? "text" : type) : type}
name={name}
id={id}
autoComplete={autoComplete}
placeholder={placeholder}
multiline={type === "description"}
rows={type === "description" ? 4 : 1}
value={value}
onInput={onInput}
onChange={onChange}
disabled={disabled}
inputRef={ref}
inputProps={{
sx: {
color: theme.palette.text.secondary,
"&:-webkit-autofill": {
WebkitBoxShadow: `0 0 0 100px ${theme.palette.other.autofill} inset`,
WebkitTextFillColor: theme.palette.text.secondary,
borderRadius: "0 5px 5px 0",
},
},
}}
sx={
type === "url"
? {
"& .MuiInputBase-root": { padding: 0 },
"& .MuiStack-root": {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
}
: {}
}
InputProps={{
startAdornment: type === "url" && (
<Stack
direction="row"
alignItems="center"
height="100%"
sx={{
borderRight: `solid 1px ${theme.palette.border.dark}`,
backgroundColor: theme.palette.background.accent,
pl: theme.spacing(6),
}}
>
<Typography
component="h5"
color={theme.palette.text.secondary}
sx={{ lineHeight: 1 }}
>
{https ? "https" : "http"}://
</Typography>
</Stack>
),
endAdornment: type === "password" && (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setVisible((show) => !show)}
tabIndex={-1}
sx={{
color: theme.palette.border.dark,
padding: theme.spacing(1),
"&:focus": {
outline: "none",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
>
{!isVisible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.text}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
);
}
);
Field.displayName = "Field";
Field.propTypes = {
type: PropTypes.oneOf([
"text",
"password",
"url",
"email",
"description",
"number",
]),
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.string,
https: PropTypes.bool,
isRequired: PropTypes.bool,
isOptional: PropTypes.bool,
optionalLabel: PropTypes.string,
autoComplete: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.string.isRequired,
onChange: PropTypes.func,
onInput: PropTypes.func,
error: PropTypes.string,
disabled: PropTypes.bool,
hidden: PropTypes.bool,
type: PropTypes.oneOf(["text", "password", "url", "email", "description", "number"]),
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.string,
https: PropTypes.bool,
isRequired: PropTypes.bool,
isOptional: PropTypes.bool,
optionalLabel: PropTypes.string,
autoComplete: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.string.isRequired,
onChange: PropTypes.func,
onInput: PropTypes.func,
error: PropTypes.string,
disabled: PropTypes.bool,
hidden: PropTypes.bool,
};
export default Field;

View File

@@ -1,18 +1,18 @@
.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
font-weight: 600;
font-weight: 600;
}
.image-field-wrapper h2.MuiTypography-root,
.MuiStack-root:has(#modal-update-picture) button,
.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.image-field-wrapper h2.MuiTypography-root {
margin-top: 10px;
margin-top: 10px;
}
.image-field-wrapper + p.MuiTypography-root {
margin-top: 8px;
margin-top: 8px;
}
.image-field-wrapper + p.MuiTypography-root,
.image-field-wrapper p.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
font-size: var(--env-var-font-size-small-plus);
}

View File

@@ -15,133 +15,139 @@ import { checkImage } from "../../../Utils/fileUtils";
*/
const ImageField = ({ id, src, loading, onChange }) => {
const theme = useTheme();
const theme = useTheme();
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = () => {
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = () => {
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
return (
<>
{!checkImage(src) || loading ? (
<>
<Box
className="image-field-wrapper"
mt={theme.spacing(8)}
sx={{
position: "relative",
height: "fit-content",
border: "dashed",
borderRadius: theme.shape.borderRadius,
borderColor: isDragging
? theme.palette.primary.main
: theme.palette.border.light,
borderWidth: "2px",
transition: "0.2s",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDragLeave}
>
<TextField
id={id}
type="file"
onChange={onChange}
sx={{
width: "100%",
"& .MuiInputBase-input[type='file']": {
opacity: 0,
cursor: "pointer",
maxWidth: "500px",
minHeight: "175px",
},
"& fieldset": {
padding: 0,
border: "none",
},
}}
/>
<Stack
className="custom-file-text"
alignItems="center"
gap="4px"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "-1",
width: "100%",
}}
>
<IconButton
sx={{
pointerEvents: "none",
borderRadius: theme.shape.borderRadius,
border: `solid ${theme.shape.borderThick}px ${theme.palette.border.light}`,
boxShadow: theme.shape.boxShadow,
}}
>
<CloudUploadIcon />
</IconButton>
<Typography component="h2" color={theme.palette.text.tertiary}>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.primary.main}
fontWeight={500}
>
Click to upload
</Typography>{" "}
or drag and drop
</Typography>
<Typography
component="p"
color={theme.palette.text.tertiary}
sx={{ opacity: 0.6 }}
>
(maximum size: 3MB)
</Typography>
</Stack>
</Box>
<Typography
component="p"
color={theme.palette.text.tertiary}
sx={{ opacity: 0.6 }}
>
Supported formats: JPG, PNG
</Typography>
</>
) : (
<Stack direction="row" justifyContent="center">
<Box
sx={{
width: "250px",
height: "250px",
borderRadius: "50%",
overflow: "hidden",
backgroundImage: `url(${src})`,
backgroundSize: "cover",
}}
></Box>
</Stack>
)}
</>
);
return (
<>
{!checkImage(src) || loading ? (
<>
<Box
className="image-field-wrapper"
mt={theme.spacing(8)}
sx={{
position: "relative",
height: "fit-content",
border: "dashed",
borderRadius: theme.shape.borderRadius,
borderColor: isDragging
? theme.palette.primary.main
: theme.palette.border.light,
borderWidth: "2px",
transition: "0.2s",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDragLeave}
>
<TextField
id={id}
type="file"
onChange={onChange}
sx={{
width: "100%",
"& .MuiInputBase-input[type='file']": {
opacity: 0,
cursor: "pointer",
maxWidth: "500px",
minHeight: "175px",
},
"& fieldset": {
padding: 0,
border: "none",
},
}}
/>
<Stack
className="custom-file-text"
alignItems="center"
gap="4px"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "-1",
width: "100%",
}}
>
<IconButton
sx={{
pointerEvents: "none",
borderRadius: theme.shape.borderRadius,
border: `solid ${theme.shape.borderThick}px ${theme.palette.border.light}`,
boxShadow: theme.shape.boxShadow,
}}
>
<CloudUploadIcon />
</IconButton>
<Typography
component="h2"
color={theme.palette.text.tertiary}
>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.primary.main}
fontWeight={500}
>
Click to upload
</Typography>{" "}
or drag and drop
</Typography>
<Typography
component="p"
color={theme.palette.text.tertiary}
sx={{ opacity: 0.6 }}
>
(maximum size: 3MB)
</Typography>
</Stack>
</Box>
<Typography
component="p"
color={theme.palette.text.tertiary}
sx={{ opacity: 0.6 }}
>
Supported formats: JPG, PNG
</Typography>
</>
) : (
<Stack
direction="row"
justifyContent="center"
>
<Box
sx={{
width: "250px",
height: "250px",
borderRadius: "50%",
overflow: "hidden",
backgroundImage: `url(${src})`,
backgroundSize: "cover",
}}
></Box>
</Stack>
)}
</>
);
};
ImageField.propTypes = {
id: PropTypes.string.isRequired,
src: PropTypes.string,
onChange: PropTypes.func.isRequired,
id: PropTypes.string.isRequired,
src: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
export default ImageField;

View File

@@ -25,62 +25,62 @@ import "./index.css";
*/
const Radio = (props) => {
const theme = useTheme();
const theme = useTheme();
return (
<FormControlLabel
className="custom-radio-button"
checked={props.checked}
value={props.value}
control={
<MUIRadio
id={props.id}
size={props.size}
checkedIcon={<RadioChecked />}
sx={{
color: "transparent",
width: 16,
height: 16,
boxShadow: "inset 0 0 0 1px #656a74",
mt: theme.spacing(0.5),
}}
/>
}
onChange={props.onChange}
label={
<>
<Typography component="p">{props.title}</Typography>
<Typography
component="h6"
mt={theme.spacing(1)}
color={theme.palette.text.secondary}
>
{props.desc}
</Typography>
</>
}
labelPlacement="end"
sx={{
alignItems: "flex-start",
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
/>
);
return (
<FormControlLabel
className="custom-radio-button"
checked={props.checked}
value={props.value}
control={
<MUIRadio
id={props.id}
size={props.size}
checkedIcon={<RadioChecked />}
sx={{
color: "transparent",
width: 16,
height: 16,
boxShadow: "inset 0 0 0 1px #656a74",
mt: theme.spacing(0.5),
}}
/>
}
onChange={props.onChange}
label={
<>
<Typography component="p">{props.title}</Typography>
<Typography
component="h6"
mt={theme.spacing(1)}
color={theme.palette.text.secondary}
>
{props.desc}
</Typography>
</>
}
labelPlacement="end"
sx={{
alignItems: "flex-start",
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
/>
);
};
Radio.propTypes = {
title: PropTypes.string.isRequired,
desc: PropTypes.string,
size: PropTypes.string,
title: PropTypes.string.isRequired,
desc: PropTypes.string,
size: PropTypes.string,
};
export default Radio;

View File

@@ -1,12 +1,5 @@
import PropTypes from "prop-types";
import {
Box,
ListItem,
Autocomplete,
TextField,
Stack,
Typography,
} from "@mui/material";
import { Box, ListItem, Autocomplete, TextField, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import SearchIcon from "../../../assets/icons/search.svg?react";
@@ -24,172 +17,171 @@ import SearchIcon from "../../../assets/icons/search.svg?react";
*/
const SearchAdornment = () => {
const theme = useTheme();
return (
<Box
mr={theme.spacing(4)}
height={16}
sx={{
"& svg": {
width: 16,
height: 16,
"& path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.2,
},
},
}}
>
<SearchIcon />
</Box>
);
const theme = useTheme();
return (
<Box
mr={theme.spacing(4)}
height={16}
sx={{
"& svg": {
width: 16,
height: 16,
"& path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.2,
},
},
}}
>
<SearchIcon />
</Box>
);
};
const Search = ({
id,
options,
filteredBy,
secondaryLabel,
value,
inputValue,
handleInputChange,
handleChange,
sx,
multiple = false,
isAdorned = true,
error,
disabled,
id,
options,
filteredBy,
secondaryLabel,
value,
inputValue,
handleInputChange,
handleChange,
sx,
multiple = false,
isAdorned = true,
error,
disabled,
}) => {
const theme = useTheme();
const theme = useTheme();
return (
<Autocomplete
multiple={multiple}
id={id}
value={value}
inputValue={inputValue}
onInputChange={(_, newValue) => {
handleInputChange(newValue);
}}
onChange={(_, newValue) => {
handleChange && handleChange(newValue);
}}
fullWidth
freeSolo
disabled={disabled}
disableClearable
options={options}
getOptionLabel={(option) => option[filteredBy]}
renderInput={(params) => (
<Stack>
<TextField
{...params}
error={Boolean(error)}
placeholder="Type to search"
InputProps={{
...params.InputProps,
...(isAdorned && { startAdornment: <SearchAdornment /> }),
}}
sx={{
"& fieldset": {
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
"& .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
borderColor: theme.palette.border.light,
},
}}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.text}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
)}
filterOptions={(options, { inputValue }) => {
const filtered = options.filter((option) =>
option[filteredBy].toLowerCase().includes(inputValue.toLowerCase())
);
return (
<Autocomplete
multiple={multiple}
id={id}
value={value}
inputValue={inputValue}
onInputChange={(_, newValue) => {
handleInputChange(newValue);
}}
onChange={(_, newValue) => {
handleChange && handleChange(newValue);
}}
fullWidth
freeSolo
disabled={disabled}
disableClearable
options={options}
getOptionLabel={(option) => option[filteredBy]}
renderInput={(params) => (
<Stack>
<TextField
{...params}
error={Boolean(error)}
placeholder="Type to search"
InputProps={{
...params.InputProps,
...(isAdorned && { startAdornment: <SearchAdornment /> }),
}}
sx={{
"& fieldset": {
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
"& .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
borderColor: theme.palette.border.light,
},
}}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.text}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
)}
filterOptions={(options, { inputValue }) => {
const filtered = options.filter((option) =>
option[filteredBy].toLowerCase().includes(inputValue.toLowerCase())
);
if (filtered.length === 0) {
return [{ [filteredBy]: "No monitors found", noOptions: true }];
}
return filtered;
}}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
sx={
option.noOptions
? {
pointerEvents: "none",
backgroundColor: theme.palette.background.main,
}
: {}
}
>
{option[filteredBy] +
(secondaryLabel ? ` (${option[secondaryLabel]})` : "")}
</ListItem>
);
}}
slotProps={{
popper: {
keepMounted: true,
sx: {
"& ul": { p: 2 },
"& li.MuiAutocomplete-option": {
color: theme.palette.text.secondary,
px: 4,
borderRadius: theme.shape.borderRadius,
},
"& .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true'], & .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true'].Mui-focused, & .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true']:hover":
{
backgroundColor: theme.palette.background.fill,
},
"& .MuiAutocomplete-noOptions": {
px: theme.spacing(6),
py: theme.spacing(5),
},
},
},
}}
sx={{
height: 34,
"&.MuiAutocomplete-root .MuiAutocomplete-input": { p: 0 },
...sx,
}}
/>
);
if (filtered.length === 0) {
return [{ [filteredBy]: "No monitors found", noOptions: true }];
}
return filtered;
}}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
sx={
option.noOptions
? {
pointerEvents: "none",
backgroundColor: theme.palette.background.main,
}
: {}
}
>
{option[filteredBy] + (secondaryLabel ? ` (${option[secondaryLabel]})` : "")}
</ListItem>
);
}}
slotProps={{
popper: {
keepMounted: true,
sx: {
"& ul": { p: 2 },
"& li.MuiAutocomplete-option": {
color: theme.palette.text.secondary,
px: 4,
borderRadius: theme.shape.borderRadius,
},
"& .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true'], & .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true'].Mui-focused, & .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true']:hover":
{
backgroundColor: theme.palette.background.fill,
},
"& .MuiAutocomplete-noOptions": {
px: theme.spacing(6),
py: theme.spacing(5),
},
},
},
}}
sx={{
height: 34,
"&.MuiAutocomplete-root .MuiAutocomplete-input": { p: 0 },
...sx,
}}
/>
);
};
Search.propTypes = {
id: PropTypes.string,
multiple: PropTypes.bool,
options: PropTypes.array.isRequired,
filteredBy: PropTypes.string.isRequired,
secondaryLabel: PropTypes.string,
value: PropTypes.array,
inputValue: PropTypes.string.isRequired,
handleInputChange: PropTypes.func.isRequired,
handleChange: PropTypes.func,
isAdorned: PropTypes.bool,
sx: PropTypes.object,
error: PropTypes.string,
disabled: PropTypes.bool,
id: PropTypes.string,
multiple: PropTypes.bool,
options: PropTypes.array.isRequired,
filteredBy: PropTypes.string.isRequired,
secondaryLabel: PropTypes.string,
value: PropTypes.array,
inputValue: PropTypes.string.isRequired,
handleInputChange: PropTypes.func.isRequired,
handleChange: PropTypes.func,
isAdorned: PropTypes.bool,
sx: PropTypes.object,
error: PropTypes.string,
disabled: PropTypes.bool,
};
export default Search;

View File

@@ -1,7 +1,7 @@
.select-wrapper .select-component > .MuiSelect-select {
padding: 0 10px;
height: 34px;
display: flex;
align-items: center;
line-height: 1;
padding: 0 10px;
height: 34px;
display: flex;
align-items: center;
line-height: 1;
}

View File

@@ -1,11 +1,6 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import {
MenuItem,
Select as MuiSelect,
Stack,
Typography,
} from "@mui/material";
import { MenuItem, Select as MuiSelect, Stack, Typography } from "@mui/material";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import "./index.css";
@@ -44,105 +39,108 @@ import "./index.css";
*/
const Select = ({
id,
label,
placeholder,
isHidden,
value,
items,
onChange,
sx,
name = "",
id,
label,
placeholder,
isHidden,
value,
items,
onChange,
sx,
name = "",
}) => {
const theme = useTheme();
const itemStyles = {
fontSize: "var(--env-var-font-size-medium)",
color: theme.palette.text.tertiary,
borderRadius: theme.shape.borderRadius,
margin: theme.spacing(2),
};
const theme = useTheme();
const itemStyles = {
fontSize: "var(--env-var-font-size-medium)",
color: theme.palette.text.tertiary,
borderRadius: theme.shape.borderRadius,
margin: theme.spacing(2),
};
return (
<Stack gap={theme.spacing(2)} className="select-wrapper">
{label && (
<Typography
component="h3"
color={theme.palette.text.secondary}
fontWeight={500}
fontSize={13}
>
{label}
</Typography>
)}
<MuiSelect
className="select-component"
value={value}
onChange={onChange}
displayEmpty
name={name}
inputProps={{ id: id }}
IconComponent={KeyboardArrowDownIcon}
MenuProps={{ disableScrollLock: true }}
sx={{
fontSize: 13,
minWidth: "125px",
"& fieldset": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.border.dark,
},
"&:not(.Mui-focused):hover fieldset": {
borderColor: theme.palette.border.dark,
},
"& svg path": {
fill: theme.palette.other.icon,
},
...sx,
}}
>
{placeholder && (
<MenuItem
className="select-placeholder"
value="0"
sx={{
display: isHidden ? "none" : "flex",
visibility: isHidden ? "none" : "visible",
...itemStyles,
}}
>
{placeholder}
</MenuItem>
)}
{items.map((item) => (
<MenuItem
value={item._id}
key={`${id}-${item._id}`}
sx={{
...itemStyles,
}}
>
{item.name}
</MenuItem>
))}
</MuiSelect>
</Stack>
);
return (
<Stack
gap={theme.spacing(2)}
className="select-wrapper"
>
{label && (
<Typography
component="h3"
color={theme.palette.text.secondary}
fontWeight={500}
fontSize={13}
>
{label}
</Typography>
)}
<MuiSelect
className="select-component"
value={value}
onChange={onChange}
displayEmpty
name={name}
inputProps={{ id: id }}
IconComponent={KeyboardArrowDownIcon}
MenuProps={{ disableScrollLock: true }}
sx={{
fontSize: 13,
minWidth: "125px",
"& fieldset": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.border.dark,
},
"&:not(.Mui-focused):hover fieldset": {
borderColor: theme.palette.border.dark,
},
"& svg path": {
fill: theme.palette.other.icon,
},
...sx,
}}
>
{placeholder && (
<MenuItem
className="select-placeholder"
value="0"
sx={{
display: isHidden ? "none" : "flex",
visibility: isHidden ? "none" : "visible",
...itemStyles,
}}
>
{placeholder}
</MenuItem>
)}
{items.map((item) => (
<MenuItem
value={item._id}
key={`${id}-${item._id}`}
sx={{
...itemStyles,
}}
>
{item.name}
</MenuItem>
))}
</MuiSelect>
</Stack>
);
};
Select.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
isHidden: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
onChange: PropTypes.func.isRequired,
sx: PropTypes.object,
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
isHidden: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
onChange: PropTypes.func.isRequired,
sx: PropTypes.object,
};
export default Select;

View File

@@ -1,7 +1,7 @@
.label {
border: 1px solid #000;
display: inline-flex;
justify-content: center;
align-items: center;
line-height: normal;
border: 1px solid #000;
display: inline-flex;
justify-content: center;
align-items: center;
line-height: normal;
}

View File

@@ -20,56 +20,56 @@ import "./index.css";
*/
const BaseLabel = ({ label, styles, children }) => {
const theme = useTheme();
// Grab the default borderRadius from the theme to match button style
const { borderRadius } = theme.shape;
// Calculate padding for the label to mimic button. Appears to scale correctly, not 100% sure though.
const padding = theme.spacing(1 * 0.75, 2);
const theme = useTheme();
// Grab the default borderRadius from the theme to match button style
const { borderRadius } = theme.shape;
// Calculate padding for the label to mimic button. Appears to scale correctly, not 100% sure though.
const padding = theme.spacing(1 * 0.75, 2);
return (
<Box
className="label"
sx={{
borderRadius: borderRadius,
borderColor: theme.palette.text.tertiary,
color: theme.palette.text.tertiary,
padding: padding,
...styles,
}}
>
{children}
{label}
</Box>
);
return (
<Box
className="label"
sx={{
borderRadius: borderRadius,
borderColor: theme.palette.text.tertiary,
color: theme.palette.text.tertiary,
padding: padding,
...styles,
}}
>
{children}
{label}
</Box>
);
};
BaseLabel.propTypes = {
label: PropTypes.string.isRequired,
styles: PropTypes.shape({
color: PropTypes.string,
backgroundColor: PropTypes.string,
}),
children: PropTypes.node,
label: PropTypes.string.isRequired,
styles: PropTypes.shape({
color: PropTypes.string,
backgroundColor: PropTypes.string,
}),
children: PropTypes.node,
};
// Produces a lighter color based on a hex color and a percent
// lightenColor("#067647", 20) will produce a color 20% lighter than #067647
const lightenColor = (color, percent) => {
let r = parseInt(color.substring(1, 3), 16);
let g = parseInt(color.substring(3, 5), 16);
let b = parseInt(color.substring(5, 7), 16);
let r = parseInt(color.substring(1, 3), 16);
let g = parseInt(color.substring(3, 5), 16);
let b = parseInt(color.substring(5, 7), 16);
const amt = Math.round((255 * percent) / 100);
const amt = Math.round((255 * percent) / 100);
r = r + amt <= 255 ? r + amt : 255;
g = g + amt <= 255 ? g + amt : 255;
b = b + amt <= 255 ? b + amt : 255;
r = r + amt <= 255 ? r + amt : 255;
g = g + amt <= 255 ? g + amt : 255;
b = b + amt <= 255 ? b + amt : 255;
r = r.toString(16).padStart(2, "0");
g = g.toString(16).padStart(2, "0");
b = b.toString(16).padStart(2, "0");
r = r.toString(16).padStart(2, "0");
g = g.toString(16).padStart(2, "0");
b = b.toString(16).padStart(2, "0");
return `#${r}${g}${b}`;
return `#${r}${g}${b}`;
};
/**
@@ -84,34 +84,31 @@ const lightenColor = (color, percent) => {
*/
const ColoredLabel = ({ label, color }) => {
const theme = useTheme();
// If an invalid color is passed, default to the labelGray color
if (
typeof color !== "string" ||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)
) {
color = theme.palette.border.light;
}
const theme = useTheme();
// If an invalid color is passed, default to the labelGray color
if (typeof color !== "string" || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) {
color = theme.palette.border.light;
}
// Calculate lighter shades for border and bg
const borderColor = lightenColor(color, 20);
const bgColor = lightenColor(color, 75);
// Calculate lighter shades for border and bg
const borderColor = lightenColor(color, 20);
const bgColor = lightenColor(color, 75);
return (
<BaseLabel
label={label}
styles={{
color: color,
borderColor: borderColor,
backgroundColor: bgColor,
}}
></BaseLabel>
);
return (
<BaseLabel
label={label}
styles={{
color: color,
borderColor: borderColor,
backgroundColor: bgColor,
}}
></BaseLabel>
);
};
ColoredLabel.propTypes = {
label: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
};
/**
@@ -126,69 +123,63 @@ ColoredLabel.propTypes = {
*/
const StatusLabel = ({ status, text, customStyles }) => {
const theme = useTheme();
const colors = {
up: {
dotColor: theme.palette.success.main,
bgColor: theme.palette.success.bg,
borderColor: theme.palette.success.light,
},
down: {
dotColor: theme.palette.error.text,
bgColor: theme.palette.error.bg,
borderColor: theme.palette.error.light,
},
paused: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
borderColor: theme.palette.warning.light,
},
pending: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
borderColor: theme.palette.warning.light,
},
"cannot resolve": {
dotColor: theme.palette.unresolved.main,
bgColor: theme.palette.unresolved.bg,
borderColor: theme.palette.unresolved.light,
},
};
const theme = useTheme();
const colors = {
up: {
dotColor: theme.palette.success.main,
bgColor: theme.palette.success.bg,
borderColor: theme.palette.success.light,
},
down: {
dotColor: theme.palette.error.text,
bgColor: theme.palette.error.bg,
borderColor: theme.palette.error.light,
},
paused: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
borderColor: theme.palette.warning.light,
},
pending: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
borderColor: theme.palette.warning.light,
},
"cannot resolve": {
dotColor: theme.palette.unresolved.main,
bgColor: theme.palette.unresolved.bg,
borderColor: theme.palette.unresolved.light,
},
};
// Look up the color for the status
const { borderColor, bgColor, dotColor } = colors[status];
// Look up the color for the status
const { borderColor, bgColor, dotColor } = colors[status];
return (
<BaseLabel
label={text}
styles={{
color: dotColor,
backgroundColor: bgColor,
borderColor: borderColor,
...customStyles,
}}
>
<Box
width={7}
height={7}
bgcolor={dotColor}
borderRadius="50%"
marginRight="5px"
/>
</BaseLabel>
);
return (
<BaseLabel
label={text}
styles={{
color: dotColor,
backgroundColor: bgColor,
borderColor: borderColor,
...customStyles,
}}
>
<Box
width={7}
height={7}
bgcolor={dotColor}
borderRadius="50%"
marginRight="5px"
/>
</BaseLabel>
);
};
StatusLabel.propTypes = {
status: PropTypes.oneOf([
"up",
"down",
"paused",
"pending",
"cannot resolve",
]),
text: PropTypes.string,
customStyles: PropTypes.object,
status: PropTypes.oneOf(["up", "down", "paused", "pending", "cannot resolve"]),
text: PropTypes.string,
customStyles: PropTypes.object,
};
export { ColoredLabel, StatusLabel };

View File

@@ -11,52 +11,52 @@ import PropTypes from "prop-types";
*/
const Link = ({ level, label, url }) => {
const theme = useTheme();
const theme = useTheme();
const levelConfig = {
primary: {},
secondary: {
color: theme.palette.text.secondary,
sx: {
":hover": {
color: theme.palette.text.secondary,
},
},
},
tertiary: {
color: theme.palette.text.tertiary,
sx: {
textDecoration: "underline",
textDecorationStyle: "dashed",
textDecorationColor: theme.palette.primary.main,
textUnderlineOffset: "1px",
":hover": {
color: theme.palette.text.tertiary,
textDecorationColor: theme.palette.primary.main,
backgroundColor: theme.palette.background.fill,
},
},
},
error: {},
};
const { sx, color } = levelConfig[level];
return (
<MuiLink
href={url}
sx={{ width: "fit-content", ...sx }}
color={color}
target="_blank"
rel="noreferrer"
>
{label}
</MuiLink>
);
const levelConfig = {
primary: {},
secondary: {
color: theme.palette.text.secondary,
sx: {
":hover": {
color: theme.palette.text.secondary,
},
},
},
tertiary: {
color: theme.palette.text.tertiary,
sx: {
textDecoration: "underline",
textDecorationStyle: "dashed",
textDecorationColor: theme.palette.primary.main,
textUnderlineOffset: "1px",
":hover": {
color: theme.palette.text.tertiary,
textDecorationColor: theme.palette.primary.main,
backgroundColor: theme.palette.background.fill,
},
},
},
error: {},
};
const { sx, color } = levelConfig[level];
return (
<MuiLink
href={url}
sx={{ width: "fit-content", ...sx }}
color={color}
target="_blank"
rel="noreferrer"
>
{label}
</MuiLink>
);
};
Link.propTypes = {
url: PropTypes.string.isRequired,
level: PropTypes.oneOf(["primary", "secondary", "tertiary", "error"]),
label: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
level: PropTypes.oneOf(["primary", "secondary", "tertiary", "error"]),
label: PropTypes.string.isRequired,
};
export default Link;

View File

@@ -1,10 +1,10 @@
.progress-bar-container h2.MuiTypography-root,
.progress-bar-container p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.progress-bar-container p.MuiTypography-root:has(span) {
font-size: 12px;
font-size: 12px;
}
.progress-bar-container p.MuiTypography-root span {
padding-left: 2px;
padding-left: 2px;
}

View File

@@ -1,13 +1,7 @@
import React from "react";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import {
Box,
IconButton,
LinearProgress,
Stack,
Typography,
} from "@mui/material";
import { Box, IconButton, LinearProgress, Stack, Typography } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import "./index.css";
@@ -23,161 +17,163 @@ import "./index.css";
* @returns {JSX.Element} The rendered component.
*/
const ProgressUpload = ({
icon,
label,
size,
progress = 0,
onClick,
error,
}) => {
const theme = useTheme();
const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) => {
const theme = useTheme();
return (
<Box
className="progress-bar-container"
mt={theme.spacing(10)}
p={theme.spacing(8)}
sx={{
minWidth: "200px",
height: "fit-content",
borderRadius: theme.shape.borderRadius,
border: 1,
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.fill,
"&:has(.input-error)": {
borderColor: theme.palette.error.main,
backgroundColor: theme.palette.error.bg,
py: theme.spacing(4),
px: theme.spacing(8),
"& > .MuiStack-root > svg": {
fill: theme.palette.error.text,
width: "20px",
height: "20px",
},
},
}}
>
<Stack
direction="row"
mb={error ? 0 : theme.spacing(5)}
gap={theme.spacing(5)}
alignItems={error ? "center" : "flex-start"}
>
{error ? (
<ErrorOutlineOutlinedIcon />
) : icon ? (
<Box
sx={{
position: "relative",
height: 30,
minWidth: 30,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: 2,
backgroundColor: theme.palette.background.main,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 23,
height: 23,
"& path": {
fill: theme.palette.other.icon,
},
},
}}
>
{icon}
</Box>
) : (
""
)}
{error ? (
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{error}
</Typography>
) : (
<Box color={theme.palette.text.tertiary}>
<Typography component="h2" mb={theme.spacing(1.5)}>
{error ? error : label}
</Typography>
<Typography component="p" sx={{ opacity: 0.6 }}>
{!error && size}
</Typography>
</Box>
)}
<IconButton
onClick={onClick}
sx={
!error
? {
alignSelf: "flex-start",
ml: "auto",
mr: theme.spacing(-2.5),
mt: theme.spacing(-2.5),
padding: theme.spacing(2.5),
"&:focus": {
outline: "none",
},
}
: {
ml: "auto",
"&:focus": {
outline: "none",
},
}
}
>
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
</Stack>
{!error ? (
<Stack direction="row" alignItems="center">
<Box sx={{ width: "100%", mr: theme.spacing(5) }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
width: "100%",
height: "10px",
borderRadius: theme.shape.borderRadius,
maxWidth: "500px",
backgroundColor: theme.palette.border.light,
}}
/>
</Box>
<Typography
component="p"
sx={{ minWidth: "max-content", opacity: 0.6 }}
>
{progress}
<span>%</span>
</Typography>
</Stack>
) : (
""
)}
</Box>
);
return (
<Box
className="progress-bar-container"
mt={theme.spacing(10)}
p={theme.spacing(8)}
sx={{
minWidth: "200px",
height: "fit-content",
borderRadius: theme.shape.borderRadius,
border: 1,
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.fill,
"&:has(.input-error)": {
borderColor: theme.palette.error.main,
backgroundColor: theme.palette.error.bg,
py: theme.spacing(4),
px: theme.spacing(8),
"& > .MuiStack-root > svg": {
fill: theme.palette.error.text,
width: "20px",
height: "20px",
},
},
}}
>
<Stack
direction="row"
mb={error ? 0 : theme.spacing(5)}
gap={theme.spacing(5)}
alignItems={error ? "center" : "flex-start"}
>
{error ? (
<ErrorOutlineOutlinedIcon />
) : icon ? (
<Box
sx={{
position: "relative",
height: 30,
minWidth: 30,
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: 2,
backgroundColor: theme.palette.background.main,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 23,
height: 23,
"& path": {
fill: theme.palette.other.icon,
},
},
}}
>
{icon}
</Box>
) : (
""
)}
{error ? (
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{error}
</Typography>
) : (
<Box color={theme.palette.text.tertiary}>
<Typography
component="h2"
mb={theme.spacing(1.5)}
>
{error ? error : label}
</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
{!error && size}
</Typography>
</Box>
)}
<IconButton
onClick={onClick}
sx={
!error
? {
alignSelf: "flex-start",
ml: "auto",
mr: theme.spacing(-2.5),
mt: theme.spacing(-2.5),
padding: theme.spacing(2.5),
"&:focus": {
outline: "none",
},
}
: {
ml: "auto",
"&:focus": {
outline: "none",
},
}
}
>
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
</Stack>
{!error ? (
<Stack
direction="row"
alignItems="center"
>
<Box sx={{ width: "100%", mr: theme.spacing(5) }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
width: "100%",
height: "10px",
borderRadius: theme.shape.borderRadius,
maxWidth: "500px",
backgroundColor: theme.palette.border.light,
}}
/>
</Box>
<Typography
component="p"
sx={{ minWidth: "max-content", opacity: 0.6 }}
>
{progress}
<span>%</span>
</Typography>
</Stack>
) : (
""
)}
</Box>
);
};
ProgressUpload.propTypes = {
icon: PropTypes.element, // JSX element for the icon (optional)
label: PropTypes.string.isRequired, // Label text for the progress item
size: PropTypes.string.isRequired, // Size information for the progress item
progress: PropTypes.number.isRequired, // Current progress value (0-100)
onClick: PropTypes.func.isRequired, // Function to handle click events on the remove button
error: PropTypes.string, // Error message to display if there's an error (optional)
icon: PropTypes.element, // JSX element for the icon (optional)
label: PropTypes.string.isRequired, // Label text for the progress item
size: PropTypes.string.isRequired, // Size information for the progress item
progress: PropTypes.number.isRequired, // Current progress value (0-100)
onClick: PropTypes.func.isRequired, // Function to handle click events on the remove button
error: PropTypes.string, // Error message to display if there's an error (optional)
};
export default ProgressUpload;

View File

@@ -5,17 +5,17 @@ import CheckCircle from "@mui/icons-material/CheckCircle";
import PropTypes from "prop-types";
const CustomStepIcon = (props) => {
const { completed, active } = props;
return completed ? (
<CheckCircle color="primary" />
) : (
<RadioButtonCheckedIcon color={active ? "primary" : "disabled"} />
);
const { completed, active } = props;
return completed ? (
<CheckCircle color="primary" />
) : (
<RadioButtonCheckedIcon color={active ? "primary" : "disabled"} />
);
};
CustomStepIcon.propTypes = {
completed: PropTypes.bool.isRequired,
active: PropTypes.bool.isRequired,
completed: PropTypes.bool.isRequired,
active: PropTypes.bool.isRequired,
};
/**
@@ -25,34 +25,43 @@ CustomStepIcon.propTypes = {
*/
const ProgressStepper = ({ steps }) => {
const [activeStep, setActiveStep] = React.useState(1);
return (
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((step, index) => {
const color = activeStep === index ? "primary" : "inherit";
return (
<Step key={step.label} onClick={() => setActiveStep(index)}>
<StepLabel StepIconComponent={CustomStepIcon}>
<Typography
variant="body1"
color={color}
sx={{ fontWeight: "bold" }}
>
{step.label}
</Typography>
</StepLabel>
<Typography variant="body1" color={color}>
{step.content}
</Typography>
</Step>
);
})}
</Stepper>
);
const [activeStep, setActiveStep] = React.useState(1);
return (
<Stepper
activeStep={activeStep}
alternativeLabel
>
{steps.map((step, index) => {
const color = activeStep === index ? "primary" : "inherit";
return (
<Step
key={step.label}
onClick={() => setActiveStep(index)}
>
<StepLabel StepIconComponent={CustomStepIcon}>
<Typography
variant="body1"
color={color}
sx={{ fontWeight: "bold" }}
>
{step.label}
</Typography>
</StepLabel>
<Typography
variant="body1"
color={color}
>
{step.content}
</Typography>
</Step>
);
})}
</Stepper>
);
};
ProgressStepper.propTypes = {
steps: PropTypes.array.isRequired,
steps: PropTypes.array.isRequired,
};
export default ProgressStepper;

View File

@@ -14,17 +14,20 @@ import PropTypes from "prop-types";
*/
const ProtectedRoute = ({ Component, ...rest }) => {
const authState = useSelector((state) => state.auth);
const authState = useSelector((state) => state.auth);
return authState.authToken ? (
<Component {...rest} />
) : (
<Navigate to="/login" replace />
);
return authState.authToken ? (
<Component {...rest} />
) : (
<Navigate
to="/login"
replace
/>
);
};
ProtectedRoute.propTypes = {
Component: PropTypes.elementType.isRequired,
Component: PropTypes.elementType.isRequired,
};
export default ProtectedRoute;

View File

@@ -1,94 +1,94 @@
aside .MuiList-root svg {
width: 20px;
height: 20px;
opacity: 0.9;
width: 20px;
height: 20px;
opacity: 0.9;
}
aside span.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
line-height: 1;
font-size: var(--env-var-font-size-medium);
line-height: 1;
}
aside .MuiStack-root + span.MuiTypography-root {
font-size: var(--env-var-font-size-medium-plus);
font-size: var(--env-var-font-size-medium-plus);
}
aside .MuiListSubheader-root {
font-size: var(--env-var-font-size-small);
font-weight: 500;
line-height: 1.5;
text-transform: uppercase;
margin-bottom: 2px;
opacity: 0.6;
font-size: var(--env-var-font-size-small);
font-weight: 500;
line-height: 1.5;
text-transform: uppercase;
margin-bottom: 2px;
opacity: 0.6;
}
aside p.MuiTypography-root {
font-size: var(--env-var-font-size-small);
opacity: 0.8;
font-size: var(--env-var-font-size-small);
opacity: 0.8;
}
aside .MuiListItemButton-root:not(.selected-path) > * {
opacity: 0.9;
opacity: 0.9;
}
aside .selected-path > * {
opacity: 1;
opacity: 1;
}
aside .selected-path span.MuiTypography-root {
font-weight: 600;
font-weight: 600;
}
aside .MuiCollapse-wrapperInner .MuiList-root > .MuiListItemButton-root {
position: relative;
position: relative;
}
aside .MuiCollapse-wrapperInner .MuiList-root svg,
aside .MuiList-root .MuiListItemText-root + svg {
width: 18px;
height: 18px;
width: 18px;
height: 18px;
}
.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root) {
padding-bottom: 0;
padding-bottom: 0;
}
.sidebar-popup svg {
width: 16px;
height: 16px;
opacity: 0.9;
width: 16px;
height: 16px;
opacity: 0.9;
}
/* TRANSITIONS */
aside {
flex: 1;
transition: max-width 650ms cubic-bezier(0.36, -0.01, 0, 0.77);
flex: 1;
transition: max-width 650ms cubic-bezier(0.36, -0.01, 0, 0.77);
}
.home-layout aside.collapsed {
max-width: 64px;
max-width: 64px;
}
aside.expanded .MuiTypography-root,
aside.expanded p.MuiTypography-root,
aside.expanded .MuiListItemText-root + svg,
aside.expanded .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root {
visibility: visible;
animation: fadeIn 1s ease;
visibility: visible;
animation: fadeIn 1s ease;
}
aside.collapsed .MuiTypography-root,
aside.collapsed p.MuiTypography-root,
aside.collapsed .MuiListItemText-root + svg,
aside.collapsed .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root {
opacity: 0;
visibility: hidden;
opacity: 0;
visibility: hidden;
}
aside .MuiListSubheader-root {
transition: padding 200ms ease;
transition: padding 200ms ease;
}
@keyframes fadeIn {
0% {
opacity: 0;
visibility: hidden;
}
30% {
opacity: 0;
visibility: hidden;
}
100% {
opacity: 0.9;
visibility: visible;
}
0% {
opacity: 0;
visibility: hidden;
}
30% {
opacity: 0;
visibility: hidden;
}
100% {
opacity: 0.9;
visibility: visible;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,183 +17,186 @@ import { createToast } from "../../../Utils/toastUtils";
*/
const PasswordPanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
const theme = useTheme();
const dispatch = useDispatch();
//redux state
const { authToken, isLoading } = useSelector((state) => state.auth);
//redux state
const { authToken, isLoading } = useSelector((state) => state.auth);
const idToName = {
"edit-current-password": "password",
"edit-new-password": "newPassword",
"edit-confirm-password": "confirm",
};
const idToName = {
"edit-current-password": "password",
"edit-new-password": "newPassword",
"edit-confirm-password": "confirm",
};
const [localData, setLocalData] = useState({
password: "",
newPassword: "",
confirm: "",
});
const [errors, setErrors] = useState({});
const [localData, setLocalData] = useState({
password: "",
newPassword: "",
confirm: "",
});
const [errors, setErrors] = useState({});
const handleChange = (event) => {
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
const handleChange = (event) => {
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
const validation = credentials.validate(
{ [name]: value },
{ abortEarly: false, context: { password: localData.newPassword } }
);
const validation = credentials.validate(
{ [name]: value },
{ abortEarly: false, context: { password: localData.newPassword } }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error) {
updatedErrors[name] = validation.error.details[0].message;
} else {
delete updatedErrors[name];
}
return updatedErrors;
});
};
if (validation.error) {
updatedErrors[name] = validation.error.details[0].message;
} else {
delete updatedErrors[name];
}
return updatedErrors;
});
};
const handleSubmit = async (event) => {
event.preventDefault();
const handleSubmit = async (event) => {
event.preventDefault();
const { error } = credentials.validate(localData, {
abortEarly: false,
context: { password: localData.newPassword },
});
const { error } = credentials.validate(localData, {
abortEarly: false,
context: { password: localData.newPassword },
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
} else {
const action = await dispatch(update({ authToken, localData }));
if (action.payload.success) {
createToast({
body: "Your password was changed successfully.",
});
setLocalData({
password: "",
newPassword: "",
confirm: "",
});
} else {
// TODO: Check for other errors?
createToast({
body: "Your password input was incorrect.",
});
setErrors({ password: "*" + action.payload.msg + "." });
}
}
};
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
} else {
const action = await dispatch(update({ authToken, localData }));
if (action.payload.success) {
createToast({
body: "Your password was changed successfully.",
});
setLocalData({
password: "",
newPassword: "",
confirm: "",
});
} else {
// TODO: Check for other errors?
createToast({
body: "Your password input was incorrect.",
});
setErrors({ password: "*" + action.payload.msg + "." });
}
}
};
return (
<TabPanel
value="password"
sx={{
"& h1, & input": {
color: theme.palette.text.tertiary,
},
}}
>
<Stack
component="form"
onSubmit={handleSubmit}
noValidate
spellCheck="false"
gap={theme.spacing(20)}
>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Current password</Typography>
</Box>
<Field
type="text"
id="hidden-username"
name="username"
autoComplete="username"
hidden={true}
value=""
/>
<Field
type="password"
id="edit-current-password"
placeholder="Enter your current password"
autoComplete="current-password"
value={localData.password}
onChange={handleChange}
error={errors[idToName["edit-current-password"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">New password</Typography>
</Box>
<Field
type="password"
id="edit-new-password"
placeholder="Enter your new password"
autoComplete="new-password"
value={localData.newPassword}
onChange={handleChange}
error={errors[idToName["edit-new-password"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Confirm new password</Typography>
</Box>
<Field
type="password"
id="edit-confirm-password"
placeholder="Reenter your new password"
autoComplete="new-password"
value={localData.confirm}
onChange={handleChange}
error={errors[idToName["edit-confirm-password"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}></Box>
<Box sx={{ flex: 1 }}>
<Alert
variant="warning"
body="New password must contain at least 8 characters and must have at least one uppercase letter, one number and one symbol."
/>
</Box>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<LoadingButton
variant="contained"
color="primary"
onClick={handleSubmit}
loading={isLoading}
loadingIndicator="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
sx={{
px: theme.spacing(12),
mt: theme.spacing(20),
}}
>
Save
</LoadingButton>
</Stack>
</Stack>
</TabPanel>
);
return (
<TabPanel
value="password"
sx={{
"& h1, & input": {
color: theme.palette.text.tertiary,
},
}}
>
<Stack
component="form"
onSubmit={handleSubmit}
noValidate
spellCheck="false"
gap={theme.spacing(20)}
>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Current password</Typography>
</Box>
<Field
type="text"
id="hidden-username"
name="username"
autoComplete="username"
hidden={true}
value=""
/>
<Field
type="password"
id="edit-current-password"
placeholder="Enter your current password"
autoComplete="current-password"
value={localData.password}
onChange={handleChange}
error={errors[idToName["edit-current-password"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">New password</Typography>
</Box>
<Field
type="password"
id="edit-new-password"
placeholder="Enter your new password"
autoComplete="new-password"
value={localData.newPassword}
onChange={handleChange}
error={errors[idToName["edit-new-password"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Confirm new password</Typography>
</Box>
<Field
type="password"
id="edit-confirm-password"
placeholder="Reenter your new password"
autoComplete="new-password"
value={localData.confirm}
onChange={handleChange}
error={errors[idToName["edit-confirm-password"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}></Box>
<Box sx={{ flex: 1 }}>
<Alert
variant="warning"
body="New password must contain at least 8 characters and must have at least one uppercase letter, one number and one symbol."
/>
</Box>
</Stack>
<Stack
direction="row"
justifyContent="flex-end"
>
<LoadingButton
variant="contained"
color="primary"
onClick={handleSubmit}
loading={isLoading}
loadingIndicator="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
sx={{
px: theme.spacing(12),
mt: theme.spacing(20),
}}
>
Save
</LoadingButton>
</Stack>
</Stack>
</TabPanel>
);
};
PasswordPanel.propTypes = {
// No props are being passed to this component, hence no specific PropTypes are defined.
// No props are being passed to this component, hence no specific PropTypes are defined.
};
export default PasswordPanel;

View File

@@ -7,11 +7,7 @@ import Field from "../../Inputs/Field";
import ImageField from "../../Inputs/Image";
import { credentials, imageValidation } from "../../../Validation/validation";
import { useDispatch, useSelector } from "react-redux";
import {
clearAuthState,
deleteUser,
update,
} from "../../../Features/Auth/authSlice";
import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice";
import ImageIcon from "@mui/icons-material/Image";
import ProgressUpload from "../../ProgressBars";
import { formatBytes } from "../../../Utils/fileUtils";
@@ -29,471 +25,508 @@ import LoadingButton from "@mui/lab/LoadingButton";
*/
const ProfilePanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
const theme = useTheme();
const dispatch = useDispatch();
//redux state
const { user, authToken, isLoading } = useSelector((state) => state.auth);
//redux state
const { user, authToken, isLoading } = useSelector((state) => state.auth);
const idToName = {
"edit-first-name": "firstName",
"edit-last-name": "lastName",
// Disabled for now, will revisit in the future
// "edit-email": "email",
};
const idToName = {
"edit-first-name": "firstName",
"edit-last-name": "lastName",
// Disabled for now, will revisit in the future
// "edit-email": "email",
};
// Local state for form data, errors, and file handling
const [localData, setLocalData] = useState({
firstName: user.firstName,
lastName: user.lastName,
// email: user.email, // Disabled for now
});
const [errors, setErrors] = useState({});
const [file, setFile] = useState();
const intervalRef = useRef(null);
const [progress, setProgress] = useState({ value: 0, isLoading: false });
// Local state for form data, errors, and file handling
const [localData, setLocalData] = useState({
firstName: user.firstName,
lastName: user.lastName,
// email: user.email, // Disabled for now
});
const [errors, setErrors] = useState({});
const [file, setFile] = useState();
const intervalRef = useRef(null);
const [progress, setProgress] = useState({ value: 0, isLoading: false });
// Handles input field changes and performs validation
const handleChange = (event) => {
errors["unchanged"] && clearError("unchanged");
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
// Handles input field changes and performs validation
const handleChange = (event) => {
errors["unchanged"] && clearError("unchanged");
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
validateField({ [name]: value }, credentials, name);
};
validateField({ [name]: value }, credentials, name);
};
// Handles image file
const handlePicture = (event) => {
const pic = event.target.files[0];
let error = validateField(
{ type: pic.type, size: pic.size },
imageValidation
);
if (error) return;
// Handles image file
const handlePicture = (event) => {
const pic = event.target.files[0];
let error = validateField({ type: pic.type, size: pic.size }, imageValidation);
if (error) return;
setProgress((prev) => ({ ...prev, isLoading: true }));
setFile({
src: URL.createObjectURL(pic),
name: pic.name,
size: formatBytes(pic.size),
delete: false,
});
setProgress((prev) => ({ ...prev, isLoading: true }));
setFile({
src: URL.createObjectURL(pic),
name: pic.name,
size: formatBytes(pic.size),
delete: false,
});
//TODO - potentitally remove, will revisit in the future
intervalRef.current = setInterval(() => {
const buffer = 12;
setProgress((prev) => {
if (prev.value + buffer >= 100) {
clearInterval(intervalRef.current);
return { value: 100, isLoading: false };
}
return { ...prev, value: prev.value + buffer };
});
}, 120);
};
//TODO - potentitally remove, will revisit in the future
intervalRef.current = setInterval(() => {
const buffer = 12;
setProgress((prev) => {
if (prev.value + buffer >= 100) {
clearInterval(intervalRef.current);
return { value: 100, isLoading: false };
}
return { ...prev, value: prev.value + buffer };
});
}, 120);
};
// Validates input against provided schema and updates error state
const validateField = (toValidate, schema, name = "picture") => {
const { error } = schema.validate(toValidate, { abortEarly: false });
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
if (error) return true;
};
// Validates input against provided schema and updates error state
const validateField = (toValidate, schema, name = "picture") => {
const { error } = schema.validate(toValidate, { abortEarly: false });
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
if (error) return true;
};
// Clears specific error from errors state
const clearError = (err) => {
setErrors((prev) => {
const updatedErrors = { ...prev };
if (updatedErrors[err]) delete updatedErrors[err];
return updatedErrors;
});
};
// Clears specific error from errors state
const clearError = (err) => {
setErrors((prev) => {
const updatedErrors = { ...prev };
if (updatedErrors[err]) delete updatedErrors[err];
return updatedErrors;
});
};
// Resets picture-related states and clears interval
const removePicture = () => {
errors["picture"] && clearError("picture");
setFile({ delete: true });
clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
setProgress({ value: 0, isLoading: false });
};
// Resets picture-related states and clears interval
const removePicture = () => {
errors["picture"] && clearError("picture");
setFile({ delete: true });
clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
setProgress({ value: 0, isLoading: false });
};
// Opens the picture update modal
const openPictureModal = () => {
setIsOpen("picture");
setFile({ delete: localData.deleteProfileImage });
};
// Opens the picture update modal
const openPictureModal = () => {
setIsOpen("picture");
setFile({ delete: localData.deleteProfileImage });
};
// Closes the picture update modal and resets related states
const closePictureModal = () => {
errors["picture"] && clearError("picture");
setFile(); //reset file
clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
setProgress({ value: 0, isLoading: false });
setIsOpen("");
};
// Closes the picture update modal and resets related states
const closePictureModal = () => {
errors["picture"] && clearError("picture");
setFile(); //reset file
clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
setProgress({ value: 0, isLoading: false });
setIsOpen("");
};
// Updates profile image displayed on UI
const handleUpdatePicture = () => {
setProgress({ value: 0, isLoading: false });
setLocalData((prev) => ({
...prev,
file: file.src,
deleteProfileImage: false,
}));
setIsOpen("");
errors["unchanged"] && clearError("unchanged");
};
// Updates profile image displayed on UI
const handleUpdatePicture = () => {
setProgress({ value: 0, isLoading: false });
setLocalData((prev) => ({
...prev,
file: file.src,
deleteProfileImage: false,
}));
setIsOpen("");
errors["unchanged"] && clearError("unchanged");
};
// Handles form submission to update user profile
const handleSaveProfile = async (event) => {
event.preventDefault();
if (
localData.firstName === user.firstName &&
localData.lastName === user.lastName &&
localData.deleteProfileImage === undefined &&
localData.file === undefined
) {
createToast({
body: "Unable to update profile — no changes detected.",
});
setErrors({ unchanged: "unable to update profile" });
return;
}
// Handles form submission to update user profile
const handleSaveProfile = async (event) => {
event.preventDefault();
if (
localData.firstName === user.firstName &&
localData.lastName === user.lastName &&
localData.deleteProfileImage === undefined &&
localData.file === undefined
) {
createToast({
body: "Unable to update profile — no changes detected.",
});
setErrors({ unchanged: "unable to update profile" });
return;
}
const action = await dispatch(update({ authToken, localData }));
if (action.payload.success) {
createToast({
body: "Your profile data was changed successfully.",
});
} else {
createToast({
body: "There was an error updating your profile data.",
});
}
};
const action = await dispatch(update({ authToken, localData }));
if (action.payload.success) {
createToast({
body: "Your profile data was changed successfully.",
});
} else {
createToast({
body: "There was an error updating your profile data.",
});
}
};
// Removes current profile image from UI
const handleDeletePicture = () => {
setLocalData((prev) => ({
...prev,
deleteProfileImage: true,
}));
errors["unchanged"] && clearError("unchanged");
};
// Removes current profile image from UI
const handleDeletePicture = () => {
setLocalData((prev) => ({
...prev,
deleteProfileImage: true,
}));
errors["unchanged"] && clearError("unchanged");
};
// Initiates the account deletion process
const handleDeleteAccount = async () => {
const action = await dispatch(deleteUser(authToken));
if (action.payload.success) {
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
};
// Initiates the account deletion process
const handleDeleteAccount = async () => {
const action = await dispatch(deleteUser(authToken));
if (action.payload.success) {
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
};
// Modal state and control functions
const [isOpen, setIsOpen] = useState("");
const isModalOpen = (name) => isOpen === name;
// Modal state and control functions
const [isOpen, setIsOpen] = useState("");
const isModalOpen = (name) => isOpen === name;
return (
<TabPanel
value="profile"
sx={{
"& h1, & p, & input": {
color: theme.palette.text.tertiary,
},
}}
>
<Stack
component="form"
className="edit-profile-form"
noValidate
spellCheck="false"
gap={theme.spacing(20)}
>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">First name</Typography>
</Box>
<Field
id="edit-first-name"
value={localData.firstName}
placeholder="Enter your first name"
autoComplete="given-name"
onChange={handleChange}
error={errors[idToName["edit-first-name"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Last name</Typography>
</Box>
<Field
id="edit-last-name"
placeholder="Enter your last name"
autoComplete="family-name"
value={localData.lastName}
onChange={handleChange}
error={errors[idToName["edit-last-name"]]}
/>
</Stack>
<Stack direction="row">
<Stack flex={0.9}>
<Typography component="h1">Email</Typography>
<Typography component="p" sx={{ opacity: 0.6 }}>
This is your current email address it cannot be changed.
</Typography>
</Stack>
<Field
id="edit-email"
value={user.email}
placeholder="Enter your email"
autoComplete="email"
onChange={() => logger.warn("disabled")}
// error={errors[idToName["edit-email"]]}
disabled={true}
/>
</Stack>
<Stack direction="row">
<Stack flex={0.9}>
<Typography component="h1">Your photo</Typography>
<Typography component="p" sx={{ opacity: 0.6 }}>
This photo will be displayed in your profile page.
</Typography>
</Stack>
<Stack direction="row" alignItems="center" flex={1}>
<Avatar
src={
localData?.deleteProfileImage
? "/static/images/avatar/2.jpg"
: localData?.file
? localData.file
: ""
}
sx={{ mr: "8px" }}
/>
<Button variant="text" color="info" onClick={handleDeletePicture}>
Delete
</Button>
<Button variant="text" color="primary" onClick={openPictureModal}>
Update
</Button>
</Stack>
</Stack>
<Divider
aria-hidden="true"
width="0"
sx={{
marginY: theme.spacing(1),
}}
/>
<Stack direction="row" justifyContent="flex-end">
<Box width="fit-content">
<LoadingButton
variant="contained"
color="primary"
onClick={handleSaveProfile}
loading={isLoading}
loadingIndicator="Saving..."
disabled={
Object.keys(errors).length !== 0 && !errors?.picture && true
}
sx={{ px: theme.spacing(12) }}
>
Save
</LoadingButton>
</Box>
</Stack>
</Stack>
<Divider
aria-hidden="true"
sx={{
marginY: theme.spacing(20),
borderColor: theme.palette.border.light,
}}
/>
{!user.role.includes("demo") && (
<Box component="form" noValidate spellCheck="false">
<Box mb={theme.spacing(6)}>
<Typography component="h1">Delete account</Typography>
<Typography component="p" sx={{ opacity: 0.6 }}>
Note that deleting your account will remove all data from our
system. This is permanent and non-recoverable.
</Typography>
</Box>
<Button
variant="contained"
color="error"
onClick={() => setIsOpen("delete")}
>
Delete account
</Button>
</Box>
)}
{/* TODO - Update ModalPopup Component with @mui for reusability */}
<Modal
aria-labelledby="modal-delete-account"
aria-describedby="delete-account-confirmation"
open={isModalOpen("delete")}
onClose={() => setIsOpen("")}
disablePortal
>
<Stack
gap={theme.spacing(5)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 500,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-account" component="h1">
Really delete this account?
</Typography>
<Typography id="delete-account-confirmation" component="p">
If you delete your account, you will no longer be able to sign in,
and all of your data will be deleted. Deleting your account is
permanent and non-recoverable action.
</Typography>
<Stack
direction="row"
gap={theme.spacing(5)}
mt={theme.spacing(5)}
justifyContent="flex-end"
>
<Button variant="text" color="info" onClick={() => setIsOpen("")}>
Cancel
</Button>
<LoadingButton
variant="contained"
color="error"
onClick={handleDeleteAccount}
loading={isLoading}
>
Delete account
</LoadingButton>
</Stack>
</Stack>
</Modal>
<Modal
aria-labelledby="modal-update-picture"
aria-describedby="update-profile-picture"
open={isModalOpen("picture")}
onClose={closePictureModal}
>
<Stack
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 475,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id="modal-update-picture"
component="h1"
color={theme.palette.text.secondary}
>
Upload Image
</Typography>
<ImageField
id="update-profile-picture"
src={
file?.delete
? ""
: file?.src
? file.src
: localData?.file
? localData.file
: user?.avatarImage
? `data:image/png;base64,${user.avatarImage}`
: ""
}
loading={progress.isLoading && progress.value !== 100}
onChange={handlePicture}
/>
{progress.isLoading || progress.value !== 0 || errors["picture"] ? (
<ProgressUpload
icon={<ImageIcon />}
label={file?.name}
size={file?.size}
progress={progress.value}
onClick={removePicture}
error={errors["picture"]}
/>
) : (
""
)}
<Stack
direction="row"
mt={theme.spacing(10)}
gap={theme.spacing(5)}
justifyContent="flex-end"
>
<Button variant="text" color="info" onClick={removePicture}>
Remove
</Button>
<Button
variant="contained"
color="primary"
onClick={handleUpdatePicture}
disabled={
(Object.keys(errors).length !== 0 && errors?.picture) ||
progress.value !== 100
? true
: false
}
>
Update
</Button>
</Stack>
</Stack>
</Modal>
</TabPanel>
);
return (
<TabPanel
value="profile"
sx={{
"& h1, & p, & input": {
color: theme.palette.text.tertiary,
},
}}
>
<Stack
component="form"
className="edit-profile-form"
noValidate
spellCheck="false"
gap={theme.spacing(20)}
>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">First name</Typography>
</Box>
<Field
id="edit-first-name"
value={localData.firstName}
placeholder="Enter your first name"
autoComplete="given-name"
onChange={handleChange}
error={errors[idToName["edit-first-name"]]}
/>
</Stack>
<Stack direction="row">
<Box flex={0.9}>
<Typography component="h1">Last name</Typography>
</Box>
<Field
id="edit-last-name"
placeholder="Enter your last name"
autoComplete="family-name"
value={localData.lastName}
onChange={handleChange}
error={errors[idToName["edit-last-name"]]}
/>
</Stack>
<Stack direction="row">
<Stack flex={0.9}>
<Typography component="h1">Email</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
This is your current email address it cannot be changed.
</Typography>
</Stack>
<Field
id="edit-email"
value={user.email}
placeholder="Enter your email"
autoComplete="email"
onChange={() => logger.warn("disabled")}
// error={errors[idToName["edit-email"]]}
disabled={true}
/>
</Stack>
<Stack direction="row">
<Stack flex={0.9}>
<Typography component="h1">Your photo</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
This photo will be displayed in your profile page.
</Typography>
</Stack>
<Stack
direction="row"
alignItems="center"
flex={1}
>
<Avatar
src={
localData?.deleteProfileImage
? "/static/images/avatar/2.jpg"
: localData?.file
? localData.file
: ""
}
sx={{ mr: "8px" }}
/>
<Button
variant="text"
color="info"
onClick={handleDeletePicture}
>
Delete
</Button>
<Button
variant="text"
color="primary"
onClick={openPictureModal}
>
Update
</Button>
</Stack>
</Stack>
<Divider
aria-hidden="true"
width="0"
sx={{
marginY: theme.spacing(1),
}}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Box width="fit-content">
<LoadingButton
variant="contained"
color="primary"
onClick={handleSaveProfile}
loading={isLoading}
loadingIndicator="Saving..."
disabled={Object.keys(errors).length !== 0 && !errors?.picture && true}
sx={{ px: theme.spacing(12) }}
>
Save
</LoadingButton>
</Box>
</Stack>
</Stack>
<Divider
aria-hidden="true"
sx={{
marginY: theme.spacing(20),
borderColor: theme.palette.border.light,
}}
/>
{!user.role.includes("demo") && (
<Box
component="form"
noValidate
spellCheck="false"
>
<Box mb={theme.spacing(6)}>
<Typography component="h1">Delete account</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
Note that deleting your account will remove all data from our system. This
is permanent and non-recoverable.
</Typography>
</Box>
<Button
variant="contained"
color="error"
onClick={() => setIsOpen("delete")}
>
Delete account
</Button>
</Box>
)}
{/* TODO - Update ModalPopup Component with @mui for reusability */}
<Modal
aria-labelledby="modal-delete-account"
aria-describedby="delete-account-confirmation"
open={isModalOpen("delete")}
onClose={() => setIsOpen("")}
disablePortal
>
<Stack
gap={theme.spacing(5)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 500,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id="modal-delete-account"
component="h1"
>
Really delete this account?
</Typography>
<Typography
id="delete-account-confirmation"
component="p"
>
If you delete your account, you will no longer be able to sign in, and all of
your data will be deleted. Deleting your account is permanent and
non-recoverable action.
</Typography>
<Stack
direction="row"
gap={theme.spacing(5)}
mt={theme.spacing(5)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={() => setIsOpen("")}
>
Cancel
</Button>
<LoadingButton
variant="contained"
color="error"
onClick={handleDeleteAccount}
loading={isLoading}
>
Delete account
</LoadingButton>
</Stack>
</Stack>
</Modal>
<Modal
aria-labelledby="modal-update-picture"
aria-describedby="update-profile-picture"
open={isModalOpen("picture")}
onClose={closePictureModal}
>
<Stack
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 475,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id="modal-update-picture"
component="h1"
color={theme.palette.text.secondary}
>
Upload Image
</Typography>
<ImageField
id="update-profile-picture"
src={
file?.delete
? ""
: file?.src
? file.src
: localData?.file
? localData.file
: user?.avatarImage
? `data:image/png;base64,${user.avatarImage}`
: ""
}
loading={progress.isLoading && progress.value !== 100}
onChange={handlePicture}
/>
{progress.isLoading || progress.value !== 0 || errors["picture"] ? (
<ProgressUpload
icon={<ImageIcon />}
label={file?.name}
size={file?.size}
progress={progress.value}
onClick={removePicture}
error={errors["picture"]}
/>
) : (
""
)}
<Stack
direction="row"
mt={theme.spacing(10)}
gap={theme.spacing(5)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={removePicture}
>
Remove
</Button>
<Button
variant="contained"
color="primary"
onClick={handleUpdatePicture}
disabled={
(Object.keys(errors).length !== 0 && errors?.picture) ||
progress.value !== 100
? true
: false
}
>
Update
</Button>
</Stack>
</Stack>
</Modal>
</TabPanel>
);
};
ProfilePanel.propTypes = {
// No props are being passed to this component, hence no specific PropTypes are defined.
// No props are being passed to this component, hence no specific PropTypes are defined.
};
export default ProfilePanel;

View File

@@ -1,13 +1,6 @@
import { useTheme } from "@emotion/react";
import TabPanel from "@mui/lab/TabPanel";
import {
Box,
Button,
ButtonGroup,
Modal,
Stack,
Typography,
} from "@mui/material";
import { Box, Button, ButtonGroup, Modal, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import Field from "../../Inputs/Field";
import { credentials } from "../../../Validation/validation";
@@ -27,216 +20,211 @@ import LoadingButton from "@mui/lab/LoadingButton";
*/
const TeamPanel = () => {
const roleMap = {
superadmin: "Super admin",
admin: "Admin",
user: "Team member",
demo: "Demo User",
};
const roleMap = {
superadmin: "Super admin",
admin: "Admin",
user: "Team member",
demo: "Demo User",
};
const theme = useTheme();
const theme = useTheme();
const { authToken, user } = useSelector((state) => state.auth);
//TODO
const [orgStates, setOrgStates] = useState({
name: "Bluewave Labs",
isEdit: false,
});
const [toInvite, setToInvite] = useState({
email: "",
role: ["0"],
});
const [tableData, setTableData] = useState({});
const [members, setMembers] = useState([]);
const [filter, setFilter] = useState("all");
const [isDisabled, setIsDisabled] = useState(true);
const [errors, setErrors] = useState({});
const [isSendingInvite, setIsSendingInvite] = useState(false);
const { authToken, user } = useSelector((state) => state.auth);
//TODO
const [orgStates, setOrgStates] = useState({
name: "Bluewave Labs",
isEdit: false,
});
const [toInvite, setToInvite] = useState({
email: "",
role: ["0"],
});
const [tableData, setTableData] = useState({});
const [members, setMembers] = useState([]);
const [filter, setFilter] = useState("all");
const [isDisabled, setIsDisabled] = useState(true);
const [errors, setErrors] = useState({});
const [isSendingInvite, setIsSendingInvite] = useState(false);
useEffect(() => {
const fetchTeam = async () => {
try {
const response = await networkService.getAllUsers({
authToken: authToken,
});
setMembers(response.data.data);
} catch (error) {
createToast({
body: error.message || "Error fetching team members.",
});
}
};
useEffect(() => {
const fetchTeam = async () => {
try {
const response = await networkService.getAllUsers({
authToken: authToken,
});
setMembers(response.data.data);
} catch (error) {
createToast({
body: error.message || "Error fetching team members.",
});
}
};
fetchTeam();
}, [user]);
fetchTeam();
}, [user]);
useEffect(() => {
let team = members;
if (filter !== "all")
team = members.filter((member) => {
if (filter === "admin") {
return (
member.role.includes("admin") || member.role.includes("superadmin")
);
}
return member.role.includes(filter);
});
useEffect(() => {
let team = members;
if (filter !== "all")
team = members.filter((member) => {
if (filter === "admin") {
return member.role.includes("admin") || member.role.includes("superadmin");
}
return member.role.includes(filter);
});
const data = {
cols: [
{ id: 1, name: "NAME" },
{ id: 2, name: "EMAIL" },
{ id: 3, name: "ROLE" },
// FEATURE STILL TO BE IMPLEMENTED
// { id: 4, name: "ACTION" },
],
rows: team?.map((member, idx) => {
const roles = member.role.map((role) => roleMap[role]).join(",");
return {
id: member._id,
data: [
{
id: idx,
data: (
<Stack>
<Typography color={theme.palette.text.secondary}>
{member.firstName + " " + member.lastName}
</Typography>
<Typography>
Created {new Date(member.createdAt).toLocaleDateString()}
</Typography>
</Stack>
),
},
{ id: idx + 1, data: member.email },
{
// TODO - Add select dropdown
id: idx + 2,
data: roles,
},
// FEATURE STILL TO BE IMPLEMENTED
// {
// // TODO - Add delete onClick
// id: idx + 3,
// data: (
// <IconButton
// aria-label="remove member"
// sx={{
// "&:focus": {
// outline: "none",
// },
// }}
// >
// <Remove />
// </IconButton>
// ),
// },
],
};
}),
};
const data = {
cols: [
{ id: 1, name: "NAME" },
{ id: 2, name: "EMAIL" },
{ id: 3, name: "ROLE" },
// FEATURE STILL TO BE IMPLEMENTED
// { id: 4, name: "ACTION" },
],
rows: team?.map((member, idx) => {
const roles = member.role.map((role) => roleMap[role]).join(",");
return {
id: member._id,
data: [
{
id: idx,
data: (
<Stack>
<Typography color={theme.palette.text.secondary}>
{member.firstName + " " + member.lastName}
</Typography>
<Typography>
Created {new Date(member.createdAt).toLocaleDateString()}
</Typography>
</Stack>
),
},
{ id: idx + 1, data: member.email },
{
// TODO - Add select dropdown
id: idx + 2,
data: roles,
},
// FEATURE STILL TO BE IMPLEMENTED
// {
// // TODO - Add delete onClick
// id: idx + 3,
// data: (
// <IconButton
// aria-label="remove member"
// sx={{
// "&:focus": {
// outline: "none",
// },
// }}
// >
// <Remove />
// </IconButton>
// ),
// },
],
};
}),
};
setTableData(data);
}, [members, filter]);
useEffect(() => {
setIsDisabled(Object.keys(errors).length !== 0 || toInvite.email === "");
}, [errors, toInvite.email]);
setTableData(data);
}, [members, filter]);
useEffect(() => {
setIsDisabled(Object.keys(errors).length !== 0 || toInvite.email === "");
}, [errors, toInvite.email]);
// RENAME ORGANIZATION
const toggleEdit = () => {
setOrgStates((prev) => ({ ...prev, isEdit: !prev.isEdit }));
};
const handleRename = () => {};
// RENAME ORGANIZATION
const toggleEdit = () => {
setOrgStates((prev) => ({ ...prev, isEdit: !prev.isEdit }));
};
const handleRename = () => {};
// INVITE MEMBER
const [isOpen, setIsOpen] = useState(false);
// INVITE MEMBER
const [isOpen, setIsOpen] = useState(false);
const handleChange = (event) => {
const { value } = event.target;
setToInvite((prev) => ({
...prev,
email: value,
}));
const handleChange = (event) => {
const { value } = event.target;
setToInvite((prev) => ({
...prev,
email: value,
}));
const validation = credentials.validate(
{ email: value },
{ abortEarly: false }
);
const validation = credentials.validate({ email: value }, { abortEarly: false });
setErrors((prev) => {
const updatedErrors = { ...prev };
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error) {
updatedErrors.email = validation.error.details[0].message;
} else {
delete updatedErrors.email;
}
return updatedErrors;
});
};
if (validation.error) {
updatedErrors.email = validation.error.details[0].message;
} else {
delete updatedErrors.email;
}
return updatedErrors;
});
};
const handleInviteMember = async () => {
if (!toInvite.email) {
setErrors((prev) => ({ ...prev, email: "Email is required." }));
return;
}
setIsSendingInvite(true);
if (!toInvite.role.includes("user") || !toInvite.role.includes("admin"))
setToInvite((prev) => ({ ...prev, role: ["user"] }));
const handleInviteMember = async () => {
if (!toInvite.email) {
setErrors((prev) => ({ ...prev, email: "Email is required." }));
return;
}
setIsSendingInvite(true);
if (!toInvite.role.includes("user") || !toInvite.role.includes("admin"))
setToInvite((prev) => ({ ...prev, role: ["user"] }));
const { error } = credentials.validate(
{ email: toInvite.email },
{
abortEarly: false,
}
);
const { error } = credentials.validate(
{ email: toInvite.email },
{
abortEarly: false,
}
);
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
return;
}
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
return;
}
try {
await networkService.requestInvitationToken({
authToken: authToken,
email: toInvite.email,
role: toInvite.role,
});
closeInviteModal();
createToast({
body: "Member invited. They will receive an email with details on how to create their account.",
});
} catch (error) {
createToast({
body: error.message || "Unknown error.",
});
} finally {
setIsSendingInvite(false);
}
};
try {
await networkService.requestInvitationToken({
authToken: authToken,
email: toInvite.email,
role: toInvite.role,
});
closeInviteModal();
createToast({
body: "Member invited. They will receive an email with details on how to create their account.",
});
} catch (error) {
createToast({
body: error.message || "Unknown error.",
});
} finally {
setIsSendingInvite(false);
}
};
const closeInviteModal = () => {
setIsOpen(false);
setToInvite({ email: "", role: ["0"] });
setErrors({});
};
const closeInviteModal = () => {
setIsOpen(false);
setToInvite({ email: "", role: ["0"] });
setErrors({});
};
return (
<TabPanel
className="team-panel table-container"
value="team"
sx={{
"& h1": {
color: theme.palette.text.tertiary,
},
"& .MuiTable-root .MuiTableBody-root .MuiTableCell-root, & .MuiTable-root p + p":
{
color: theme.palette.text.accent,
},
}}
>
{/* FEATURE STILL TO BE IMPLEMENTED */}
{/* <Stack component="form">
return (
<TabPanel
className="team-panel table-container"
value="team"
sx={{
"& h1": {
color: theme.palette.text.tertiary,
},
"& .MuiTable-root .MuiTableBody-root .MuiTableCell-root, & .MuiTable-root p + p":
{
color: theme.palette.text.accent,
},
}}
>
{/* FEATURE STILL TO BE IMPLEMENTED */}
{/* <Stack component="form">
<Box sx={{ alignSelf: "flex-start" }}>
<Typography component="h1">Organization name</Typography>
</Box>
@@ -282,161 +270,163 @@ const TeamPanel = () => {
</Stack>
</Stack>
<Divider aria-hidden="true" sx={{ marginY: theme.spacing(4) }} /> */}
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.spacing(12)}
>
<Typography component="h1">Team members</Typography>
<Stack direction="row" justifyContent="space-between">
<Stack
direction="row"
alignItems="flex-end"
gap={theme.spacing(6)}
sx={{ fontSize: 14 }}
>
<ButtonGroup>
<Button
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
>
All
</Button>
<Button
variant="group"
filled={(filter === "admin").toString()}
onClick={() => setFilter("admin")}
>
Administrator
</Button>
<Button
variant="group"
filled={(filter === "user").toString()}
onClick={() => setFilter("user")}
>
Member
</Button>
</ButtonGroup>
</Stack>
<LoadingButton
loading={isSendingInvite}
variant="contained"
color="primary"
onClick={() => setIsOpen(true)}
>
Invite a team member
</LoadingButton>
</Stack>
<BasicTable
data={tableData}
paginated={false}
reversed={true}
table={"team"}
/>
</Stack>
<Modal
aria-labelledby="modal-invite-member"
aria-describedby="invite-member-to-team"
open={isOpen}
onClose={closeInviteModal}
>
<Stack
gap={theme.spacing(5)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 450,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Box>
<Typography
id="modal-invite-member"
component="h1"
fontWeight={600}
fontColor={theme.palette.text.secondary}
>
Invite new team member
</Typography>
<Typography
id="invite-member-to-team"
component="p"
fontSize={13}
color={theme.palette.text.accent}
sx={{ mt: theme.spacing(1), mb: theme.spacing(4) }}
>
When you add a new team member, they will get access to all
monitors.
</Typography>
</Box>
<Field
type="email"
id="input-team-member"
placeholder="Email"
value={toInvite.email}
onChange={handleChange}
error={errors.email}
/>
<Select
id="team-member-role"
placeholder="Select role"
isHidden={true}
value={toInvite.role[0]}
onChange={(event) =>
setToInvite((prev) => ({
...prev,
role: [event.target.value],
}))
}
items={[
{ _id: "admin", name: "Admin" },
{ _id: "user", name: "User" },
]}
/>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(8)}
justifyContent="flex-end"
>
<LoadingButton
loading={isSendingInvite}
variant="text"
color="info"
onClick={closeInviteModal}
>
Cancel
</LoadingButton>
<LoadingButton
variant="contained"
color="primary"
onClick={handleInviteMember}
loading={isSendingInvite}
disabled={isDisabled}
>
Send invite
</LoadingButton>
</Stack>
</Stack>
</Modal>
</TabPanel>
);
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.spacing(12)}
>
<Typography component="h1">Team members</Typography>
<Stack
direction="row"
justifyContent="space-between"
>
<Stack
direction="row"
alignItems="flex-end"
gap={theme.spacing(6)}
sx={{ fontSize: 14 }}
>
<ButtonGroup>
<Button
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
>
All
</Button>
<Button
variant="group"
filled={(filter === "admin").toString()}
onClick={() => setFilter("admin")}
>
Administrator
</Button>
<Button
variant="group"
filled={(filter === "user").toString()}
onClick={() => setFilter("user")}
>
Member
</Button>
</ButtonGroup>
</Stack>
<LoadingButton
loading={isSendingInvite}
variant="contained"
color="primary"
onClick={() => setIsOpen(true)}
>
Invite a team member
</LoadingButton>
</Stack>
<BasicTable
data={tableData}
paginated={false}
reversed={true}
table={"team"}
/>
</Stack>
<Modal
aria-labelledby="modal-invite-member"
aria-describedby="invite-member-to-team"
open={isOpen}
onClose={closeInviteModal}
>
<Stack
gap={theme.spacing(5)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 450,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Box>
<Typography
id="modal-invite-member"
component="h1"
fontWeight={600}
fontColor={theme.palette.text.secondary}
>
Invite new team member
</Typography>
<Typography
id="invite-member-to-team"
component="p"
fontSize={13}
color={theme.palette.text.accent}
sx={{ mt: theme.spacing(1), mb: theme.spacing(4) }}
>
When you add a new team member, they will get access to all monitors.
</Typography>
</Box>
<Field
type="email"
id="input-team-member"
placeholder="Email"
value={toInvite.email}
onChange={handleChange}
error={errors.email}
/>
<Select
id="team-member-role"
placeholder="Select role"
isHidden={true}
value={toInvite.role[0]}
onChange={(event) =>
setToInvite((prev) => ({
...prev,
role: [event.target.value],
}))
}
items={[
{ _id: "admin", name: "Admin" },
{ _id: "user", name: "User" },
]}
/>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(8)}
justifyContent="flex-end"
>
<LoadingButton
loading={isSendingInvite}
variant="text"
color="info"
onClick={closeInviteModal}
>
Cancel
</LoadingButton>
<LoadingButton
variant="contained"
color="primary"
onClick={handleInviteMember}
loading={isSendingInvite}
disabled={isDisabled}
>
Send invite
</LoadingButton>
</Stack>
</Stack>
</Modal>
</TabPanel>
);
};
TeamPanel.propTypes = {
// No props are being passed to this component, hence no specific PropTypes are defined.
// No props are being passed to this component, hence no specific PropTypes are defined.
};
export default TeamPanel;

View File

@@ -4,270 +4,254 @@ import { jwtDecode } from "jwt-decode";
import axios from "axios";
const initialState = {
isLoading: false,
authToken: "",
user: "",
success: null,
msg: null,
isLoading: false,
authToken: "",
user: "",
success: null,
msg: null,
};
export const register = createAsyncThunk(
"auth/register",
async (form, thunkApi) => {
try {
const res = await networkService.registerUser(form);
return res.data;
} catch (error) {
if (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 login = createAsyncThunk("auth/login", async (form, thunkApi) => {
try {
const res = await networkService.loginUser(form);
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 register = createAsyncThunk("auth/register", async (form, thunkApi) => {
try {
const res = await networkService.registerUser(form);
return res.data;
} catch (error) {
if (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 update = createAsyncThunk(
"auth/update",
async (data, thunkApi) => {
const { authToken: token, localData: form } = data;
const user = jwtDecode(token);
try {
const fd = new FormData();
form.firstName && fd.append("firstName", form.firstName);
form.lastName && fd.append("lastName", form.lastName);
form.password && fd.append("password", form.password);
form.newPassword && fd.append("newPassword", form.newPassword);
if (form.file && form.file !== "") {
const imageResult = await axios.get(form.file, {
responseType: "blob",
});
fd.append("profileImage", imageResult.data);
}
form.deleteProfileImage &&
fd.append("deleteProfileImage", form.deleteProfileImage);
export const login = createAsyncThunk("auth/login", async (form, thunkApi) => {
try {
const res = await networkService.loginUser(form);
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 res = await networkService.updateUser({
authToken: token,
userId: user._id,
form: fd,
});
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 update = createAsyncThunk("auth/update", async (data, thunkApi) => {
const { authToken: token, localData: form } = data;
const user = jwtDecode(token);
try {
const fd = new FormData();
form.firstName && fd.append("firstName", form.firstName);
form.lastName && fd.append("lastName", form.lastName);
form.password && fd.append("password", form.password);
form.newPassword && fd.append("newPassword", form.newPassword);
if (form.file && form.file !== "") {
const imageResult = await axios.get(form.file, {
responseType: "blob",
});
fd.append("profileImage", imageResult.data);
}
form.deleteProfileImage && fd.append("deleteProfileImage", form.deleteProfileImage);
export const deleteUser = createAsyncThunk(
"auth/delete",
async (data, thunkApi) => {
const user = jwtDecode(data);
const res = await networkService.updateUser({
authToken: token,
userId: user._id,
form: fd,
});
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);
}
});
try {
const res = await networkService.deleteUser({
authToken: data,
userId: user._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 deleteUser = createAsyncThunk("auth/delete", async (data, thunkApi) => {
const user = jwtDecode(data);
try {
const res = await networkService.deleteUser({
authToken: data,
userId: user._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 forgotPassword = createAsyncThunk(
"auth/forgotPassword",
async (form, thunkApi) => {
try {
const res = await networkService.forgotPassword(form);
return res.data;
} catch (error) {
if (error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
"auth/forgotPassword",
async (form, thunkApi) => {
try {
const res = await networkService.forgotPassword(form);
return res.data;
} catch (error) {
if (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 setNewPassword = createAsyncThunk(
"auth/setNewPassword",
async (data, thunkApi) => {
const { token, form } = data;
try {
await networkService.validateRecoveryToken({ recoveryToken: token });
const res = await networkService.setNewPassword({
recoveryToken: token,
form: form,
});
return res.data;
} catch (error) {
if (error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
"auth/setNewPassword",
async (data, thunkApi) => {
const { token, form } = data;
try {
await networkService.validateRecoveryToken({ recoveryToken: token });
const res = await networkService.setNewPassword({
recoveryToken: token,
form: form,
});
return res.data;
} catch (error) {
if (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 handleAuthFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.authToken = action.payload.data.token;
state.user = action.payload.data.user;
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.authToken = action.payload.data.token;
state.user = action.payload.data.user;
};
const handleAuthRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to login or register";
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to login or register";
};
const handleUpdateFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.user = action.payload.data;
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.user = action.payload.data;
};
const handleUpdateRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update profile data.";
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to update profile data.";
};
const handleDeleteFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
};
const handleDeleteRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to delete account.";
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to delete account.";
};
const handleForgotFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
};
const handleForgotRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to send reset instructions.";
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to send reset instructions.";
};
const handleNewPasswordRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to reset password.";
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to reset password.";
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
clearAuthState: (state) => {
state.authToken = "";
state.user = "";
state.isLoading = false;
state.success = true;
state.msg = "Logged out successfully";
},
},
extraReducers: (builder) => {
// Register thunk
builder
.addCase(register.pending, (state) => {
state.isLoading = true;
})
.addCase(register.fulfilled, handleAuthFulfilled)
.addCase(register.rejected, handleAuthRejected);
name: "auth",
initialState,
reducers: {
clearAuthState: (state) => {
state.authToken = "";
state.user = "";
state.isLoading = false;
state.success = true;
state.msg = "Logged out successfully";
},
},
extraReducers: (builder) => {
// Register thunk
builder
.addCase(register.pending, (state) => {
state.isLoading = true;
})
.addCase(register.fulfilled, handleAuthFulfilled)
.addCase(register.rejected, handleAuthRejected);
// Login thunk
builder
.addCase(login.pending, (state) => {
state.isLoading = true;
})
.addCase(login.fulfilled, handleAuthFulfilled)
.addCase(login.rejected, handleAuthRejected);
// Login thunk
builder
.addCase(login.pending, (state) => {
state.isLoading = true;
})
.addCase(login.fulfilled, handleAuthFulfilled)
.addCase(login.rejected, handleAuthRejected);
// Update thunk
builder
.addCase(update.pending, (state) => {
state.isLoading = true;
})
.addCase(update.fulfilled, handleUpdateFulfilled)
.addCase(update.rejected, handleUpdateRejected);
// Update thunk
builder
.addCase(update.pending, (state) => {
state.isLoading = true;
})
.addCase(update.fulfilled, handleUpdateFulfilled)
.addCase(update.rejected, handleUpdateRejected);
// Delete thunk
builder
.addCase(deleteUser.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteUser.fulfilled, handleDeleteFulfilled)
.addCase(deleteUser.rejected, handleDeleteRejected);
// Delete thunk
builder
.addCase(deleteUser.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteUser.fulfilled, handleDeleteFulfilled)
.addCase(deleteUser.rejected, handleDeleteRejected);
// Forgot password thunk
builder
.addCase(forgotPassword.pending, (state) => {
state.isLoading = true;
})
.addCase(forgotPassword.fulfilled, handleForgotFulfilled)
.addCase(forgotPassword.rejected, handleForgotRejected);
// Forgot password thunk
builder
.addCase(forgotPassword.pending, (state) => {
state.isLoading = true;
})
.addCase(forgotPassword.fulfilled, handleForgotFulfilled)
.addCase(forgotPassword.rejected, handleForgotRejected);
// Set new password thunk
builder
.addCase(setNewPassword.pending, (state) => {
state.isLoading = true;
})
.addCase(setNewPassword.fulfilled, handleAuthFulfilled)
.addCase(setNewPassword.rejected, handleNewPasswordRejected);
},
// Set new password thunk
builder
.addCase(setNewPassword.pending, (state) => {
state.isLoading = true;
})
.addCase(setNewPassword.fulfilled, handleAuthFulfilled)
.addCase(setNewPassword.rejected, handleNewPasswordRejected);
},
});
export default authSlice.reducer;

View File

@@ -2,283 +2,283 @@ import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitorsSummary: [],
success: null,
msg: null,
isLoading: false,
monitorsSummary: [],
success: null,
msg: null,
};
export const createPageSpeed = createAsyncThunk(
"pageSpeedMonitors/createPageSpeed",
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);
}
}
"pageSpeedMonitors/createPageSpeed",
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 getPagespeedMonitorById = createAsyncThunk(
"monitors/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);
}
}
"monitors/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 getPageSpeedByTeamId = createAsyncThunk(
"pageSpeedMonitors/getPageSpeedByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["pagespeed"],
});
"pageSpeedMonitors/getPageSpeedByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["pagespeed"],
});
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);
}
}
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 updatePageSpeed = createAsyncThunk(
"pageSpeedMonitors/updatePageSpeed",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const updatedFields = {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
// notifications: monitor.notifications,
};
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);
}
}
"pageSpeedMonitors/updatePageSpeed",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const updatedFields = {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
// notifications: monitor.notifications,
};
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 deletePageSpeed = createAsyncThunk(
"pageSpeedMonitors/deletePageSpeed",
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);
}
}
"pageSpeedMonitors/deletePageSpeed",
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 pausePageSpeed = createAsyncThunk(
"pageSpeedMonitors/pausePageSpeed",
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);
}
}
"pageSpeedMonitors/pausePageSpeed",
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);
}
}
);
const pageSpeedMonitorSlice = createSlice({
name: "pageSpeedMonitor",
initialState,
reducers: {
clearMonitorState: (state) => {
state.isLoading = false;
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
},
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by teamId
// *****************************************************
name: "pageSpeedMonitor",
initialState,
reducers: {
clearMonitorState: (state) => {
state.isLoading = false;
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
},
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by teamId
// *****************************************************
.addCase(getPageSpeedByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getPageSpeedByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitorsSummary = action.payload.data;
})
.addCase(getPageSpeedByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting page speed monitors failed";
})
.addCase(getPageSpeedByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getPageSpeedByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitorsSummary = action.payload.data;
})
.addCase(getPageSpeedByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting page speed monitors failed";
})
// *****************************************************
.addCase(getPagespeedMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getPagespeedMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getPagespeedMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to get pagespeed monitor";
})
// *****************************************************
.addCase(getPagespeedMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getPagespeedMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getPagespeedMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to get pagespeed monitor";
})
// *****************************************************
// Create Monitor
// *****************************************************
.addCase(createPageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(createPageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(createPageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to create page speed monitor";
})
// *****************************************************
// Create Monitor
// *****************************************************
.addCase(createPageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(createPageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(createPageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to create page speed monitor";
})
// *****************************************************
// Update Monitor
// *****************************************************
.addCase(updatePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(updatePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(updatePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update page speed monitor";
})
// *****************************************************
// Update Monitor
// *****************************************************
.addCase(updatePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(updatePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(updatePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update page speed monitor";
})
// *****************************************************
// Delete Monitor
// *****************************************************
.addCase(deletePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(deletePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deletePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete page speed monitor";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pausePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(pausePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pausePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause page speed monitor";
});
},
// *****************************************************
// Delete Monitor
// *****************************************************
.addCase(deletePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(deletePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deletePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete page speed monitor";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pausePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(pausePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pausePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause page speed monitor";
});
},
});
export const { setMonitors, clearMonitorState } = pageSpeedMonitorSlice.actions;

View File

@@ -2,116 +2,114 @@ import { networkService } from "../../main";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
const initialState = {
isLoading: false,
apiBaseUrl: "",
logLevel: "debug",
isLoading: false,
apiBaseUrl: "",
logLevel: "debug",
};
export const getAppSettings = createAsyncThunk(
"settings/getSettings",
async (data, thunkApi) => {
try {
const res = await networkService.getAppSettings({
authToken: data.authToken,
});
return res.data;
} catch (error) {
if (error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
"settings/getSettings",
async (data, thunkApi) => {
try {
const res = await networkService.getAppSettings({
authToken: data.authToken,
});
return res.data;
} catch (error) {
if (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 updateAppSettings = createAsyncThunk(
"settings/updateSettings",
async ({ settings, authToken }, thunkApi) => {
networkService.setBaseUrl(settings.apiBaseUrl);
try {
const parsedSettings = {
apiBaseUrl: settings.apiBaseUrl,
logLevel: settings.logLevel,
clientHost: settings.clientHost,
jwtSecret: settings.jwtSecret,
dbType: settings.dbType,
dbConnectionString: settings.dbConnectionString,
redisHost: settings.redisHost,
redisPort: settings.redisPort,
jwtTTL: settings.jwtTTL,
pagespeedApiKey: settings.pagespeedApiKey,
systemEmailHost: settings.systemEmailHost,
systemEmailPort: settings.systemEmailPort,
systemEmailAddress: settings.systemEmailAddress,
systemEmailPassword: settings.systemEmailPassword,
};
const res = await networkService.updateAppSettings({
settings: parsedSettings,
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);
}
}
"settings/updateSettings",
async ({ settings, authToken }, thunkApi) => {
networkService.setBaseUrl(settings.apiBaseUrl);
try {
const parsedSettings = {
apiBaseUrl: settings.apiBaseUrl,
logLevel: settings.logLevel,
clientHost: settings.clientHost,
jwtSecret: settings.jwtSecret,
dbType: settings.dbType,
dbConnectionString: settings.dbConnectionString,
redisHost: settings.redisHost,
redisPort: settings.redisPort,
jwtTTL: settings.jwtTTL,
pagespeedApiKey: settings.pagespeedApiKey,
systemEmailHost: settings.systemEmailHost,
systemEmailPort: settings.systemEmailPort,
systemEmailAddress: settings.systemEmailAddress,
systemEmailPassword: settings.systemEmailPassword,
};
const res = await networkService.updateAppSettings({
settings: parsedSettings,
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 handleGetSettingsFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.apiBaseUrl = action.payload.data.apiBaseUrl;
state.logLevel = action.payload.data.logLevel;
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.apiBaseUrl = action.payload.data.apiBaseUrl;
state.logLevel = action.payload.data.logLevel;
};
const handleGetSettingsRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to get settings.";
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to get settings.";
};
const handleUpdateSettingsFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.apiBaseUrl = action.payload.data.apiBaseUrl;
state.logLevel = action.payload.data.logLevel;
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.apiBaseUrl = action.payload.data.apiBaseUrl;
state.logLevel = action.payload.data.logLevel;
};
const handleUpdateSettingsRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update settings.";
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to update settings.";
};
const settingsSlice = createSlice({
name: "settings",
initialState,
extraReducers: (builder) => {
builder
.addCase(getAppSettings.pending, (state) => {
state.isLoading = true;
})
.addCase(getAppSettings.fulfilled, handleGetSettingsFulfilled)
.addCase(getAppSettings.rejected, handleGetSettingsRejected);
name: "settings",
initialState,
extraReducers: (builder) => {
builder
.addCase(getAppSettings.pending, (state) => {
state.isLoading = true;
})
.addCase(getAppSettings.fulfilled, handleGetSettingsFulfilled)
.addCase(getAppSettings.rejected, handleGetSettingsRejected);
builder
.addCase(updateAppSettings.pending, (state) => {
state.isLoading = true;
})
.addCase(updateAppSettings.fulfilled, handleUpdateSettingsFulfilled)
.addCase(updateAppSettings.rejected, handleUpdateSettingsRejected);
},
builder
.addCase(updateAppSettings.pending, (state) => {
state.isLoading = true;
})
.addCase(updateAppSettings.fulfilled, handleUpdateSettingsFulfilled)
.addCase(updateAppSettings.rejected, handleUpdateSettingsRejected);
},
});
export default settingsSlice.reducer;

View File

@@ -3,54 +3,49 @@ import { createSlice } from "@reduxjs/toolkit";
// Initial state for UI settings.
// Add more settings as needed (e.g., theme preferences, user settings)
const initialState = {
monitors: {
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
},
maintenance: {
rowsPerPage: 5,
},
sidebar: {
collapsed: false,
},
mode: "light",
greeting: { index: 0, lastUpdate: null },
timezone: "America/Toronto",
monitors: {
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
},
maintenance: {
rowsPerPage: 5,
},
sidebar: {
collapsed: false,
},
mode: "light",
greeting: { index: 0, lastUpdate: null },
timezone: "America/Toronto",
};
const uiSlice = createSlice({
name: "ui",
initialState,
reducers: {
setRowsPerPage: (state, action) => {
const { table, value } = action.payload;
if (state[table]) {
state[table].rowsPerPage = value;
}
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
},
setMode: (state, action) => {
state.mode = action.payload;
},
setGreeting(state, action) {
state.greeting.index = action.payload.index;
state.greeting.lastUpdate = action.payload.lastUpdate;
},
setTimezone(state, action) {
state.timezone = action.payload.timezone;
},
},
name: "ui",
initialState,
reducers: {
setRowsPerPage: (state, action) => {
const { table, value } = action.payload;
if (state[table]) {
state[table].rowsPerPage = value;
}
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
},
setMode: (state, action) => {
state.mode = action.payload;
},
setGreeting(state, action) {
state.greeting.index = action.payload.index;
state.greeting.lastUpdate = action.payload.lastUpdate;
},
setTimezone(state, action) {
state.timezone = action.payload.timezone;
},
},
});
export default uiSlice.reducer;
export const {
setRowsPerPage,
toggleSidebar,
setMode,
setGreeting,
setTimezone,
} = uiSlice.actions;
export const { setRowsPerPage, toggleSidebar, setMode, setGreeting, setTimezone } =
uiSlice.actions;

View File

@@ -2,447 +2,442 @@ import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitorsSummary: [],
success: null,
msg: null,
isLoading: false,
monitorsSummary: [],
success: null,
msg: null,
};
export const createUptimeMonitor = createAsyncThunk(
"monitors/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);
}
}
"monitors/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 checkEndpointResolution = createAsyncThunk(
"monitors/checkEndpoint",
async(data, thunkApi) => {
try{
const { authToken, monitorURL } = data;
"monitors/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);
}
}
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 getUptimeMonitorById = createAsyncThunk(
"monitors/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);
}
}
"monitors/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 getUptimeMonitorsByTeamId = createAsyncThunk(
"monitors/getMonitorsByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["http", "ping"],
});
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);
}
}
"monitors/getMonitorsByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsAndSummaryByTeamId({
authToken: token,
teamId: user.teamId,
types: ["http", "ping"],
});
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 updateUptimeMonitor = createAsyncThunk(
"monitors/updateMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const updatedFields = {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications,
};
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);
}
}
"monitors/updateMonitor",
async (data, thunkApi) => {
try {
const { authToken, monitor } = data;
const updatedFields = {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications,
};
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 deleteUptimeMonitor = createAsyncThunk(
"monitors/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);
}
}
"monitors/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 pauseUptimeMonitor = createAsyncThunk(
"monitors/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);
}
}
"monitors/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 deleteMonitorChecksByTeamId = createAsyncThunk(
"monitors/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);
}
}
"monitors/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 addDemoMonitors = createAsyncThunk(
"monitors/addDemoMonitors",
async (data, thunkApi) => {
try {
const { authToken } = data;
const res = await networkService.addDemoMonitors({
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);
}
}
"monitors/addDemoMonitors",
async (data, thunkApi) => {
try {
const { authToken } = data;
const res = await networkService.addDemoMonitors({
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);
}
}
);
export const deleteAllMonitors = createAsyncThunk(
"monitors/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);
}
}
"monitors/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 uptimeMonitorsSlice = createSlice({
name: "uptimeMonitors",
initialState,
reducers: {
clearUptimeMonitorState: (state) => {
state.isLoading = false;
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
},
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by teamId
// *****************************************************
name: "uptimeMonitors",
initialState,
reducers: {
clearUptimeMonitorState: (state) => {
state.isLoading = false;
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
},
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by teamId
// *****************************************************
.addCase(getUptimeMonitorsByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getUptimeMonitorsByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitorsSummary = action.payload.data;
})
.addCase(getUptimeMonitorsByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting uptime monitors failed";
})
.addCase(getUptimeMonitorsByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(getUptimeMonitorsByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitorsSummary = action.payload.data;
})
.addCase(getUptimeMonitorsByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Getting uptime monitors failed";
})
// *****************************************************
// Create Monitor
// *****************************************************
.addCase(createUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(createUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(createUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to create uptime monitor";
})
// *****************************************************
// Resolve Endpoint
// *****************************************************
.addCase(checkEndpointResolution.pending, (state) => {
state.isLoading = true;
})
.addCase(checkEndpointResolution.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(checkEndpointResolution.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(getUptimeMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getUptimeMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getUptimeMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to get uptime monitor";
})
// *****************************************************
// update Monitor
// *****************************************************
.addCase(updateUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(updateUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(updateUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update uptime monitor";
})
// *****************************************************
// Create Monitor
// *****************************************************
.addCase(createUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(createUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(createUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to create uptime monitor";
})
// *****************************************************
// Resolve Endpoint
// *****************************************************
.addCase(checkEndpointResolution.pending, (state) => {
state.isLoading = true;
})
.addCase(checkEndpointResolution.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(checkEndpointResolution.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(getUptimeMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getUptimeMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getUptimeMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to get uptime monitor";
})
// *****************************************************
// update Monitor
// *****************************************************
.addCase(updateUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(updateUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(updateUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update uptime monitor";
})
// *****************************************************
// Delete Monitor
// *****************************************************
.addCase(deleteUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete uptime monitor";
})
// *****************************************************
// Delete Monitor checks by Team ID
// *****************************************************
.addCase(deleteMonitorChecksByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteMonitorChecksByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteMonitorChecksByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete monitor checks";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pauseUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(pauseUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pauseUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause uptime monitor";
})
// *****************************************************
// Add Demo Monitors
// *****************************************************
.addCase(addDemoMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(addDemoMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(addDemoMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to add demo uptime monitors";
})
// *****************************************************
// Delete all Monitors
// *****************************************************
.addCase(deleteAllMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteAllMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteAllMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete all monitors";
});
},
// *****************************************************
// Delete Monitor
// *****************************************************
.addCase(deleteUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete uptime monitor";
})
// *****************************************************
// Delete Monitor checks by Team ID
// *****************************************************
.addCase(deleteMonitorChecksByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteMonitorChecksByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteMonitorChecksByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete monitor checks";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pauseUptimeMonitor.pending, (state) => {
state.isLoading = true;
})
.addCase(pauseUptimeMonitor.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pauseUptimeMonitor.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause uptime monitor";
})
// *****************************************************
// Add Demo Monitors
// *****************************************************
.addCase(addDemoMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(addDemoMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(addDemoMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to add demo uptime monitors";
})
// *****************************************************
// Delete all Monitors
// *****************************************************
.addCase(deleteAllMonitors.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteAllMonitors.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteAllMonitors.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to delete all monitors";
});
},
});
export const { setUptimeMonitors, clearUptimeMonitorState } =
uptimeMonitorsSlice.actions;
export const { setUptimeMonitors, clearUptimeMonitorState } = uptimeMonitorsSlice.actions;
export default uptimeMonitorsSlice.reducer;

View File

@@ -5,28 +5,33 @@ import { logger } from "../Utils/Logger";
import { networkService } from "../main";
const withAdminCheck = (WrappedComponent) => {
const WithAdminCheck = (props) => {
const navigate = useNavigate();
const WithAdminCheck = (props) => {
const navigate = useNavigate();
useEffect(() => {
networkService
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === true) {
navigate("/login");
}
})
.catch((error) => {
logger.error(error);
});
}, [navigate]);
return <WrappedComponent {...props} isSuperAdmin={true} />;
};
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
WithAdminCheck.displayName = `WithAdminCheck(${wrappedComponentName})`;
useEffect(() => {
networkService
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === true) {
navigate("/login");
}
})
.catch((error) => {
logger.error(error);
});
}, [navigate]);
return (
<WrappedComponent
{...props}
isSuperAdmin={true}
/>
);
};
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
WithAdminCheck.displayName = `WithAdminCheck(${wrappedComponentName})`;
return WithAdminCheck;
return WithAdminCheck;
};
export default withAdminCheck;

View File

@@ -1,20 +1,25 @@
import { useSelector } from "react-redux";
const withAdminProp = (WrappedComponent) => {
const WithAdminProp = (props) => {
const { user } = useSelector((state) => state.auth);
const isAdmin =
(user?.role?.includes("admin") ?? false) ||
(user?.role?.includes("superadmin") ?? false);
const WithAdminProp = (props) => {
const { user } = useSelector((state) => state.auth);
const isAdmin =
(user?.role?.includes("admin") ?? false) ||
(user?.role?.includes("superadmin") ?? false);
return <WrappedComponent {...props} isAdmin={isAdmin} />;
};
return (
<WrappedComponent
{...props}
isAdmin={isAdmin}
/>
);
};
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
WithAdminProp.displayName = `WithAdminProp(${wrappedComponentName})`;
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
WithAdminProp.displayName = `WithAdminProp(${wrappedComponentName})`;
return WithAdminProp;
return WithAdminProp;
};
export default withAdminProp;

View File

@@ -1,38 +1,38 @@
.home-layout {
position: relative;
min-height: 100vh;
max-width: 1400px;
margin: 0 auto;
padding: var(--env-var-spacing-2);
position: relative;
min-height: 100vh;
max-width: 1400px;
margin: 0 auto;
padding: var(--env-var-spacing-2);
}
.home-layout aside {
position: sticky;
top: var(--env-var-spacing-2);
left: 0;
position: sticky;
top: var(--env-var-spacing-2);
left: 0;
height: calc(100vh - var(--env-var-spacing-2) * 2);
max-width: var(--env-var-side-bar-width);
height: calc(100vh - var(--env-var-spacing-2) * 2);
max-width: var(--env-var-side-bar-width);
}
.home-layout > div {
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
flex: 1;
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
flex: 1;
}
.home-layout > div:has(> [class*="fallback__"]) .background-pattern-svg {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
z-index: 0;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
z-index: 0;
width: 100%;
max-width: 800px;
height: 100%;
max-height: 800px;
width: 100%;
max-width: 800px;
height: 100%;
max-height: 800px;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}

View File

@@ -5,12 +5,16 @@ import { Stack } from "@mui/material";
import "./index.css";
const HomeLayout = () => {
return (
<Stack className="home-layout" flexDirection="row" gap={14}>
<Sidebar />
<Outlet />
</Stack>
);
return (
<Stack
className="home-layout"
flexDirection="row"
gap={14}
>
<Sidebar />
<Outlet />
</Stack>
);
};
export default HomeLayout;

View File

@@ -4,35 +4,35 @@
.account button,
.account td,
.account .MuiSelect-select {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.account h1.MuiTypography-root {
font-weight: 600;
font-weight: 600;
}
.account .MuiTabPanel-root {
padding: 0;
margin-top: 50px;
padding: 0;
margin-top: 50px;
}
.account button:not(.MuiIconButton-root) {
height: 34px;
height: 34px;
}
.account .field {
flex: 1;
flex: 1;
}
#modal-delete-account,
#modal-edit-org-name,
#modal-invite-member {
font-size: var(--env-var-font-size-large);
font-size: var(--env-var-font-size-large);
}
.account .MuiStack-root:has(span.MuiTypography-root.input-error) {
position: relative;
position: relative;
}
.account:not(:has(#modal-invite-member)) span.MuiTypography-root.input-error {
position: absolute;
top: 100%;
position: absolute;
top: 100%;
}
.account .MuiTableBody-root .MuiTableCell-root {
padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2);
padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2);
}

View File

@@ -16,78 +16,81 @@ import "./index.css";
*/
const Account = ({ open = "profile" }) => {
const theme = useTheme();
const navigate = useNavigate();
const tab = open;
const handleTabChange = (event, newTab) => {
navigate(`/account/${newTab}`);
};
const { user } = useSelector((state) => state.auth);
const theme = useTheme();
const navigate = useNavigate();
const tab = open;
const handleTabChange = (event, newTab) => {
navigate(`/account/${newTab}`);
};
const { user } = useSelector((state) => state.auth);
const requiredRoles = ["superadmin", "admin"];
let tabList = ["Profile", "Password", "Team"];
const hideTeams = !requiredRoles.some((role) => user.role.includes(role));
if (hideTeams) {
tabList = ["Profile", "Password"];
}
const requiredRoles = ["superadmin", "admin"];
let tabList = ["Profile", "Password", "Team"];
const hideTeams = !requiredRoles.some((role) => user.role.includes(role));
if (hideTeams) {
tabList = ["Profile", "Password"];
}
// Remove password for demo
if (user.role.includes("demo")) {
tabList = ["Profile"];
}
// Remove password for demo
if (user.role.includes("demo")) {
tabList = ["Profile"];
}
return (
<Box
className="account"
px={theme.spacing(20)}
py={theme.spacing(12)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
<TabContext value={tab}>
<Box
sx={{
borderBottom: 1,
borderColor: theme.palette.border.light,
"& .MuiTabs-root": { height: "fit-content", minHeight: "0" },
}}
>
<TabList onChange={handleTabChange} aria-label="account tabs">
{tabList.map((label, index) => (
<Tab
label={label}
key={index}
value={label.toLowerCase()}
sx={{
fontSize: 13,
color: theme.palette.text.tertiary,
textTransform: "none",
minWidth: "fit-content",
minHeight: 0,
paddingLeft: 0,
paddingY: theme.spacing(4),
fontWeight: 400,
marginRight: theme.spacing(8),
"&:focus": {
outline: "none",
},
}}
/>
))}
</TabList>
</Box>
<ProfilePanel />
{user.role.includes("superadmin") && <PasswordPanel />}
{!hideTeams && <TeamPanel />}
</TabContext>
</Box>
);
return (
<Box
className="account"
px={theme.spacing(20)}
py={theme.spacing(12)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
<TabContext value={tab}>
<Box
sx={{
borderBottom: 1,
borderColor: theme.palette.border.light,
"& .MuiTabs-root": { height: "fit-content", minHeight: "0" },
}}
>
<TabList
onChange={handleTabChange}
aria-label="account tabs"
>
{tabList.map((label, index) => (
<Tab
label={label}
key={index}
value={label.toLowerCase()}
sx={{
fontSize: 13,
color: theme.palette.text.tertiary,
textTransform: "none",
minWidth: "fit-content",
minHeight: 0,
paddingLeft: 0,
paddingY: theme.spacing(4),
fontWeight: 400,
marginRight: theme.spacing(8),
"&:focus": {
outline: "none",
},
}}
/>
))}
</TabList>
</Box>
<ProfilePanel />
{user.role.includes("superadmin") && <PasswordPanel />}
{!hideTeams && <TeamPanel />}
</TabContext>
</Box>
);
};
Account.propTypes = {
open: PropTypes.oneOf(["profile", "password", "team"]),
open: PropTypes.oneOf(["profile", "password", "team"]),
};
export default Account;

View File

@@ -9,257 +9,255 @@ import PropTypes from "prop-types";
import LoadingButton from "@mui/lab/LoadingButton";
import { ConfigBox } from "../Settings/styled";
import { useNavigate } from "react-router";
import {
getAppSettings,
updateAppSettings,
} from "../../Features/Settings/settingsSlice";
import { getAppSettings, updateAppSettings } from "../../Features/Settings/settingsSlice";
import { useState, useEffect } from "react";
import Select from "../../Components/Inputs/Select";
const AdvancedSettings = ({ isAdmin }) => {
const navigate = useNavigate();
const navigate = useNavigate();
useEffect(() => {
if (!isAdmin) {
navigate("/");
}
}, [navigate, isAdmin]);
useEffect(() => {
if (!isAdmin) {
navigate("/");
}
}, [navigate, isAdmin]);
const theme = useTheme();
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const settings = useSelector((state) => state.settings);
const [localSettings, setLocalSettings] = useState({
apiBaseUrl: "",
logLevel: "debug",
systemEmailHost: "",
systemEmailPort: "",
systemEmailAddress: "",
systemEmailPassword: "",
jwtTTL: "",
dbType: "",
redisHost: "",
redisPort: "",
pagespeedApiKey: "",
});
const theme = useTheme();
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const settings = useSelector((state) => state.settings);
const [localSettings, setLocalSettings] = useState({
apiBaseUrl: "",
logLevel: "debug",
systemEmailHost: "",
systemEmailPort: "",
systemEmailAddress: "",
systemEmailPassword: "",
jwtTTL: "",
dbType: "",
redisHost: "",
redisPort: "",
pagespeedApiKey: "",
});
useEffect(() => {
const getSettings = async () => {
const action = await dispatch(getAppSettings({ authToken }));
if (action.payload.success) {
setLocalSettings(action.payload.data);
} else {
createToast({ body: "Failed to get settings" });
}
};
getSettings();
}, [authToken, dispatch]);
useEffect(() => {
const getSettings = async () => {
const action = await dispatch(getAppSettings({ authToken }));
if (action.payload.success) {
setLocalSettings(action.payload.data);
} else {
createToast({ body: "Failed to get settings" });
}
};
getSettings();
}, [authToken, dispatch]);
const logItems = [
{ _id: 1, name: "none" },
{ _id: 2, name: "debug" },
{ _id: 3, name: "error" },
{ _id: 4, name: "warn" },
];
const logItems = [
{ _id: 1, name: "none" },
{ _id: 2, name: "debug" },
{ _id: 3, name: "error" },
{ _id: 4, name: "warn" },
];
const logItemLookup = {
none: 1,
debug: 2,
error: 3,
warn: 4,
};
const logItemLookup = {
none: 1,
debug: 2,
error: 3,
warn: 4,
};
const handleLogLevel = (e) => {
const id = e.target.value;
const newLogLevel = logItems.find((item) => item._id === id).name;
setLocalSettings({ ...localSettings, logLevel: newLogLevel });
};
const handleLogLevel = (e) => {
const id = e.target.value;
const newLogLevel = logItems.find((item) => item._id === id).name;
setLocalSettings({ ...localSettings, logLevel: newLogLevel });
};
const handleChange = (event) => {
const { value, id } = event.target;
setLocalSettings({ ...localSettings, [id]: value });
};
const handleChange = (event) => {
const { value, id } = event.target;
setLocalSettings({ ...localSettings, [id]: value });
};
const handleSave = async () => {
const action = await dispatch(
updateAppSettings({ settings: localSettings, authToken })
);
let body = "";
if (action.payload.success) {
console.log(action.payload.data);
setLocalSettings(action.payload.data);
body = "Settings saved successfully";
} else {
body = "Failed to save settings";
}
createToast({ body });
};
const handleSave = async () => {
const action = await dispatch(
updateAppSettings({ settings: localSettings, authToken })
);
let body = "";
if (action.payload.success) {
console.log(action.payload.data);
setLocalSettings(action.payload.data);
body = "Settings saved successfully";
} else {
body = "Failed to save settings";
}
createToast({ body });
};
return (
<Box
className="settings"
style={{
paddingBottom: 0,
}}
>
<Stack
component="form"
gap={theme.spacing(12)}
noValidate
spellCheck="false"
>
<ConfigBox>
<Box>
<Typography component="h1">Client settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Modify client settings here.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
id="apiBaseUrl"
label="API URL Host"
value={localSettings.apiBaseUrl}
onChange={handleChange}
/>
<Select
id="logLevel"
label="Log level"
name="logLevel"
items={logItems}
value={logItemLookup[localSettings.logLevel]}
onChange={handleLogLevel}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Email settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Set your host email settings here. These settings are used for
sending system emails.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="systemEmailHost"
label="Email host"
name="systemEmailHost"
value={localSettings.systemEmailHost}
onChange={handleChange}
/>
<Field
type="number"
id="systemEmailPort"
label="System email address"
name="systemEmailPort"
value={localSettings.systemEmailPort.toString()}
onChange={handleChange}
/>
<Field
type="email"
id="systemEmailAddress"
label="System email address"
name="systemEmailAddress"
value={localSettings.systemEmailAddress}
onChange={handleChange}
/>
<Field
type="text"
id="systemEmailPassword"
label="System email password"
name="systemEmailPassword"
value={localSettings.systemEmailPassword}
onChange={handleChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Server settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Modify server settings here.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="jwtTTL"
label="JWT time to live"
name="jwtTTL"
value={localSettings.jwtTTL}
onChange={handleChange}
/>
<Field
type="text"
id="dbType"
label="Database type"
name="dbType"
value={localSettings.dbType}
onChange={handleChange}
/>
<Field
type="text"
id="redisHost"
label="Redis host"
name="redisHost"
value={localSettings.redisHost}
onChange={handleChange}
/>
<Field
type="number"
id="redisPort"
label="Redis port"
name="redisPort"
value={localSettings.redisPort.toString()}
onChange={handleChange}
/>
<Field
type="text"
id="pagespeedApiKey"
label="PageSpeed API key"
name="pagespeedApiKey"
value={localSettings.pagespeedApiKey}
onChange={handleChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">About</Typography>
</Box>
<Box>
<Typography component="h2">BlueWave Uptime v1.0.0</Typography>
<Typography
sx={{ mt: theme.spacing(2), mb: theme.spacing(6), opacity: 0.6 }}
>
Developed by Bluewave Labs.
</Typography>
<Link
level="secondary"
url="https://github.com/bluewave-labs"
label="https://github.com/bluewave-labs"
/>
</Box>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<LoadingButton
loading={settings.isLoading || settings.authIsLoading}
variant="contained"
color="primary"
sx={{ px: theme.spacing(12), mt: theme.spacing(20) }}
onClick={handleSave}
>
Save
</LoadingButton>
</Stack>
</Stack>
</Box>
);
return (
<Box
className="settings"
style={{
paddingBottom: 0,
}}
>
<Stack
component="form"
gap={theme.spacing(12)}
noValidate
spellCheck="false"
>
<ConfigBox>
<Box>
<Typography component="h1">Client settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Modify client settings here.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
id="apiBaseUrl"
label="API URL Host"
value={localSettings.apiBaseUrl}
onChange={handleChange}
/>
<Select
id="logLevel"
label="Log level"
name="logLevel"
items={logItems}
value={logItemLookup[localSettings.logLevel]}
onChange={handleLogLevel}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Email settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Set your host email settings here. These settings are used for sending
system emails.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="systemEmailHost"
label="Email host"
name="systemEmailHost"
value={localSettings.systemEmailHost}
onChange={handleChange}
/>
<Field
type="number"
id="systemEmailPort"
label="System email address"
name="systemEmailPort"
value={localSettings.systemEmailPort.toString()}
onChange={handleChange}
/>
<Field
type="email"
id="systemEmailAddress"
label="System email address"
name="systemEmailAddress"
value={localSettings.systemEmailAddress}
onChange={handleChange}
/>
<Field
type="text"
id="systemEmailPassword"
label="System email password"
name="systemEmailPassword"
value={localSettings.systemEmailPassword}
onChange={handleChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Server settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Modify server settings here.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="jwtTTL"
label="JWT time to live"
name="jwtTTL"
value={localSettings.jwtTTL}
onChange={handleChange}
/>
<Field
type="text"
id="dbType"
label="Database type"
name="dbType"
value={localSettings.dbType}
onChange={handleChange}
/>
<Field
type="text"
id="redisHost"
label="Redis host"
name="redisHost"
value={localSettings.redisHost}
onChange={handleChange}
/>
<Field
type="number"
id="redisPort"
label="Redis port"
name="redisPort"
value={localSettings.redisPort.toString()}
onChange={handleChange}
/>
<Field
type="text"
id="pagespeedApiKey"
label="PageSpeed API key"
name="pagespeedApiKey"
value={localSettings.pagespeedApiKey}
onChange={handleChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">About</Typography>
</Box>
<Box>
<Typography component="h2">BlueWave Uptime v1.0.0</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(6), opacity: 0.6 }}>
Developed by Bluewave Labs.
</Typography>
<Link
level="secondary"
url="https://github.com/bluewave-labs"
label="https://github.com/bluewave-labs"
/>
</Box>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<LoadingButton
loading={settings.isLoading || settings.authIsLoading}
variant="contained"
color="primary"
sx={{ px: theme.spacing(12), mt: theme.spacing(20) }}
onClick={handleSave}
>
Save
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
AdvancedSettings.propTypes = {
isAdmin: PropTypes.bool,
isAdmin: PropTypes.bool,
};
export default AdvancedSettings;

View File

@@ -12,192 +12,198 @@ import Logo from "../../assets/icons/bwu-icon.svg?react";
import "./index.css";
const CheckEmail = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const [email, setEmail] = useState();
const [disabled, setDisabled] = useState(false);
useEffect(() => {
setEmail(sessionStorage.getItem("email"));
}, []);
const [email, setEmail] = useState();
const [disabled, setDisabled] = useState(false);
useEffect(() => {
setEmail(sessionStorage.getItem("email"));
}, []);
// TODO - fix
const openMail = () => {
window.location.href = "mailto:";
};
// TODO - fix
const openMail = () => {
window.location.href = "mailto:";
};
const toastFail = [
{
body: "Email not found.",
},
{
body: "Redirecting in 3...",
},
{
body: "Redirecting in 2...",
},
{
body: "Redirecting in 1...",
},
];
const toastFail = [
{
body: "Email not found.",
},
{
body: "Redirecting in 3...",
},
{
body: "Redirecting in 2...",
},
{
body: "Redirecting in 1...",
},
];
const resendToken = async () => {
setDisabled(true); // prevent resent button from being spammed
if (!email) {
let index = 0;
const interval = setInterval(() => {
if (index < toastFail.length) {
createToast(toastFail[index]);
index++;
} else {
clearInterval(interval);
navigate("/forgot-password");
}
}, 1000);
} else {
const form = { email: email };
const action = await dispatch(forgotPassword(form));
if (action.payload.success) {
createToast({
body: `Instructions sent to ${form.email}.`,
});
setDisabled(false);
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
};
const resendToken = async () => {
setDisabled(true); // prevent resent button from being spammed
if (!email) {
let index = 0;
const interval = setInterval(() => {
if (index < toastFail.length) {
createToast(toastFail[index]);
index++;
} else {
clearInterval(interval);
navigate("/forgot-password");
}
}, 1000);
} else {
const form = { email: email };
const action = await dispatch(forgotPassword(form));
if (action.payload.success) {
createToast({
body: `Instructions sent to ${form.email}.`,
});
setDisabled(false);
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
};
const handleNavigate = () => {
sessionStorage.removeItem("email");
navigate("/login");
};
const handleNavigate = () => {
sessionStorage.removeItem("email");
navigate("/login");
};
return (
<Stack
className="check-email-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 22,
},
"& p": { color: theme.palette.text.accent, fontSize: 13.5 },
"& span": { fontSize: "inherit" },
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(10) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<EmailIcon alt="email icon" />
</IconBox>
<Typography component="h1">Check your email</Typography>
<Typography>
We sent a password reset link to{" "}
<Typography className="email-sent-to" component="span">
{email || "username@email.com"}
</Typography>
</Typography>
</Box>
<Button
variant="contained"
color="primary"
onClick={openMail}
sx={{
width: "100%",
maxWidth: 400,
}}
>
Open email app
</Button>
<Typography sx={{ alignSelf: "center", mt: theme.spacing(6) }}>
Didn&apos;t receive the email?{" "}
<Typography
component="span"
onClick={resendToken}
sx={{
color: theme.palette.primary.main,
userSelect: "none",
pointerEvents: disabled ? "none" : "auto",
cursor: disabled ? "default" : "pointer",
opacity: disabled ? 0.5 : 1,
}}
>
Click to resend
</Typography>
</Typography>
</Stack>
</Stack>
<Box textAlign="center" p={theme.spacing(12)}>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
ml={theme.spacing(2)}
color={theme.palette.primary.main}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
return (
<Stack
className="check-email-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 22,
},
"& p": { color: theme.palette.text.accent, fontSize: 13.5 },
"& span": { fontSize: "inherit" },
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(10) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<EmailIcon alt="email icon" />
</IconBox>
<Typography component="h1">Check your email</Typography>
<Typography>
We sent a password reset link to{" "}
<Typography
className="email-sent-to"
component="span"
>
{email || "username@email.com"}
</Typography>
</Typography>
</Box>
<Button
variant="contained"
color="primary"
onClick={openMail}
sx={{
width: "100%",
maxWidth: 400,
}}
>
Open email app
</Button>
<Typography sx={{ alignSelf: "center", mt: theme.spacing(6) }}>
Didn&apos;t receive the email?{" "}
<Typography
component="span"
onClick={resendToken}
sx={{
color: theme.palette.primary.main,
userSelect: "none",
pointerEvents: disabled ? "none" : "auto",
cursor: disabled ? "default" : "pointer",
opacity: disabled ? 0.5 : 1,
}}
>
Click to resend
</Typography>
</Typography>
</Stack>
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
ml={theme.spacing(2)}
color={theme.palette.primary.main}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
};
export default CheckEmail;

View File

@@ -15,196 +15,194 @@ import LoadingButton from "@mui/lab/LoadingButton";
import "./index.css";
const ForgotPassword = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const { isLoading } = useSelector((state) => state.auth);
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
email: "",
});
const { isLoading } = useSelector((state) => state.auth);
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
email: "",
});
useEffect(() => {
const email = sessionStorage.getItem("email");
email && setForm({ email: sessionStorage.getItem("email") });
}, []);
useEffect(() => {
const email = sessionStorage.getItem("email");
email && setForm({ email: sessionStorage.getItem("email") });
}, []);
const handleSubmit = async (event) => {
event.preventDefault();
const handleSubmit = async (event) => {
event.preventDefault();
const { error } = credentials.validate(form, { abortEarly: false });
const { error } = credentials.validate(form, { abortEarly: false });
if (error) {
// validation errors
const err =
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data.";
setErrors({ email: err });
createToast({
body: err,
});
} else {
const action = await dispatch(forgotPassword(form));
if (action.payload.success) {
sessionStorage.setItem("email", form.email);
navigate("/check-email");
createToast({
body: `Instructions sent to ${form.email}.`,
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
};
if (error) {
// validation errors
const err =
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data.";
setErrors({ email: err });
createToast({
body: err,
});
} else {
const action = await dispatch(forgotPassword(form));
if (action.payload.success) {
sessionStorage.setItem("email", form.email);
navigate("/check-email");
createToast({
body: `Instructions sent to ${form.email}.`,
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
};
const handleChange = (event) => {
const { value } = event.target;
setForm({ email: value });
const handleChange = (event) => {
const { value } = event.target;
setForm({ email: value });
const { error } = credentials.validate(
{ email: value },
{ abortEarly: false }
);
const { error } = credentials.validate({ email: value }, { abortEarly: false });
if (error) setErrors({ email: error.details[0].message });
else delete errors.email;
};
if (error) setErrors({ email: error.details[0].message });
else delete errors.email;
};
const handleNavigate = () => {
sessionStorage.removeItem("email");
navigate("/login");
};
const handleNavigate = () => {
sessionStorage.removeItem("email");
navigate("/login");
};
return (
<Stack
className="forgot-password-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 21,
},
"& p": {
fontSize: 14,
color: theme.palette.text.accent,
},
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<Key alt="password key icon" />
</IconBox>
<Typography component="h1">Forgot password?</Typography>
<Typography>
No worries, we&apos;ll send you reset instructions.
</Typography>
</Box>
<Box
component="form"
width="95%"
textAlign="left"
noValidate
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
type="email"
id="forgot-password-email-input"
label="Email"
isRequired={true}
placeholder="Enter your email"
value={form.email}
onChange={handleChange}
error={errors.email}
/>
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
disabled={errors.email !== undefined}
onClick={handleSubmit}
sx={{
width: "100%",
mt: theme.spacing(15),
}}
>
Send instructions
</LoadingButton>
</Box>
</Stack>
</Stack>
<Box textAlign="center" p={theme.spacing(12)}>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
return (
<Stack
className="forgot-password-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 21,
},
"& p": {
fontSize: 14,
color: theme.palette.text.accent,
},
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<Key alt="password key icon" />
</IconBox>
<Typography component="h1">Forgot password?</Typography>
<Typography>No worries, we&apos;ll send you reset instructions.</Typography>
</Box>
<Box
component="form"
width="95%"
textAlign="left"
noValidate
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
type="email"
id="forgot-password-email-input"
label="Email"
isRequired={true}
placeholder="Enter your email"
value={form.email}
onChange={handleChange}
error={errors.email}
/>
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
disabled={errors.email !== undefined}
onClick={handleSubmit}
sx={{
width: "100%",
mt: theme.spacing(15),
}}
>
Send instructions
</LoadingButton>
</Box>
</Stack>
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
};
export default ForgotPassword;

View File

@@ -25,85 +25,85 @@ const DEMO = import.meta.env.VITE_APP_DEMO;
* @returns {JSX.Element}
*/
const LandingPage = ({ onContinue }) => {
const theme = useTheme();
const theme = useTheme();
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<Typography component="h1">Log In</Typography>
<Typography>We are pleased to see you again!</Typography>
</Box>
<Box width="100%">
<Button
variant="outlined"
color="info"
onClick={onContinue}
sx={{
width: "100%",
"& svg": {
mr: theme.spacing(4),
"& path": {
stroke: theme.palette.other.icon,
},
},
}}
>
<Mail />
Continue with Email
</Button>
</Box>
<Box maxWidth={400}>
<Typography className="tos-p">
By continuing, you agree to our{" "}
<Typography
component="span"
onClick={() => {
window.open(
"https://bluewavelabs.ca/terms-of-service-open-source",
"_blank",
"noreferrer"
);
}}
sx={{
"&:hover": {
color: theme.palette.text.tertiary,
},
}}
>
Terms of Service
</Typography>{" "}
and{" "}
<Typography
component="span"
onClick={() => {
window.open(
"https://bluewavelabs.ca/privacy-policy-open-source",
"_blank",
"noreferrer"
);
}}
sx={{
"&:hover": {
color: theme.palette.text.tertiary,
},
}}
>
Privacy Policy.
</Typography>
</Typography>
</Box>
</Stack>
</>
);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<Typography component="h1">Log In</Typography>
<Typography>We are pleased to see you again!</Typography>
</Box>
<Box width="100%">
<Button
variant="outlined"
color="info"
onClick={onContinue}
sx={{
width: "100%",
"& svg": {
mr: theme.spacing(4),
"& path": {
stroke: theme.palette.other.icon,
},
},
}}
>
<Mail />
Continue with Email
</Button>
</Box>
<Box maxWidth={400}>
<Typography className="tos-p">
By continuing, you agree to our{" "}
<Typography
component="span"
onClick={() => {
window.open(
"https://bluewavelabs.ca/terms-of-service-open-source",
"_blank",
"noreferrer"
);
}}
sx={{
"&:hover": {
color: theme.palette.text.tertiary,
},
}}
>
Terms of Service
</Typography>{" "}
and{" "}
<Typography
component="span"
onClick={() => {
window.open(
"https://bluewavelabs.ca/privacy-policy-open-source",
"_blank",
"noreferrer"
);
}}
sx={{
"&:hover": {
color: theme.palette.text.tertiary,
},
}}
>
Privacy Policy.
</Typography>
</Typography>
</Box>
</Stack>
</>
);
};
LandingPage.propTypes = {
onContinue: PropTypes.func.isRequired,
onContinue: PropTypes.func.isRequired,
};
/**
@@ -118,79 +118,89 @@ LandingPage.propTypes = {
* @returns {JSX.Element}
*/
const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const inputRef = useRef(null);
const theme = useTheme();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
textAlign="center"
>
<Box>
<Typography component="h1">Log In</Typography>
<Typography>Enter your email address</Typography>
</Box>
<Box textAlign="left" mb={theme.spacing(5)}>
<form noValidate spellCheck={false} onSubmit={onSubmit}>
<Field
type="email"
id="login-email-input"
label="Email"
isRequired={true}
placeholder="jordan.ellis@domain.com"
autoComplete="email"
value={form.email}
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email}
ref={inputRef}
/>
</form>
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
</Stack>
</>
);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
textAlign="center"
>
<Box>
<Typography component="h1">Log In</Typography>
<Typography>Enter your email address</Typography>
</Box>
<Box
textAlign="left"
mb={theme.spacing(5)}
>
<form
noValidate
spellCheck={false}
onSubmit={onSubmit}
>
<Field
type="email"
id="login-email-input"
label="Email"
isRequired={true}
placeholder="jordan.ellis@domain.com"
autoComplete="email"
value={form.email}
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email}
ref={inputRef}
/>
</form>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
</Stack>
</>
);
};
StepOne.propTypes = {
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
/**
@@ -205,326 +215,333 @@ StepOne.propTypes = {
* @returns {JSX.Element}
*/
const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
const theme = useTheme();
const navigate = useNavigate();
const inputRef = useRef(null);
const theme = useTheme();
const navigate = useNavigate();
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleNavigate = () => {
if (form.email !== "" && !errors.email) {
sessionStorage.setItem("email", form.email);
}
navigate("/forgot-password");
};
const handleNavigate = () => {
if (form.email !== "" && !errors.email) {
sessionStorage.setItem("email", form.email);
}
navigate("/forgot-password");
};
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
position="relative"
textAlign="center"
>
<Box>
<Typography component="h1">Log In</Typography>
<Typography>Enter your password</Typography>
</Box>
<Box textAlign="left" mb={theme.spacing(5)}>
<form noValidate spellCheck={false} onSubmit={onSubmit}>
<Field
type="password"
id="login-password-input"
label="Password"
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password}
ref={inputRef}
/>
</form>
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.password && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
<Box
textAlign="center"
sx={{
position: "absolute",
top: "104%",
left: "50%",
transform: "translateX(-50%)",
}}
>
<Typography
className="forgot-p"
display="inline-block"
color={theme.palette.primary.main}
>
Forgot password?
</Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
sx={{ userSelect: "none" }}
onClick={handleNavigate}
>
Reset password
</Typography>
</Box>
</Stack>
</>
);
return (
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
position="relative"
textAlign="center"
>
<Box>
<Typography component="h1">Log In</Typography>
<Typography>Enter your password</Typography>
</Box>
<Box
textAlign="left"
mb={theme.spacing(5)}
>
<form
noValidate
spellCheck={false}
onSubmit={onSubmit}
>
<Field
type="password"
id="login-password-input"
label="Password"
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onChange}
error={errors.password}
ref={inputRef}
/>
</form>
</Box>
<Stack
direction="row"
justifyContent="space-between"
>
<Button
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.password && true}
sx={{ width: "30%" }}
>
Continue
</Button>
</Stack>
<Box
textAlign="center"
sx={{
position: "absolute",
top: "104%",
left: "50%",
transform: "translateX(-50%)",
}}
>
<Typography
className="forgot-p"
display="inline-block"
color={theme.palette.primary.main}
>
Forgot password?
</Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
sx={{ userSelect: "none" }}
onClick={handleNavigate}
>
Reset password
</Typography>
</Box>
</Stack>
</>
);
};
StepTwo.propTypes = {
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
form: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
const Login = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const { authToken } = authState;
const authState = useSelector((state) => state.auth);
const { authToken } = authState;
const idMap = {
"login-email-input": "email",
"login-password-input": "password",
};
const idMap = {
"login-email-input": "email",
"login-password-input": "password",
};
const [form, setForm] = useState({
email: DEMO !== undefined ? "uptimedemo@demo.com" : "",
password: DEMO !== undefined ? "Demouser1!" : "",
});
const [errors, setErrors] = useState({});
const [step, setStep] = useState(0);
const [form, setForm] = useState({
email: DEMO !== undefined ? "uptimedemo@demo.com" : "",
password: DEMO !== undefined ? "Demouser1!" : "",
});
const [errors, setErrors] = useState({});
const [step, setStep] = useState(0);
useEffect(() => {
if (authToken) {
navigate("/monitors");
return;
}
networkService
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === false) {
navigate("/register");
}
})
.catch((error) => {
logger.error(error);
});
}, [authToken, navigate]);
useEffect(() => {
if (authToken) {
navigate("/monitors");
return;
}
networkService
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === false) {
navigate("/register");
}
})
.catch((error) => {
logger.error(error);
});
}, [authToken, navigate]);
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
setForm((prev) => ({
...prev,
[name]: value,
}));
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
setForm((prev) => ({
...prev,
[name]: value,
}));
const { error } = credentials.validate(
{ [name]: value },
{ abortEarly: false }
);
const { error } = credentials.validate({ [name]: value }, { abortEarly: false });
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
};
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
};
const handleSubmit = async (event) => {
event.preventDefault();
const handleSubmit = async (event) => {
event.preventDefault();
if (step === 1) {
const { error } = credentials.validate(
{ email: form.email },
{ abortEarly: false }
);
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
createToast({ body: error.details[0].message });
} else {
setStep(2);
}
} else if (step === 2) {
const { error } = credentials.validate(form, { abortEarly: false });
if (step === 1) {
const { error } = credentials.validate(
{ email: form.email },
{ abortEarly: false }
);
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
createToast({ body: error.details[0].message });
} else {
setStep(2);
}
} else if (step === 2) {
const { error } = credentials.validate(form, { abortEarly: false });
if (error) {
// validation errors
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data.",
});
} else {
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/monitors");
createToast({
body: "Welcome back! You're successfully logged in.",
});
} else {
if (action.payload) {
if (action.payload.msg === "Incorrect password")
setErrors({
password:
"The password you provided does not match our records",
});
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
}
};
if (error) {
// validation errors
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data.",
});
} else {
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/monitors");
createToast({
body: "Welcome back! You're successfully logged in.",
});
} else {
if (action.payload) {
if (action.payload.msg === "Incorrect password")
setErrors({
password: "The password you provided does not match our records",
});
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
}
};
return (
<Stack
className="login-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 28,
},
"& p": { fontSize: 14, color: theme.palette.text.accent },
"& span": { fontSize: "inherit" },
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
{step === 0 ? (
<LandingPage onContinue={() => setStep(1)} />
) : step === 1 ? (
<StepOne
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
onBack={() => setStep(0)}
/>
) : (
step === 2 && (
<StepTwo
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
onBack={() => setStep(1)}
/>
)
)}
</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>
);
return (
<Stack
className="login-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 28,
},
"& p": { fontSize: 14, color: theme.palette.text.accent },
"& span": { fontSize: "inherit" },
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
{step === 0 ? (
<LandingPage onContinue={() => setStep(1)} />
) : step === 1 ? (
<StepOne
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
onBack={() => setStep(0)}
/>
) : (
step === 2 && (
<StepTwo
form={form}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
onBack={() => setStep(1)}
/>
)
)}
</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>
);
};
export default Login;

View File

@@ -11,111 +11,113 @@ import Logo from "../../assets/icons/bwu-icon.svg?react";
import "./index.css";
const NewPasswordConfirmed = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const handleNavigate = () => {
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
navigate("/login");
};
const handleNavigate = () => {
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
navigate("/login");
};
return (
<Stack
className="password-confirmed-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 21,
},
"& p": { fontSize: 13.5, color: theme.palette.text.accent },
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<ConfirmIcon alt="password confirm icon" />
</IconBox>
<Typography component="h1">Password reset</Typography>
<Typography mb={theme.spacing(2)}>
Your password has been successfully reset. Click below to log in
magically.
</Typography>
</Box>
<Button
variant="contained"
color="primary"
onClick={() => navigate("/monitors")}
sx={{
width: "100%",
maxWidth: 400,
}}
>
Continue
</Button>
</Stack>
</Stack>
<Box textAlign="center" p={theme.spacing(12)}>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
return (
<Stack
className="password-confirmed-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 21,
},
"& p": { fontSize: 13.5, color: theme.palette.text.accent },
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<ConfirmIcon alt="password confirm icon" />
</IconBox>
<Typography component="h1">Password reset</Typography>
<Typography mb={theme.spacing(2)}>
Your password has been successfully reset. Click below to log in magically.
</Typography>
</Box>
<Button
variant="contained"
color="primary"
onClick={() => navigate("/monitors")}
sx={{
width: "100%",
maxWidth: 400,
}}
>
Continue
</Button>
</Stack>
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
};
export default NewPasswordConfirmed;

File diff suppressed because it is too large Load Diff

View File

@@ -17,290 +17,290 @@ import "./index.css";
import { IconBox } from "./styled";
const SetNewPassword = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
password: "",
confirm: "",
});
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
password: "",
confirm: "",
});
const idMap = {
"register-password-input": "password",
"confirm-password-input": "confirm",
};
const idMap = {
"register-password-input": "password",
"confirm-password-input": "confirm",
};
const { isLoading } = useSelector((state) => state.auth);
const { token } = useParams();
const { isLoading } = useSelector((state) => state.auth);
const { token } = useParams();
const handleSubmit = async (e) => {
e.preventDefault();
const handleSubmit = async (e) => {
e.preventDefault();
const passwordForm = { ...form };
const { error } = credentials.validate(passwordForm, {
abortEarly: false,
context: { password: form.password },
});
const passwordForm = { ...form };
const { error } = credentials.validate(passwordForm, {
abortEarly: false,
context: { password: form.password },
});
if (error) {
// validation errors
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data.",
});
} else {
delete passwordForm.confirm;
const action = await dispatch(
setNewPassword({ token: token, form: passwordForm })
);
if (action.payload.success) {
navigate("/new-password-confirmed");
createToast({
body: "Your password was reset successfully.",
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
};
if (error) {
// validation errors
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data.",
});
} else {
delete passwordForm.confirm;
const action = await dispatch(setNewPassword({ token: token, form: passwordForm }));
if (action.payload.success) {
navigate("/new-password-confirmed");
createToast({
body: "Your password was reset successfully.",
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
}
};
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
setForm((prev) => ({
...prev,
[name]: value,
}));
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
setForm((prev) => ({
...prev,
[name]: value,
}));
const { error } = credentials.validate(
{ [name]: value },
{ abortEarly: false, context: { password: form.password } }
);
const { error } = credentials.validate(
{ [name]: value },
{ abortEarly: false, context: { password: form.password } }
);
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
};
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
};
return (
<Stack
className="set-new-password-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 24,
},
"& p": {
fontSize: 14,
color: theme.palette.text.accent,
},
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(12)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<LockIcon alt="lock icon" />
</IconBox>
<Typography component="h1">Set new password</Typography>
<Typography>
Your new password must be different to previously used passwords.
</Typography>
</Box>
<Box
width="100%"
textAlign="left"
sx={{
"& .input-error": {
display: "none",
},
}}
>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
type="password"
id="register-password-input"
label="Password"
isRequired={true}
placeholder="••••••••"
value={form.password}
onChange={handleChange}
error={errors.password}
/>
</Box>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
type="password"
id="confirm-password-input"
label="Confirm password"
isRequired={true}
placeholder="••••••••"
value={form.confirm}
onChange={handleChange}
error={errors.confirm}
/>
</Box>
<Stack gap={theme.spacing(4)} mb={theme.spacing(12)}>
<Check
text={
<>
<Typography component="span">Must be at least</Typography> 8
characters long
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: form.password.length < 8
? "error"
: "success"
}
/>
<Check
text={
<>
<Typography component="span">Must contain</Typography> one
special character and a number
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[!@#$%^&*(),.?":{}|])(?=.*\d).+$/.test(
form.password
)
? "error"
: "success"
}
/>
<Check
text={
<>
<Typography component="span">
Must contain at least
</Typography>{" "}
one upper and lower character
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[A-Z])(?=.*[a-z]).+$/.test(form.password)
? "error"
: "success"
}
/>
</Stack>
</Box>
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
onClick={handleSubmit}
disabled={Object.keys(errors).length !== 0}
sx={{ width: "100%", maxWidth: 400 }}
>
Reset password
</LoadingButton>
</Stack>
</Stack>
<Box textAlign="center" p={theme.spacing(12)}>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => navigate("/login")}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
return (
<Stack
className="set-new-password-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 24,
},
"& p": {
fontSize: 14,
color: theme.palette.text.accent,
},
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.border.light,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>BlueWave Uptime</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(12)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.border.light,
backgroundColor: theme.palette.background.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<IconBox>
<LockIcon alt="lock icon" />
</IconBox>
<Typography component="h1">Set new password</Typography>
<Typography>
Your new password must be different to previously used passwords.
</Typography>
</Box>
<Box
width="100%"
textAlign="left"
sx={{
"& .input-error": {
display: "none",
},
}}
>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
type="password"
id="register-password-input"
label="Password"
isRequired={true}
placeholder="••••••••"
value={form.password}
onChange={handleChange}
error={errors.password}
/>
</Box>
<Box
component="form"
noValidate
spellCheck={false}
onSubmit={handleSubmit}
>
<Field
type="password"
id="confirm-password-input"
label="Confirm password"
isRequired={true}
placeholder="••••••••"
value={form.confirm}
onChange={handleChange}
error={errors.confirm}
/>
</Box>
<Stack
gap={theme.spacing(4)}
mb={theme.spacing(12)}
>
<Check
text={
<>
<Typography component="span">Must be at least</Typography> 8
characters long
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: form.password.length < 8
? "error"
: "success"
}
/>
<Check
text={
<>
<Typography component="span">Must contain</Typography> one special
character and a number
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[!@#$%^&*(),.?":{}|])(?=.*\d).+$/.test(form.password)
? "error"
: "success"
}
/>
<Check
text={
<>
<Typography component="span">Must contain at least</Typography> one
upper and lower character
</>
}
variant={
errors?.password === "Password is required"
? "error"
: form.password === ""
? "info"
: !/^(?=.*[A-Z])(?=.*[a-z]).+$/.test(form.password)
? "error"
: "success"
}
/>
</Stack>
</Box>
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
onClick={handleSubmit}
disabled={Object.keys(errors).length !== 0}
sx={{ width: "100%", maxWidth: 400 }}
>
Reset password
</LoadingButton>
</Stack>
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => navigate("/login")}
sx={{ userSelect: "none" }}
>
Log In
</Typography>
</Box>
</Stack>
);
};
export default SetNewPassword;

View File

@@ -1,120 +1,120 @@
/* AUTH */
.auth {
height: 100vh;
height: 100vh;
}
.auth h1 {
font-size: var(--env-var-font-size-xlarge);
font-weight: 600;
font-size: var(--env-var-font-size-xlarge);
font-weight: 600;
}
.auth button:not(.MuiIconButton-root) {
font-size: var(--env-var-font-size-medium-plus);
font-size: var(--env-var-font-size-medium-plus);
}
.auth p + span {
opacity: 0.8;
cursor: pointer;
transition: opacity 300ms ease-in;
opacity: 0.8;
cursor: pointer;
transition: opacity 300ms ease-in;
}
.auth p > span:not(.email-sent-to) {
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
transition: all 200ms;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
transition: all 200ms;
}
.auth p > span:not(.email-sent-to):hover {
text-underline-offset: 4px;
text-underline-offset: 4px;
}
.auth p + span:hover {
opacity: 1;
opacity: 1;
}
.auth button:not(.MuiIconButton-root) {
user-select: none;
border-radius: var(--env-var-radius-2);
line-height: 1;
user-select: none;
border-radius: var(--env-var-radius-2);
line-height: 1;
}
.auth button:not(.MuiIconButton-root),
.auth .field .MuiInputBase-root:has(input) {
height: 38px;
height: 38px;
}
.auth .field svg {
width: 24px;
height: 24px;
width: 24px;
height: 24px;
}
.auth .field h3.MuiTypography-root,
.auth .field .input-error,
.auth .check span.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.auth form + form {
margin: var(--env-var-spacing-1-plus) 0;
margin: var(--env-var-spacing-1-plus) 0;
}
.auth .email-sent-to {
font-weight: 600;
cursor: default;
font-weight: 600;
cursor: default;
}
.auth > .MuiStack-root:nth-of-type(2) {
height: var(--env-var-nav-bar-height);
height: var(--env-var-nav-bar-height);
}
.auth .background-pattern-svg {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -30%);
z-index: -1;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -30%);
z-index: -1;
width: 100%;
max-width: 800px;
height: 100%;
max-height: 800px;
width: 100%;
max-width: 800px;
height: 100%;
max-height: 800px;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
.auth .field {
position: relative;
position: relative;
}
.auth .input-error {
position: absolute;
top: calc(100% - 2px);
position: absolute;
top: calc(100% - 2px);
}
@media (max-width: 800px) {
.auth h1 {
font-size: var(--env-var-font-size-large-plus);
}
.auth button:not(.MuiIconButton-root),
.auth .field input,
.auth p,
.auth span {
font-size: var(--env-var-font-size-medium);
}
.auth button:not(.MuiIconButton-root),
.auth .field .MuiInputBase-root:has(input) {
height: 36px;
}
.auth .check span.MuiTypography-root,
.auth .field .input-error,
.auth .field h3.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
.auth .check span > span {
display: none;
}
.auth form + form {
margin: var(--env-var-spacing-1-plus) 0;
}
.auth .background-pattern-svg {
max-width: 750px;
max-height: 750px;
}
.auth h1 {
font-size: var(--env-var-font-size-large-plus);
}
.auth button:not(.MuiIconButton-root),
.auth .field input,
.auth p,
.auth span {
font-size: var(--env-var-font-size-medium);
}
.auth button:not(.MuiIconButton-root),
.auth .field .MuiInputBase-root:has(input) {
height: 36px;
}
.auth .check span.MuiTypography-root,
.auth .field .input-error,
.auth .field h3.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
.auth .check span > span {
display: none;
}
.auth form + form {
margin: var(--env-var-spacing-1-plus) 0;
}
.auth .background-pattern-svg {
max-width: 750px;
max-height: 750px;
}
.forgot-password-page h1,
.check-email-page h1,
.password-confirmed-page h1,
.set-new-password-page h1 {
font-size: 18px;
}
.forgot-password-page h1,
.check-email-page h1,
.password-confirmed-page h1,
.set-new-password-page h1 {
font-size: 18px;
}
}

View File

@@ -1,26 +1,26 @@
import { Box, styled } from "@mui/material";
export const IconBox = styled(Box)(({ theme }) => ({
height: 48,
minWidth: 48,
width: 48,
position: "relative",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
borderRadius: 12,
backgroundColor: theme.palette.background.accent,
margin: "auto",
marginBottom: 8,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 24,
height: 24,
"& path": {
stroke: theme.palette.text.tertiary,
},
},
height: 48,
minWidth: 48,
width: 48,
position: "relative",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
borderRadius: 12,
backgroundColor: theme.palette.background.accent,
margin: "auto",
marginBottom: 8,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 24,
height: 24,
"& path": {
stroke: theme.palette.text.tertiary,
},
},
}));

View File

@@ -1,16 +1,16 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Pagination,
PaginationItem,
Paper,
Typography,
Box,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Pagination,
PaginationItem,
Paper,
Typography,
Box,
} from "@mui/material";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
@@ -26,183 +26,193 @@ import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react"
import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react";
const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const { authToken, user } = useSelector((state) => state.auth);
const mode = useSelector((state) => state.ui.mode);
const [checks, setChecks] = useState([]);
const [checksCount, setChecksCount] = useState(0);
const [paginationController, setPaginationController] = useState({
page: 0,
rowsPerPage: 14,
});
const theme = useTheme();
const { authToken, user } = useSelector((state) => state.auth);
const mode = useSelector((state) => state.ui.mode);
const [checks, setChecks] = useState([]);
const [checksCount, setChecksCount] = useState(0);
const [paginationController, setPaginationController] = useState({
page: 0,
rowsPerPage: 14,
});
useEffect(() => {
setPaginationController((prevPaginationController) => ({
...prevPaginationController,
page: 0,
}));
}, [filter, selectedMonitor]);
useEffect(() => {
setPaginationController((prevPaginationController) => ({
...prevPaginationController,
page: 0,
}));
}, [filter, selectedMonitor]);
useEffect(() => {
const fetchPage = async () => {
if (!monitors || Object.keys(monitors).length === 0) {
return;
}
try {
let res;
if (selectedMonitor === "0") {
res = await networkService.getChecksByTeam({
authToken: authToken,
teamId: user.teamId,
sortOrder: "desc",
limit: null,
dateRange: null,
filter: filter,
page: paginationController.page,
rowsPerPage: paginationController.rowsPerPage,
});
} else {
res = await networkService.getChecksByMonitor({
authToken: authToken,
monitorId: selectedMonitor,
sortOrder: "desc",
limit: null,
dateRange: null,
sitler: filter,
page: paginationController.page,
rowsPerPage: paginationController.rowsPerPage,
});
}
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
logger.error(error);
}
};
fetchPage();
}, [
authToken,
user,
monitors,
selectedMonitor,
filter,
paginationController.page,
paginationController.rowsPerPage,
]);
useEffect(() => {
const fetchPage = async () => {
if (!monitors || Object.keys(monitors).length === 0) {
return;
}
try {
let res;
if (selectedMonitor === "0") {
res = await networkService.getChecksByTeam({
authToken: authToken,
teamId: user.teamId,
sortOrder: "desc",
limit: null,
dateRange: null,
filter: filter,
page: paginationController.page,
rowsPerPage: paginationController.rowsPerPage,
});
} else {
res = await networkService.getChecksByMonitor({
authToken: authToken,
monitorId: selectedMonitor,
sortOrder: "desc",
limit: null,
dateRange: null,
sitler: filter,
page: paginationController.page,
rowsPerPage: paginationController.rowsPerPage,
});
}
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
logger.error(error);
}
};
fetchPage();
}, [
authToken,
user,
monitors,
selectedMonitor,
filter,
paginationController.page,
paginationController.rowsPerPage,
]);
const handlePageChange = (_, newPage) => {
setPaginationController({
...paginationController,
page: newPage - 1, // 0-indexed
});
};
const handlePageChange = (_, newPage) => {
setPaginationController({
...paginationController,
page: newPage - 1, // 0-indexed
});
};
let paginationComponent = <></>;
if (checksCount > paginationController.rowsPerPage) {
paginationComponent = (
<Pagination
count={Math.ceil(checksCount / paginationController.rowsPerPage)}
page={paginationController.page + 1} //0-indexed
onChange={handlePageChange}
shape="rounded"
renderItem={(item) => (
<PaginationItem
slots={{
previous: ArrowBackRoundedIcon,
next: ArrowForwardRoundedIcon,
}}
{...item}
/>
)}
sx={{ mt: "auto" }}
/>
);
}
let paginationComponent = <></>;
if (checksCount > paginationController.rowsPerPage) {
paginationComponent = (
<Pagination
count={Math.ceil(checksCount / paginationController.rowsPerPage)}
page={paginationController.page + 1} //0-indexed
onChange={handlePageChange}
shape="rounded"
renderItem={(item) => (
<PaginationItem
slots={{
previous: ArrowBackRoundedIcon,
next: ArrowForwardRoundedIcon,
}}
{...item}
/>
)}
sx={{ mt: "auto" }}
/>
);
}
let sharedStyles = {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.main,
p: theme.spacing(30),
};
let sharedStyles = {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.main,
p: theme.spacing(30),
};
return (
<>
{checks?.length === 0 && selectedMonitor === "0" ? (
<Box sx={{ ...sharedStyles }}>
<Box textAlign="center" pb={theme.spacing(20)}>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
</Box>
<Typography textAlign="center" color={theme.palette.text.secondary}>
No incidents recorded yet.
</Typography>
</Box>
) : checks?.length === 0 ? (
<Box sx={{ ...sharedStyles }}>
<Box textAlign="center" pb={theme.spacing(20)}>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
</Box>
<Typography textAlign="center" color={theme.palette.text.secondary}>
The monitor you have selected has no recorded incidents yet.
</Typography>
</Box>
) : (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Monitor Name</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Status Code</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
const formattedDate = formatDateWithTz(
check.createdAt,
"YYYY-MM-DD HH:mm:ss A",
uiTimezone
);
return (
<>
{checks?.length === 0 && selectedMonitor === "0" ? (
<Box sx={{ ...sharedStyles }}>
<Box
textAlign="center"
pb={theme.spacing(20)}
>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
</Box>
<Typography
textAlign="center"
color={theme.palette.text.secondary}
>
No incidents recorded yet.
</Typography>
</Box>
) : checks?.length === 0 ? (
<Box sx={{ ...sharedStyles }}>
<Box
textAlign="center"
pb={theme.spacing(20)}
>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
</Box>
<Typography
textAlign="center"
color={theme.palette.text.secondary}
>
The monitor you have selected has no recorded incidents yet.
</Typography>
</Box>
) : (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Monitor Name</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Status Code</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
const formattedDate = formatDateWithTz(
check.createdAt,
"YYYY-MM-DD HH:mm:ss A",
uiTimezone
);
return (
<TableRow key={check._id}>
<TableCell>{monitors[check.monitorId]?.name}</TableCell>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>{formattedDate}</TableCell>
<TableCell>
{check.statusCode ? check.statusCode : "N/A"}
</TableCell>
<TableCell>{check.message}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginationComponent}
</>
)}
</>
);
return (
<TableRow key={check._id}>
<TableCell>{monitors[check.monitorId]?.name}</TableCell>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>{formattedDate}</TableCell>
<TableCell>{check.statusCode ? check.statusCode : "N/A"}</TableCell>
<TableCell>{check.message}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginationComponent}
</>
)}
</>
);
};
IncidentTable.propTypes = {
monitors: PropTypes.object.isRequired,
selectedMonitor: PropTypes.string.isRequired,
filter: PropTypes.string.isRequired,
monitors: PropTypes.object.isRequired,
selectedMonitor: PropTypes.string.isRequired,
filter: PropTypes.string.isRequired,
};
export default IncidentTable;

View File

@@ -1,11 +1,11 @@
.incidents h1.MuiTypography-root {
font-size: var(--env-var-font-size-large);
font-weight: 600;
font-size: var(--env-var-font-size-large);
font-weight: 600;
}
.incidents button.MuiButtonBase-root,
.incidents p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.incidents button.MuiButtonBase-root {
height: 34px;
height: 34px;
}

View File

@@ -11,119 +11,127 @@ import SkeletonLayout from "./skeleton";
import "./index.css";
const Incidents = () => {
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const { monitorId } = useParams();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const { monitorId } = useParams();
const [monitors, setMonitors] = useState({});
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [loading, setLoading] = useState(false);
const [monitors, setMonitors] = useState({});
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [loading, setLoading] = useState(false);
// TODO do something with these filters
const [filter, setFilter] = useState("all");
// TODO do something with these filters
const [filter, setFilter] = useState("all");
useEffect(() => {
const fetchMonitors = async () => {
setLoading(true);
const res = await networkService.getMonitorsByTeamId({
authToken: authState.authToken,
teamId: authState.user.teamId,
limit: -1,
types: null,
status: null,
checkOrder: null,
normalize: null,
page: null,
rowsPerPage: null,
filter: null,
field: null,
order: null,
});
// Reduce to a lookup object for 0(1) lookup
if (res?.data?.data?.monitors?.length > 0) {
const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => {
acc[monitor._id] = monitor;
return acc;
}, {});
setMonitors(monitorLookup);
monitorId !== undefined && setSelectedMonitor(monitorId);
}
setLoading(false);
};
useEffect(() => {
const fetchMonitors = async () => {
setLoading(true);
const res = await networkService.getMonitorsByTeamId({
authToken: authState.authToken,
teamId: authState.user.teamId,
limit: -1,
types: null,
status: null,
checkOrder: null,
normalize: null,
page: null,
rowsPerPage: null,
filter: null,
field: null,
order: null,
});
// Reduce to a lookup object for 0(1) lookup
if (res?.data?.data?.monitors?.length > 0) {
const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => {
acc[monitor._id] = monitor;
return acc;
}, {});
setMonitors(monitorLookup);
monitorId !== undefined && setSelectedMonitor(monitorId);
}
setLoading(false);
};
fetchMonitors();
}, [authState]);
fetchMonitors();
}, [authState]);
useEffect(() => {}, []);
useEffect(() => {}, []);
const handleSelect = (event) => {
setSelectedMonitor(event.target.value);
};
const handleSelect = (event) => {
setSelectedMonitor(event.target.value);
};
return (
<Stack className="incidents table-container" pt={theme.spacing(6)} gap={theme.spacing(12)}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.text.secondary}
>
Incidents for
</Typography>
<Select
id="incidents-select-monitor"
placeholder="All servers"
value={selectedMonitor}
onChange={handleSelect}
items={Object.values(monitors)}
sx={{
backgroundColor: theme.palette.background.main,
}}
/>
<ButtonGroup
sx={{
ml: "auto",
"& .MuiButtonBase-root, & .MuiButtonBase-root:hover": {
borderColor: theme.palette.border.light,
},
}}
>
<Button
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
>
All
</Button>
<Button
variant="group"
filled={(filter === "down").toString()}
onClick={() => setFilter("down")}
>
Down
</Button>
<Button
variant="group"
filled={(filter === "resolve").toString()}
onClick={() => setFilter("resolve")}
>
Cannot Resolve
</Button>
</ButtonGroup>
</Stack>
<IncidentTable
monitors={monitors}
selectedMonitor={selectedMonitor}
filter={filter}
/>
</>
)}
</Stack>
);
return (
<Stack
className="incidents table-container"
pt={theme.spacing(6)}
gap={theme.spacing(12)}
>
{loading ? (
<SkeletonLayout />
) : (
<>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
<Typography
display="inline-block"
component="h1"
color={theme.palette.text.secondary}
>
Incidents for
</Typography>
<Select
id="incidents-select-monitor"
placeholder="All servers"
value={selectedMonitor}
onChange={handleSelect}
items={Object.values(monitors)}
sx={{
backgroundColor: theme.palette.background.main,
}}
/>
<ButtonGroup
sx={{
ml: "auto",
"& .MuiButtonBase-root, & .MuiButtonBase-root:hover": {
borderColor: theme.palette.border.light,
},
}}
>
<Button
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
>
All
</Button>
<Button
variant="group"
filled={(filter === "down").toString()}
onClick={() => setFilter("down")}
>
Down
</Button>
<Button
variant="group"
filled={(filter === "resolve").toString()}
onClick={() => setFilter("resolve")}
>
Cannot Resolve
</Button>
</ButtonGroup>
</Stack>
<IncidentTable
monitors={monitors}
selectedMonitor={selectedMonitor}
filter={filter}
/>
</>
)}
</Stack>
);
};
export default Incidents;

View File

@@ -7,24 +7,44 @@ import { useTheme } from "@emotion/react";
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
const theme = useTheme();
return (
<>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<Skeleton variant="rounded" width={150} height={34} />
<Skeleton variant="rounded" width="15%" height={34} />
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ ml: "auto" }}
/>
</Stack>
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</>
);
return (
<>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
<Skeleton
variant="rounded"
width={150}
height={34}
/>
<Skeleton
variant="rounded"
width="15%"
height={34}
/>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ ml: "auto" }}
/>
</Stack>
<Skeleton
variant="rounded"
width="100%"
height={300}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
</>
);
};
export default SkeletonLayout;

View File

@@ -1,10 +1,10 @@
.integrations h1.MuiTypography-root {
font-size: var(--env-var-font-size-large);
font-weight: 600;
font-size: var(--env-var-font-size-large);
font-weight: 600;
}
.integrations p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.integrations button {
height: var(--env-var-height-2);
height: var(--env-var-height-2);
}

View File

@@ -17,54 +17,61 @@ import "./index.css";
* @returns {JSX.Element} The JSX representation of the IntegrationsComponent.
*/
const IntegrationsComponent = ({ icon, header, info, onClick }) => {
const theme = useTheme();
const theme = useTheme();
return (
<Grid item lg={6} flexGrow={1}>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
p={theme.spacing(8)}
pl={theme.spacing(12)}
height="100%"
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
{icon}
<Stack gap={theme.spacing(2)} flex={1}>
<Typography component="h1">{header}</Typography>
<Typography
sx={{
maxWidth: "300px",
wordWrap: "break-word",
}}
>
{info}
</Typography>
</Stack>
<Button
variant="contained"
color="primary"
onClick={onClick}
sx={{ alignSelf: "center" }}
disabled={true}
>
Add
</Button>
</Stack>
</Grid>
);
return (
<Grid
item
lg={6}
flexGrow={1}
>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
p={theme.spacing(8)}
pl={theme.spacing(12)}
height="100%"
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
{icon}
<Stack
gap={theme.spacing(2)}
flex={1}
>
<Typography component="h1">{header}</Typography>
<Typography
sx={{
maxWidth: "300px",
wordWrap: "break-word",
}}
>
{info}
</Typography>
</Stack>
<Button
variant="contained"
color="primary"
onClick={onClick}
sx={{ alignSelf: "center" }}
disabled={true}
>
Add
</Button>
</Stack>
</Grid>
);
};
// PropTypes for IntegrationsComponent
IntegrationsComponent.propTypes = {
icon: PropTypes.object.isRequired,
header: PropTypes.string.isRequired,
info: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
icon: PropTypes.object.isRequired,
header: PropTypes.string.isRequired,
info: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
/**
@@ -73,73 +80,76 @@ IntegrationsComponent.propTypes = {
*/
const Integrations = () => {
const theme = useTheme();
const theme = useTheme();
const integrations = [
{
icon: (
<Slack
alt="slack integration"
style={{ width: "45px", height: "45px", alignSelf: "center" }}
/>
),
header: "Slack",
info: "Connect with Slack and see incidents in a channel",
onClick: () => {},
},
{
icon: (
<Discord
alt="discord integration"
style={{ width: "42px", height: "42px", alignSelf: "center" }}
/>
),
header: "Discord",
info: "Connect with Discord and view incidents directly in a channel",
onClick: () => {},
},
{
icon: (
<Zapier
alt="zapier integration"
style={{ width: "42px", height: "42px", alignSelf: "center" }}
/>
),
header: "Zapier",
info: "Send all incidents to Zapier, and then see them everywhere",
onClick: () => {},
},
// Add more integrations as needed
];
const integrations = [
{
icon: (
<Slack
alt="slack integration"
style={{ width: "45px", height: "45px", alignSelf: "center" }}
/>
),
header: "Slack",
info: "Connect with Slack and see incidents in a channel",
onClick: () => {},
},
{
icon: (
<Discord
alt="discord integration"
style={{ width: "42px", height: "42px", alignSelf: "center" }}
/>
),
header: "Discord",
info: "Connect with Discord and view incidents directly in a channel",
onClick: () => {},
},
{
icon: (
<Zapier
alt="zapier integration"
style={{ width: "42px", height: "42px", alignSelf: "center" }}
/>
),
header: "Zapier",
info: "Send all incidents to Zapier, and then see them everywhere",
onClick: () => {},
},
// Add more integrations as needed
];
return (
<Stack
className="integrations"
pt={theme.spacing(20)}
gap={theme.spacing(2)}
sx={{
"& h1, & p": {
color: theme.palette.text.secondary,
},
}}
>
<Typography component="h1">Integrations</Typography>
<Typography mb={theme.spacing(12)}>
Connect BlueWave Uptime to your favorite service.
</Typography>
<Grid container spacing={theme.spacing(12)}>
{integrations.map((integration, index) => (
<IntegrationsComponent
key={index}
icon={integration.icon}
header={integration.header}
info={integration.info}
onClick={integration.onClick}
/>
))}
</Grid>
</Stack>
);
return (
<Stack
className="integrations"
pt={theme.spacing(20)}
gap={theme.spacing(2)}
sx={{
"& h1, & p": {
color: theme.palette.text.secondary,
},
}}
>
<Typography component="h1">Integrations</Typography>
<Typography mb={theme.spacing(12)}>
Connect BlueWave Uptime to your favorite service.
</Typography>
<Grid
container
spacing={theme.spacing(12)}
>
{integrations.map((integration, index) => (
<IntegrationsComponent
key={index}
icon={integration.icon}
header={integration.header}
info={integration.info}
onClick={integration.onClick}
/>
))}
</Grid>
</Stack>
);
};
export default Integrations;

View File

@@ -1,3 +1,3 @@
.create-maintenance button {
height: 35px;
height: 35px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,25 @@
import { Stack, styled } from "@mui/material";
export const ConfigBox = styled(Stack)(({ theme }) => ({
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.background.main,
"& > *": { padding: theme.spacing(14) },
"& > :first-of-type, & > .MuiStack-root > div:first-of-type": {
flex: 0.6,
},
"& > div:last-of-type, & > .MuiStack-root > div:last-of-type": {
flex: 1,
},
"& > .MuiStack-root > div:first-of-type": { paddingRight: theme.spacing(14) },
"& > .MuiStack-root > div:last-of-type": {
paddingLeft: theme.spacing(14),
},
"& h2": { fontSize: 13.5, fontWeight: 500 },
"& h3, & p": {
color: theme.palette.text.tertiary,
},
"& h3": { fontWeight: 500 },
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.background.main,
"& > *": { padding: theme.spacing(14) },
"& > :first-of-type, & > .MuiStack-root > div:first-of-type": {
flex: 0.6,
},
"& > div:last-of-type, & > .MuiStack-root > div:last-of-type": {
flex: 1,
},
"& > .MuiStack-root > div:first-of-type": { paddingRight: theme.spacing(14) },
"& > .MuiStack-root > div:last-of-type": {
paddingLeft: theme.spacing(14),
},
"& h2": { fontSize: 13.5, fontWeight: 500 },
"& h3, & p": {
color: theme.palette.text.tertiary,
},
"& h3": { fontWeight: 500 },
}));

View File

@@ -3,13 +3,13 @@ import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import {
Button,
IconButton,
Menu,
MenuItem,
Modal,
Stack,
Typography,
Button,
IconButton,
Menu,
MenuItem,
Modal,
Stack,
Typography,
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import { logger } from "../../../../Utils/Logger";
@@ -19,211 +19,215 @@ import { networkService } from "../../../../main";
import { createToast } from "../../../../Utils/toastUtils";
const ActionsMenu = ({ isAdmin, maintenanceWindow, updateCallback }) => {
maintenanceWindow;
const { authToken } = useSelector((state) => state.auth);
const [anchorEl, setAnchorEl] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
maintenanceWindow;
const { authToken } = useSelector((state) => state.auth);
const [anchorEl, setAnchorEl] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const theme = useTheme();
const theme = useTheme();
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
try {
setIsLoading(true);
await networkService.deleteMaintenanceWindow({
authToken,
maintenanceWindowId: maintenanceWindow._id,
});
updateCallback();
createToast({ body: "Maintenance window deleted successfully." });
} catch (error) {
createToast({ body: "Failed to delete maintenance window." });
logger.error("Failed to delete maintenance window", error);
} finally {
setIsLoading(false);
}
setIsOpen(false);
};
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
try {
setIsLoading(true);
await networkService.deleteMaintenanceWindow({
authToken,
maintenanceWindowId: maintenanceWindow._id,
});
updateCallback();
createToast({ body: "Maintenance window deleted successfully." });
} catch (error) {
createToast({ body: "Failed to delete maintenance window." });
logger.error("Failed to delete maintenance window", error);
} finally {
setIsLoading(false);
}
setIsOpen(false);
};
const handlePause = async () => {
try {
setIsLoading(true);
const data = {
active: !maintenanceWindow.active,
};
await networkService.editMaintenanceWindow({
authToken,
maintenanceWindowId: maintenanceWindow._id,
maintenanceWindow: data,
});
updateCallback();
} catch (error) {
logger.error(error);
createToast({ body: "Failed to pause maintenance window." });
} finally {
setIsLoading(false);
}
};
const handlePause = async () => {
try {
setIsLoading(true);
const data = {
active: !maintenanceWindow.active,
};
await networkService.editMaintenanceWindow({
authToken,
maintenanceWindowId: maintenanceWindow._id,
maintenanceWindow: data,
});
updateCallback();
} catch (error) {
logger.error(error);
createToast({ body: "Failed to pause maintenance window." });
} finally {
setIsLoading(false);
}
};
const handleEdit = () => {
navigate(`/maintenance/create/${maintenanceWindow._id}`);
};
const handleEdit = () => {
navigate(`/maintenance/create/${maintenanceWindow._id}`);
};
const openMenu = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const openMenu = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.other.icon,
},
}}
>
<Settings />
</IconButton>
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.other.icon,
},
}}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
},
},
},
}}
>
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
handleEdit();
}}
>
Edit
</MenuItem>
<MenuItem
onClick={(e) => {
handlePause();
closeMenu(e);
e.stopPropagation();
}}
>
{`${maintenanceWindow.active === true ? "Pause" : "Resume"}`}
</MenuItem>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
},
},
},
}}
>
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
handleEdit();
}}
>
Edit
</MenuItem>
<MenuItem
onClick={(e) => {
handlePause();
closeMenu(e);
e.stopPropagation();
}}
>
{`${maintenanceWindow.active === true ? "Pause" : "Resume"}`}
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
</Menu>
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
open={isOpen}
onClose={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography id="modal-delete-maintenance" component="h2" variant="h2">
Do you really want to remove this maintenance window?
</Typography>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
Cancel
</Button>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
onClick={(e) => {
e.stopPropagation(e);
handleRemove(e);
}}
>
Delete
</LoadingButton>
</Stack>
</Stack>
</Modal>
</>
);
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
</Menu>
<Modal
aria-labelledby="modal-delete-monitor"
aria-describedby="delete-monitor-confirmation"
open={isOpen}
onClose={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id="modal-delete-maintenance"
component="h2"
variant="h2"
>
Do you really want to remove this maintenance window?
</Typography>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
>
Cancel
</Button>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
onClick={(e) => {
e.stopPropagation(e);
handleRemove(e);
}}
>
Delete
</LoadingButton>
</Stack>
</Stack>
</Modal>
</>
);
};
ActionsMenu.propTypes = {
maintenanceWindow: PropTypes.object,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
maintenanceWindow: PropTypes.object,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
};
export default ActionsMenu;

View File

@@ -1,17 +1,17 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
} from "@mui/material";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
@@ -42,321 +42,321 @@ import dayjs from "dayjs";
* @returns {JSX.Element} Pagination actions component.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
};
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
const MaintenanceTable = ({
page,
setPage,
sort,
setSort,
maintenanceWindows,
maintenanceWindowCount,
updateCallback,
page,
setPage,
sort,
setSort,
maintenanceWindows,
maintenanceWindowCount,
updateCallback,
}) => {
const { rowsPerPage } = useSelector((state) => state.ui.maintenance);
const theme = useTheme();
const dispatch = useDispatch();
const { rowsPerPage } = useSelector((state) => state.ui.maintenance);
const theme = useTheme();
const dispatch = useDispatch();
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const getTimeToNextWindow = (startTime, endTime, repeat) => {
//1. Advance time closest to next window as possible
const now = dayjs();
let start = dayjs(startTime);
let end = dayjs(endTime);
if (repeat > 0) {
// Advance time closest to next window as possible
while (start.isBefore(now) && end.isBefore(now)) {
start = start.add(repeat, "milliseconds");
end = end.add(repeat, "milliseconds");
}
}
const getTimeToNextWindow = (startTime, endTime, repeat) => {
//1. Advance time closest to next window as possible
const now = dayjs();
let start = dayjs(startTime);
let end = dayjs(endTime);
if (repeat > 0) {
// Advance time closest to next window as possible
while (start.isBefore(now) && end.isBefore(now)) {
start = start.add(repeat, "milliseconds");
end = end.add(repeat, "milliseconds");
}
}
//Check if we are in a window
if (now.isAfter(start) && now.isBefore(end)) {
return "In maintenance window";
}
//Check if we are in a window
if (now.isAfter(start) && now.isBefore(end)) {
return "In maintenance window";
}
if (start.isAfter(now)) {
const diffInMinutes = start.diff(now, "minutes");
const diffInHours = start.diff(now, "hours");
const diffInDays = start.diff(now, "days");
if (start.isAfter(now)) {
const diffInMinutes = start.diff(now, "minutes");
const diffInHours = start.diff(now, "hours");
const diffInDays = start.diff(now, "days");
if (diffInMinutes < 60) {
return diffInMinutes + " minutes";
} else if (diffInHours < 24) {
return diffInHours + " hours";
} else if (diffInDays < 7) {
return diffInDays + " days";
} else {
return diffInDays + " days";
}
}
};
if (diffInMinutes < 60) {
return diffInMinutes + " minutes";
} else if (diffInHours < 24) {
return diffInHours + " hours";
} else if (diffInDays < 7) {
return diffInDays + " days";
} else {
return diffInDays + " days";
}
}
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "maintenance",
})
);
setPage(0);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "maintenance",
})
);
setPage(0);
};
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(
page * rowsPerPage + rowsPerPage,
maintenanceWindowCount
);
return `${start} - ${end}`;
};
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, maintenanceWindowCount);
return `${start} - ${end}`;
};
const handleSort = async (field) => {
let order = "";
if (sort.field !== field) {
order = "desc";
} else {
order = sort.order === "asc" ? "desc" : "asc";
}
setSort({ field, order });
};
const handleSort = async (field) => {
let order = "";
if (sort.field !== field) {
order = "desc";
} else {
order = sort.order === "asc" ? "desc" : "asc";
}
setSort({ field, order });
};
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
<Box>
Maintenance Window Name
<span
style={{
visibility: sort.field === "name" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
{" "}
<Box width="max-content">
{" "}
Status
<span
style={{
visibility:
sort.field === "active" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell>Next Window</TableCell>
<TableCell>Repeat</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{maintenanceWindows.map((maintenanceWindow) => {
const text = maintenanceWindow.active ? "active" : "paused";
const status = maintenanceWindow.active ? "up" : "paused";
return (
<TableRow key={maintenanceWindow._id}>
<TableCell>{maintenanceWindow.name}</TableCell>
<TableCell>
<StatusLabel
status={status}
text={text}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{getTimeToNextWindow(
maintenanceWindow.start,
maintenanceWindow.end,
maintenanceWindow.repeat
)}
</TableCell>
<TableCell>
{maintenanceWindow.repeat === 0
? "N/A"
: formatDurationRounded(maintenanceWindow.repeat)}
</TableCell>
<TableCell>
<ActionsMenu
maintenanceWindow={maintenanceWindow}
updateCallback={updateCallback}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
>
<Typography px={theme.spacing(2)} variant="body2" sx={{ opacity: 0.7 }}>
Showing {getRange()} of {maintenanceWindowCount} maintenance window(s)
</Typography>
<TablePagination
component="div"
count={maintenanceWindowCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
disableScrollLock: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
</>
);
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
<Box>
Maintenance Window Name
<span
style={{
visibility: sort.field === "name" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
{" "}
<Box width="max-content">
{" "}
Status
<span
style={{
visibility: sort.field === "active" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell>Next Window</TableCell>
<TableCell>Repeat</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{maintenanceWindows.map((maintenanceWindow) => {
const text = maintenanceWindow.active ? "active" : "paused";
const status = maintenanceWindow.active ? "up" : "paused";
return (
<TableRow key={maintenanceWindow._id}>
<TableCell>{maintenanceWindow.name}</TableCell>
<TableCell>
<StatusLabel
status={status}
text={text}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{getTimeToNextWindow(
maintenanceWindow.start,
maintenanceWindow.end,
maintenanceWindow.repeat
)}
</TableCell>
<TableCell>
{maintenanceWindow.repeat === 0
? "N/A"
: formatDurationRounded(maintenanceWindow.repeat)}
</TableCell>
<TableCell>
<ActionsMenu
maintenanceWindow={maintenanceWindow}
updateCallback={updateCallback}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
>
<Typography
px={theme.spacing(2)}
variant="body2"
sx={{ opacity: 0.7 }}
>
Showing {getRange()} of {maintenanceWindowCount} maintenance window(s)
</Typography>
<TablePagination
component="div"
count={maintenanceWindowCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
disableScrollLock: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
</>
);
};
MaintenanceTable.propTypes = {
isAdmin: PropTypes.bool,
page: PropTypes.number,
setPage: PropTypes.func,
rowsPerPage: PropTypes.number,
setRowsPerPage: PropTypes.func,
sort: PropTypes.object,
setSort: PropTypes.func,
maintenanceWindows: PropTypes.array,
maintenanceWindowCount: PropTypes.number,
updateCallback: PropTypes.func,
isAdmin: PropTypes.bool,
page: PropTypes.number,
setPage: PropTypes.func,
rowsPerPage: PropTypes.number,
setRowsPerPage: PropTypes.func,
sort: PropTypes.object,
setSort: PropTypes.func,
maintenanceWindows: PropTypes.array,
maintenanceWindowCount: PropTypes.number,
updateCallback: PropTypes.func,
};
const MemoizedMaintenanceTable = memo(MaintenanceTable);

View File

@@ -10,103 +10,100 @@ import Breadcrumbs from "../../Components/Breadcrumbs";
import { useNavigate } from "react-router-dom";
const Maintenance = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const { authToken } = useSelector((state) => state.auth);
const { rowsPerPage } = useSelector((state) => state.ui.maintenance);
const theme = useTheme();
const navigate = useNavigate();
const { authToken } = useSelector((state) => state.auth);
const { rowsPerPage } = useSelector((state) => state.ui.maintenance);
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [maintenanceWindowCount, setMaintenanceWindowCount] = useState(0);
const [page, setPage] = useState(0);
const [sort, setSort] = useState({});
const [updateTrigger, setUpdateTrigger] = useState(false);
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [maintenanceWindowCount, setMaintenanceWindowCount] = useState(0);
const [page, setPage] = useState(0);
const [sort, setSort] = useState({});
const [updateTrigger, setUpdateTrigger] = useState(false);
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
useEffect(() => {
const fetchMaintenanceWindows = async () => {
try {
const response = await networkService.getMaintenanceWindowsByTeamId({
authToken: authToken,
page: page,
rowsPerPage: rowsPerPage,
});
const { maintenanceWindows, maintenanceWindowCount } =
response.data.data;
setMaintenanceWindows(maintenanceWindows);
setMaintenanceWindowCount(maintenanceWindowCount);
} catch (error) {
console.log(error);
}
};
fetchMaintenanceWindows();
}, [authToken, page, rowsPerPage, updateTrigger]);
useEffect(() => {
const fetchMaintenanceWindows = async () => {
try {
const response = await networkService.getMaintenanceWindowsByTeamId({
authToken: authToken,
page: page,
rowsPerPage: rowsPerPage,
});
const { maintenanceWindows, maintenanceWindowCount } = response.data.data;
setMaintenanceWindows(maintenanceWindows);
setMaintenanceWindowCount(maintenanceWindowCount);
} catch (error) {
console.log(error);
}
};
fetchMaintenanceWindows();
}, [authToken, page, rowsPerPage, updateTrigger]);
return (
<Box
className="maintenance table-container"
sx={{
':has(> [class*="fallback__"])': {
position: "relative",
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
borderStyle: "dashed",
backgroundColor: theme.palette.background.main,
overflow: "hidden",
},
}}
>
{maintenanceWindows.length > 0 && (
<Stack gap={theme.spacing(8)}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Breadcrumbs
list={[{ name: "maintenance", path: "/maintenance" }]}
/>
<Button
variant="contained"
color="primary"
onClick={() => {
navigate("/maintenance/create");
}}
sx={{ fontWeight: 500 }}
>
Create maintenance window
</Button>
</Stack>
<MaintenanceTable
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sort={sort}
setSort={setSort}
maintenanceWindows={maintenanceWindows}
maintenanceWindowCount={maintenanceWindowCount}
updateCallback={handleActionMenuDelete}
/>
</Stack>
)}
{maintenanceWindows.length === 0 && (
<Fallback
title="maintenance window"
checks={[
"Mark your maintenance periods",
"Eliminate any misunderstandings",
"Stop sending alerts in maintenance windows",
]}
link="/maintenance/create"
isAdmin={true}
/>
)}
</Box>
);
return (
<Box
className="maintenance table-container"
sx={{
':has(> [class*="fallback__"])': {
position: "relative",
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
borderStyle: "dashed",
backgroundColor: theme.palette.background.main,
overflow: "hidden",
},
}}
>
{maintenanceWindows.length > 0 && (
<Stack gap={theme.spacing(8)}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Breadcrumbs list={[{ name: "maintenance", path: "/maintenance" }]} />
<Button
variant="contained"
color="primary"
onClick={() => {
navigate("/maintenance/create");
}}
sx={{ fontWeight: 500 }}
>
Create maintenance window
</Button>
</Stack>
<MaintenanceTable
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sort={sort}
setSort={setSort}
maintenanceWindows={maintenanceWindows}
maintenanceWindowCount={maintenanceWindowCount}
updateCallback={handleActionMenuDelete}
/>
</Stack>
)}
{maintenanceWindows.length === 0 && (
<Fallback
title="maintenance window"
checks={[
"Mark your maintenance periods",
"Eliminate any misunderstandings",
"Stop sending alerts in maintenance windows",
]}
link="/maintenance/create"
isAdmin={true}
/>
)}
</Box>
);
};
export default Maintenance;

View File

@@ -1,11 +1,11 @@
.configure-monitor button.MuiButtonBase-root {
height: var(--env-var-height-2);
height: var(--env-var-height-2);
}
.configure-monitor .MuiStack-root:has(span.MuiTypography-root.input-error) {
position: relative;
position: relative;
}
.configure-monitor span.MuiTypography-root.input-error {
position: absolute;
top: 100%;
position: absolute;
top: 100%;
}

View File

@@ -8,11 +8,11 @@ import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../styled";
import {
updateUptimeMonitor,
pauseUptimeMonitor,
getUptimeMonitorById,
getUptimeMonitorsByTeamId,
deleteUptimeMonitor,
updateUptimeMonitor,
pauseUptimeMonitor,
getUptimeMonitorById,
getUptimeMonitorsByTeamId,
deleteUptimeMonitor,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Field from "../../../Components/Inputs/Field";
import PauseIcon from "../../../assets/icons/pause-icon.svg?react";
@@ -33,11 +33,11 @@ import Dialog from "../../../Components/Dialog";
* @returns {URL} - The parsed URL object if valid, otherwise an empty string.
*/
const parseUrl = (url) => {
try {
return new URL(url);
} catch (error) {
return null;
}
try {
return new URL(url);
} catch (error) {
return null;
}
};
/**
@@ -45,426 +45,430 @@ const parseUrl = (url) => {
* @component
*/
const Configure = () => {
const MS_PER_MINUTE = 60000;
const navigate = useNavigate();
const theme = useTheme();
const dispatch = useDispatch();
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const { monitorId } = useParams();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const MS_PER_MINUTE = 60000;
const navigate = useNavigate();
const theme = useTheme();
const dispatch = useDispatch();
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const { monitorId } = useParams();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
useEffect(() => {
const fetchMonitor = async () => {
try {
const action = await dispatch(
getUptimeMonitorById({ authToken, monitorId })
);
useEffect(() => {
const fetchMonitor = async () => {
try {
const action = await dispatch(getUptimeMonitorById({ authToken, monitorId }));
if (getUptimeMonitorById.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (getUptimeMonitorById.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
};
fetchMonitor();
}, [monitorId, authToken, navigate]);
if (getUptimeMonitorById.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (getUptimeMonitorById.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
};
fetchMonitor();
}, [monitorId, authToken, navigate]);
const handleChange = (event, name) => {
let { value, id } = event.target;
if (!name) name = idMap[id];
const handleChange = (event, name) => {
let { value, id } = event.target;
if (!name) name = idMap[id];
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
if (name === "interval") {
value = value * MS_PER_MINUTE;
}
setMonitor((prev) => ({
...prev,
[name]: value,
}));
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
if (name === "interval") {
value = value * MS_PER_MINUTE;
}
setMonitor((prev) => ({
...prev,
[name]: value,
}));
const validation = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
const validation = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error)
updatedErrors[name] = validation.error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
if (validation.error) updatedErrors[name] = validation.error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
const handlePause = async () => {
try {
const action = await dispatch(
pauseUptimeMonitor({ authToken, monitorId })
);
if (pauseUptimeMonitor.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (pauseUptimeMonitor.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error pausing monitor: " + monitorId);
createToast({ body: "Failed to pause monitor" });
}
};
const handlePause = async () => {
try {
const action = await dispatch(pauseUptimeMonitor({ authToken, monitorId }));
if (pauseUptimeMonitor.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (pauseUptimeMonitor.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error pausing monitor: " + monitorId);
createToast({ body: "Failed to pause monitor" });
}
};
const handleSubmit = async (event) => {
event.preventDefault();
const action = await dispatch(
updateUptimeMonitor({ authToken, monitor: monitor })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor updated successfully!" });
dispatch(getUptimeMonitorsByTeamId(authToken));
} else {
createToast({ body: "Failed to update monitor." });
}
};
const handleSubmit = async (event) => {
event.preventDefault();
const action = await dispatch(updateUptimeMonitor({ authToken, monitor: monitor }));
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor updated successfully!" });
dispatch(getUptimeMonitorsByTeamId(authToken));
} else {
createToast({ body: "Failed to update monitor." });
}
};
const [isOpen, setIsOpen] = useState(false);
const handleRemove = async (event) => {
event.preventDefault();
const action = await dispatch(deleteUptimeMonitor({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/monitors");
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const [isOpen, setIsOpen] = useState(false);
const handleRemove = async (event) => {
event.preventDefault();
const action = await dispatch(deleteUptimeMonitor({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/monitors");
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const frequencies = [
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 3, name: "3 minutes" },
{ _id: 4, name: "4 minutes" },
{ _id: 5, name: "5 minutes" },
];
const frequencies = [
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 3, name: "3 minutes" },
{ _id: 4, name: "4 minutes" },
{ _id: 5, name: "5 minutes" },
];
// Parse the URL
const parsedUrl = parseUrl(monitor?.url);
const protocol = parsedUrl?.protocol?.replace(":", "") || "";
// Parse the URL
const parsedUrl = parseUrl(monitor?.url);
const protocol = parsedUrl?.protocol?.replace(":", "") || "";
const statusColor = {
true: theme.palette.success.main,
false: theme.palette.error.main,
undefined: theme.palette.warning.main,
};
const statusColor = {
true: theme.palette.success.main,
false: theme.palette.error.main,
undefined: theme.palette.warning.main,
};
const statusMsg = {
true: "Your site is up.",
false: "Your site is down.",
undefined: "Pending...",
};
const statusMsg = {
true: "Your site is up.",
false: "Your site is down.",
undefined: "Pending...",
};
return (
<Stack className="configure-monitor" gap={theme.spacing(10)}>
{Object.keys(monitor).length === 0 ? (
<SkeletonLayout />
) : (
<>
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "details", path: `/monitors/${monitorId}` },
{ name: "configure", path: `/monitors/configure/${monitorId}` },
]}
/>
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.spacing(12)}
flex={1}
>
<Stack direction="row" gap={theme.spacing(2)}>
<Box>
<Typography component="h1" variant="h1">
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems="center"
height="fit-content"
gap={theme.spacing(2)}
>
<Tooltip
title={statusMsg[monitor?.status ?? undefined]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<Box>
<PulseDot
color={statusColor[monitor?.status ?? undefined]}
/>
</Box>
</Tooltip>
<Typography component="h2" variant="h2">
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
position="relative"
variant="body2"
ml={theme.spacing(6)}
mt={theme.spacing(1)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -10,
top: "50%",
transform: "translateY(-50%)",
},
}}
>
Editting...
</Typography>
</Stack>
</Box>
<Box
sx={{
alignSelf: "flex-end",
ml: "auto",
}}
>
<LoadingButton
variant="contained"
color="secondary"
loading={isLoading}
sx={{
pl: theme.spacing(4),
pr: theme.spacing(6),
mr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
width: 22,
height: 22,
"& path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.7,
},
},
}}
onClick={handlePause}
>
{monitor?.isActive ? (
<>
<PauseIcon />
Pause
</>
) : (
<>
<ResumeIcon />
Resume
</>
)}
</LoadingButton>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
sx={{ px: theme.spacing(8) }}
onClick={() => setIsOpen(true)}
>
Remove
</LoadingButton>
</Box>
</Stack>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the
type of monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type={monitor?.type === "http" ? "url" : "text"}
https={protocol === "https"}
id="monitor-url"
label="URL to monitor"
placeholder="google.com"
value={parsedUrl?.host || monitor?.url || ""}
disabled={true}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor?.name || ""}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">
When there is a new incident,
</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={
monitor?.notifications?.some(
(notification) => notification.type === "email"
) || false
}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor?.notifications?.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="monitor-interval-configure"
label="Check frequency"
value={monitor?.interval / MS_PER_MINUTE || 1}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end" mt="auto">
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
sx={{ px: theme.spacing(12) }}
onClick={handleSubmit}
>
Save
</LoadingButton>
</Stack>
</Stack>
</>
)}
<Dialog
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
title="Do you really want to delete this monitor?"
confirmationBtnLbl="Delete"
confirmationBtnOnClick={handleRemove}
cancelBtnLbl="Cancel"
cancelBtnOnClick={() => setIsOpen(false)}
theme={theme}
isLoading={isLoading}
description="Once deleted, this monitor cannot be retrieved."
>
</Dialog>
</Stack>
);
return (
<Stack
className="configure-monitor"
gap={theme.spacing(10)}
>
{Object.keys(monitor).length === 0 ? (
<SkeletonLayout />
) : (
<>
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "details", path: `/monitors/${monitorId}` },
{ name: "configure", path: `/monitors/configure/${monitorId}` },
]}
/>
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.spacing(12)}
flex={1}
>
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Box>
<Typography
component="h1"
variant="h1"
>
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems="center"
height="fit-content"
gap={theme.spacing(2)}
>
<Tooltip
title={statusMsg[monitor?.status ?? undefined]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<Box>
<PulseDot color={statusColor[monitor?.status ?? undefined]} />
</Box>
</Tooltip>
<Typography
component="h2"
variant="h2"
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
position="relative"
variant="body2"
ml={theme.spacing(6)}
mt={theme.spacing(1)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -10,
top: "50%",
transform: "translateY(-50%)",
},
}}
>
Editting...
</Typography>
</Stack>
</Box>
<Box
sx={{
alignSelf: "flex-end",
ml: "auto",
}}
>
<LoadingButton
variant="contained"
color="secondary"
loading={isLoading}
sx={{
pl: theme.spacing(4),
pr: theme.spacing(6),
mr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
width: 22,
height: 22,
"& path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.7,
},
},
}}
onClick={handlePause}
>
{monitor?.isActive ? (
<>
<PauseIcon />
Pause
</>
) : (
<>
<ResumeIcon />
Resume
</>
)}
</LoadingButton>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
sx={{ px: theme.spacing(8) }}
onClick={() => setIsOpen(true)}
>
Remove
</LoadingButton>
</Box>
</Stack>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of
monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type={monitor?.type === "http" ? "url" : "text"}
https={protocol === "https"}
id="monitor-url"
label="URL to monitor"
placeholder="google.com"
value={parsedUrl?.host || monitor?.url || ""}
disabled={true}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor?.name || ""}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={
monitor?.notifications?.some(
(notification) => notification.type === "email"
) || false
}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor?.notifications?.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="monitor-interval-configure"
label="Check frequency"
value={monitor?.interval / MS_PER_MINUTE || 1}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
mt="auto"
>
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
sx={{ px: theme.spacing(12) }}
onClick={handleSubmit}
>
Save
</LoadingButton>
</Stack>
</Stack>
</>
)}
<Dialog
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
title="Do you really want to delete this monitor?"
confirmationBtnLbl="Delete"
confirmationBtnOnClick={handleRemove}
cancelBtnLbl="Cancel"
cancelBtnOnClick={() => setIsOpen(false)}
theme={theme}
isLoading={isLoading}
description="Once deleted, this monitor cannot be retrieved."
></Dialog>
</Stack>
);
};
export default Configure;

View File

@@ -7,46 +7,84 @@ import { useTheme } from "@emotion/react";
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)}>
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.spacing(4) }}
/>
<Skeleton variant="rounded" width="50%" height={18} />
</Box>
<Stack
direction="row"
gap={theme.spacing(6)}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton variant="rounded" width={150} height={34} />
</Stack>
</Stack>
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={200} />
<Stack direction="row" justifyContent="flex-end">
<Skeleton variant="rounded" width="15%" height={34} />
</Stack>
</Stack>
</>
);
return (
<>
<Skeleton
variant="rounded"
width="15%"
height={34}
/>
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(6)}
>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.spacing(4) }}
/>
<Skeleton
variant="rounded"
width="50%"
height={18}
/>
</Box>
<Stack
direction="row"
gap={theme.spacing(6)}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton
variant="rounded"
width={150}
height={34}
/>
</Stack>
</Stack>
<Skeleton
variant="rounded"
width="100%"
height={200}
/>
<Skeleton
variant="rounded"
width="100%"
height={200}
/>
<Skeleton
variant="rounded"
width="100%"
height={200}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Skeleton
variant="rounded"
width="15%"
height={34}
/>
</Stack>
</Stack>
</>
);
};
export default SkeletonLayout;

View File

@@ -26,130 +26,128 @@ const CreateMonitor = () => {
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({
url: "",
name: "",
type: "http",
notifications: [],
interval: 1,
});
const [https, setHttps] = useState(true);
const [errors, setErrors] = useState({});
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({
url: "",
name: "",
type: "http",
notifications: [],
interval: 1,
});
const [https, setHttps] = useState(true);
const [errors, setErrors] = useState({});
useEffect(() => {
const fetchMonitor = async () => {
if (monitorId) {
const action = await dispatch(
getUptimeMonitorById({ authToken, monitorId })
);
useEffect(() => {
const fetchMonitor = async () => {
if (monitorId) {
const action = await dispatch(getUptimeMonitorById({ authToken, monitorId }));
if (action.payload.success) {
const data = action.payload.data;
const { name, ...rest } = data; //data.name is read-only
if (rest.type === "http") {
const url = new URL(rest.url);
rest.url = url.host;
}
rest.name = `${name} (Clone)`;
rest.interval /= MS_PER_MINUTE;
setMonitor({
...rest,
});
} else {
navigate("/not-found", { replace: true });
createToast({
body: "There was an error cloning the monitor.",
});
}
}
};
fetchMonitor();
}, [monitorId, authToken, monitors]);
if (action.payload.success) {
const data = action.payload.data;
const { name, ...rest } = data; //data.name is read-only
if (rest.type === "http") {
const url = new URL(rest.url);
rest.url = url.host;
}
rest.name = `${name} (Clone)`;
rest.interval /= MS_PER_MINUTE;
setMonitor({
...rest,
});
} else {
navigate("/not-found", { replace: true });
createToast({
body: "There was an error cloning the monitor.",
});
}
}
};
fetchMonitor();
}, [monitorId, authToken, monitors]);
const handleChange = (event, name) => {
const { value, id } = event.target;
if (!name) name = idMap[id];
const handleChange = (event, name) => {
const { value, id } = event.target;
if (!name) name = idMap[id];
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
setMonitor((prev) => ({
...prev,
[name]: value,
}));
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
setMonitor((prev) => ({
...prev,
[name]: value,
}));
const { error } = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
console.log(error);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
const { error } = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
console.log(error);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
const handleCreateMonitor = async (event) => {
event.preventDefault();
//obj to submit
let form = {
url:
//preprending protocol for url
monitor.type === "http"
? `http${https ? "s" : ""}://` + monitor.url
: monitor.url,
name: monitor.name === "" ? monitor.url : monitor.name,
type: monitor.type,
interval: monitor.interval * MS_PER_MINUTE,
};
const handleCreateMonitor = async (event) => {
event.preventDefault();
//obj to submit
let form = {
url:
//preprending protocol for url
monitor.type === "http"
? `http${https ? "s" : ""}://` + monitor.url
: monitor.url,
name: monitor.name === "" ? monitor.url : monitor.name,
type: monitor.type,
interval: monitor.interval * MS_PER_MINUTE,
};
const { error } = monitorValidation.validate(form, {
abortEarly: false,
});
const { error } = monitorValidation.validate(form, {
abortEarly: false,
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
if (monitor.type === "http") {
const checkEndpointAction = await dispatch(
checkEndpointResolution({ authToken, monitorURL: form.url })
@@ -161,228 +159,234 @@ const CreateMonitor = () => {
}
}
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(
createUptimeMonitor({ authToken, monitor: form })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/monitors");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(createUptimeMonitor({ authToken, monitor: form }));
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/monitors");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 3, name: "3 minutes" },
{ _id: 4, name: "4 minutes" },
{ _id: 5, name: "5 minutes" },
];
//select values
const frequencies = [
{ _id: 1, name: "1 minute" },
{ _id: 2, name: "2 minutes" },
{ _id: 3, name: "3 minutes" },
{ _id: 4, name: "4 minutes" },
{ _id: 5, name: "5 minutes" },
];
return (
<Box className="create-monitor">
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "create", path: `/monitors/create` },
]}
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
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"
>
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 type of
monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
type={monitor.type === "http" ? "url" : "text"}
id="monitor-url"
label="URL to monitor"
https={https}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "http"}
onChange={(event) => handleChange(event)}
/>
{monitor.type === "http" ? (
<ButtonGroup sx={{ ml: theme.spacing(16) }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
) : (
""
)}
</Stack>
<Radio
id="monitor-checks-ping"
title="Ping monitoring"
desc="Check whether your server is available or not."
size="small"
value="ping"
checked={monitor.type === "ping"}
onChange={(event) => handleChange(event)}
/>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={monitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 1}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<LoadingButton
return (
<Box className="create-monitor">
<Breadcrumbs
list={[
{ name: "monitors", path: "/monitors" },
{ name: "create", path: `/monitors/create` },
]}
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
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"
>
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 type of monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
type={monitor.type === "http" ? "url" : "text"}
id="monitor-url"
label="URL to monitor"
https={https}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "http"}
onChange={(event) => handleChange(event)}
/>
{monitor.type === "http" ? (
<ButtonGroup sx={{ ml: theme.spacing(16) }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
) : (
""
)}
</Stack>
<Radio
id="monitor-checks-ping"
title="Ping monitoring"
desc="Check whether your server is available or not."
size="small"
value="ping"
checked={monitor.type === "ping"}
onChange={(event) => handleChange(event)}
/>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={monitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 1}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<LoadingButton
variant="contained"
color="primary"
onClick={handleCreateMonitor}
@@ -391,10 +395,10 @@ const CreateMonitor = () => {
>
Create monitor
</LoadingButton>
</Stack>
</Stack>
</Box>
);
</Stack>
</Stack>
</Box>
);
};
export default CreateMonitor;

View File

@@ -1,135 +1,138 @@
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import {
BarChart,
Bar,
XAxis,
ResponsiveContainer,
Cell,
RadialBarChart,
RadialBar,
BarChart,
Bar,
XAxis,
ResponsiveContainer,
Cell,
RadialBarChart,
RadialBar,
} from "recharts";
import { memo, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "../../../../Utils/timeUtils";
const CustomLabels = ({
x,
width,
height,
firstDataPoint,
lastDataPoint,
type,
}) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const dateFormat = type === "day" ? "MMM D, h:mm A" : "MMM D";
const CustomLabels = ({ x, width, height, firstDataPoint, lastDataPoint, type }) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const dateFormat = type === "day" ? "MMM D, h:mm A" : "MMM D";
return (
<>
<text x={x} y={height} dy={-3} textAnchor="start" fontSize={11}>
{formatDateWithTz(
new Date(firstDataPoint.time),
dateFormat,
uiTimezone
)}
</text>
<text x={width} y={height} dy={-3} textAnchor="end" fontSize={11}>
{formatDateWithTz(new Date(lastDataPoint.time), dateFormat, uiTimezone)}
</text>
</>
);
return (
<>
<text
x={x}
y={height}
dy={-3}
textAnchor="start"
fontSize={11}
>
{formatDateWithTz(new Date(firstDataPoint.time), dateFormat, uiTimezone)}
</text>
<text
x={width}
y={height}
dy={-3}
textAnchor="end"
fontSize={11}
>
{formatDateWithTz(new Date(lastDataPoint.time), dateFormat, uiTimezone)}
</text>
</>
);
};
CustomLabels.propTypes = {
x: PropTypes.number.isRequired,
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
firstDataPoint: PropTypes.object.isRequired,
lastDataPoint: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
x: PropTypes.number.isRequired,
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
firstDataPoint: PropTypes.object.isRequired,
lastDataPoint: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
};
const UpBarChart = memo(({ data, type, onBarHover }) => {
const theme = useTheme();
const theme = useTheme();
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
const getColorRange = (uptime) => {
return uptime > 80
? { main: theme.palette.success.main, light: theme.palette.success.light }
: uptime > 50
? { main: theme.palette.warning.main, light: theme.palette.warning.light }
: { main: theme.palette.error.text, light: theme.palette.error.light };
};
const getColorRange = (uptime) => {
return uptime > 80
? { main: theme.palette.success.main, light: theme.palette.success.light }
: uptime > 50
? { main: theme.palette.warning.main, light: theme.palette.warning.light }
: { main: theme.palette.error.text, light: theme.palette.error.light };
};
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
return (
<ResponsiveContainer width="100%" minWidth={210} height={155}>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalChecks: 0, uptimePercentage: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalChecks"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => {
let { main, light } = getColorRange(entry.uptimePercentage);
return (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index ? main : chartHovered ? light : main
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({
time: null,
totalChecks: 0,
uptimePercentage: 0,
});
}}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
);
return (
<ResponsiveContainer
width="100%"
minWidth={210}
height={155}
>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalChecks: 0, uptimePercentage: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalChecks"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => {
let { main, light } = getColorRange(entry.uptimePercentage);
return (
<Cell
key={`cell-${entry.time}`}
fill={hoveredBarIndex === index ? main : chartHovered ? light : main}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({
time: null,
totalChecks: 0,
uptimePercentage: 0,
});
}}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
// Add display name for the component
@@ -137,182 +140,207 @@ UpBarChart.displayName = "UpBarChart";
// Validate props using PropTypes
UpBarChart.propTypes = {
data: PropTypes.arrayOf(PropTypes.object),
type: PropTypes.string,
onBarHover: PropTypes.func,
data: PropTypes.arrayOf(PropTypes.object),
type: PropTypes.string,
onBarHover: PropTypes.func,
};
export { UpBarChart };
const DownBarChart = memo(({ data, type, onBarHover }) => {
const theme = useTheme();
const theme = useTheme();
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
return (
<ResponsiveContainer width="100%" minWidth={250} height={155}>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalIncidents: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalIncidents"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index
? theme.palette.error.text
: chartHovered
? theme.palette.error.light
: theme.palette.error.text
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({ time: null, totalIncidents: 0 });
}}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
return (
<ResponsiveContainer
width="100%"
minWidth={250}
height={155}
>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalIncidents: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalIncidents"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index
? theme.palette.error.text
: chartHovered
? theme.palette.error.light
: theme.palette.error.text
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({ time: null, totalIncidents: 0 });
}}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
DownBarChart.displayName = "DownBarChart";
DownBarChart.propTypes = {
data: PropTypes.arrayOf(PropTypes.object),
type: PropTypes.string,
onBarHover: PropTypes.func,
data: PropTypes.arrayOf(PropTypes.object),
type: PropTypes.string,
onBarHover: PropTypes.func,
};
export { DownBarChart };
const ResponseGaugeChart = ({ data }) => {
const theme = useTheme();
const theme = useTheme();
let max = 1000; // max ms
let max = 1000; // max ms
const memoizedData = useMemo(
() => [{ response: max, fill: "transparent", background: false }, ...data],
[data[0].response]
);
const memoizedData = useMemo(
() => [{ response: max, fill: "transparent", background: false }, ...data],
[data[0].response]
);
let responseTime = Math.floor(memoizedData[1].response);
let responseProps =
responseTime <= 200
? {
category: "Excellent",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 500
? {
category: "Fair",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 600
? {
category: "Acceptable",
main: theme.palette.warning.main,
bg: theme.palette.warning.bg,
}
: {
category: "Poor",
main: theme.palette.error.text,
bg: theme.palette.error.bg,
};
let responseTime = Math.floor(memoizedData[1].response);
let responseProps =
responseTime <= 200
? {
category: "Excellent",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 500
? {
category: "Fair",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 600
? {
category: "Acceptable",
main: theme.palette.warning.main,
bg: theme.palette.warning.bg,
}
: {
category: "Poor",
main: theme.palette.error.text,
bg: theme.palette.error.bg,
};
return (
<ResponsiveContainer width="100%" minWidth={210} height={155}>
<RadialBarChart
width="100%"
height="100%"
cy="89%"
data={memoizedData}
startAngle={180}
endAngle={0}
innerRadius={100}
outerRadius={150}
>
<text x={0} y="100%" dx="5%" dy={-2} textAnchor="start" fontSize={11}>
low
</text>
<text x="100%" y="100%" dx="-3%" dy={-2} textAnchor="end" fontSize={11}>
high
</text>
<text
x="50%"
y="45%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={18}
fontWeight={400}
>
{responseProps.category}
</text>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="hanging"
fontSize={25}
>
<tspan fontWeight={600}>{responseTime}</tspan>{" "}
<tspan opacity={0.8}>ms</tspan>
</text>
<RadialBar
background={{ fill: responseProps.bg }}
clockWise
dataKey="response"
stroke="none"
>
<Cell fill="transparent" background={false} barSize={0} />
<Cell fill={responseProps.main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
);
return (
<ResponsiveContainer
width="100%"
minWidth={210}
height={155}
>
<RadialBarChart
width="100%"
height="100%"
cy="89%"
data={memoizedData}
startAngle={180}
endAngle={0}
innerRadius={100}
outerRadius={150}
>
<text
x={0}
y="100%"
dx="5%"
dy={-2}
textAnchor="start"
fontSize={11}
>
low
</text>
<text
x="100%"
y="100%"
dx="-3%"
dy={-2}
textAnchor="end"
fontSize={11}
>
high
</text>
<text
x="50%"
y="45%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={18}
fontWeight={400}
>
{responseProps.category}
</text>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="hanging"
fontSize={25}
>
<tspan fontWeight={600}>{responseTime}</tspan> <tspan opacity={0.8}>ms</tspan>
</text>
<RadialBar
background={{ fill: responseProps.bg }}
clockWise
dataKey="response"
stroke="none"
>
<Cell
fill="transparent"
background={false}
barSize={0}
/>
<Cell fill={responseProps.main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
);
};
ResponseGaugeChart.propTypes = {
data: PropTypes.arrayOf(PropTypes.object).isRequired,
data: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export { ResponseGaugeChart };

View File

@@ -1,14 +1,14 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
PaginationItem,
Pagination,
Paper,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
PaginationItem,
Pagination,
Paper,
} from "@mui/material";
import { useState, useEffect } from "react";
@@ -21,128 +21,126 @@ import { logger } from "../../../../Utils/Logger";
import { formatDateWithTz } from "../../../../Utils/timeUtils";
const PaginationTable = ({ monitorId, dateRange }) => {
const { authToken } = useSelector((state) => state.auth);
const [checks, setChecks] = useState([]);
const [checksCount, setChecksCount] = useState(0);
const [paginationController, setPaginationController] = useState({
page: 0,
rowsPerPage: 5,
});
const uiTimezone = useSelector((state) => state.ui.timezone);
const { authToken } = useSelector((state) => state.auth);
const [checks, setChecks] = useState([]);
const [checksCount, setChecksCount] = useState(0);
const [paginationController, setPaginationController] = useState({
page: 0,
rowsPerPage: 5,
});
const uiTimezone = useSelector((state) => state.ui.timezone);
useEffect(() => {
setPaginationController((prevPaginationController) => ({
...prevPaginationController,
page: 0,
}));
}, [dateRange]);
useEffect(() => {
setPaginationController((prevPaginationController) => ({
...prevPaginationController,
page: 0,
}));
}, [dateRange]);
useEffect(() => {
const fetchPage = async () => {
try {
const res = await networkService.getChecksByMonitor({
authToken: authToken,
monitorId: monitorId,
sortOrder: "desc",
limit: null,
dateRange: dateRange,
filter: null,
page: paginationController.page,
rowsPerPage: paginationController.rowsPerPage,
});
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
logger.error(error);
}
};
fetchPage();
}, [
authToken,
monitorId,
dateRange,
paginationController.page,
paginationController.rowsPerPage,
]);
useEffect(() => {
const fetchPage = async () => {
try {
const res = await networkService.getChecksByMonitor({
authToken: authToken,
monitorId: monitorId,
sortOrder: "desc",
limit: null,
dateRange: dateRange,
filter: null,
page: paginationController.page,
rowsPerPage: paginationController.rowsPerPage,
});
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
logger.error(error);
}
};
fetchPage();
}, [
authToken,
monitorId,
dateRange,
paginationController.page,
paginationController.rowsPerPage,
]);
const handlePageChange = (_, newPage) => {
setPaginationController({
...paginationController,
page: newPage - 1, // 0-indexed
});
};
const handlePageChange = (_, newPage) => {
setPaginationController({
...paginationController,
page: newPage - 1, // 0-indexed
});
};
let paginationComponent = <></>;
if (checksCount > paginationController.rowsPerPage) {
paginationComponent = (
<Pagination
count={Math.ceil(checksCount / paginationController.rowsPerPage)}
page={paginationController.page + 1} //0-indexed
onChange={handlePageChange}
shape="rounded"
renderItem={(item) => (
<PaginationItem
slots={{
previous: ArrowBackRoundedIcon,
next: ArrowForwardRoundedIcon,
}}
{...item}
/>
)}
/>
);
}
let paginationComponent = <></>;
if (checksCount > paginationController.rowsPerPage) {
paginationComponent = (
<Pagination
count={Math.ceil(checksCount / paginationController.rowsPerPage)}
page={paginationController.page + 1} //0-indexed
onChange={handlePageChange}
shape="rounded"
renderItem={(item) => (
<PaginationItem
slots={{
previous: ArrowBackRoundedIcon,
next: ArrowForwardRoundedIcon,
}}
{...item}
/>
)}
/>
);
}
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Status Code</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Date & Time</TableCell>
<TableCell>Status Code</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{checks.map((check) => {
const status = check.status === true ? "up" : "down";
return (
<TableRow key={check._id}>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</TableCell>
<TableCell>
{check.statusCode ? check.statusCode : "N/A"}
</TableCell>
<TableCell>{check.message}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginationComponent}
</>
);
return (
<TableRow key={check._id}>
<TableCell>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</TableCell>
<TableCell>{check.statusCode ? check.statusCode : "N/A"}</TableCell>
<TableCell>{check.message}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{paginationComponent}
</>
);
};
PaginationTable.propTypes = {
monitorId: PropTypes.string.isRequired,
dateRange: PropTypes.string.isRequired,
monitorId: PropTypes.string.isRequired,
dateRange: PropTypes.string.isRequired,
};
export default PaginationTable;

View File

@@ -1 +0,0 @@

View File

@@ -6,68 +6,115 @@ import { Box, Skeleton, Stack, useTheme } from "@mui/material";
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="20%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)}>
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton variant="rounded" width="50%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.spacing(4) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
>
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(8)}
>
<Skeleton
variant="rounded"
width="20%"
height={24}
sx={{ alignSelf: "flex-end" }}
/>
<Skeleton variant="rounded" width="20%" height={34} />
</Stack>
<Box sx={{ height: "200px" }}>
<Skeleton variant="rounded" width="100%" height="100%" />
</Box>
</Box>
<Stack gap={theme.spacing(8)}>
<Skeleton variant="rounded" width="20%" height={24} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={50} />
</Stack>
</Stack>
</>
);
return (
<>
<Skeleton
variant="rounded"
width="20%"
height={34}
/>
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(6)}
>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
/>
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.spacing(4) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
>
<Skeleton
variant="rounded"
width="100%"
height={80}
/>
<Skeleton
variant="rounded"
width="100%"
height={80}
/>
<Skeleton
variant="rounded"
width="100%"
height={80}
/>
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(8)}
>
<Skeleton
variant="rounded"
width="20%"
height={24}
sx={{ alignSelf: "flex-end" }}
/>
<Skeleton
variant="rounded"
width="20%"
height={34}
/>
</Stack>
<Box sx={{ height: "200px" }}>
<Skeleton
variant="rounded"
width="100%"
height="100%"
/>
</Box>
</Box>
<Stack gap={theme.spacing(8)}>
<Skeleton
variant="rounded"
width="20%"
height={24}
/>
<Skeleton
variant="rounded"
width="100%"
height={200}
/>
<Skeleton
variant="rounded"
width="100%"
height={50}
/>
</Stack>
</Stack>
</>
);
};
export default SkeletonLayout;

View File

@@ -1,96 +1,96 @@
import { Box, Stack, styled } from "@mui/material";
export const ChartBox = styled(Stack)(({ theme }) => ({
flex: "1 30%",
gap: theme.spacing(8),
height: 300,
minWidth: 250,
padding: theme.spacing(8),
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
backgroundColor: theme.palette.background.main,
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 500,
},
"& .MuiBox-root:not(.area-tooltip) p": {
color: theme.palette.text.tertiary,
fontSize: 13,
},
"& .MuiBox-root > span": {
color: theme.palette.text.primary,
fontSize: 20,
"& span": {
opacity: 0.8,
marginLeft: 2,
fontSize: 15,
},
},
"& .MuiStack-root": {
flexDirection: "row",
gap: theme.spacing(6),
},
"& .MuiStack-root:first-of-type": {
alignItems: "center",
},
"& tspan, & text": {
fill: theme.palette.text.tertiary,
},
"& path": {
transition: "fill 300ms ease, stroke-width 400ms ease",
},
flex: "1 30%",
gap: theme.spacing(8),
height: 300,
minWidth: 250,
padding: theme.spacing(8),
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
backgroundColor: theme.palette.background.main,
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 500,
},
"& .MuiBox-root:not(.area-tooltip) p": {
color: theme.palette.text.tertiary,
fontSize: 13,
},
"& .MuiBox-root > span": {
color: theme.palette.text.primary,
fontSize: 20,
"& span": {
opacity: 0.8,
marginLeft: 2,
fontSize: 15,
},
},
"& .MuiStack-root": {
flexDirection: "row",
gap: theme.spacing(6),
},
"& .MuiStack-root:first-of-type": {
alignItems: "center",
},
"& tspan, & text": {
fill: theme.palette.text.tertiary,
},
"& path": {
transition: "fill 300ms ease, stroke-width 400ms ease",
},
}));
export const IconBox = styled(Box)(({ theme }) => ({
height: 34,
minWidth: 34,
width: 34,
position: "relative",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
borderRadius: 4,
backgroundColor: theme.palette.background.accent,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 20,
height: 20,
"& path": {
stroke: theme.palette.text.tertiary,
},
},
height: 34,
minWidth: 34,
width: 34,
position: "relative",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
borderRadius: 4,
backgroundColor: theme.palette.background.accent,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 20,
height: 20,
"& path": {
stroke: theme.palette.text.tertiary,
},
},
}));
export const StatBox = styled(Box)(({ theme }) => ({
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
minWidth: 200,
width: 225,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
backgroundColor: theme.palette.background.main,
background: `linear-gradient(340deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
"& h2": {
fontSize: 13,
fontWeight: 500,
color: theme.palette.text.secondary,
textTransform: "uppercase",
},
"& p": {
fontSize: 18,
color: theme.palette.text.primary,
marginTop: theme.spacing(2),
"& span": {
color: theme.palette.text.tertiary,
marginLeft: theme.spacing(2),
fontSize: 15,
},
},
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
minWidth: 200,
width: 225,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
backgroundColor: theme.palette.background.main,
background: `linear-gradient(340deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
"& h2": {
fontSize: 13,
fontWeight: 500,
color: theme.palette.text.secondary,
textTransform: "uppercase",
},
"& p": {
fontSize: 18,
color: theme.palette.text.primary,
marginTop: theme.spacing(2),
"& span": {
color: theme.palette.text.tertiary,
marginLeft: theme.spacing(2),
fontSize: 15,
},
},
}));

View File

@@ -1,17 +1,17 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
} from "@mui/material";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
@@ -48,386 +48,381 @@ import useUtils from "../../utils";
* @returns {JSX.Element} Pagination actions component.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
};
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
const MonitorTable = ({ isAdmin, filter, setLoading }) => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const { determineState } = useUtils();
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const { determineState } = useUtils();
const { rowsPerPage } = useSelector((state) => state.ui.monitors);
const [page, setPage] = useState(0);
const [monitors, setMonitors] = useState([]);
const [monitorCount, setMonitorCount] = useState(0);
const authState = useSelector((state) => state.auth);
const [updateTrigger, setUpdateTrigger] = useState(false);
const [sort, setSort] = useState({});
const prevFilter = useRef(filter);
const { rowsPerPage } = useSelector((state) => state.ui.monitors);
const [page, setPage] = useState(0);
const [monitors, setMonitors] = useState([]);
const [monitorCount, setMonitorCount] = useState(0);
const authState = useSelector((state) => state.auth);
const [updateTrigger, setUpdateTrigger] = useState(false);
const [sort, setSort] = useState({});
const prevFilter = useRef(filter);
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "monitors",
})
);
setPage(0);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "monitors",
})
);
setPage(0);
};
const fetchPage = useCallback(async () => {
try {
const { authToken } = authState;
const user = jwtDecode(authToken);
const res = await networkService.getMonitorsByTeamId({
authToken,
teamId: user.teamId,
limit: 25,
types: ["http", "ping"],
status: null,
checkOrder: "desc",
normalize: true,
page: page,
rowsPerPage: rowsPerPage,
filter: filter,
field: sort.field,
order: sort.order,
});
setMonitors(res?.data?.data?.monitors ?? []);
setMonitorCount(res?.data?.data?.monitorCount ?? 0);
setLoading(false);
} catch (error) {
logger.error(error);
}
}, [authState, page, rowsPerPage, filter, sort, setLoading]);
const fetchPage = useCallback(async () => {
try {
const { authToken } = authState;
const user = jwtDecode(authToken);
const res = await networkService.getMonitorsByTeamId({
authToken,
teamId: user.teamId,
limit: 25,
types: ["http", "ping"],
status: null,
checkOrder: "desc",
normalize: true,
page: page,
rowsPerPage: rowsPerPage,
filter: filter,
field: sort.field,
order: sort.order,
});
setMonitors(res?.data?.data?.monitors ?? []);
setMonitorCount(res?.data?.data?.monitorCount ?? 0);
setLoading(false);
} catch (error) {
logger.error(error);
}
}, [authState, page, rowsPerPage, filter, sort, setLoading]);
useEffect(() => {
fetchPage();
}, [
updateTrigger,
authState,
page,
rowsPerPage,
filter,
sort,
setLoading,
fetchPage,
]);
useEffect(() => {
fetchPage();
}, [updateTrigger, authState, page, rowsPerPage, filter, sort, setLoading, fetchPage]);
// Listen for changes in filter, if new value reset the page
useEffect(() => {
if (prevFilter.current !== filter) {
setPage(0);
fetchPage();
}
prevFilter.current = filter;
}, [filter, fetchPage]);
// Listen for changes in filter, if new value reset the page
useEffect(() => {
if (prevFilter.current !== filter) {
setPage(0);
fetchPage();
}
prevFilter.current = filter;
}, [filter, fetchPage]);
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, monitorCount);
return `${start} - ${end}`;
};
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, monitorCount);
return `${start} - ${end}`;
};
const handleSort = async (field) => {
let order = "";
if (sort.field !== field) {
order = "desc";
} else {
order = sort.order === "asc" ? "desc" : "asc";
}
setSort({ field, order });
const handleSort = async (field) => {
let order = "";
if (sort.field !== field) {
order = "desc";
} else {
order = sort.order === "asc" ? "desc" : "asc";
}
setSort({ field, order });
const { authToken } = authState;
const user = jwtDecode(authToken);
const { authToken } = authState;
const user = jwtDecode(authToken);
const res = await networkService.getMonitorsByTeamId({
authToken,
teamId: user.teamId,
limit: 25,
types: ["http", "ping"],
status: null,
checkOrder: "desc",
normalize: true,
page: page,
rowsPerPage: rowsPerPage,
filter: null,
field: field,
order: order,
});
setMonitors(res?.data?.data?.monitors ?? []);
setMonitorCount(res?.data?.data?.monitorCount ?? 0);
};
const res = await networkService.getMonitorsByTeamId({
authToken,
teamId: user.teamId,
limit: 25,
types: ["http", "ping"],
status: null,
checkOrder: "desc",
normalize: true,
page: page,
rowsPerPage: rowsPerPage,
filter: null,
field: field,
order: order,
});
setMonitors(res?.data?.data?.monitors ?? []);
setMonitorCount(res?.data?.data?.monitorCount ?? 0);
};
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
<Box>
Host
<span
style={{
visibility: sort.field === "name" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
{" "}
<Box width="max-content">
{" "}
Status
<span
style={{
visibility:
sort.field === "status" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell>Response Time</TableCell>
<TableCell>Type</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{monitors.map((monitor) => {
let uptimePercentage = "";
let percentageColor = theme.palette.percentage.uptimeExcellent;
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
<Box>
Host
<span
style={{
visibility: sort.field === "name" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell
sx={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
{" "}
<Box width="max-content">
{" "}
Status
<span
style={{
visibility: sort.field === "status" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<ArrowUpwardRoundedIcon />
) : (
<ArrowDownwardRoundedIcon />
)}
</span>
</Box>
</TableCell>
<TableCell>Response Time</TableCell>
<TableCell>Type</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{monitors.map((monitor) => {
let uptimePercentage = "";
let percentageColor = theme.palette.percentage.uptimeExcellent;
// Determine uptime percentage and color based on the monitor's uptimePercentage value
if (monitor.uptimePercentage !== undefined) {
uptimePercentage =
monitor.uptimePercentage === 0
? "0"
: (monitor.uptimePercentage * 100).toFixed(2);
// Determine uptime percentage and color based on the monitor's uptimePercentage value
if (monitor.uptimePercentage !== undefined) {
uptimePercentage =
monitor.uptimePercentage === 0
? "0"
: (monitor.uptimePercentage * 100).toFixed(2);
percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.percentage.uptimePoor
: monitor.uptimePercentage < 0.5
? theme.palette.percentage.uptimeFair
: monitor.uptimePercentage < 0.75
? theme.palette.percentage.uptimeGood
: theme.palette.percentage.uptimeExcellent;
}
percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.percentage.uptimePoor
: monitor.uptimePercentage < 0.5
? theme.palette.percentage.uptimeFair
: monitor.uptimePercentage < 0.75
? theme.palette.percentage.uptimeGood
: theme.palette.percentage.uptimeExcellent;
}
const params = {
url: monitor.url,
title: monitor.name,
percentage: uptimePercentage,
percentageColor,
status: determineState(monitor),
};
const params = {
url: monitor.url,
title: monitor.name,
percentage: uptimePercentage,
percentageColor,
status: determineState(monitor),
};
return (
<TableRow
key={monitor._id}
sx={{
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
onClick={() => {
navigate(`/monitors/${monitor._id}`);
}}
>
<TableCell>
<Host key={monitor._id} params={params} />
</TableCell>
<TableCell>
<StatusLabel
status={params.status}
text={params.status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
<BarChart checks={monitor.checks.slice().reverse()} />
</TableCell>
<TableCell>
<span style={{ textTransform: "uppercase" }}>
{monitor.type}
</span>
</TableCell>
<TableCell>
<ActionsMenu
monitor={monitor}
isAdmin={isAdmin}
updateCallback={handleActionMenuDelete}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
>
<Typography px={theme.spacing(2)} variant="body2" sx={{ opacity: 0.7 }}>
Showing {getRange()} of {monitorCount} monitor(s)
</Typography>
<TablePagination
component="div"
count={monitorCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
disableScrollLock: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
</>
);
return (
<TableRow
key={monitor._id}
sx={{
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
onClick={() => {
navigate(`/monitors/${monitor._id}`);
}}
>
<TableCell>
<Host
key={monitor._id}
params={params}
/>
</TableCell>
<TableCell>
<StatusLabel
status={params.status}
text={params.status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
<BarChart checks={monitor.checks.slice().reverse()} />
</TableCell>
<TableCell>
<span style={{ textTransform: "uppercase" }}>{monitor.type}</span>
</TableCell>
<TableCell>
<ActionsMenu
monitor={monitor}
isAdmin={isAdmin}
updateCallback={handleActionMenuDelete}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
>
<Typography
px={theme.spacing(2)}
variant="body2"
sx={{ opacity: 0.7 }}
>
Showing {getRange()} of {monitorCount} monitor(s)
</Typography>
<TablePagination
component="div"
count={monitorCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
disableScrollLock: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
</>
);
};
MonitorTable.propTypes = {
isAdmin: PropTypes.bool,
filter: PropTypes.string,
setLoading: PropTypes.func,
isAdmin: PropTypes.bool,
filter: PropTypes.string,
setLoading: PropTypes.func,
};
const MemoizedMonitorTable = memo(MonitorTable);

View File

@@ -6,106 +6,106 @@ import Background from "../../../assets/Images/background-grid.svg?react";
import ClockSnooze from "../../../assets/icons/clock-snooze.svg?react";
const StatusBox = ({ title, value }) => {
const theme = useTheme();
const theme = useTheme();
let sharedStyles = {
position: "absolute",
right: 8,
opacity: 0.5,
"& svg path": { stroke: theme.palette.other.icon },
};
let sharedStyles = {
position: "absolute",
right: 8,
opacity: 0.5,
"& svg path": { stroke: theme.palette.other.icon },
};
let color;
let icon;
if (title === "up") {
color = theme.palette.success.main;
icon = (
<Box sx={{ ...sharedStyles, top: 8 }}>
<Arrow />
</Box>
);
} else if (title === "down") {
color = theme.palette.error.text;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: 5 }}>
<Arrow />
</Box>
);
} else if (title === "paused") {
color = theme.palette.warning.main;
icon = (
<Box sx={{ ...sharedStyles, top: 12, right: 12 }}>
<ClockSnooze />
</Box>
);
}
let color;
let icon;
if (title === "up") {
color = theme.palette.success.main;
icon = (
<Box sx={{ ...sharedStyles, top: 8 }}>
<Arrow />
</Box>
);
} else if (title === "down") {
color = theme.palette.error.text;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: 5 }}>
<Arrow />
</Box>
);
} else if (title === "paused") {
color = theme.palette.warning.main;
icon = (
<Box sx={{ ...sharedStyles, top: 12, right: 12 }}>
<ClockSnooze />
</Box>
);
}
return (
<Box
position="relative"
flex={1}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
p={theme.spacing(8)}
overflow="hidden"
sx={{
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
>
<Box
sx={{
position: "absolute",
top: "-10%",
left: "5%",
pointerEvents: "none",
"& svg g g:last-of-type path": {
stroke: theme.palette.other.grid,
},
}}
>
<Background />
</Box>
<Box
textTransform="uppercase"
fontSize={15}
letterSpacing={0.5}
color={theme.palette.text.secondary}
mb={theme.spacing(8)}
sx={{ opacity: 0.6 }}
>
{title}
</Box>
{icon}
<Stack
direction="row"
alignItems="flex-start"
fontSize={36}
fontWeight={600}
color={color}
gap="2px"
>
{value}
<Typography
component="span"
fontSize={20}
fontWeight={300}
color={theme.palette.text.secondary}
sx={{ opacity: 0.3 }}
>
#
</Typography>
</Stack>
</Box>
);
return (
<Box
position="relative"
flex={1}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
p={theme.spacing(8)}
overflow="hidden"
sx={{
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
>
<Box
sx={{
position: "absolute",
top: "-10%",
left: "5%",
pointerEvents: "none",
"& svg g g:last-of-type path": {
stroke: theme.palette.other.grid,
},
}}
>
<Background />
</Box>
<Box
textTransform="uppercase"
fontSize={15}
letterSpacing={0.5}
color={theme.palette.text.secondary}
mb={theme.spacing(8)}
sx={{ opacity: 0.6 }}
>
{title}
</Box>
{icon}
<Stack
direction="row"
alignItems="flex-start"
fontSize={36}
fontWeight={600}
color={color}
gap="2px"
>
{value}
<Typography
component="span"
fontSize={20}
fontWeight={300}
color={theme.palette.text.secondary}
sx={{ opacity: 0.3 }}
>
#
</Typography>
</Stack>
</Box>
);
};
StatusBox.propTypes = {
title: PropTypes.oneOf(["up", "down", "paused"]).isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
title: PropTypes.oneOf(["up", "down", "paused"]).isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
};
export default StatusBox;

View File

@@ -4,226 +4,222 @@ import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { IconButton, Menu, MenuItem } from "@mui/material";
import {
IconButton,
Menu,
MenuItem
} from "@mui/material";
import {
deleteUptimeMonitor,
pauseUptimeMonitor,
getUptimeMonitorsByTeamId,
deleteUptimeMonitor,
pauseUptimeMonitor,
getUptimeMonitorsByTeamId,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Settings from "../../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
import Dialog from "../../../Components/Dialog"
import Dialog from "../../../Components/Dialog";
const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const authToken = authState.authToken;
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
const theme = useTheme();
const authState = useSelector((state) => state.auth);
const authToken = authState.authToken;
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { _id: actions.id };
const action = await dispatch(
deleteUptimeMonitor({ authToken: authState.authToken, monitor })
);
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
updateCallback();
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { _id: actions.id };
const action = await dispatch(
deleteUptimeMonitor({ authToken: authState.authToken, monitor })
);
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
updateCallback();
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const handlePause = async () => {
try {
const action = await dispatch(
pauseUptimeMonitor({ authToken, monitorId: monitor._id })
);
if (pauseUptimeMonitor.fulfilled.match(action)) {
updateCallback();
const state = action?.payload?.data.isActive === false ? "paused" : "resumed";
createToast({ body: `Monitor ${state} successfully.` });
} else {
throw new Error(action?.error?.message ?? "Failed to pause monitor.");
}
} catch (error) {
logger.error("Error pausing monitor:", monitor._id, error);
createToast({ body: "Failed to pause monitor." });
}
};
const handlePause = async () => {
try {
const action = await dispatch(
pauseUptimeMonitor({ authToken, monitorId: monitor._id })
);
if (pauseUptimeMonitor.fulfilled.match(action)) {
updateCallback();
const state = action?.payload?.data.isActive === false ? "paused" : "resumed";
createToast({ body: `Monitor ${state} successfully.` });
} else {
throw new Error(action?.error?.message ?? "Failed to pause monitor.");
}
} catch (error) {
logger.error("Error pausing monitor:", monitor._id, error);
createToast({ body: "Failed to pause monitor." });
}
};
const openMenu = (event, id, url) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const openMenu = (event, id, url) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(
event,
monitor._id,
monitor.type === "ping" ? null : monitor.url
);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.other.icon,
},
}}
>
<Settings />
</IconButton>
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event, monitor._id, monitor.type === "ping" ? null : monitor.url);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.other.icon,
},
}}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
},
},
},
}}
>
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/${actions.id}`);
}}
>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/incidents/${actions.id}`);
}}
>
Incidents
</MenuItem>
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
},
},
},
}}
>
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/${actions.id}`);
}}
>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/incidents/${actions.id}`);
}}
>
Incidents
</MenuItem>
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/create/${actions.id}`);
}}
>
Clone
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
handlePause(e);
}}
>
{monitor?.isActive === true ? "Pause" : "Resume"}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
)}
</Menu>
<Dialog
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
title="Do you really want to delete this monitor?"
confirmationBtnLbl="Delete"
confirmationBtnOnClick={e => { e.stopPropagation(); handleRemove(e) }}
cancelBtnLbl="Cancel"
cancelBtnOnClick={e => { e.stopPropagation(); setIsOpen(false) }}
theme={theme}
isLoading={isLoading}
description="Once deleted, this monitor cannot be retrieved."
>
</Dialog>
</>
);
navigate(`/monitors/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/monitors/create/${actions.id}`);
}}
>
Clone
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
handlePause(e);
}}
>
{monitor?.isActive === true ? "Pause" : "Resume"}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
Remove
</MenuItem>
)}
</Menu>
<Dialog
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
title="Do you really want to delete this monitor?"
confirmationBtnLbl="Delete"
confirmationBtnOnClick={(e) => {
e.stopPropagation();
handleRemove(e);
}}
cancelBtnLbl="Cancel"
cancelBtnOnClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
theme={theme}
isLoading={isLoading}
description="Once deleted, this monitor cannot be retrieved."
></Dialog>
</>
);
};
ActionsMenu.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
isActive: PropTypes.bool,
}).isRequired,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
monitor: PropTypes.shape({
_id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
isActive: PropTypes.bool,
}).isRequired,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
};
export default ActionsMenu;

View File

@@ -7,49 +7,53 @@ import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?re
import PropTypes from "prop-types";
const Fallback = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const mode = useSelector((state) => state.ui.mode);
const theme = useTheme();
const navigate = useNavigate();
const mode = useSelector((state) => state.ui.mode);
return (
<Stack
alignItems="center"
backgroundColor={theme.palette.background.main}
p={theme.spacing(30)}
pt={theme.spacing(25)}
gap={theme.spacing(2)}
border={1}
borderRadius={theme.shape.borderRadius}
borderColor={theme.palette.border.light}
color={theme.palette.text.secondary}
>
<Box pb={theme.spacing(20)}>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
</Box>
<Typography component="h2" variant="h2" fontWeight={500}>
No monitors found to display
</Typography>
<Typography variant="body1">
It looks like you dont have any monitors set up yet.
</Typography>
{isAdmin && (
<Button
variant="contained"
color="primary"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ mt: theme.spacing(12) }}
>
Create your first monitor
</Button>
)}
</Stack>
);
return (
<Stack
alignItems="center"
backgroundColor={theme.palette.background.main}
p={theme.spacing(30)}
pt={theme.spacing(25)}
gap={theme.spacing(2)}
border={1}
borderRadius={theme.shape.borderRadius}
borderColor={theme.palette.border.light}
color={theme.palette.text.secondary}
>
<Box pb={theme.spacing(20)}>
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
</Box>
<Typography
component="h2"
variant="h2"
fontWeight={500}
>
No monitors found to display
</Typography>
<Typography variant="body1">
It looks like you dont have any monitors set up yet.
</Typography>
{isAdmin && (
<Button
variant="contained"
color="primary"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ mt: theme.spacing(12) }}
>
Create your first monitor
</Button>
)}
</Stack>
);
};
Fallback.propTypes = {
isAdmin: PropTypes.bool,
isAdmin: PropTypes.bool,
};
export default Fallback;

View File

@@ -12,50 +12,50 @@ import PropTypes from "prop-types";
* @returns {React.ElementType} Returns a div element with the host details.
*/
const Host = ({ params }) => {
return (
<Box className="host">
<Box
display="inline-block"
position="relative"
sx={{
fontWeight: 500,
"&:before": {
position: "absolute",
content: `""`,
width: "4px",
height: "4px",
borderRadius: "50%",
backgroundColor: "gray",
opacity: 0.8,
right: "-10px",
top: "42%",
},
}}
>
{params.title}
</Box>
<Typography
component="span"
sx={{
color: params.percentageColor,
fontWeight: 500,
ml: "15px",
}}
>
{params.percentage}%
</Typography>
<Box sx={{ opacity: 0.6 }}>{params.url}</Box>
</Box>
);
return (
<Box className="host">
<Box
display="inline-block"
position="relative"
sx={{
fontWeight: 500,
"&:before": {
position: "absolute",
content: `""`,
width: "4px",
height: "4px",
borderRadius: "50%",
backgroundColor: "gray",
opacity: 0.8,
right: "-10px",
top: "42%",
},
}}
>
{params.title}
</Box>
<Typography
component="span"
sx={{
color: params.percentageColor,
fontWeight: 500,
ml: "15px",
}}
>
{params.percentage}%
</Typography>
<Box sx={{ opacity: 0.6 }}>{params.url}</Box>
</Box>
);
};
Host.propTypes = {
params: PropTypes.shape({
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.string,
url: PropTypes.string,
}).isRequired,
params: PropTypes.shape({
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.string,
url: PropTypes.string,
}).isRequired,
};
export default Host;

View File

@@ -1,19 +1,19 @@
.monitors .MuiStack-root > button:not(.MuiIconButton-root) {
font-size: var(--env-var-font-size-medium);
height: var(--env-var-height-2);
min-width: fit-content;
font-size: var(--env-var-font-size-medium);
height: var(--env-var-height-2);
min-width: fit-content;
}
.current-monitors-counter {
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
min-width: 25px;
min-height: 25px;
border-radius: 50%;
font-size: var(--env-var-font-size-medium);
font-weight: 500;
margin-left: 10px;
line-height: 0.8;
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
min-width: 25px;
min-height: 25px;
border-radius: 50%;
font-size: var(--env-var-font-size-medium);
font-weight: 500;
margin-left: 10px;
line-height: 0.8;
}

View File

@@ -4,13 +4,7 @@ import { useSelector, useDispatch } from "react-redux";
import { getUptimeMonitorsByTeamId } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import {
Box,
Button,
CircularProgress,
Stack,
Typography,
} from "@mui/material";
import { Box, Button, CircularProgress, Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import SkeletonLayout from "./skeleton";
import Fallback from "./fallback";
@@ -22,173 +16,174 @@ import Search from "../../../Components/Inputs/Search";
import useDebounce from "../../../Utils/debounce";
const Monitors = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const monitorState = useSelector((state) => state.uptimeMonitors);
const authState = useSelector((state) => state.auth);
const [search, setSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const dispatch = useDispatch({});
const debouncedFilter = useDebounce(search, 500);
const theme = useTheme();
const navigate = useNavigate();
const monitorState = useSelector((state) => state.uptimeMonitors);
const authState = useSelector((state) => state.auth);
const [search, setSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const dispatch = useDispatch({});
const debouncedFilter = useDebounce(search, 500);
const handleSearch = (value) => {
setIsSearching(true);
setSearch(value);
};
const handleSearch = (value) => {
setIsSearching(true);
setSearch(value);
};
useEffect(() => {
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
}, [authState.authToken, dispatch]);
useEffect(() => {
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
}, [authState.authToken, dispatch]);
let loading =
monitorState?.isLoading &&
monitorState?.monitorsSummary?.monitors?.length === 0;
let loading =
monitorState?.isLoading && monitorState?.monitorsSummary?.monitors?.length === 0;
return (
<Stack className="monitors table-container" gap={theme.spacing(8)}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Box>
<Breadcrumbs list={[{ name: `monitors`, path: "/monitors" }]} />
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Greeting type="uptime" />
{isAdmin &&
monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<Button
variant="contained"
color="primary"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ fontWeight: 500 }}
>
Create monitor
</Button>
)}
</Stack>
</Box>
{isAdmin && monitorState?.monitorsSummary?.monitors?.length === 0 && (
<Fallback isAdmin={isAdmin} />
)}
return (
<Stack
className="monitors table-container"
gap={theme.spacing(8)}
>
{loading ? (
<SkeletonLayout />
) : (
<>
<Box>
<Breadcrumbs list={[{ name: `monitors`, path: "/monitors" }]} />
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Greeting type="uptime" />
{isAdmin && monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<Button
variant="contained"
color="primary"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ fontWeight: 500 }}
>
Create monitor
</Button>
)}
</Stack>
</Box>
{isAdmin && monitorState?.monitorsSummary?.monitors?.length === 0 && (
<Fallback isAdmin={isAdmin} />
)}
{monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<>
<Stack
gap={theme.spacing(8)}
direction="row"
justifyContent="space-between"
>
<StatusBox
title="up"
value={monitorState?.monitorsSummary?.monitorCounts?.up ?? 0}
/>
<StatusBox
title="down"
value={
monitorState?.monitorsSummary?.monitorCounts?.down ?? 0
}
/>
<StatusBox
title="paused"
value={
monitorState?.monitorsSummary?.monitorCounts?.paused ?? 0
}
/>
</Stack>
<Box
flex={1}
px={theme.spacing(10)}
py={theme.spacing(8)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(8)}
>
<Typography
component="h2"
variant="h2"
fontWeight={500}
letterSpacing={-0.2}
>
Actively monitoring
</Typography>
<Box
className="current-monitors-counter"
color={theme.palette.text.primary}
border={1}
borderColor={theme.palette.border.light}
backgroundColor={theme.palette.background.accent}
>
{monitorState?.monitorsSummary?.monitorCounts?.total || 0}
</Box>
<Box width="25%" minWidth={150} ml="auto">
<Search
options={monitorState?.monitorsSummary?.monitors ?? []}
filteredBy="name"
inputValue={search}
handleInputChange={handleSearch}
/>
</Box>
</Stack>
<Box position="relative">
{isSearching && (
<>
<Box
width="100%"
height="100%"
position="absolute"
sx={{
backgroundColor: theme.palette.background.main,
opacity: 0.8,
zIndex: 100,
}}
/>
<Box
height="100%"
position="absolute"
top="20%"
left="50%"
sx={{
transform: "translateX(-50%)",
zIndex: 101,
}}
>
<CircularProgress
sx={{
color: theme.palette.other.icon,
}}
/>
</Box>
</>
)}
<MonitorTable
isAdmin={isAdmin}
filter={debouncedFilter}
setLoading={setIsSearching}
/>
</Box>
</Box>
</>
)}
</>
)}
</Stack>
);
{monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<>
<Stack
gap={theme.spacing(8)}
direction="row"
justifyContent="space-between"
>
<StatusBox
title="up"
value={monitorState?.monitorsSummary?.monitorCounts?.up ?? 0}
/>
<StatusBox
title="down"
value={monitorState?.monitorsSummary?.monitorCounts?.down ?? 0}
/>
<StatusBox
title="paused"
value={monitorState?.monitorsSummary?.monitorCounts?.paused ?? 0}
/>
</Stack>
<Box
flex={1}
px={theme.spacing(10)}
py={theme.spacing(8)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(8)}
>
<Typography
component="h2"
variant="h2"
fontWeight={500}
letterSpacing={-0.2}
>
Actively monitoring
</Typography>
<Box
className="current-monitors-counter"
color={theme.palette.text.primary}
border={1}
borderColor={theme.palette.border.light}
backgroundColor={theme.palette.background.accent}
>
{monitorState?.monitorsSummary?.monitorCounts?.total || 0}
</Box>
<Box
width="25%"
minWidth={150}
ml="auto"
>
<Search
options={monitorState?.monitorsSummary?.monitors ?? []}
filteredBy="name"
inputValue={search}
handleInputChange={handleSearch}
/>
</Box>
</Stack>
<Box position="relative">
{isSearching && (
<>
<Box
width="100%"
height="100%"
position="absolute"
sx={{
backgroundColor: theme.palette.background.main,
opacity: 0.8,
zIndex: 100,
}}
/>
<Box
height="100%"
position="absolute"
top="20%"
left="50%"
sx={{
transform: "translateX(-50%)",
zIndex: 101,
}}
>
<CircularProgress
sx={{
color: theme.palette.other.icon,
}}
/>
</Box>
</>
)}
<MonitorTable
isAdmin={isAdmin}
filter={debouncedFilter}
setLoading={setIsSearching}
/>
</Box>
</Box>
</>
)}
</>
)}
</Stack>
);
};
Monitors.propTypes = {
isAdmin: PropTypes.bool,
isAdmin: PropTypes.bool,
};
export default Monitors;

View File

@@ -7,26 +7,55 @@ import { useTheme } from "@emotion/react";
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
const theme = useTheme();
return (
<>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Skeleton variant="rounded" width="50%" height={36} />
<Skeleton variant="rounded" width="15%" height={36} />
</Stack>
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
<Skeleton variant="rounded" width="100%" height={100} />
</Stack>
<Skeleton variant="rounded" width="100%" height="100%" flex={1} />
</>
);
return (
<>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Skeleton
variant="rounded"
width="50%"
height={36}
/>
<Skeleton
variant="rounded"
width="15%"
height={36}
/>
</Stack>
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
</Stack>
<Skeleton
variant="rounded"
width="100%"
height="100%"
flex={1}
/>
</>
);
};
export default SkeletonLayout;

View File

@@ -1,38 +1,38 @@
import { Stack, styled } from "@mui/material";
export const ConfigBox = styled(Stack)(({ theme }) => ({
flexDirection: "row",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.background.main,
"& > *": {
paddingTop: theme.spacing(12),
paddingBottom: theme.spacing(18),
},
"& > div:first-of-type": {
flex: 0.7,
borderRight: 1,
borderRightStyle: "solid",
borderRightColor: theme.palette.border.light,
paddingRight: theme.spacing(15),
paddingLeft: theme.spacing(15),
},
"& > div:last-of-type": {
flex: 1,
paddingRight: theme.spacing(20),
paddingLeft: theme.spacing(18),
},
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 600,
},
"& h3, & p": {
color: theme.palette.text.tertiary,
},
"& p": {
fontSize: 13,
},
flexDirection: "row",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.background.main,
"& > *": {
paddingTop: theme.spacing(12),
paddingBottom: theme.spacing(18),
},
"& > div:first-of-type": {
flex: 0.7,
borderRight: 1,
borderRightStyle: "solid",
borderRightColor: theme.palette.border.light,
paddingRight: theme.spacing(15),
paddingLeft: theme.spacing(15),
},
"& > div:last-of-type": {
flex: 1,
paddingRight: theme.spacing(20),
paddingLeft: theme.spacing(18),
},
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 600,
},
"& h3, & p": {
color: theme.palette.text.tertiary,
},
"& p": {
fontSize: 13,
},
}));

View File

@@ -1,88 +1,88 @@
import { useTheme } from "@mui/material";
const useUtils = () => {
const theme = useTheme();
const determineState = (monitor) => {
if (monitor.isActive === false) return "paused";
if (monitor?.status === undefined) return "pending";
return monitor?.status == true ? "up" : "down";
};
const theme = useTheme();
const determineState = (monitor) => {
if (monitor.isActive === false) return "paused";
if (monitor?.status === undefined) return "pending";
return monitor?.status == true ? "up" : "down";
};
const statusColor = {
up: theme.palette.success.main,
down: theme.palette.error.main,
paused: theme.palette.warning.main,
pending: theme.palette.warning.main,
};
const statusColor = {
up: theme.palette.success.main,
down: theme.palette.error.main,
paused: theme.palette.warning.main,
pending: theme.palette.warning.main,
};
const statusMsg = {
up: "Your site is up.",
down: "Your site is down.",
paused: "Pending...",
};
const statusMsg = {
up: "Your site is up.",
down: "Your site is down.",
paused: "Pending...",
};
const pagespeedStatusMsg = {
up: "Live (collecting data)",
down: "Inactive",
paused: "Paused",
};
const statusStyles = {
up: {
backgroundColor: theme.palette.success.bg,
background: `linear-gradient(340deg, ${theme.palette.success.light} -60%, ${theme.palette.success.bg} 35%)`,
borderColor: theme.palette.success.light,
"& h2": { color: theme.palette.success.main },
},
down: {
backgroundColor: theme.palette.error.bg,
background: `linear-gradient(340deg, ${theme.palette.error.light} -60%, ${theme.palette.error.bg} 35%)`,
borderColor: theme.palette.error.light,
"& h2": { color: theme.palette.error.main },
},
paused: {
backgroundColor: theme.palette.warning.bg,
background: `linear-gradient(340deg, ${theme.palette.warning.light} -60%, ${theme.palette.warning.bg} 35%)`,
borderColor: theme.palette.warning.light,
"& h2": { color: theme.palette.warning.main },
},
pending: {
backgroundColor: theme.palette.warning.light,
background: `linear-gradient(340deg, ${theme.palette.warning.dark} -60%, ${theme.palette.warning.light} 35%)`,
borderColor: theme.palette.warning.dark,
"& h2": { color: theme.palette.warning.main },
},
};
const pagespeedStyles = {
up: {
bg: theme.palette.success.bg,
light: theme.palette.success.light,
stroke: theme.palette.success.main,
},
down: {
bg: theme.palette.error.bg,
light: theme.palette.error.light,
stroke: theme.palette.error.main,
},
paused: {
bg: theme.palette.warning.bg,
light: theme.palette.warning.light,
stroke: theme.palette.warning.main,
},
pending: {
bg: theme.palette.warning.bg,
light: theme.palette.warning.light,
stroke: theme.palette.warning.main,
},
};
const pagespeedStatusMsg = {
up: "Live (collecting data)",
down: "Inactive",
paused: "Paused",
};
const statusStyles = {
up: {
backgroundColor: theme.palette.success.bg,
background: `linear-gradient(340deg, ${theme.palette.success.light} -60%, ${theme.palette.success.bg} 35%)`,
borderColor: theme.palette.success.light,
"& h2": { color: theme.palette.success.main },
},
down: {
backgroundColor: theme.palette.error.bg,
background: `linear-gradient(340deg, ${theme.palette.error.light} -60%, ${theme.palette.error.bg} 35%)`,
borderColor: theme.palette.error.light,
"& h2": { color: theme.palette.error.main },
},
paused: {
backgroundColor: theme.palette.warning.bg,
background: `linear-gradient(340deg, ${theme.palette.warning.light} -60%, ${theme.palette.warning.bg} 35%)`,
borderColor: theme.palette.warning.light,
"& h2": { color: theme.palette.warning.main },
},
pending: {
backgroundColor: theme.palette.warning.light,
background: `linear-gradient(340deg, ${theme.palette.warning.dark} -60%, ${theme.palette.warning.light} 35%)`,
borderColor: theme.palette.warning.dark,
"& h2": { color: theme.palette.warning.main },
},
};
const pagespeedStyles = {
up: {
bg: theme.palette.success.bg,
light: theme.palette.success.light,
stroke: theme.palette.success.main,
},
down: {
bg: theme.palette.error.bg,
light: theme.palette.error.light,
stroke: theme.palette.error.main,
},
paused: {
bg: theme.palette.warning.bg,
light: theme.palette.warning.light,
stroke: theme.palette.warning.main,
},
pending: {
bg: theme.palette.warning.bg,
light: theme.palette.warning.light,
stroke: theme.palette.warning.main,
},
};
return {
determineState,
statusColor,
statusMsg,
pagespeedStatusMsg,
pagespeedStyles,
statusStyles,
};
return {
determineState,
statusColor,
statusMsg,
pagespeedStatusMsg,
pagespeedStyles,
statusStyles,
};
};
export default useUtils;

View File

@@ -10,8 +10,8 @@ import { useTheme } from "@emotion/react";
* So That why we're using JavaScript default parameters instead.
*/
const DefaultValue = {
title: "Oh no! You dropped your sushi!",
desc: "Either the URL doesnt exist, or you dont have access to it.",
title: "Oh no! You dropped your sushi!",
desc: "Either the URL doesnt exist, or you dont have access to it.",
};
/**
@@ -31,33 +31,47 @@ const DefaultValue = {
* @returns {JSX.Element} The rendered error page component.
*/
const NotFound = ({ title = DefaultValue.title, desc = DefaultValue.desc }) => {
const navigate = useNavigate();
const theme = useTheme();
const navigate = useNavigate();
const theme = useTheme();
return (
<Stack height="100vh" justifyContent="center">
<Stack gap={theme.spacing(2)} alignItems="center">
<img src={NotFoundSvg} alt="404" style={{ maxHeight: "25rem" }} />
<Typography component="h1" variant="h1" fontSize={16}>
{title}
</Typography>
<Typography variant="body1">{desc}</Typography>
<Button
variant="contained"
color="primary"
sx={{ mt: theme.spacing(10) }}
onClick={() => navigate("/")}
>
Go to the main dashboard
</Button>
</Stack>
</Stack>
);
return (
<Stack
height="100vh"
justifyContent="center"
>
<Stack
gap={theme.spacing(2)}
alignItems="center"
>
<img
src={NotFoundSvg}
alt="404"
style={{ maxHeight: "25rem" }}
/>
<Typography
component="h1"
variant="h1"
fontSize={16}
>
{title}
</Typography>
<Typography variant="body1">{desc}</Typography>
<Button
variant="contained"
color="primary"
sx={{ mt: theme.spacing(10) }}
onClick={() => navigate("/")}
>
Go to the main dashboard
</Button>
</Stack>
</Stack>
);
};
NotFound.propTypes = {
title: PropTypes.string,
desc: PropTypes.string,
title: PropTypes.string,
desc: PropTypes.string,
};
export default NotFound;

View File

@@ -1,9 +1,9 @@
.configure-pagespeed button {
height: var(--env-var-height-2);
height: var(--env-var-height-2);
}
.configure-pagespeed .field,
.configure-pagespeed .section-disabled,
.configure-pagespeed .select-wrapper {
flex: 1;
flex: 1;
}

View File

@@ -4,11 +4,11 @@ import { Box, Button, Modal, Stack, Tooltip, Typography } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router";
import {
deletePageSpeed,
getPagespeedMonitorById,
getPageSpeedByTeamId,
updatePageSpeed,
pausePageSpeed,
deletePageSpeed,
getPagespeedMonitorById,
getPageSpeedByTeamId,
updatePageSpeed,
pausePageSpeed,
} from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
@@ -27,456 +27,472 @@ import useUtils from "../../Monitors/utils";
import "./index.css";
const PageSpeedConfigure = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
useEffect(() => {
const fetchMonitor = async () => {
try {
const action = await dispatch(
getPagespeedMonitorById({ authToken, monitorId })
);
useEffect(() => {
const fetchMonitor = async () => {
try {
const action = await dispatch(getPagespeedMonitorById({ authToken, monitorId }));
if (getPagespeedMonitorById.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (getPagespeedMonitorById.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
};
fetchMonitor();
}, [dispatch, authToken, monitorId, navigate]);
if (getPagespeedMonitorById.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (getPagespeedMonitorById.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
};
fetchMonitor();
}, [dispatch, authToken, monitorId, navigate]);
const handleChange = (event, name) => {
let { value, id } = event.target;
if (!name) name = idMap[id];
const handleChange = (event, name) => {
let { value, id } = event.target;
if (!name) name = idMap[id];
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
if (name === "interval") {
value = value * MS_PER_MINUTE;
}
setMonitor((prev) => ({
...prev,
[name]: value,
}));
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
if (name === "interval") {
value = value * MS_PER_MINUTE;
}
setMonitor((prev) => ({
...prev,
[name]: value,
}));
const validation = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
const validation = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error)
updatedErrors[name] = validation.error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
if (validation.error) updatedErrors[name] = validation.error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
const handlePause = async () => {
try {
const action = await dispatch(pausePageSpeed({ authToken, monitorId }));
if (pausePageSpeed.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (pausePageSpeed.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error pausing monitor: " + monitorId);
createToast({ body: "Failed to pause monitor" });
}
};
const handlePause = async () => {
try {
const action = await dispatch(pausePageSpeed({ authToken, monitorId }));
if (pausePageSpeed.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (pausePageSpeed.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error pausing monitor: " + monitorId);
createToast({ body: "Failed to pause monitor" });
}
};
const handleSave = async (event) => {
event.preventDefault();
const action = await dispatch(
updatePageSpeed({ authToken, monitor: monitor })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor updated successfully!" });
dispatch(getPageSpeedByTeamId(authToken));
} else {
createToast({ body: "Failed to update monitor." });
}
};
const handleSave = async (event) => {
event.preventDefault();
const action = await dispatch(updatePageSpeed({ authToken, monitor: monitor }));
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor updated successfully!" });
dispatch(getPageSpeedByTeamId(authToken));
} else {
createToast({ body: "Failed to update monitor." });
}
};
const [isOpen, setIsOpen] = useState(false);
const handleRemove = async (event) => {
event.preventDefault();
const action = await dispatch(deletePageSpeed({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/pagespeed");
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const [isOpen, setIsOpen] = useState(false);
const handleRemove = async (event) => {
event.preventDefault();
const action = await dispatch(deletePageSpeed({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/pagespeed");
} else {
createToast({ body: "Failed to delete monitor." });
}
};
return (
<Stack className="configure-pagespeed" gap={theme.spacing(10)}>
{Object.keys(monitor).length === 0 ? (
<SkeletonLayout />
) : (
<>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `/pagespeed/${monitorId}` },
{ name: "configure", path: `/pagespeed/configure/${monitorId}` },
]}
/>
<Stack
component="form"
noValidate
spellCheck="false"
onSubmit={handleSave}
flex={1}
gap={theme.spacing(10)}
>
<Stack direction="row" gap={theme.spacing(2)}>
<Box>
<Typography component="h1" variant="h1">
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems="center"
height="fit-content"
gap={theme.spacing(2)}
>
<Tooltip
title={pagespeedStatusMsg[determineState(monitor)]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<Box>
<PulseDot color={statusColor[determineState(monitor)]} />
</Box>
</Tooltip>
<Typography component="h2" variant="h2">
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
position="relative"
variant="body2"
ml={theme.spacing(6)}
mt={theme.spacing(1)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -10,
top: "50%",
transform: "translateY(-50%)",
},
}}
>
Editing...
</Typography>
</Stack>
</Box>
<Box alignSelf="flex-end" ml="auto">
<LoadingButton
onClick={handlePause}
loading={isLoading}
variant="contained"
color="secondary"
sx={{
pl: theme.spacing(4),
pr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
"& path": {
stroke: theme.palette.other.icon,
strokeWidth: 0.1,
},
},
}}
>
{monitor?.isActive ? (
<>
<PauseCircleOutlineIcon />
Pause
</>
) : (
<>
<PlayCircleOutlineRoundedIcon />
Resume
</>
)}
</LoadingButton>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{
ml: theme.spacing(6),
}}
>
Remove
</LoadingButton>
</Box>
</Stack>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the
type of monitor.
</Typography>
</Box>
<Stack
gap={theme.spacing(20)}
sx={{
".MuiInputBase-root:has(> .Mui-disabled)": {
backgroundColor: theme.palette.background.accent,
},
}}
>
<Field
type="url"
id="monitor-url"
label="URL"
placeholder="random.website.com"
value={monitor?.url?.replace("http://", "") || ""}
onChange={handleChange}
error={errors.url}
disabled={true}
/>
<Field
type="text"
id="monitor-name"
label="Monitor display name"
placeholder="Example monitor"
isOptional={true}
value={monitor?.name || ""}
onChange={handleChange}
error={errors.name}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">
When there is a new incident,
</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={
monitor?.notifications?.some(
(notification) => notification.type === "email"
) || false
}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor?.notifications?.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="monitor-frequency"
label="Check frequency"
items={frequencies}
value={monitor?.interval / MS_PER_MINUTE || 3}
onChange={(event) => handleChange(event, "interval")}
/>
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end" mt="auto">
<LoadingButton
loading={isLoading}
type="submit"
variant="contained"
color="primary"
onClick={handleSave}
sx={{ px: theme.spacing(12) }}
>
Save
</LoadingButton>
</Stack>
</Stack>
</>
)}
<Modal
aria-labelledby="modal-delete-pagespeed-monitor"
aria-describedby="delete-pagespeed-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
disablePortal
>
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id="modal-delete-pagespeed-monitor"
component="h2"
variant="h2"
fontWeight={500}
>
Do you really want to delete this monitor?
</Typography>
<Typography
id="delete-pagespeed-monitor-confirmation"
variant="body1"
>
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button variant="contained" color="error" onClick={handleRemove}>
Delete
</Button>
</Stack>
</Stack>
</Modal>
</Stack>
);
return (
<Stack
className="configure-pagespeed"
gap={theme.spacing(10)}
>
{Object.keys(monitor).length === 0 ? (
<SkeletonLayout />
) : (
<>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `/pagespeed/${monitorId}` },
{ name: "configure", path: `/pagespeed/configure/${monitorId}` },
]}
/>
<Stack
component="form"
noValidate
spellCheck="false"
onSubmit={handleSave}
flex={1}
gap={theme.spacing(10)}
>
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Box>
<Typography
component="h1"
variant="h1"
>
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems="center"
height="fit-content"
gap={theme.spacing(2)}
>
<Tooltip
title={pagespeedStatusMsg[determineState(monitor)]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<Box>
<PulseDot color={statusColor[determineState(monitor)]} />
</Box>
</Tooltip>
<Typography
component="h2"
variant="h2"
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
position="relative"
variant="body2"
ml={theme.spacing(6)}
mt={theme.spacing(1)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -10,
top: "50%",
transform: "translateY(-50%)",
},
}}
>
Editing...
</Typography>
</Stack>
</Box>
<Box
alignSelf="flex-end"
ml="auto"
>
<LoadingButton
onClick={handlePause}
loading={isLoading}
variant="contained"
color="secondary"
sx={{
pl: theme.spacing(4),
pr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
"& path": {
stroke: theme.palette.other.icon,
strokeWidth: 0.1,
},
},
}}
>
{monitor?.isActive ? (
<>
<PauseCircleOutlineIcon />
Pause
</>
) : (
<>
<PlayCircleOutlineRoundedIcon />
Resume
</>
)}
</LoadingButton>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{
ml: theme.spacing(6),
}}
>
Remove
</LoadingButton>
</Box>
</Stack>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of
monitor.
</Typography>
</Box>
<Stack
gap={theme.spacing(20)}
sx={{
".MuiInputBase-root:has(> .Mui-disabled)": {
backgroundColor: theme.palette.background.accent,
},
}}
>
<Field
type="url"
id="monitor-url"
label="URL"
placeholder="random.website.com"
value={monitor?.url?.replace("http://", "") || ""}
onChange={handleChange}
error={errors.url}
disabled={true}
/>
<Field
type="text"
id="monitor-name"
label="Monitor display name"
placeholder="Example monitor"
isOptional={true}
value={monitor?.name || ""}
onChange={handleChange}
error={errors.name}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={
monitor?.notifications?.some(
(notification) => notification.type === "email"
) || false
}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor?.notifications?.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="monitor-frequency"
label="Check frequency"
items={frequencies}
value={monitor?.interval / MS_PER_MINUTE || 3}
onChange={(event) => handleChange(event, "interval")}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
mt="auto"
>
<LoadingButton
loading={isLoading}
type="submit"
variant="contained"
color="primary"
onClick={handleSave}
sx={{ px: theme.spacing(12) }}
>
Save
</LoadingButton>
</Stack>
</Stack>
</>
)}
<Modal
aria-labelledby="modal-delete-pagespeed-monitor"
aria-describedby="delete-pagespeed-monitor-confirmation"
open={isOpen}
onClose={() => setIsOpen(false)}
disablePortal
>
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: theme.palette.background.main,
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id="modal-delete-pagespeed-monitor"
component="h2"
variant="h2"
fontWeight={500}
>
Do you really want to delete this monitor?
</Typography>
<Typography
id="delete-pagespeed-monitor-confirmation"
variant="body1"
>
Once deleted, this monitor cannot be retrieved.
</Typography>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant="contained"
color="error"
onClick={handleRemove}
>
Delete
</Button>
</Stack>
</Stack>
</Modal>
</Stack>
);
};
export default PageSpeedConfigure;

View File

@@ -7,45 +7,80 @@ import { useTheme } from "@emotion/react";
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)} maxWidth="1000px">
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.spacing(4) }}
/>
<Skeleton variant="rounded" width="50%" height={18} />
</Box>
<Stack
direction="row"
gap={theme.spacing(6)}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton variant="rounded" width={100} height={34} />
<Skeleton variant="rounded" width={100} height={34} />
</Stack>
</Stack>
<Skeleton variant="rounded" width="100%" height={500} />
<Stack direction="row" justifyContent="flex-end">
<Skeleton variant="rounded" width="15%" height={34} />
</Stack>
</Stack>
</>
);
return (
<>
<Skeleton
variant="rounded"
width="15%"
height={34}
/>
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(6)}
maxWidth="1000px"
>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.spacing(4) }}
/>
<Skeleton
variant="rounded"
width="50%"
height={18}
/>
</Box>
<Stack
direction="row"
gap={theme.spacing(6)}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton
variant="rounded"
width={100}
height={34}
/>
<Skeleton
variant="rounded"
width={100}
height={34}
/>
</Stack>
</Stack>
<Skeleton
variant="rounded"
width="100%"
height={500}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Skeleton
variant="rounded"
width="15%"
height={34}
/>
</Stack>
</Stack>
</>
);
};
export default SkeletonLayout;

View File

@@ -25,86 +25,86 @@ const CreatePageSpeed = () => {
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const [monitor, setMonitor] = useState({
url: "",
name: "",
type: "pagespeed",
notifications: [],
interval: 3,
});
const [https, setHttps] = useState(true);
const [errors, setErrors] = useState({});
const [monitor, setMonitor] = useState({
url: "",
name: "",
type: "pagespeed",
notifications: [],
interval: 3,
});
const [https, setHttps] = useState(true);
const [errors, setErrors] = useState({});
const handleChange = (event, name) => {
const { value, id } = event.target;
if (!name) name = idMap[id];
const handleChange = (event, name) => {
const { value, id } = event.target;
if (!name) name = idMap[id];
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
setMonitor((prev) => ({
...prev,
[name]: value,
}));
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
setMonitor((prev) => ({
...prev,
[name]: value,
}));
const { error } = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
const { error } = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
const handleCreateMonitor = async (event) => {
event.preventDefault();
//obj to submit
let form = {
url: `http${https ? "s" : ""}://` + monitor.url,
name: monitor.name === "" ? monitor.url : monitor.name,
type: monitor.type,
interval: monitor.interval * MS_PER_MINUTE,
};
const handleCreateMonitor = async (event) => {
event.preventDefault();
//obj to submit
let form = {
url: `http${https ? "s" : ""}://` + monitor.url,
name: monitor.name === "" ? monitor.url : monitor.name,
type: monitor.type,
interval: monitor.interval * MS_PER_MINUTE,
};
const { error } = monitorValidation.validate(form, {
abortEarly: false,
});
const { error } = monitorValidation.validate(form, {
abortEarly: false,
});
if (error) {
const newErrors = {};

View File

@@ -1,12 +1,12 @@
import PropTypes from "prop-types";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import { useTheme } from "@emotion/react";
import { useMemo, useState } from "react";
@@ -15,26 +15,26 @@ import { formatDateWithTz } from "../../../../Utils/timeUtils";
import { useSelector } from "react-redux";
const config = {
seo: {
id: "seo",
text: "SEO",
color: "unresolved",
},
performance: {
id: "performance",
text: "performance",
color: "success",
},
bestPractices: {
id: "bestPractices",
text: "best practices",
color: "warning",
},
accessibility: {
id: "accessibility",
text: "accessibility",
color: "primary",
},
seo: {
id: "seo",
text: "SEO",
color: "unresolved",
},
performance: {
id: "performance",
text: "performance",
color: "success",
},
bestPractices: {
id: "bestPractices",
text: "best practices",
color: "warning",
},
accessibility: {
id: "accessibility",
text: "accessibility",
color: "primary",
},
};
/**
@@ -47,81 +47,79 @@ const config = {
*/
const CustomToolTip = ({ active, payload, label, config }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
if (active && payload && payload.length) {
return (
<Box
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>
{Object.keys(config)
.reverse()
.map((key) => {
const { color } = config[key];
const dotColor = theme.palette[color].main;
if (active && payload && payload.length) {
return (
<Box
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>
{Object.keys(config)
.reverse()
.map((key) => {
const { color } = config[key];
const dotColor = theme.palette[color].main;
return (
<Stack
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{config[key].text}
</Typography>{" "}
<Typography component="span">
{payload[0].payload[key]}
</Typography>
</Stack>
);
})}
</Box>
);
}
return null;
return (
<Stack
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
sx={{
"& span": {
color: theme.palette.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{config[key].text}
</Typography>{" "}
<Typography component="span">{payload[0].payload[key]}</Typography>
</Stack>
);
})}
</Box>
);
}
return null;
};
CustomToolTip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.array,
label: PropTypes.string,
config: PropTypes.object,
active: PropTypes.bool,
payload: PropTypes.array,
label: PropTypes.string,
config: PropTypes.object,
};
/**
@@ -131,38 +129,38 @@ CustomToolTip.propTypes = {
* @returns {Array} The formatted data with gaps.
*/
const processDataWithGaps = (data, interval) => {
if (data.length === 0) return [];
let formattedData = [];
let last = new Date(data[0].createdAt).getTime();
if (data.length === 0) return [];
let formattedData = [];
let last = new Date(data[0].createdAt).getTime();
// Helper function to add a null entry
const addNullEntry = (timestamp) => {
formattedData.push({
accessibility: "N/A",
bestPractices: "N/A",
performance: "N/A",
seo: "N/A",
createdAt: timestamp,
});
};
// Helper function to add a null entry
const addNullEntry = (timestamp) => {
formattedData.push({
accessibility: "N/A",
bestPractices: "N/A",
performance: "N/A",
seo: "N/A",
createdAt: timestamp,
});
};
data.forEach((entry) => {
const current = new Date(entry.createdAt).getTime();
data.forEach((entry) => {
const current = new Date(entry.createdAt).getTime();
if (current - last > interval * 2) {
// Insert null entries for each interval
let temp = last + interval;
while (temp < current) {
addNullEntry(new Date(temp).toISOString());
temp += interval;
}
}
if (current - last > interval * 2) {
// Insert null entries for each interval
let temp = last + interval;
while (temp < current) {
addNullEntry(new Date(temp).toISOString());
temp += interval;
}
}
formattedData.push(entry);
last = current;
});
formattedData.push(entry);
last = current;
});
return formattedData;
return formattedData;
};
/**
@@ -177,32 +175,32 @@ const processDataWithGaps = (data, interval) => {
* @returns {JSX.Element|null} The tick element or null if the tick should be hidden.
*/
const CustomTick = ({ x, y, payload, index }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
// Render nothing for the first tick
if (index === 0) return null;
// Render nothing for the first tick
if (index === 0) return null;
return (
<Text
x={x}
y={y + 8}
textAnchor="middle"
fill={theme.palette.text.tertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
</Text>
);
return (
<Text
x={x}
y={y + 8}
textAnchor="middle"
fill={theme.palette.text.tertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
</Text>
);
};
CustomTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.shape({
value: PropTypes.string.isRequired,
}),
index: PropTypes.number,
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.shape({
value: PropTypes.string.isRequired,
}),
index: PropTypes.number,
};
/**
@@ -214,102 +212,115 @@ CustomTick.propTypes = {
*/
const PagespeedDetailsAreaChart = ({ data, interval, metrics }) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const memoizedData = useMemo(
() => processDataWithGaps(data, interval),
[data[0]]
);
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const memoizedData = useMemo(() => processDataWithGaps(data, interval), [data[0]]);
const filteredConfig = Object.keys(config).reduce((result, key) => {
if (metrics[key]) {
result[key] = config[key];
}
return result;
}, {});
const filteredConfig = Object.keys(config).reduce((result, key) => {
if (metrics[key]) {
result[key] = config[key];
}
return result;
}, {});
return (
<ResponsiveContainer width="100%" minWidth={25} height={215}>
<AreaChart
width="100%"
height="100%"
data={memoizedData}
margin={{ top: 10 }}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<XAxis
stroke={theme.palette.border.dark}
dataKey="createdAt"
tick={<CustomTick />}
axisLine={false}
tickLine={false}
height={18}
minTickGap={0}
interval="equidistantPreserveStart"
/>
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={<CustomToolTip config={filteredConfig} />}
/>
<defs>
{Object.values(filteredConfig).map(({ id, color }) => {
const startColor = theme.palette[color].main;
const endColor = theme.palette[color].light;
return (
<ResponsiveContainer
width="100%"
minWidth={25}
height={215}
>
<AreaChart
width="100%"
height="100%"
data={memoizedData}
margin={{ top: 10 }}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<XAxis
stroke={theme.palette.border.dark}
dataKey="createdAt"
tick={<CustomTick />}
axisLine={false}
tickLine={false}
height={18}
minTickGap={0}
interval="equidistantPreserveStart"
/>
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={<CustomToolTip config={filteredConfig} />}
/>
<defs>
{Object.values(filteredConfig).map(({ id, color }) => {
const startColor = theme.palette[color].main;
const endColor = theme.palette[color].light;
return (
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1" key={id}>
<stop offset="0%" stopColor={startColor} stopOpacity={0.8} />
<stop offset="100%" stopColor={endColor} stopOpacity={0} />
</linearGradient>
);
})}
</defs>
{Object.keys(filteredConfig).map((key) => {
const { color } = filteredConfig[key];
const strokeColor = theme.palette[color].main;
const bgColor = theme.palette.background.main;
return (
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
key={id}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={0}
/>
</linearGradient>
);
})}
</defs>
{Object.keys(filteredConfig).map((key) => {
const { color } = filteredConfig[key];
const strokeColor = theme.palette[color].main;
const bgColor = theme.palette.background.main;
return (
<Area
connectNulls
key={key}
dataKey={key}
stackId={1}
stroke={strokeColor}
strokeWidth={isHovered ? 2.5 : 1.5}
fill={`url(#${filteredConfig[key].id})`}
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
);
return (
<Area
connectNulls
key={key}
dataKey={key}
stackId={1}
stroke={strokeColor}
strokeWidth={isHovered ? 2.5 : 1.5}
fill={`url(#${filteredConfig[key].id})`}
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
);
};
PagespeedDetailsAreaChart.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
createdAt: PropTypes.string.isRequired,
accessibility: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
bestPractices: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
performance: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
seo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
})
).isRequired,
interval: PropTypes.number.isRequired,
metrics: PropTypes.object,
data: PropTypes.arrayOf(
PropTypes.shape({
createdAt: PropTypes.string.isRequired,
accessibility: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
bestPractices: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
performance: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
seo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
})
).isRequired,
interval: PropTypes.number.isRequired,
metrics: PropTypes.object,
};
export default PagespeedDetailsAreaChart;

Some files were not shown because too many files have changed in this diff Show More