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

This commit is contained in:
om-3004 committed 2024-10-18 11:39:07 +05:30
commit 34a13530b3
202 files changed
+33101 -32065

No files matched your search

+11 -11
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);
}
+69 -66
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;
+234 -236
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;
+186 -180
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;
+178 -180
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;
+460 -443
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;
+105 -103
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
+274 -274
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;
+73 -73
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;
}
}
+22 -22
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,
},
},
}));
+187 -177
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;
+4 -4
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;
}
+114 -106
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;
+37 -17
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;
+4 -4
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);
}
+118 -108
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;
@@ -1,3 +1,3 @@
.create-maintenance button {
height: 35px;
height: 35px;
}
File diff suppressed because it is too large. Load diff
@@ -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 },
}));
@@ -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;
@@ -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);
+90 -93
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;
@@ -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%;
}
+419 -415
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;
@@ -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;
+343 -339
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;
+295 -267
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 };
@@ -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;
@@ -1 +0,0 @@
+108 -61
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;
+86 -86
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,
},
},
}));
@@ -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);
+94 -94
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;
+199 -203
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;
+43 -39
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 don’t 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 don’t 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;
+41 -41
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;
+14 -14
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;
}
+162 -167
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;
+48 -19
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;
+34 -34
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,
},
}));
+78 -78
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;
+39 -25
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 doesn’t exist, or you don’t have access to it.",
title: "Oh no! You dropped your sushi!",
desc: "Either the URL doesn’t exist, or you don’t 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;
@@ -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;
}
+459 -443
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;
@@ -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;
@@ -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 = {};
@@ -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;
@@ -14,33 +14,38 @@ import { Box } from "@mui/material";
* @returns {JSX.Element}
*/
const PieCenterLabel = ({ value, color, setExpand }) => {
const { width, height } = useDrawingArea();
return (
<g
transform={`translate(${width / 2}, ${height / 2})`}
onMouseEnter={() => setExpand(true)}
>
<circle cx={0} cy={0} r={width / 4} fill="transparent" />
<text
className="pie-label"
style={{
fill: color,
fontSize: 48,
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
}}
>
{value}
</text>
</g>
);
const { width, height } = useDrawingArea();
return (
<g
transform={`translate(${width / 2}, ${height / 2})`}
onMouseEnter={() => setExpand(true)}
>
<circle
cx={0}
cy={0}
r={width / 4}
fill="transparent"
/>
<text
className="pie-label"
style={{
fill: color,
fontSize: 48,
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
}}
>
{value}
</text>
</g>
);
};
PieCenterLabel.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
color: PropTypes.string,
setExpand: PropTypes.func,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
color: PropTypes.string,
setExpand: PropTypes.func,
};
/**
@@ -56,257 +61,257 @@ PieCenterLabel.propTypes = {
* @returns {JSX.Element}
*/
const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => {
const { width, height } = useDrawingArea();
const { width, height } = useDrawingArea();
// Compute the midpoint angle in radians
const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180;
const radius = height / 4; // length from center of the circle to where the text is positioned
// Compute the midpoint angle in radians
const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180;
const radius = height / 4; // length from center of the circle to where the text is positioned
// Calculate x and y positions
const x = Math.sin(angle) * radius;
const y = -Math.cos(angle) * radius;
// Calculate x and y positions
const x = Math.sin(angle) * radius;
const y = -Math.cos(angle) * radius;
return (
<g transform={`translate(${width / 2}, ${height / 2})`}>
<text
className="pie-value-label"
x={x}
y={y}
style={{
fill: highlighted ? color : "rgba(0,0,0,0)",
fontSize: 12,
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
pointerEvents: "none",
}}
>
+{value}
</text>
</g>
);
return (
<g transform={`translate(${width / 2}, ${height / 2})`}>
<text
className="pie-value-label"
x={x}
y={y}
style={{
fill: highlighted ? color : "rgba(0,0,0,0)",
fontSize: 12,
textAnchor: "middle",
dominantBaseline: "central",
userSelect: "none",
pointerEvents: "none",
}}
>
+{value}
</text>
</g>
);
};
// Validate props using PropTypes
PieValueLabel.propTypes = {
value: PropTypes.number.isRequired,
startAngle: PropTypes.number.isRequired,
endAngle: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
highlighted: PropTypes.bool.isRequired,
value: PropTypes.number.isRequired,
startAngle: PropTypes.number.isRequired,
endAngle: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
highlighted: PropTypes.bool.isRequired,
};
const PieChart = ({ audits }) => {
const theme = useTheme();
const theme = useTheme();
/**
* Weight constants for different performance metrics.
* @type {Object}
*/
const weights = {
fcp: 10,
si: 10,
lcp: 25,
tbt: 30,
cls: 25,
};
/**
* Weight constants for different performance metrics.
* @type {Object}
*/
const weights = {
fcp: 10,
si: 10,
lcp: 25,
tbt: 30,
cls: 25,
};
/**
* Retrieves color properties based on the performance value.
*
* @param {number} value - The performance score used to determine the color properties.
* @returns {{stroke: string, strokeBg: string, text: string, bg: string}} The color properties for the given performance value.
*/
const getColors = (value) => {
if (value >= 90 && value <= 100)
return {
stroke: theme.palette.success.main,
strokeBg: theme.palette.success.light,
text: theme.palette.success.text,
bg: theme.palette.success.bg,
};
else if (value >= 50 && value < 90)
return {
stroke: theme.palette.warning.main,
strokeBg: theme.palette.warning.light,
text: theme.palette.warning.text,
bg: theme.palette.warning.bg,
};
else if (value >= 0 && value < 50)
return {
stroke: theme.palette.error.text,
strokeBg: theme.palette.error.light,
text: theme.palette.error.text,
bg: theme.palette.error.bg,
};
return {
stroke: theme.palette.unresolved.main,
strokeBg: theme.palette.unresolved.light,
text: theme.palette.unresolved.main,
bg: theme.palette.unresolved.bg,
};
};
/**
* Retrieves color properties based on the performance value.
*
* @param {number} value - The performance score used to determine the color properties.
* @returns {{stroke: string, strokeBg: string, text: string, bg: string}} The color properties for the given performance value.
*/
const getColors = (value) => {
if (value >= 90 && value <= 100)
return {
stroke: theme.palette.success.main,
strokeBg: theme.palette.success.light,
text: theme.palette.success.text,
bg: theme.palette.success.bg,
};
else if (value >= 50 && value < 90)
return {
stroke: theme.palette.warning.main,
strokeBg: theme.palette.warning.light,
text: theme.palette.warning.text,
bg: theme.palette.warning.bg,
};
else if (value >= 0 && value < 50)
return {
stroke: theme.palette.error.text,
strokeBg: theme.palette.error.light,
text: theme.palette.error.text,
bg: theme.palette.error.bg,
};
return {
stroke: theme.palette.unresolved.main,
strokeBg: theme.palette.unresolved.light,
text: theme.palette.unresolved.main,
bg: theme.palette.unresolved.bg,
};
};
/**
* Calculates and formats the data needed for rendering a pie chart based on audit scores and weights.
* This function generates properties for each pie slice, including angles, radii, and colors.
* It also calculates performance based on the weighted values.
*
* @returns {Array<Object>} An array of objects, each representing the properties for a slice of the pie chart.
* @returns {number} performance - A variable updated with the rounded sum of weighted values.
*/
let performance = 0;
const getPieData = (audits) => {
let data = [];
let startAngle = 0;
const padding = 3; // padding between arcs
const max = 360 - padding * (Object.keys(audits).length - 1); // _id is a child of audits
/**
* Calculates and formats the data needed for rendering a pie chart based on audit scores and weights.
* This function generates properties for each pie slice, including angles, radii, and colors.
* It also calculates performance based on the weighted values.
*
* @returns {Array<Object>} An array of objects, each representing the properties for a slice of the pie chart.
* @returns {number} performance - A variable updated with the rounded sum of weighted values.
*/
let performance = 0;
const getPieData = (audits) => {
let data = [];
let startAngle = 0;
const padding = 3; // padding between arcs
const max = 360 - padding * (Object.keys(audits).length - 1); // _id is a child of audits
Object.keys(audits).forEach((key) => {
if (audits[key].score) {
let value = audits[key].score * weights[key];
let endAngle = startAngle + (weights[key] * max) / 100;
Object.keys(audits).forEach((key) => {
if (audits[key].score) {
let value = audits[key].score * weights[key];
let endAngle = startAngle + (weights[key] * max) / 100;
let theme = getColors(audits[key].score * 100);
data.push({
id: key,
data: [
{
value: value,
color: theme.stroke,
label: key.toUpperCase(),
},
{
value: weights[key] - value,
color: theme.strokeBg,
label: "",
},
],
arcLabel: (item) => `${item.label}`,
arcLabelRadius: 95,
startAngle: startAngle,
endAngle: endAngle,
innerRadius: 73,
outerRadius: 80,
cornerRadius: 2,
highlightScope: { faded: "global", highlighted: "series" },
faded: {
innerRadius: 73,
outerRadius: 80,
additionalRadius: -20,
arcLabelRadius: 5,
},
cx: pieSize.width / 2,
});
let theme = getColors(audits[key].score * 100);
data.push({
id: key,
data: [
{
value: value,
color: theme.stroke,
label: key.toUpperCase(),
},
{
value: weights[key] - value,
color: theme.strokeBg,
label: "",
},
],
arcLabel: (item) => `${item.label}`,
arcLabelRadius: 95,
startAngle: startAngle,
endAngle: endAngle,
innerRadius: 73,
outerRadius: 80,
cornerRadius: 2,
highlightScope: { faded: "global", highlighted: "series" },
faded: {
innerRadius: 73,
outerRadius: 80,
additionalRadius: -20,
arcLabelRadius: 5,
},
cx: pieSize.width / 2,
});
performance += Math.floor(value);
startAngle = endAngle + padding;
}
});
performance += Math.floor(value);
startAngle = endAngle + padding;
}
});
return data;
};
return data;
};
const pieSize = { width: 230, height: 230 };
const pieData = getPieData(audits);
const colorMap = getColors(performance);
const pieSize = { width: 230, height: 230 };
const pieData = getPieData(audits);
const colorMap = getColors(performance);
const [highlightedItem, setHighLightedItem] = useState(null);
const [expand, setExpand] = useState(false);
return (
<Box onMouseLeave={() => setExpand(false)}>
{expand ? (
<MuiPieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 77,
cx: pieSize.width / 2,
},
...pieData,
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
onHighlightChange={setHighLightedItem}
slotProps={{
legend: { hidden: true },
}}
tooltip={{ trigger: "none" }}
sx={{
"&:has(.MuiPieArcLabel-faded) .pie-label": {
fill: "rgba(0,0,0,0) !important",
},
}}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
{pieData?.map((pie) => (
<PieValueLabel
key={pie.id}
value={Math.round(pie.data[0].value * 10) / 10}
startAngle={pie.startAngle}
endAngle={pie.endAngle}
color={pie.data[0].color}
highlighted={highlightedItem?.seriesId === pie.id}
/>
))}
</MuiPieChart>
) : (
<MuiPieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 77,
cx: pieSize.width / 2,
},
{
data: [
{
value: performance,
color: colorMap.stroke,
},
],
innerRadius: 73,
outerRadius: 80,
paddingAngle: 5,
cornerRadius: 2,
startAngle: 0,
endAngle: (performance / 100) * 360,
cx: pieSize.width / 2,
},
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
tooltip={{ trigger: "none" }}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
</MuiPieChart>
)}
</Box>
);
const [highlightedItem, setHighLightedItem] = useState(null);
const [expand, setExpand] = useState(false);
return (
<Box onMouseLeave={() => setExpand(false)}>
{expand ? (
<MuiPieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 77,
cx: pieSize.width / 2,
},
...pieData,
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
onHighlightChange={setHighLightedItem}
slotProps={{
legend: { hidden: true },
}}
tooltip={{ trigger: "none" }}
sx={{
"&:has(.MuiPieArcLabel-faded) .pie-label": {
fill: "rgba(0,0,0,0) !important",
},
}}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
{pieData?.map((pie) => (
<PieValueLabel
key={pie.id}
value={Math.round(pie.data[0].value * 10) / 10}
startAngle={pie.startAngle}
endAngle={pie.endAngle}
color={pie.data[0].color}
highlighted={highlightedItem?.seriesId === pie.id}
/>
))}
</MuiPieChart>
) : (
<MuiPieChart
series={[
{
data: [
{
value: 100,
color: colorMap.bg,
},
],
outerRadius: 77,
cx: pieSize.width / 2,
},
{
data: [
{
value: performance,
color: colorMap.stroke,
},
],
innerRadius: 73,
outerRadius: 80,
paddingAngle: 5,
cornerRadius: 2,
startAngle: 0,
endAngle: (performance / 100) * 360,
cx: pieSize.width / 2,
},
]}
width={pieSize.width}
height={pieSize.height}
margin={{ left: 0, top: 0, right: 0, bottom: 0 }}
tooltip={{ trigger: "none" }}
>
<PieCenterLabel
value={performance}
color={colorMap.text}
setExpand={setExpand}
/>
</MuiPieChart>
)}
</Box>
);
};
PieChart.propTypes = {
audits: PropTypes.object,
audits: PropTypes.object,
};
export default PieChart;
+6 -6
View File
@@ -1,18 +1,18 @@
.page-speed-details button.MuiButtonBase-root {
height: 34px;
height: 34px;
}
.page-speed-details .MuiPieArcLabel-root {
font-size: var(--env-var-font-size-small-plus);
transition: fill 300ms ease;
font-size: var(--env-var-font-size-small-plus);
transition: fill 300ms ease;
}
.page-speed-details .MuiPieArcLabel-faded {
fill: rgba(0, 0, 0, 0.3);
fill: rgba(0, 0, 0, 0.3);
}
.page-speed-details .pie-label,
.page-speed-details .pie-value-label {
transition: all 400ms ease;
transition: all 400ms ease;
}
.page-speed-details .MuiPieArc-root {
stroke: inherit;
stroke: inherit;
}
+400 -364
View File
@@ -1,20 +1,10 @@
import PropTypes from "prop-types";
import {
Box,
Button,
Divider,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import { Box, Button, Divider, Stack, Tooltip, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import {
formatDurationRounded,
formatDurationSplit,
} from "../../../Utils/timeUtils";
import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils";
import { ChartBox, IconBox, StatBox } from "./styled";
import { logger } from "../../../Utils/Logger";
import { networkService } from "../../../main";
@@ -33,370 +23,416 @@ import useUtils from "../../Monitors/utils";
import "./index.css";
const PageSpeedDetails = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
const [monitor, setMonitor] = useState({});
const [audits, setAudits] = useState({});
const { monitorId } = useParams();
const { authToken } = useSelector((state) => state.auth);
const theme = useTheme();
const navigate = useNavigate();
const { statusColor, pagespeedStatusMsg, determineState } = useUtils();
const [monitor, setMonitor] = useState({});
const [audits, setAudits] = useState({});
const { monitorId } = useParams();
const { authToken } = useSelector((state) => state.auth);
useEffect(() => {
const fetchMonitor = async () => {
try {
const res = await networkService.getStatsByMonitorId({
authToken: authToken,
monitorId: monitorId,
sortOrder: "desc",
limit: 50,
dateRange: "day",
numToDisplay: null,
normalize: null,
});
setMonitor(res?.data?.data ?? {});
setAudits(res?.data?.data?.checks?.[0]?.audits ?? {});
} catch (error) {
logger.error(logger);
navigate("/not-found", { replace: true });
}
};
useEffect(() => {
const fetchMonitor = async () => {
try {
const res = await networkService.getStatsByMonitorId({
authToken: authToken,
monitorId: monitorId,
sortOrder: "desc",
limit: 50,
dateRange: "day",
numToDisplay: null,
normalize: null,
});
setMonitor(res?.data?.data ?? {});
setAudits(res?.data?.data?.checks?.[0]?.audits ?? {});
} catch (error) {
logger.error(logger);
navigate("/not-found", { replace: true });
}
};
fetchMonitor();
}, [monitorId, authToken, navigate]);
fetchMonitor();
}, [monitorId, authToken, navigate]);
let loading = Object.keys(monitor).length === 0;
const data = monitor?.checks ? [...monitor.checks].reverse() : [];
let loading = Object.keys(monitor).length === 0;
const data = monitor?.checks ? [...monitor.checks].reverse() : [];
const splitDuration = (duration) => {
const { time, format } = formatDurationSplit(duration);
return (
<>
{time}
<Typography component="span">{format}</Typography>
</>
);
};
const splitDuration = (duration) => {
const { time, format } = formatDurationSplit(duration);
return (
<>
{time}
<Typography component="span">{format}</Typography>
</>
);
};
const [metrics, setMetrics] = useState({
accessibility: true,
bestPractices: true,
performance: true,
seo: true,
});
const handleMetrics = (id) => {
setMetrics((prev) => ({ ...prev, [id]: !prev[id] }));
};
const [metrics, setMetrics] = useState({
accessibility: true,
bestPractices: true,
performance: true,
seo: true,
});
const handleMetrics = (id) => {
setMetrics((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
<Stack className="page-speed-details" gap={theme.spacing(10)}>
{loading ? (
<SkeletonLayout />
) : (
<>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `/pagespeed/${monitorId}` },
]}
/>
<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}
</Typography>
<Typography
position="relative"
variant="body2"
mt={theme.spacing(1)}
ml={theme.spacing(6)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -9,
top: "50%",
transform: "translateY(-50%)",
},
}}
>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Box>
{isAdmin && (
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/pagespeed/configure/${monitorId}`)}
sx={{
ml: "auto",
alignSelf: "flex-end",
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.other.icon,
},
},
}}
>
<SettingsIcon />
Configure
</Button>
)}
</Stack>
<Stack direction="row" gap={theme.spacing(8)}>
<StatBox>
<Typography component="h2">checks since</Typography>
<Typography>
{splitDuration(monitor?.uptimeDuration)}
<Typography component="span">ago</Typography>
</Typography>
</StatBox>
<StatBox>
<Typography component="h2">last check</Typography>
<Typography>
{splitDuration(monitor?.lastChecked)}
<Typography component="span">ago</Typography>
</Typography>
</StatBox>
</Stack>
<Box>
<Typography variant="body2" my={theme.spacing(8)}>
Showing statistics for past 24 hours.
</Typography>
<ChartBox sx={{ gridTemplateColumns: "75% 25%" }}>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<IconBox>
<ScoreIcon />
</IconBox>
<Typography component="h2">Score history</Typography>
</Stack>
<Box>
<PagespeedDetailsAreaChart
data={data}
interval={monitor?.interval}
metrics={metrics}
/>
</Box>
<Box>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
<IconBox>
<MetricsIcon />
</IconBox>
<Typography component="h2">Metrics</Typography>
</Stack>
<Stack
gap={theme.spacing(4)}
mt={theme.spacing(16)}
sx={{
"& label": { pl: theme.spacing(6) },
}}
>
<Box>
<Typography fontSize={11} fontWeight={500}>
Shown
</Typography>
<Divider sx={{ mt: theme.spacing(2) }} />
</Box>
<Checkbox
id="accessibility-toggle"
label="Accessibility"
isChecked={metrics.accessibility}
onChange={() => handleMetrics("accessibility")}
/>
<Divider />
<Checkbox
id="best-practices-toggle"
label="Best Practices"
isChecked={metrics.bestPractices}
onChange={() => handleMetrics("bestPractices")}
/>
<Divider />
<Checkbox
id="performance-toggle"
label="Performance"
isChecked={metrics.performance}
onChange={() => handleMetrics("performance")}
/>
<Divider />
<Checkbox
id="seo-toggle"
label="Search Engine Optimization"
isChecked={metrics.seo}
onChange={() => handleMetrics("seo")}
/>
<Divider />
</Stack>
</Box>
</ChartBox>
</Box>
<ChartBox
flex={1}
sx={{ gridTemplateColumns: "50% 50%", gridTemplateRows: "15% 85%" }}
>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<IconBox>
<PerformanceIcon />
</IconBox>
<Typography component="h2">Performance report</Typography>
</Stack>
<Stack
alignItems="center"
textAlign="center"
minWidth="300px"
flex={1}
pt={theme.spacing(6)}
pb={theme.spacing(12)}
>
<PieChart audits={audits} />
<Typography variant="body1" mt="auto">
Values are estimated and may vary.{" "}
<Typography
component="span"
fontSize="inherit"
sx={{
color: theme.palette.primary.main,
fontWeight: 500,
textDecoration: "underline",
textUnderlineOffset: 2,
transition: "all 200ms",
cursor: "pointer",
"&:hover": {
textUnderlineOffset: 4,
},
}}
>
See calculator
</Typography>
</Typography>
</Stack>
<Box px={theme.spacing(20)} py={theme.spacing(8)} height="100%">
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<IconBox>
<SpedometerIcon />
</IconBox>
<Typography component="h2">Performance Metrics</Typography>
</Stack>
<Stack
flexWrap="wrap"
mt={theme.spacing(4)}
gap={theme.spacing(4)}
>
{Object.keys(audits).map((key) => {
if (key === "_id") return;
return (
<Stack
className="page-speed-details"
gap={theme.spacing(10)}
>
{loading ? (
<SkeletonLayout />
) : (
<>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `/pagespeed/${monitorId}` },
]}
/>
<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}
</Typography>
<Typography
position="relative"
variant="body2"
mt={theme.spacing(1)}
ml={theme.spacing(6)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -9,
top: "50%",
transform: "translateY(-50%)",
},
}}
>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Box>
{isAdmin && (
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/pagespeed/configure/${monitorId}`)}
sx={{
ml: "auto",
alignSelf: "flex-end",
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.other.icon,
},
},
}}
>
<SettingsIcon />
Configure
</Button>
)}
</Stack>
<Stack
direction="row"
gap={theme.spacing(8)}
>
<StatBox>
<Typography component="h2">checks since</Typography>
<Typography>
{splitDuration(monitor?.uptimeDuration)}
<Typography component="span">ago</Typography>
</Typography>
</StatBox>
<StatBox>
<Typography component="h2">last check</Typography>
<Typography>
{splitDuration(monitor?.lastChecked)}
<Typography component="span">ago</Typography>
</Typography>
</StatBox>
</Stack>
<Box>
<Typography
variant="body2"
my={theme.spacing(8)}
>
Showing statistics for past 24 hours.
</Typography>
<ChartBox sx={{ gridTemplateColumns: "75% 25%" }}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
<IconBox>
<ScoreIcon />
</IconBox>
<Typography component="h2">Score history</Typography>
</Stack>
<Box>
<PagespeedDetailsAreaChart
data={data}
interval={monitor?.interval}
metrics={metrics}
/>
</Box>
<Box>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
<IconBox>
<MetricsIcon />
</IconBox>
<Typography component="h2">Metrics</Typography>
</Stack>
<Stack
gap={theme.spacing(4)}
mt={theme.spacing(16)}
sx={{
"& label": { pl: theme.spacing(6) },
}}
>
<Box>
<Typography
fontSize={11}
fontWeight={500}
>
Shown
</Typography>
<Divider sx={{ mt: theme.spacing(2) }} />
</Box>
<Checkbox
id="accessibility-toggle"
label="Accessibility"
isChecked={metrics.accessibility}
onChange={() => handleMetrics("accessibility")}
/>
<Divider />
<Checkbox
id="best-practices-toggle"
label="Best Practices"
isChecked={metrics.bestPractices}
onChange={() => handleMetrics("bestPractices")}
/>
<Divider />
<Checkbox
id="performance-toggle"
label="Performance"
isChecked={metrics.performance}
onChange={() => handleMetrics("performance")}
/>
<Divider />
<Checkbox
id="seo-toggle"
label="Search Engine Optimization"
isChecked={metrics.seo}
onChange={() => handleMetrics("seo")}
/>
<Divider />
</Stack>
</Box>
</ChartBox>
</Box>
<ChartBox
flex={1}
sx={{ gridTemplateColumns: "50% 50%", gridTemplateRows: "15% 85%" }}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
<IconBox>
<PerformanceIcon />
</IconBox>
<Typography component="h2">Performance report</Typography>
</Stack>
<Stack
alignItems="center"
textAlign="center"
minWidth="300px"
flex={1}
pt={theme.spacing(6)}
pb={theme.spacing(12)}
>
<PieChart audits={audits} />
<Typography
variant="body1"
mt="auto"
>
Values are estimated and may vary.{" "}
<Typography
component="span"
fontSize="inherit"
sx={{
color: theme.palette.primary.main,
fontWeight: 500,
textDecoration: "underline",
textUnderlineOffset: 2,
transition: "all 200ms",
cursor: "pointer",
"&:hover": {
textUnderlineOffset: 4,
},
}}
>
See calculator
</Typography>
</Typography>
</Stack>
<Box
px={theme.spacing(20)}
py={theme.spacing(8)}
height="100%"
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
<IconBox>
<SpedometerIcon />
</IconBox>
<Typography component="h2">Performance Metrics</Typography>
</Stack>
<Stack
flexWrap="wrap"
mt={theme.spacing(4)}
gap={theme.spacing(4)}
>
{Object.keys(audits).map((key) => {
if (key === "_id") return;
let audit = audits[key];
let score = audit.score * 100;
let bg =
score >= 90
? theme.palette.success.main
: score >= 50
? theme.palette.warning.main
: score >= 0
? theme.palette.error.text
: theme.palette.unresolved.main;
let audit = audits[key];
let score = audit.score * 100;
let bg =
score >= 90
? theme.palette.success.main
: score >= 50
? theme.palette.warning.main
: score >= 0
? theme.palette.error.text
: theme.palette.unresolved.main;
// Find the position where the number ends and the unit begins
const match = audit.displayValue.match(
/(\d+\.?\d*)\s*([a-zA-Z]+)/
);
let value;
let unit;
if (match) {
value = match[1];
match[2] === "s" ? (unit = "seconds") : (unit = match[2]);
} else {
value = audit.displayValue;
}
// Find the position where the number ends and the unit begins
const match = audit.displayValue.match(/(\d+\.?\d*)\s*([a-zA-Z]+)/);
let value;
let unit;
if (match) {
value = match[1];
match[2] === "s" ? (unit = "seconds") : (unit = match[2]);
} else {
value = audit.displayValue;
}
return (
<Stack
key={`${key}-box`}
justifyContent="space-between"
direction="row"
gap={theme.spacing(4)}
p={theme.spacing(3)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={4}
>
<Box>
<Typography
fontSize={12}
fontWeight={500}
lineHeight={1}
mb={1}
textTransform="uppercase"
>
{audit.title}
</Typography>
<Typography
component="span"
fontSize={14}
fontWeight={500}
color={theme.palette.text.primary}
>
{value}
<Typography component="span" variant="body2" ml={2}>
{unit}
</Typography>
</Typography>
</Box>
<Box width={4} backgroundColor={bg} borderRadius={4} />
</Stack>
);
})}
</Stack>
</Box>
</ChartBox>
</>
)}
</Stack>
);
return (
<Stack
key={`${key}-box`}
justifyContent="space-between"
direction="row"
gap={theme.spacing(4)}
p={theme.spacing(3)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={4}
>
<Box>
<Typography
fontSize={12}
fontWeight={500}
lineHeight={1}
mb={1}
textTransform="uppercase"
>
{audit.title}
</Typography>
<Typography
component="span"
fontSize={14}
fontWeight={500}
color={theme.palette.text.primary}
>
{value}
<Typography
component="span"
variant="body2"
ml={2}
>
{unit}
</Typography>
</Typography>
</Box>
<Box
width={4}
backgroundColor={bg}
borderRadius={4}
/>
</Stack>
);
})}
</Stack>
</Box>
</ChartBox>
</>
)}
</Stack>
);
};
PageSpeedDetails.propTypes = {
isAdmin: PropTypes.bool,
push: PropTypes.func,
isAdmin: PropTypes.bool,
push: PropTypes.func,
};
export default PageSpeedDetails;
+82 -32
View File
@@ -6,38 +6,88 @@ import { Box, Skeleton, Stack } from "@mui/material";
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack direction="row" gap={4}>
<Skeleton variant="circular" style={{ minWidth: 24, minHeight: 24 }} />
<Box width="85%">
<Skeleton variant="rounded" width="50%" height={24} />
<Skeleton variant="rounded" width="50%" height={18} sx={{ mt: 4 }} />
</Box>
<Skeleton
variant="rounded"
width="15%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={20}
flexWrap="wrap"
>
<Skeleton variant="rounded" width="30%" height={90} sx={{ flex: 1 }} />
<Skeleton variant="rounded" width="30%" height={90} sx={{ flex: 1 }} />
<Skeleton variant="rounded" width="30%" height={90} sx={{ flex: 1 }} />
</Stack>
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton variant="rounded" width="100%" height={300} />
</>
);
return (
<>
<Skeleton
variant="rounded"
width="15%"
height={34}
/>
<Stack
direction="row"
gap={4}
>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="85%">
<Skeleton
variant="rounded"
width="50%"
height={24}
/>
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: 4 }}
/>
</Box>
<Skeleton
variant="rounded"
width="15%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={20}
flexWrap="wrap"
>
<Skeleton
variant="rounded"
width="30%"
height={90}
sx={{ flex: 1 }}
/>
<Skeleton
variant="rounded"
width="30%"
height={90}
sx={{ flex: 1 }}
/>
<Skeleton
variant="rounded"
width="30%"
height={90}
sx={{ flex: 1 }}
/>
</Stack>
<Skeleton
variant="rounded"
width="25%"
height={24}
/>
<Skeleton
variant="rounded"
width="100%"
height={300}
/>
<Skeleton
variant="rounded"
width="25%"
height={24}
/>
<Skeleton
variant="rounded"
width="100%"
height={300}
/>
</>
);
};
export default SkeletonLayout;
+83 -83
View File
@@ -1,93 +1,93 @@
import { Box, Stack, styled } from "@mui/material";
export const ChartBox = styled(Stack)(({ theme }) => ({
display: "grid",
height: 300,
minWidth: 250,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
backgroundColor: theme.palette.background.main,
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 500,
},
"& p": { color: theme.palette.text.tertiary },
"& > :nth-of-type(1)": {
gridColumn: 1,
gridRow: 1,
height: "fit-content",
paddingTop: theme.spacing(8),
paddingLeft: theme.spacing(8),
},
"& > :nth-of-type(2)": { gridColumn: 1, gridRow: 2 },
"& > :nth-of-type(3)": {
gridColumn: 2,
gridRow: "span 2",
padding: theme.spacing(8),
borderLeft: 1,
borderLeftStyle: "solid",
borderLeftColor: theme.palette.border.light,
borderRadius: 16,
backgroundColor: theme.palette.background.main,
background: `linear-gradient(325deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
},
"& path": {
transition: "stroke-width 400ms ease",
},
display: "grid",
height: 300,
minWidth: 250,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
backgroundColor: theme.palette.background.main,
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 500,
},
"& p": { color: theme.palette.text.tertiary },
"& > :nth-of-type(1)": {
gridColumn: 1,
gridRow: 1,
height: "fit-content",
paddingTop: theme.spacing(8),
paddingLeft: theme.spacing(8),
},
"& > :nth-of-type(2)": { gridColumn: 1, gridRow: 2 },
"& > :nth-of-type(3)": {
gridColumn: 2,
gridRow: "span 2",
padding: theme.spacing(8),
borderLeft: 1,
borderLeftStyle: "solid",
borderLeftColor: theme.palette.border.light,
borderRadius: 16,
backgroundColor: theme.palette.background.main,
background: `linear-gradient(325deg, ${theme.palette.background.accent} 20%, ${theme.palette.background.main} 45%)`,
},
"& path": {
transition: "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,
},
},
}));
+256 -252
View File
@@ -5,13 +5,7 @@ import { Box, Grid, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router";
import { useTheme } from "@emotion/react";
import { IconBox } from "./Details/styled";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
} from "recharts";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import { useSelector } from "react-redux";
import { formatDateWithTz, formatDurationSplit } from "../../Utils/timeUtils";
import useUtils from "../Monitors/utils";
@@ -24,68 +18,68 @@ import { useState } from "react";
* @returns {JSX.Element} The rendered tooltip component
*/
const CustomToolTip = ({ payload }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
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(
payload[0]?.payload.createdAt,
"ddd, MMMM D, YYYY, h:mm A",
uiTimezone
)}
</Typography>
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(
payload[0]?.payload.createdAt,
"ddd, MMMM D, YYYY, h:mm A",
uiTimezone
)}
</Typography>
<Stack
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={payload[0]?.color}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{payload[0]?.name}
</Typography>{" "}
<Typography component="span">{payload[0]?.payload.score}</Typography>
</Stack>
</Box>
);
<Stack
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={payload[0]?.color}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{payload[0]?.name}
</Typography>{" "}
<Typography component="span">{payload[0]?.payload.score}</Typography>
</Stack>
</Box>
);
};
CustomToolTip.propTypes = {
payload: PropTypes.array,
payload: PropTypes.array,
};
/**
@@ -94,25 +88,21 @@ CustomToolTip.propTypes = {
* @returns {Array<Object>} - The formatted data array with scores.
*/
const processData = (data) => {
if (data.length === 0) return [];
let formattedData = [];
if (data.length === 0) return [];
let formattedData = [];
const calculateScore = (entry) => {
return (
(entry.accessibility +
entry.bestPractices +
entry.performance +
entry.seo) /
4
);
};
const calculateScore = (entry) => {
return (
(entry.accessibility + entry.bestPractices + entry.performance + entry.seo) / 4
);
};
data.forEach((entry) => {
entry = { ...entry, score: calculateScore(entry) };
formattedData.push(entry);
});
data.forEach((entry) => {
entry = { ...entry, score: calculateScore(entry) };
formattedData.push(entry);
});
return formattedData;
return formattedData;
};
/**
@@ -123,80 +113,84 @@ const processData = (data) => {
* @returns {JSX.Element} - The rendered area chart.
*/
const PagespeedAreaChart = ({ data, status }) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const { pagespeedStyles } = useUtils();
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const { pagespeedStyles } = useUtils();
const formattedData = processData(data);
const formattedData = processData(data);
return (
<ResponsiveContainer width="100%" minWidth={25} height={85}>
<AreaChart
width="100%"
height="100%"
data={formattedData}
margin={{ top: 10, bottom: -5 }}
style={{ cursor: "pointer" }}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={<CustomToolTip />}
/>
<defs>
<linearGradient
id={`pagespeed-chart-${status}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={pagespeedStyles[status].stroke}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={pagespeedStyles[status].light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
dataKey="score"
stroke={pagespeedStyles[status].stroke}
strokeWidth={isHovered ? 2.5 : 1.5}
fill={`url(#pagespeed-chart-${status})`}
activeDot={{
stroke: pagespeedStyles[status].light,
fill: pagespeedStyles[status].stroke,
r: 4.5,
}}
/>
</AreaChart>
</ResponsiveContainer>
);
return (
<ResponsiveContainer
width="100%"
minWidth={25}
height={85}
>
<AreaChart
width="100%"
height="100%"
data={formattedData}
margin={{ top: 10, bottom: -5 }}
style={{ cursor: "pointer" }}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.border.light}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.border.light }}
content={<CustomToolTip />}
/>
<defs>
<linearGradient
id={`pagespeed-chart-${status}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={pagespeedStyles[status].stroke}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={pagespeedStyles[status].light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
dataKey="score"
stroke={pagespeedStyles[status].stroke}
strokeWidth={isHovered ? 2.5 : 1.5}
fill={`url(#pagespeed-chart-${status})`}
activeDot={{
stroke: pagespeedStyles[status].light,
fill: pagespeedStyles[status].stroke,
r: 4.5,
}}
/>
</AreaChart>
</ResponsiveContainer>
);
};
PagespeedAreaChart.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
accessibility: PropTypes.number.isRequired,
bestPractices: PropTypes.number.isRequired,
performance: PropTypes.number.isRequired,
seo: PropTypes.number.isRequired,
})
).isRequired,
status: PropTypes.string.isRequired,
data: PropTypes.arrayOf(
PropTypes.shape({
accessibility: PropTypes.number.isRequired,
bestPractices: PropTypes.number.isRequired,
performance: PropTypes.number.isRequired,
seo: PropTypes.number.isRequired,
})
).isRequired,
status: PropTypes.string.isRequired,
};
/**
@@ -206,113 +200,123 @@ PagespeedAreaChart.propTypes = {
* @returns {JSX.Element} - The rendered card.
*/
const Card = ({ monitor }) => {
const { determineState, pagespeedStatusMsg } = useUtils();
const theme = useTheme();
const navigate = useNavigate();
const monitorState = determineState(monitor);
const { determineState, pagespeedStatusMsg } = useUtils();
const theme = useTheme();
const navigate = useNavigate();
const monitorState = determineState(monitor);
return (
<Grid item lg={6} flexGrow={1}>
<Box
position="relative"
p={theme.spacing(8)}
onClick={() => navigate(`/pagespeed/${monitor._id}`)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
sx={{
display: "grid",
gridTemplateColumns: "34px 2fr 1fr",
columnGap: theme.spacing(5),
gridTemplateRows: "34px 1fr 3fr",
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
"& path": {
transition: "stroke-width 400ms ease",
},
}}
>
<IconBox>
<PageSpeedIcon />
</IconBox>
<Typography
component="h2"
variant="h2"
fontWeight={500}
alignSelf="center"
>
{monitor.name}
</Typography>
<StatusLabel
status={monitorState}
text={pagespeedStatusMsg[monitorState] || "Pending..."}
customStyles={{
width: "max-content",
textTransform: "capitalize",
alignSelf: "flex-start",
justifySelf: "flex-end",
}}
/>
<Typography
variant="body2"
mt={theme.spacing(-2)}
sx={{ gridColumnStart: 2 }}
>
{monitor.url}
</Typography>
<Box
mx={theme.spacing(-8)}
mt={theme.spacing(4)}
mb={theme.spacing(-8)}
sx={{ gridColumnStart: 1, gridColumnEnd: 4 }}
>
<PagespeedAreaChart data={monitor.checks} status={monitorState} />
</Box>
<Box
position="absolute"
bottom={0}
py={theme.spacing(1)}
px={theme.spacing(4)}
borderTop={1}
borderRight={1}
borderColor={theme.palette.border.light}
backgroundColor={theme.palette.background.accent}
sx={{
pointerEvents: "none",
userSelect: "none",
borderTopRightRadius: 8,
borderBottomLeftRadius: 4,
}}
>
<Typography fontSize={11} color={theme.palette.text.accent}>
Checking every{" "}
{(() => {
const { time, format } = formatDurationSplit(monitor?.interval);
return (
<>
<Typography
component="span"
fontSize={12}
color={theme.palette.text.primary}
>
{time}{" "}
</Typography>
{format}
</>
);
})()}
</Typography>
</Box>
</Box>
</Grid>
);
return (
<Grid
item
lg={6}
flexGrow={1}
>
<Box
position="relative"
p={theme.spacing(8)}
onClick={() => navigate(`/pagespeed/${monitor._id}`)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
sx={{
display: "grid",
gridTemplateColumns: "34px 2fr 1fr",
columnGap: theme.spacing(5),
gridTemplateRows: "34px 1fr 3fr",
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
"& path": {
transition: "stroke-width 400ms ease",
},
}}
>
<IconBox>
<PageSpeedIcon />
</IconBox>
<Typography
component="h2"
variant="h2"
fontWeight={500}
alignSelf="center"
>
{monitor.name}
</Typography>
<StatusLabel
status={monitorState}
text={pagespeedStatusMsg[monitorState] || "Pending..."}
customStyles={{
width: "max-content",
textTransform: "capitalize",
alignSelf: "flex-start",
justifySelf: "flex-end",
}}
/>
<Typography
variant="body2"
mt={theme.spacing(-2)}
sx={{ gridColumnStart: 2 }}
>
{monitor.url}
</Typography>
<Box
mx={theme.spacing(-8)}
mt={theme.spacing(4)}
mb={theme.spacing(-8)}
sx={{ gridColumnStart: 1, gridColumnEnd: 4 }}
>
<PagespeedAreaChart
data={monitor.checks}
status={monitorState}
/>
</Box>
<Box
position="absolute"
bottom={0}
py={theme.spacing(1)}
px={theme.spacing(4)}
borderTop={1}
borderRight={1}
borderColor={theme.palette.border.light}
backgroundColor={theme.palette.background.accent}
sx={{
pointerEvents: "none",
userSelect: "none",
borderTopRightRadius: 8,
borderBottomLeftRadius: 4,
}}
>
<Typography
fontSize={11}
color={theme.palette.text.accent}
>
Checking every{" "}
{(() => {
const { time, format } = formatDurationSplit(monitor?.interval);
return (
<>
<Typography
component="span"
fontSize={12}
color={theme.palette.text.primary}
>
{time}{" "}
</Typography>
{format}
</>
);
})()}
</Typography>
</Box>
</Box>
</Grid>
);
};
Card.propTypes = {
monitor: PropTypes.object.isRequired,
monitor: PropTypes.object.isRequired,
};
export default Card;
+4 -4
View File
@@ -1,8 +1,8 @@
.page-speed .label {
padding: 7px;
height: 24px;
font-size: var(--env-var-font-size-small);
padding: 7px;
height: 24px;
font-size: var(--env-var-font-size-small);
}
.page-speed:not(:has([class*="fallback__"])) button {
height: 34px;
height: 34px;
}
+108 -102
View File
@@ -14,114 +14,120 @@ import Card from "./card";
import { networkService } from "../../main";
const PageSpeed = ({ isAdmin }) => {
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const dispatch = useDispatch();
const navigate = useNavigate();
const { user, authToken } = useSelector((state) => state.auth);
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState([]);
useEffect(() => {
dispatch(getPageSpeedByTeamId(authToken));
}, [authToken, dispatch]);
const { user, authToken } = useSelector((state) => state.auth);
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState([]);
useEffect(() => {
dispatch(getPageSpeedByTeamId(authToken));
}, [authToken, dispatch]);
useEffect(() => {
const fetchMonitors = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorsByTeamId({
authToken: authToken,
teamId: user.teamId,
limit: 10,
types: ["pagespeed"],
status: null,
checkOrder: "desc",
normalize: true,
page: null,
rowsPerPage: null,
filter: null,
field: null,
order: null,
});
if (res?.data?.data?.monitors) {
setMonitors(res.data.data.monitors);
}
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const fetchMonitors = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorsByTeamId({
authToken: authToken,
teamId: user.teamId,
limit: 10,
types: ["pagespeed"],
status: null,
checkOrder: "desc",
normalize: true,
page: null,
rowsPerPage: null,
filter: null,
field: null,
order: null,
});
if (res?.data?.data?.monitors) {
setMonitors(res.data.data.monitors);
}
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, []);
fetchMonitors();
}, []);
// will show skeletons only on initial load
// since monitor state is being added to redux persist, there's no reason to display skeletons on every render
let isActuallyLoading = isLoading && monitors?.length === 0;
return (
<Box
className="page-speed"
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",
},
}}
>
{isActuallyLoading ? (
<SkeletonLayout />
) : monitors?.length !== 0 ? (
<Box>
<Box mb={theme.spacing(12)}>
<Breadcrumbs list={[{ name: `pagespeed`, path: "/pagespeed" }]} />
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Greeting type="pagespeed" />
{isAdmin && (
<Button
variant="contained"
color="primary"
onClick={() => navigate("/pagespeed/create")}
sx={{ whiteSpace: "nowrap" }}
>
Create new
</Button>
)}
</Stack>
</Box>
<Grid container spacing={theme.spacing(12)}>
{monitors?.map((monitor) => (
<Card monitor={monitor} key={monitor._id} />
))}
</Grid>
</Box>
) : (
<Fallback
title="pagespeed monitor"
checks={[
"Report on the user experience of a page",
"Help analyze webpage speed",
"Give suggestions on how the page can be improved",
]}
link="/pagespeed/create"
isAdmin={isAdmin}
/>
)}
</Box>
);
// will show skeletons only on initial load
// since monitor state is being added to redux persist, there's no reason to display skeletons on every render
let isActuallyLoading = isLoading && monitors?.length === 0;
return (
<Box
className="page-speed"
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",
},
}}
>
{isActuallyLoading ? (
<SkeletonLayout />
) : monitors?.length !== 0 ? (
<Box>
<Box mb={theme.spacing(12)}>
<Breadcrumbs list={[{ name: `pagespeed`, path: "/pagespeed" }]} />
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Greeting type="pagespeed" />
{isAdmin && (
<Button
variant="contained"
color="primary"
onClick={() => navigate("/pagespeed/create")}
sx={{ whiteSpace: "nowrap" }}
>
Create new
</Button>
)}
</Stack>
</Box>
<Grid
container
spacing={theme.spacing(12)}
>
{monitors?.map((monitor) => (
<Card
monitor={monitor}
key={monitor._id}
/>
))}
</Grid>
</Box>
) : (
<Fallback
title="pagespeed monitor"
checks={[
"Report on the user experience of a page",
"Help analyze webpage speed",
"Give suggestions on how the page can be improved",
]}
link="/pagespeed/create"
isAdmin={isAdmin}
/>
)}
</Box>
);
};
PageSpeed.propTypes = {
isAdmin: PropTypes.bool,
isAdmin: PropTypes.bool,
};
export default PageSpeed;
+60 -52
View File
@@ -7,59 +7,67 @@ import { useTheme } from "@emotion/react";
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
const theme = useTheme();
return (
<Stack gap={theme.spacing(2)}>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(12)}
>
<Box width="80%">
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={19.5}
sx={{ mt: theme.spacing(2) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack direction="row" flexWrap="wrap" gap={theme.spacing(12)}>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
</Stack>
</Stack>
);
return (
<Stack gap={theme.spacing(2)}>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(12)}
>
<Box width="80%">
<Skeleton
variant="rounded"
width="25%"
height={24}
/>
<Skeleton
variant="rounded"
width="50%"
height={19.5}
sx={{ mt: theme.spacing(2) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(12)}
>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
</Stack>
</Stack>
);
};
export default SkeletonLayout;
+1 -1
View File
@@ -1 +1 @@
#Pages Folder
#Pages Folder
+7 -7
View File
@@ -3,19 +3,19 @@
.settings span.MuiTypography-root,
.settings button,
.settings p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
font-size: var(--env-var-font-size-medium);
}
.settings h1.MuiTypography-root {
font-size: var(--env-var-font-size-large);
font-size: var(--env-var-font-size-large);
}
.settings h1.MuiTypography-root,
.settings h2.MuiTypography-root {
font-weight: 600;
font-weight: 600;
}
.settings button {
height: 34px;
height: 34px;
}
.settings span.MuiTypography-root {
opacity: 0.6;
margin-right: 4px;
}
opacity: 0.6;
margin-right: 4px;
}
+347 -344
View File
@@ -8,382 +8,385 @@ import "./index.css";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import {
deleteMonitorChecksByTeamId,
addDemoMonitors,
deleteAllMonitors,
deleteMonitorChecksByTeamId,
addDemoMonitors,
deleteAllMonitors,
} from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { update } from "../../Features/Auth/authSlice";
import PropTypes from "prop-types";
import LoadingButton from "@mui/lab/LoadingButton";
import { setTimezone, setMode } from "../../Features/UI/uiSlice";
import timezones from "../../Utils/timezones.json";
import { useState,useEffect } from "react";
import { useState, useEffect } from "react";
import { ConfigBox } from "./styled";
import { networkService } from "../../main";
import { settingsValidation } from "../../Validation/validation";
import { useNavigate } from "react-router";
import Dialog from "../../Components/Dialog"
import Dialog from "../../Components/Dialog";
const SECONDS_PER_DAY = 86400;
const Settings = ({ isAdmin }) => {
const theme = useTheme();
const { user, authToken } = useSelector((state) => state.auth);
const { checkTTL } = user;
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const { isLoading: authIsLoading } = useSelector((state) => state.auth);
const { timezone } = useSelector((state) => state.ui);
const {mode} = useSelector((state) => state.ui);
const [checksIsLoading, setChecksIsLoading] = useState(false);
const [form, setForm] = useState({
ttl: checkTTL ? (checkTTL / SECONDS_PER_DAY).toString() : 0,
});
const [version,setVersion]=useState("unknown");
const [errors, setErrors] = useState({});
const deleteStatsMonitorsInitState = { deleteMonitors: false, deleteStats: false };
const [isOpen, setIsOpen] = useState(deleteStatsMonitorsInitState);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const { user, authToken } = useSelector((state) => state.auth);
const { checkTTL } = user;
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const { isLoading: authIsLoading } = useSelector((state) => state.auth);
const { timezone } = useSelector((state) => state.ui);
const { mode } = useSelector((state) => state.ui);
const [checksIsLoading, setChecksIsLoading] = useState(false);
const [form, setForm] = useState({
ttl: checkTTL ? (checkTTL / SECONDS_PER_DAY).toString() : 0,
});
const [version, setVersion] = useState("unknown");
const [errors, setErrors] = useState({});
const deleteStatsMonitorsInitState = { deleteMonitors: false, deleteStats: false };
const [isOpen, setIsOpen] = useState(deleteStatsMonitorsInitState);
const dispatch = useDispatch();
const navigate = useNavigate();
//Fetching latest release version from github
useEffect(() => {
//Fetching latest release version from github
useEffect(() => {
const fetchLatestVersion = async () => {
let version="unknown";
let version = "unknown";
try {
const response = await networkService.fetchGithubLatestRelease();
if (!response.status===200) {
if (!response.status === 200) {
throw new Error("Failed to fetch latest version");
}
version=response.data.tag_name;
version = response.data.tag_name;
} catch (error) {
createToast({ body: error.message || "Error fetching latest version" }); // Set error message
} finally{
setVersion(version);
}
} finally {
setVersion(version);
}
};
fetchLatestVersion();
},[]);
}, []);
const handleChange = (event) => {
const { value, id } = event.target;
const { error } = settingsValidation.validate(
{ [id]: value },
{
abortEarly: false,
}
);
if (!error || error.details.length === 0) {
setErrors({});
} else {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
console.log(newErrors);
logger.error("Validation errors:", error.details);
}
let inputValue = value;
id === "ttl" && (inputValue = value.replace(/[^0-9]/g, ""));
setForm((prev) => ({
...prev,
[id]: inputValue,
}));
};
const handleChange = (event) => {
const { value, id } = event.target;
const { error } = settingsValidation.validate(
{ [id]: value },
{
abortEarly: false,
}
);
if (!error || error.details.length === 0) {
setErrors({});
} else {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
console.log(newErrors);
logger.error("Validation errors:", error.details);
}
let inputValue = value;
id === "ttl" && (inputValue = value.replace(/[^0-9]/g, ""));
setForm((prev) => ({
...prev,
[id]: inputValue,
}));
};
// TODO Handle saving
const handleSave = async () => {
try {
setChecksIsLoading(true);
await networkService.updateChecksTTL({
authToken: authToken,
ttl: form.ttl,
});
const updatedUser = { ...user, checkTTL: form.ttl };
const action = await dispatch(
update({ authToken, localData: updatedUser })
);
if (action.payload.success) {
createToast({
body: "Settings saved successfully",
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
} catch (error) {
console.log(error);
createToast({ body: "Failed to save settings" });
} finally {
setChecksIsLoading(false);
}
};
// TODO Handle saving
const handleSave = async () => {
try {
setChecksIsLoading(true);
await networkService.updateChecksTTL({
authToken: authToken,
ttl: form.ttl,
});
const updatedUser = { ...user, checkTTL: form.ttl };
const action = await dispatch(update({ authToken, localData: updatedUser }));
if (action.payload.success) {
createToast({
body: "Settings saved successfully",
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
} catch (error) {
console.log(error);
createToast({ body: "Failed to save settings" });
} finally {
setChecksIsLoading(false);
}
};
const handleClearStats = async () => {
try {
const action = await dispatch(
deleteMonitorChecksByTeamId({ teamId: user.teamId, authToken })
);
const handleClearStats = async () => {
try {
const action = await dispatch(
deleteMonitorChecksByTeamId({ teamId: user.teamId, authToken })
);
if (deleteMonitorChecksByTeamId.fulfilled.match(action)) {
createToast({ body: "Stats cleared successfully" });
} else {
createToast({ body: "Failed to clear stats" });
}
} catch (error) {
logger.error(error);
createToast({ body: "Failed to clear stats" });
} finally {
setIsOpen(deleteStatsMonitorsInitState)
}
};
if (deleteMonitorChecksByTeamId.fulfilled.match(action)) {
createToast({ body: "Stats cleared successfully" });
} else {
createToast({ body: "Failed to clear stats" });
}
} catch (error) {
logger.error(error);
createToast({ body: "Failed to clear stats" });
} finally {
setIsOpen(deleteStatsMonitorsInitState);
}
};
const handleInsertDemoMonitors = async () => {
try {
const action = await dispatch(addDemoMonitors({ authToken }));
if (addDemoMonitors.fulfilled.match(action)) {
createToast({ body: "Successfully added demo monitors" });
} else {
createToast({ body: "Failed to add demo monitors" });
}
} catch (error) {
logger.error(error);
createToast({ Body: "Failed to add demo monitors" });
}
};
const handleInsertDemoMonitors = async () => {
try {
const action = await dispatch(addDemoMonitors({ authToken }));
if (addDemoMonitors.fulfilled.match(action)) {
createToast({ body: "Successfully added demo monitors" });
} else {
createToast({ body: "Failed to add demo monitors" });
}
} catch (error) {
logger.error(error);
createToast({ Body: "Failed to add demo monitors" });
}
};
const handleDeleteAllMonitors = async () => {
try {
const action = await dispatch(deleteAllMonitors({ authToken }));
if (deleteAllMonitors.fulfilled.match(action)) {
createToast({ body: "Successfully deleted all monitors" });
} else {
createToast({ body: "Failed to add demo monitors" });
}
} catch (error) {
logger.error(error);
createToast({ Body: "Failed to delete all monitors" });
} finally {
setIsOpen(deleteStatsMonitorsInitState)
}
};
return (
<Box
className="settings"
style={{
paddingBottom: 0,
}}
>
<Stack
component="form"
gap={theme.spacing(12)}
noValidate
spellCheck="false"
>
<ConfigBox>
<Box>
<Typography component="h1">General settings</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(2) }}>
<Typography component="span">Display timezone</Typography>- The
timezone of the dashboard you publicly display.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="display-timezone"
label="Display timezone"
value={timezone}
onChange={(e) => {
dispatch(setTimezone({ timezone: e.target.value }));
}}
items={timezones}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Appearance</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(2) }}>
Switch between light and dark mode.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="theme-mode"
label="Theme Mode"
value={mode}
onChange={(e) => {
dispatch(setMode(e.target.value));
}}
items={[{_id:'light', name:'Light'},{_id:'dark', name:'Dark'}]}
>
</Select>
</Stack>
</ConfigBox>
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">History and monitoring</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Define here for how long you want to keep the data. You can also
remove all past data.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
id="ttl"
label="The days you want to keep monitoring history."
optionalLabel="0 for infinite"
value={form.ttl}
onChange={handleChange}
error={errors.ttl}
/>
<Box>
<Typography>Clear all stats. This is irreversible.</Typography>
<Button
variant="contained"
color="error"
onClick={()=>setIsOpen({...deleteStatsMonitorsInitState, deleteStats: true})}
sx={{ mt: theme.spacing(4) }}
>
Clear all stats
</Button>
</Box>
</Stack>
<Dialog
modelTitle="model-clear-stats"
modelDescription="clear-stats-confirmation"
open={isOpen.deleteStats}
onClose={() => setIsOpen(deleteStatsMonitorsInitState)}
title="Do you want to clear all stats?"
confirmationBtnLbl="Yes, clear all stats"
confirmationBtnOnClick={handleClearStats}
cancelBtnLbl="Cancel"
cancelBtnOnClick={() => setIsOpen(deleteStatsMonitorsInitState)}
theme={theme}
isLoading={isLoading || authIsLoading || checksIsLoading}
>
</Dialog>
</ConfigBox>
)}
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">Demo monitors</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Here you can add and remove demo monitors
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Box>
<Typography>Add demo monitors</Typography>
<LoadingButton
variant="contained"
color="primary"
loading={isLoading || authIsLoading || checksIsLoading}
onClick={handleInsertDemoMonitors}
sx={{ mt: theme.spacing(4) }}
>
Add demo monitors
</LoadingButton>
</Box>
<Box>
<Typography>Remove all monitors</Typography>
<LoadingButton
variant="contained"
color="error"
loading={isLoading || authIsLoading || checksIsLoading}
onClick={()=>setIsOpen({...deleteStatsMonitorsInitState, deleteMonitors: true})}
sx={{ mt: theme.spacing(4) }}
>
Remove all monitors
</LoadingButton>
</Box>
</Stack>
<Dialog
modelTitle="model-delete-all-monitors"
modelDescription="delete-all-monitors-confirmation"
open={isOpen.deleteMonitors}
onClose={() => setIsOpen(deleteStatsMonitorsInitState)}
title="Do you want to remove all monitors?"
confirmationBtnLbl="Yes, clear all monitors"
confirmationBtnOnClick={handleDeleteAllMonitors}
cancelBtnLbl="Cancel"
cancelBtnOnClick={() => setIsOpen(deleteStatsMonitorsInitState)}
theme={theme}
isLoading={isLoading || authIsLoading || checksIsLoading}
>
</Dialog>
</ConfigBox>
)}
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">Advanced settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Click here to modify advanced settings
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Box>
<Button
variant="contained"
onClick={() => {
navigate("/advanced-settings");
}}
>
Advanced Settings
</Button>
</Box>
</Stack>
</ConfigBox>
)}
<ConfigBox>
<Box>
<Typography component="h1">About</Typography>
</Box>
<Box>
<Typography component="h2">BlueWave Uptime {version}</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/bluewave-uptime"
label="https://github.com/bluewave-labs/bluewave-uptime"
/>
</Box>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<LoadingButton
loading={isLoading || authIsLoading || checksIsLoading}
disabled={Object.keys(errors).length > 0}
variant="contained"
color="primary"
sx={{ px: theme.spacing(12), mt: theme.spacing(20) }}
onClick={handleSave}
>
Save
</LoadingButton>
</Stack>
</Stack>
</Box>
);
const handleDeleteAllMonitors = async () => {
try {
const action = await dispatch(deleteAllMonitors({ authToken }));
if (deleteAllMonitors.fulfilled.match(action)) {
createToast({ body: "Successfully deleted all monitors" });
} else {
createToast({ body: "Failed to add demo monitors" });
}
} catch (error) {
logger.error(error);
createToast({ Body: "Failed to delete all monitors" });
} finally {
setIsOpen(deleteStatsMonitorsInitState);
}
};
return (
<Box
className="settings"
style={{
paddingBottom: 0,
}}
>
<Stack
component="form"
gap={theme.spacing(12)}
noValidate
spellCheck="false"
>
<ConfigBox>
<Box>
<Typography component="h1">General settings</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(2) }}>
<Typography component="span">Display timezone</Typography>- The timezone of
the dashboard you publicly display.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="display-timezone"
label="Display timezone"
value={timezone}
onChange={(e) => {
dispatch(setTimezone({ timezone: e.target.value }));
}}
items={timezones}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Appearance</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(2) }}>
Switch between light and dark mode.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
id="theme-mode"
label="Theme Mode"
value={mode}
onChange={(e) => {
dispatch(setMode(e.target.value));
}}
items={[
{ _id: "light", name: "Light" },
{ _id: "dark", name: "Dark" },
]}
></Select>
</Stack>
</ConfigBox>
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">History and monitoring</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Define here for how long you want to keep the data. You can also remove
all past data.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
id="ttl"
label="The days you want to keep monitoring history."
optionalLabel="0 for infinite"
value={form.ttl}
onChange={handleChange}
error={errors.ttl}
/>
<Box>
<Typography>Clear all stats. This is irreversible.</Typography>
<Button
variant="contained"
color="error"
onClick={() =>
setIsOpen({ ...deleteStatsMonitorsInitState, deleteStats: true })
}
sx={{ mt: theme.spacing(4) }}
>
Clear all stats
</Button>
</Box>
</Stack>
<Dialog
modelTitle="model-clear-stats"
modelDescription="clear-stats-confirmation"
open={isOpen.deleteStats}
onClose={() => setIsOpen(deleteStatsMonitorsInitState)}
title="Do you want to clear all stats?"
confirmationBtnLbl="Yes, clear all stats"
confirmationBtnOnClick={handleClearStats}
cancelBtnLbl="Cancel"
cancelBtnOnClick={() => setIsOpen(deleteStatsMonitorsInitState)}
theme={theme}
isLoading={isLoading || authIsLoading || checksIsLoading}
></Dialog>
</ConfigBox>
)}
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">Demo monitors</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Here you can add and remove demo monitors
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Box>
<Typography>Add demo monitors</Typography>
<LoadingButton
variant="contained"
color="primary"
loading={isLoading || authIsLoading || checksIsLoading}
onClick={handleInsertDemoMonitors}
sx={{ mt: theme.spacing(4) }}
>
Add demo monitors
</LoadingButton>
</Box>
<Box>
<Typography>Remove all monitors</Typography>
<LoadingButton
variant="contained"
color="error"
loading={isLoading || authIsLoading || checksIsLoading}
onClick={() =>
setIsOpen({ ...deleteStatsMonitorsInitState, deleteMonitors: true })
}
sx={{ mt: theme.spacing(4) }}
>
Remove all monitors
</LoadingButton>
</Box>
</Stack>
<Dialog
modelTitle="model-delete-all-monitors"
modelDescription="delete-all-monitors-confirmation"
open={isOpen.deleteMonitors}
onClose={() => setIsOpen(deleteStatsMonitorsInitState)}
title="Do you want to remove all monitors?"
confirmationBtnLbl="Yes, clear all monitors"
confirmationBtnOnClick={handleDeleteAllMonitors}
cancelBtnLbl="Cancel"
cancelBtnOnClick={() => setIsOpen(deleteStatsMonitorsInitState)}
theme={theme}
isLoading={isLoading || authIsLoading || checksIsLoading}
></Dialog>
</ConfigBox>
)}
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">Advanced settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Click here to modify advanced settings
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Box>
<Button
variant="contained"
onClick={() => {
navigate("/advanced-settings");
}}
>
Advanced Settings
</Button>
</Box>
</Stack>
</ConfigBox>
)}
<ConfigBox>
<Box>
<Typography component="h1">About</Typography>
</Box>
<Box>
<Typography component="h2">BlueWave Uptime {version}</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/bluewave-uptime"
label="https://github.com/bluewave-labs/bluewave-uptime"
/>
</Box>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<LoadingButton
loading={isLoading || authIsLoading || checksIsLoading}
disabled={Object.keys(errors).length > 0}
variant="contained"
color="primary"
sx={{ px: theme.spacing(12), mt: theme.spacing(20) }}
onClick={handleSave}
>
Save
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
Settings.propTypes = {
isAdmin: PropTypes.bool,
isAdmin: PropTypes.bool,
};
export default Settings;
+24 -24
View File
@@ -1,28 +1,28 @@
import { Stack, styled } from "@mui/material";
export const ConfigBox = styled(Stack)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing(20),
paddingTop: theme.spacing(12),
paddingInline: theme.spacing(15),
paddingBottom: theme.spacing(25),
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: theme.spacing(2),
"& > div:first-of-type": {
flex: 0.7,
},
"& > div:last-of-type": {
flex: 1,
},
"& h1, & h2": {
color: theme.palette.text.secondary,
},
"& p": {
color: theme.palette.text.tertiary,
},
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing(20),
paddingTop: theme.spacing(12),
paddingInline: theme.spacing(15),
paddingBottom: theme.spacing(25),
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: theme.spacing(2),
"& > div:first-of-type": {
flex: 0.7,
},
"& > div:last-of-type": {
flex: 1,
},
"& h1, & h2": {
color: theme.palette.text.secondary,
},
"& p": {
color: theme.palette.text.tertiary,
},
}));
+26 -26
View File
@@ -3,33 +3,33 @@ import { useTheme } from "@emotion/react";
import Fallback from "../../Components/Fallback";
const Status = () => {
const theme = useTheme();
const theme = useTheme();
return (
<Box
className="status"
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",
},
}}
>
<Fallback
title="status page"
checks={[
"Share your uptime publicly",
"Keep your users informed about incidents",
"Build trust with your customers",
]}
/>
</Box>
);
return (
<Box
className="status"
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",
},
}}
>
<Fallback
title="status page"
checks={[
"Share your uptime publicly",
"Keep your users informed about incidents",
"Build trust with your customers",
]}
/>
</Box>
);
};
export default Status;