mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-05-20 00:18:47 -05:00
Merge branch 'feat/fe/statuspage-3' into feat/fe/create-status
This commit is contained in:
@@ -1 +1,2 @@
|
||||
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
|
||||
VITE_STATUS_PAGE_SUBDOMAIN_PREFIX=UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX
|
||||
|
||||
Generated
+75
@@ -11,6 +11,7 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource/roboto": "^5.0.13",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@mui/icons-material": "6.4.2",
|
||||
"@mui/lab": "6.0.0-beta.25",
|
||||
"@mui/material": "6.4.2",
|
||||
@@ -23,6 +24,7 @@
|
||||
"immutability-helper": "^3.1.1",
|
||||
"joi": "17.13.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mui-color-input": "^5.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
@@ -335,6 +337,14 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz",
|
||||
"integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
@@ -987,6 +997,24 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hello-pangea/dnd": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-17.0.0.tgz",
|
||||
"integrity": "sha512-LDDPOix/5N0j5QZxubiW9T0M0+1PR0rTDWeZF5pu1Tz91UQnuVK4qQ/EjY83Qm2QeX0eM8qDXANfDh3VVqtR4Q==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"css-box-model": "^1.2.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react-redux": "^9.1.2",
|
||||
"redux": "^5.0.1",
|
||||
"use-memo-one": "^1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -2950,6 +2978,14 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-box-model": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||
"dependencies": {
|
||||
"tiny-invariant": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -4966,6 +5002,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
@@ -5006,6 +5047,27 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mui-color-input": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mui-color-input/-/mui-color-input-5.0.1.tgz",
|
||||
"integrity": "sha512-50Ws4vhg4UPQSZEZDCNc7vyUBSb9x1bK+bO1o0wxJvQYgeSyg2r7mYDlavpCh+ZvisgBL/98y0GVN6M9901JWg==",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@mui/material": "^6.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
@@ -5423,6 +5485,11 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -6391,6 +6458,14 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-memo-one": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz",
|
||||
"integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource/roboto": "^5.0.13",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@mui/icons-material": "6.4.2",
|
||||
"@mui/lab": "6.0.0-beta.25",
|
||||
"@mui/material": "6.4.2",
|
||||
@@ -26,6 +27,7 @@
|
||||
"immutability-helper": "^3.1.1",
|
||||
"joi": "17.13.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mui-color-input": "^5.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { logger } from "./Utils/Logger"; // Import the logger
|
||||
import { networkService } from "./main";
|
||||
import { Routes } from "./Routes";
|
||||
|
||||
import CreateStatus from "./Pages/Status/CreateStatus";
|
||||
function App() {
|
||||
const mode = useSelector((state) => state.ui.mode);
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
|
||||
@@ -29,10 +29,7 @@ const ConfigBox = styled(Stack)(({ theme }) => ({
|
||||
},
|
||||
"& h1, & h2": {
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
},
|
||||
"& p": {
|
||||
color: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
||||
export default ConfigBox;
|
||||
|
||||
@@ -115,7 +115,7 @@ Checkbox.propTypes = {
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
size: PropTypes.oneOf(["small", "medium", "large"]),
|
||||
isChecked: PropTypes.bool.isRequired,
|
||||
value: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
onChange: PropTypes.func,
|
||||
isDisabled: PropTypes.bool,
|
||||
alignSelf: PropTypes.bool,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { MuiColorInput } from "mui-color-input";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} id The ID of the component
|
||||
* @param {*} value The color value of the component
|
||||
* @param {*} error The error of the component
|
||||
* @param {*} onChange The Change handler function
|
||||
* @param {*} onBlur The Blur handler function
|
||||
* @returns The ColorPicker component
|
||||
* Example usage:
|
||||
* <ColorPicker
|
||||
* id="color"
|
||||
* value={form.color}
|
||||
* error={errors["color"]}
|
||||
* onChange={handleColorChange}
|
||||
* onBlur={handleBlur}
|
||||
* >
|
||||
* </ColorPicker>
|
||||
*/
|
||||
const ColorPicker = ({ id, value, error, onChange, onBlur }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<MuiColorInput
|
||||
format="hex"
|
||||
value={value}
|
||||
id={id}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{error && (
|
||||
<Typography
|
||||
component="span"
|
||||
className="input-error"
|
||||
color={theme.palette.error.main}
|
||||
mt={theme.spacing(2)}
|
||||
sx={{
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
ColorPicker.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onBlur: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -11,11 +11,12 @@ import { checkImage } from "../../../Utils/fileUtils";
|
||||
* @param {string} props.id - The unique identifier for the input field.
|
||||
* @param {string} props.src - The URL of the image to display.
|
||||
* @param {function} props.onChange - The function to handle file input change.
|
||||
* @param {string} props.isRound - The shape of the image to display.
|
||||
* @param {boolean} props.isRound - Whether the shape of the image to display is round.
|
||||
* @param {string} props.maxSize - Custom message for the max uploaded file size
|
||||
* @returns {JSX.Element} The rendered component.
|
||||
*/
|
||||
|
||||
const ImageField = ({ id, src, loading, onChange, error, isRound = true }) => {
|
||||
const ImageField = ({ id, src, loading, onChange, error, isRound = true, maxSize }) => {
|
||||
const theme = useTheme();
|
||||
const error_border_style = error ? { borderColor: theme.palette.error.main } : {};
|
||||
|
||||
@@ -32,7 +33,7 @@ const ImageField = ({ id, src, loading, onChange, error, isRound = true }) => {
|
||||
return (
|
||||
<>
|
||||
{!checkImage(src) || loading ? (
|
||||
<>
|
||||
<>
|
||||
<Box
|
||||
className="image-field-wrapper"
|
||||
mt={theme.spacing(8)}
|
||||
@@ -50,7 +51,7 @@ const ImageField = ({ id, src, loading, onChange, error, isRound = true }) => {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
|
||||
},
|
||||
...error_border_style
|
||||
...error_border_style,
|
||||
}}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -117,9 +118,9 @@ const ImageField = ({ id, src, loading, onChange, error, isRound = true }) => {
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
(maximum size: 3MB)
|
||||
(maximum size: {maxSize ?? "3MB"})
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography
|
||||
component="p"
|
||||
@@ -140,7 +141,7 @@ const ImageField = ({ id, src, loading, onChange, error, isRound = true }) => {
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Stack
|
||||
@@ -168,6 +169,7 @@ ImageField.propTypes = {
|
||||
src: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isRound: PropTypes.bool,
|
||||
maxSize: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ImageField;
|
||||
|
||||
@@ -12,6 +12,7 @@ import SearchIcon from "../../../assets/icons/search.svg?react";
|
||||
* @param {string} props.filteredBy - Key to access the option label from the options
|
||||
* @param {string} props.value - Current input value for the Autocomplete
|
||||
* @param {Function} props.handleChange - Function to call when the input changes
|
||||
* @param {Function} Prop.onBlur - Function to call when the input is blured
|
||||
* @param {Object} props.sx - Additional styles to apply to the component
|
||||
* @returns {JSX.Element} The rendered Search component
|
||||
*/
|
||||
@@ -54,10 +55,14 @@ const Search = ({
|
||||
isAdorned = true,
|
||||
error,
|
||||
disabled,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
onBlur
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Autocomplete
|
||||
onBlur={onBlur}
|
||||
multiple={multiple}
|
||||
id={id}
|
||||
value={value}
|
||||
@@ -65,15 +70,15 @@ const Search = ({
|
||||
onInputChange={(_, newValue) => {
|
||||
handleInputChange(newValue);
|
||||
}}
|
||||
onChange={(_, newValue) => {
|
||||
handleChange && handleChange(newValue);
|
||||
onChange={(e, newValue) => {
|
||||
handleChange && handleChange(e, newValue);
|
||||
}}
|
||||
fullWidth
|
||||
freeSolo
|
||||
disabled={disabled}
|
||||
disableClearable
|
||||
options={options}
|
||||
getOptionLabel={(option) => option[filteredBy]}
|
||||
getOptionLabel={(option) => option[filteredBy]??""}
|
||||
renderInput={(params) => (
|
||||
<Stack>
|
||||
<Typography
|
||||
@@ -88,9 +93,13 @@ const Search = ({
|
||||
{...params}
|
||||
error={Boolean(error)}
|
||||
placeholder="Type to search"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
...(isAdorned && { startAdornment: <SearchAdornment /> }),
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
...(isAdorned && { startAdornment: <SearchAdornment /> }),
|
||||
...(startAdornment && { startAdornment: startAdornment }),
|
||||
...(endAdornment && { endAdornment: endAdornment }),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
"& fieldset": {
|
||||
@@ -204,7 +213,7 @@ Search.propTypes = {
|
||||
options: PropTypes.array.isRequired,
|
||||
filteredBy: PropTypes.string.isRequired,
|
||||
secondaryLabel: PropTypes.string,
|
||||
value: PropTypes.array,
|
||||
value: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
|
||||
inputValue: PropTypes.string.isRequired,
|
||||
handleInputChange: PropTypes.func.isRequired,
|
||||
handleChange: PropTypes.func,
|
||||
@@ -212,6 +221,9 @@ Search.propTypes = {
|
||||
sx: PropTypes.object,
|
||||
error: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
startAdornment: PropTypes.object,
|
||||
endAdornment: PropTypes.object,
|
||||
onBlur: PropTypes.func
|
||||
};
|
||||
|
||||
export default Search;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useTheme } from "@mui/material/styles";
|
||||
import PropTypes from "prop-types";
|
||||
import VisibilityOff from "@mui/icons-material/VisibilityOff";
|
||||
import Visibility from "@mui/icons-material/Visibility";
|
||||
import ReorderRoundedIcon from '@mui/icons-material/ReorderRounded';
|
||||
import DeleteIcon from "../../../../assets/icons/trash-bin.svg?react";
|
||||
import ReorderRoundedIcon from "@mui/icons-material/ReorderRounded";
|
||||
import DeleteIcon from "../../../../assets/icons/trash-bin.svg?react";
|
||||
|
||||
export const HttpAdornment = ({ https, prefix }) => {
|
||||
const theme = useTheme();
|
||||
@@ -67,10 +67,12 @@ PasswordEndAdornment.propTypes = {
|
||||
setFieldType: PropTypes.func,
|
||||
};
|
||||
|
||||
export const ServerStartAdornment = () => {
|
||||
export const ServerStartAdornment = (props) => {
|
||||
return (
|
||||
<InputAdornment position="start">
|
||||
<ReorderRoundedIcon />
|
||||
<Stack {...props.dragHandleProps}>
|
||||
<ReorderRoundedIcon />
|
||||
</Stack>
|
||||
</InputAdornment>
|
||||
);
|
||||
};
|
||||
@@ -83,7 +85,7 @@ export const ServerEndAdornment = ({ id, removeItem }) => {
|
||||
aria-label="remove server"
|
||||
onClick={() => removeItem(id)}
|
||||
sx={{
|
||||
color: theme.palette.border.dark,
|
||||
color: theme.palette.primary.contrastText,
|
||||
padding: theme.spacing(1),
|
||||
"&:focus-visible": {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
@@ -100,3 +102,8 @@ export const ServerEndAdornment = ({ id, removeItem }) => {
|
||||
</InputAdornment>
|
||||
);
|
||||
};
|
||||
|
||||
ServerEndAdornment.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
removeItem: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ import DotsVertical from "../../assets/icons/dots-vertical.svg?react";
|
||||
import ChangeLog from "../../assets/icons/changeLog.svg?react";
|
||||
import Docs from "../../assets/icons/docs.svg?react";
|
||||
import Folder from "../../assets/icons/folder.svg?react";
|
||||
import StatusPages from "../../assets/icons/status-pages.svg?react";
|
||||
import ChatBubbleOutlineRoundedIcon from "@mui/icons-material/ChatBubbleOutlineRounded";
|
||||
|
||||
import "./index.css";
|
||||
@@ -52,7 +53,8 @@ const menu = [
|
||||
{ name: "Pagespeed", path: "pagespeed", icon: <PageSpeed /> },
|
||||
{ name: "Infrastructure", path: "infrastructure", icon: <Integrations /> },
|
||||
{ name: "Incidents", path: "incidents", icon: <Incidents /> },
|
||||
// { name: "Status pages", path: "status", icon: <StatusPages /> },
|
||||
|
||||
{ name: "Status pages", path: "status", icon: <StatusPages /> },
|
||||
{ name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
|
||||
// { name: "Integrations", path: "integrations", icon: <Integrations /> },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { Button, Box, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import TabPanel from "@mui/lab/TabPanel";
|
||||
|
||||
import ConfigBox from "../../../Components/ConfigBox";
|
||||
import { StatusFormContext } from "../../../Pages/Status/CreateStatusContext";
|
||||
import { useSelector } from "react-redux";
|
||||
import { logger } from "../../../Utils/Logger";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
import { networkService } from "../../../main";
|
||||
import ServersList from "./ServersList";
|
||||
import Checkbox from "../../Inputs/Checkbox";
|
||||
import { publicPageSettingsValidation } from "../../../Validation/validation";
|
||||
import { buildErrors } from "../../../Validation/error";
|
||||
|
||||
/**
|
||||
* Content Panel is used to compose the second part of the status page
|
||||
* for the servers/monitors to watch for in its public page presence and some
|
||||
* other server related configurations etc
|
||||
*
|
||||
*/
|
||||
const ContentPanel = () => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
form,
|
||||
setForm,
|
||||
errors,
|
||||
setErrors,
|
||||
handleBlur,
|
||||
handelCheckboxChange,
|
||||
} = useContext(StatusFormContext);
|
||||
const [cards, setCards] = useState([]);
|
||||
const { user, authToken } = useSelector((state) => state.auth);
|
||||
const [monitors, setMonitors] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
const response = await networkService.getMonitorsByTeamId({
|
||||
authToken: authToken,
|
||||
teamId: user.teamId,
|
||||
limit: null, // donot return any checks for the monitors
|
||||
types: ["http"], // status page is available only for the uptime type
|
||||
});
|
||||
if (response.data.data.monitors.length == 0) {
|
||||
setErrors({ monitors: "Please config monitors to setup status page" });
|
||||
}
|
||||
const fullMonitors = response.data.data.monitors;
|
||||
setMonitors(fullMonitors);
|
||||
if (form.monitors.length > 0) {
|
||||
const initiCards = form.monitors.map((mid, idx) => ({
|
||||
id: "" + idx,
|
||||
val: fullMonitors.filter((fM) =>
|
||||
mid._id ? fM._id == mid._id : fM._id == mid
|
||||
)[0],
|
||||
}));
|
||||
setCards(initiCards);
|
||||
}
|
||||
} catch (error) {
|
||||
createToast({ body: "Failed to fetch monitors data" });
|
||||
logger.error("Failed to fetch monitors", error);
|
||||
}
|
||||
};
|
||||
fetchMonitors();
|
||||
}, [user, authToken]);
|
||||
const handleAddNew = () => {
|
||||
if (cards.length === monitors.length) return;
|
||||
const newCards = [...cards, { id: "" + Math.random(), val: {} }];
|
||||
setCards(newCards);
|
||||
};
|
||||
const removeCard = (id) => {
|
||||
const newCards = cards.filter((c) => c?.id != id);
|
||||
setCards(newCards);
|
||||
setForm({
|
||||
...form,
|
||||
monitors: newCards.filter((c) => c.val !== undefined).map((c) => c.val),
|
||||
});
|
||||
};
|
||||
|
||||
const handleServersBlur = () => {
|
||||
const { error } = publicPageSettingsValidation.validate(
|
||||
{ "monitors": form.monitors },
|
||||
{
|
||||
abortEarly: false,
|
||||
}
|
||||
);
|
||||
setErrors((prev) => {
|
||||
return buildErrors(prev, "monitors", error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<TabPanel
|
||||
value="Contents"
|
||||
sx={{
|
||||
"& h1, & p, & input": {
|
||||
color: theme.palette.text.tertiary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
component="form"
|
||||
className="status-contents-form"
|
||||
noValidate
|
||||
spellCheck="false"
|
||||
gap={theme.spacing(12)}
|
||||
mt={theme.spacing(6)}
|
||||
>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Status page servers</Typography>
|
||||
<Typography component="p">
|
||||
You can add any number of servers that you monitor to your status page.
|
||||
You can also reorder them for the best viewing experience.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack
|
||||
className="status-contents-server-list"
|
||||
sx={{
|
||||
margin: theme.spacing(6),
|
||||
border: "solid",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderWidth: "1px",
|
||||
transition: "0.2s",
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Typography
|
||||
component="p"
|
||||
alignSelf={"center"}
|
||||
>
|
||||
{" "}
|
||||
Servers list{" "}
|
||||
</Typography>
|
||||
<Button
|
||||
color="info"
|
||||
onClick={handleAddNew}
|
||||
disabled={monitors.length === 0 || monitors.length <= cards.length}
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<ServersList
|
||||
monitors={monitors}
|
||||
cards={cards}
|
||||
setCards={setCards}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
removeItem={removeCard}
|
||||
onBlur= {handleServersBlur}
|
||||
/>
|
||||
|
||||
{errors["monitors"] && (
|
||||
<Typography
|
||||
component="span"
|
||||
className="input-error"
|
||||
color={theme.palette.error.main}
|
||||
sx={{
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{errors["monitors"]}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Features</Typography>
|
||||
<Typography component="p">Show more details on the status page</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack sx={{ margin: theme.spacing(6) }}>
|
||||
<Checkbox
|
||||
id="showBarcode"
|
||||
label={`Show Barcode`}
|
||||
isChecked={form.showBarcode}
|
||||
onChange={handelCheckboxChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<Checkbox
|
||||
id="showUptimePercentage"
|
||||
label={`Show Uptime Percentage`}
|
||||
isChecked={form.showUptimePercentage}
|
||||
onChange={handelCheckboxChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentPanel;
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useRef, useContext } from "react";
|
||||
import { Box, Button, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import TabPanel from "@mui/lab/TabPanel";
|
||||
import ImageIcon from "@mui/icons-material/Image";
|
||||
|
||||
import ConfigBox from "../../../Components/ConfigBox";
|
||||
import TextInput from "../../Inputs/TextInput";
|
||||
import ImageField from "../../Inputs/Image";
|
||||
import timezones from "../../../Utils/timezones.json";
|
||||
import Select from "../../Inputs/Select";
|
||||
import { logoImageValidation } from "../../../Validation/validation";
|
||||
import { formatBytes } from "../../../Utils/fileUtils";
|
||||
import ProgressUpload from "../../ProgressBars";
|
||||
import { StatusFormContext } from "../../../Pages/Status/CreateStatusContext";
|
||||
import ColorPicker from "../../Inputs/ColorPicker";
|
||||
import Checkbox from "../../Inputs/Checkbox";
|
||||
|
||||
/**
|
||||
* General settings panel is ued to compose part of the public static page
|
||||
* for general informations like company name, subdomain url, logo and color etc
|
||||
*/
|
||||
const GeneralSettingsPanel = () => {
|
||||
const theme = useTheme();
|
||||
const { form, setForm, errors, setErrors, handleBlur, handleChange, handelCheckboxChange } =
|
||||
useContext(StatusFormContext);
|
||||
const [logo, setLogo] = useState(form.logo);
|
||||
|
||||
const [progress, setProgress] = useState({ value: 0, isLoading: false });
|
||||
const intervalRef = useRef(null);
|
||||
const STATUS_PAGE = import.meta.env.VITE_STATU_PAGE_URL ?? "status-page";
|
||||
|
||||
// Clears specific error from errors state
|
||||
const clearError = (err) => {
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
if (updatedErrors[err]) delete updatedErrors[err];
|
||||
return updatedErrors;
|
||||
});
|
||||
};
|
||||
const removeLogo = () => {
|
||||
errors["logo"] && clearError("logo");
|
||||
setLogo({});
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
logo: logo?.src,
|
||||
}));
|
||||
// interrupt interval if image upload is canceled prior to completing the process
|
||||
clearInterval(intervalRef.current);
|
||||
setProgress({ value: 0, isLoading: false });
|
||||
};
|
||||
|
||||
const handleColorChange = (newValue) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
color: newValue,
|
||||
}));
|
||||
};
|
||||
|
||||
const validateField = (toValidate, schema, name = "logo") => {
|
||||
const { error } = schema.validate(toValidate, { abortEarly: false });
|
||||
setErrors((prev) => {
|
||||
let prevErrors = { ...prev };
|
||||
if (error) prevErrors[name] = error?.details[0].message;
|
||||
else delete prevErrors[name];
|
||||
return prevErrors;
|
||||
});
|
||||
if (error) return true;
|
||||
};
|
||||
|
||||
const handleLogo = (event) => {
|
||||
const pic = event.target?.files?.[0];
|
||||
let error = validateField({ type: pic?.type, size: pic?.size }, logoImageValidation);
|
||||
if (error) return;
|
||||
|
||||
const newLogo = {
|
||||
src: URL.createObjectURL(pic),
|
||||
name: pic.name,
|
||||
type: pic.type,
|
||||
size: pic.size,
|
||||
};
|
||||
setProgress((prev) => ({ ...prev, isLoading: true }));
|
||||
setLogo(newLogo);
|
||||
setForm({ ...form, logo: newLogo });
|
||||
intervalRef.current = setInterval(() => {
|
||||
const buffer = 12;
|
||||
setProgress((prev) => {
|
||||
if (prev.value + buffer >= 100) {
|
||||
clearInterval(intervalRef.current);
|
||||
return { value: 100, isLoading: false };
|
||||
}
|
||||
return { ...prev, value: prev.value + buffer };
|
||||
});
|
||||
}, 120);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanel value="General settings">
|
||||
<Stack
|
||||
component="form"
|
||||
className="status-general-settings-form"
|
||||
noValidate
|
||||
spellCheck="false"
|
||||
gap={theme.spacing(12)}
|
||||
mt={theme.spacing(6)}
|
||||
>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Access</Typography>
|
||||
<Typography component="p">
|
||||
If your status page is ready, you can mark it as published.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Checkbox
|
||||
id="publish"
|
||||
label={`Published and visible to the public`}
|
||||
isChecked={form.publish}
|
||||
value={form.publish}
|
||||
onChange={handelCheckboxChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Basic Information</Typography>
|
||||
<Typography component="p">
|
||||
Define company name and the subdomain that your status page points to.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(18)}>
|
||||
<TextInput
|
||||
id="companyName"
|
||||
type="text"
|
||||
label="Company name"
|
||||
value={form.companyName}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
helperText={errors["companyName"]}
|
||||
error={errors["companyName"] ? true : false}
|
||||
/>
|
||||
<TextInput
|
||||
id="url"
|
||||
type="url"
|
||||
label="Your status page address"
|
||||
disabled
|
||||
value={form.url ?? "/" + STATUS_PAGE}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
helperText={errors["url"]}
|
||||
error={errors["url"] ? true : false}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Appearance</Typography>
|
||||
<Typography component="p">
|
||||
Define the default look and feel of your public status page.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Select
|
||||
id="timezone"
|
||||
name="timezone"
|
||||
label="Display timezone"
|
||||
value={form.timezone}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
items={timezones}
|
||||
error={errors["display-timezone"]}
|
||||
/>
|
||||
<Stack direction={"column"}>
|
||||
<Typography
|
||||
component="h3"
|
||||
color={theme.palette.text.secondary}
|
||||
fontWeight={500}
|
||||
fontSize={13}
|
||||
sx={{ mb: theme.spacing(2) }}
|
||||
>
|
||||
Logo
|
||||
</Typography>
|
||||
<ImageField
|
||||
id="logo"
|
||||
src={form.logo?.src ?? logo?.src}
|
||||
loading={progress.isLoading && progress.value !== 100}
|
||||
onChange={handleLogo}
|
||||
isRound={false}
|
||||
/>
|
||||
{progress.isLoading || progress.value !== 0 || errors["logo"] ? (
|
||||
<ProgressUpload
|
||||
icon={<ImageIcon />}
|
||||
label={logo?.name}
|
||||
size={formatBytes(logo?.size)}
|
||||
progress={progress.value}
|
||||
onClick={removeLogo}
|
||||
error={errors["logo"]}
|
||||
/>
|
||||
) : logo && logo.type ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={removeLogo}
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: "200px",
|
||||
alignSelf: "center",
|
||||
mt: theme.spacing(4)
|
||||
}}
|
||||
>
|
||||
Remove Logo
|
||||
</Button>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction={"column"}>
|
||||
<Typography
|
||||
component="h3"
|
||||
color={theme.palette.text.secondary}
|
||||
fontWeight={500}
|
||||
fontSize={13}
|
||||
>
|
||||
Color
|
||||
</Typography>
|
||||
|
||||
<ColorPicker
|
||||
id="color"
|
||||
value={form.color}
|
||||
error={errors["color"]}
|
||||
onChange={handleColorChange}
|
||||
onBlur={handleBlur}
|
||||
></ColorPicker>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettingsPanel;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ServerStartAdornment, ServerEndAdornment } from "../../../../Inputs/TextInput/Adornments";
|
||||
import Search from "../../../../Inputs/Search";
|
||||
import {useState} from "react"
|
||||
import React from "react";
|
||||
import { Stack } from "@mui/material";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} id The Id of the Server component
|
||||
* @param {*} monitors The server monitors options
|
||||
* @param {*} value - Current input value for the Autocomplete
|
||||
* @param {*} removeItem The function used to remove a single server
|
||||
* @param {*} onChange The Change handler function to handle when the server value is changed
|
||||
* used to update the server(monitor) lists*
|
||||
* @param {*} onBlur Function to call when the input is blured
|
||||
* @param {*} dragHandleProps the dragHandleProps passed on to the designated dom node
|
||||
* so that there will be a draging indicator when mouse over it
|
||||
* @returns A single server whose value is one of the existing monitors
|
||||
*/
|
||||
|
||||
const Server = ({ id, value, monitors, onChange, removeItem, onBlur, dragHandleProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const handleSearch = (val) => {
|
||||
setSearch(val);
|
||||
};
|
||||
return (
|
||||
<Stack direction={"row"}>
|
||||
<Search
|
||||
id={id}
|
||||
multiple={false}
|
||||
isAdorned={false}
|
||||
options={monitors ? monitors : []}
|
||||
filteredBy="name"
|
||||
inputValue={search}
|
||||
onBlur={onBlur}
|
||||
value={value}
|
||||
startAdornment={<ServerStartAdornment dragHandleProps={dragHandleProps} />}
|
||||
endAdornment={
|
||||
<ServerEndAdornment
|
||||
id={id}
|
||||
removeItem={removeItem}
|
||||
/>
|
||||
}
|
||||
handleInputChange={handleSearch}
|
||||
handleChange={onChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Server.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
monitors: PropTypes.array.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
|
||||
removeItem: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onBlur: PropTypes.func.isRequired,
|
||||
dragHandleProps: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default Server;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import Server from "./Server";
|
||||
import update from "immutability-helper";
|
||||
import PropTypes from "prop-types";
|
||||
import { Stack, useTheme } from "@mui/material";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} monitors The server monitors options
|
||||
* @param {*} cards A set of servers/monitors user can add/remove/drag/drop to be displayed in status page
|
||||
* each card can be autocompleted/selected from the existing monitors list
|
||||
* @param {*} setCards Function to set the cards
|
||||
* @param {*} form The Status page form
|
||||
* @param {*} setForm Function to set the form
|
||||
* @param {*} removeItem The function used to remove a single server
|
||||
* @param {*} onBlur Function to call when the input is blured*
|
||||
* @returns A list of user selected Servers/Monitors
|
||||
*/
|
||||
|
||||
|
||||
const ServersList = ({ monitors, cards, setCards, form, setForm, removeItem, onBlur }) => {
|
||||
const theme = useTheme()
|
||||
const grid = parseInt(theme.spacing(4));
|
||||
|
||||
const handleCardChange = (event, val) => {
|
||||
let newCards;
|
||||
const { id } = event.target;
|
||||
let idx = cards.findIndex((a) => {
|
||||
let found = false;
|
||||
let optionIdx = id.indexOf("-option");
|
||||
if (optionIdx !== -1) found = a.id == id.substr(0, optionIdx);
|
||||
else found = a.id == id;
|
||||
return found;
|
||||
});
|
||||
|
||||
if (idx >= 0) {
|
||||
let x = JSON.parse(JSON.stringify(cards[idx]));
|
||||
x.val = val;
|
||||
newCards = update(cards, { $splice: [[idx, 1, x]] });
|
||||
} else {
|
||||
newCards = update(cards, { $push: [{ id: id, val: val }] });
|
||||
}
|
||||
setCards(newCards);
|
||||
setForm({ ...form, monitors: newCards.map((c) => c.val) });
|
||||
};
|
||||
|
||||
const moveCard = (dragIndex, hoverIndex) => {
|
||||
const dragCard = cards[dragIndex];
|
||||
const newCards = update(cards, {
|
||||
$splice: [
|
||||
[dragIndex, 1],
|
||||
[hoverIndex, 0, dragCard],
|
||||
],
|
||||
});
|
||||
setCards(newCards);
|
||||
setForm({ ...form, monitors: newCards.map((c) => c.val) });
|
||||
};
|
||||
|
||||
const handleDragEnd = (result) => {
|
||||
// dropped outside the list
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
moveCard(result.source.index, result.destination.index);
|
||||
};
|
||||
|
||||
const getItemStyle = (isDragging, draggableStyle) => ({
|
||||
// some basic styles to make the items look a bit nicer
|
||||
userSelect: "none",
|
||||
padding: grid,
|
||||
margin: `0 0 ${grid}px 0`,
|
||||
|
||||
// change background colour if dragging
|
||||
background: isDragging ? "#D0D5DD" : "#F8F9F8",
|
||||
|
||||
// styles we need to apply on draggables
|
||||
...draggableStyle,
|
||||
});
|
||||
|
||||
const getListStyle = (isDraggingOver) => ({
|
||||
background: isDraggingOver ? "lightblue" : "white",
|
||||
padding: grid,
|
||||
});
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided, snapshot) => (
|
||||
<Stack
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
sx={{...getListStyle(snapshot.isDraggingOver)}}
|
||||
|
||||
>
|
||||
{cards.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={item.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<Stack
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
sx={{...getItemStyle(
|
||||
snapshot.isDragging,
|
||||
provided.draggableProps.style
|
||||
)}}
|
||||
>
|
||||
<Server
|
||||
key={index}
|
||||
id={item?.id ?? "" + Math.random()}
|
||||
monitors={monitors}
|
||||
value={item.val ?? {}}
|
||||
onChange={handleCardChange}
|
||||
removeItem={removeItem}
|
||||
dragHandleProps={provided.dragHandleProps}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
ServersList.propTypes = {
|
||||
monitors: PropTypes.array.isRequired,
|
||||
cards: PropTypes.array.isRequired,
|
||||
setCards: PropTypes.func.isRequired,
|
||||
form: PropTypes.object.isRequired,
|
||||
setForm: PropTypes.func.isRequired,
|
||||
removeItem: PropTypes.func.isRequired,
|
||||
onBlur: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ServersList;
|
||||
@@ -181,7 +181,7 @@ const CreateMaintenance = () => {
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
const handleSelectMonitors = (monitors) => {
|
||||
const handleSelectMonitors = (_, monitors) => {
|
||||
setForm({ ...form, monitors });
|
||||
const { error } = maintenanceWindowValidation.validate(
|
||||
{ monitors },
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState } from "react";
|
||||
import { Box, Tab, useTheme, Stack, Button } from "@mui/material";
|
||||
import TabContext from "@mui/lab/TabContext";
|
||||
import TabList from "@mui/lab/TabList";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import GeneralSettingsPanel from "../../../Components/TabPanels/Status/GeneralSettingsPanel";
|
||||
import ContentPanel from "../../../Components/TabPanels/Status/ContentPanel";
|
||||
import { publicPageSettingsValidation } from "../../../Validation/validation";
|
||||
import { hasValidationErrors } from "../../../Validation/error";
|
||||
import { StatusFormProvider } from "../CreateStatusContext";
|
||||
import { formatBytes } from "../../../Utils/fileUtils";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
import { networkService } from "../../../main";
|
||||
import { buildErrors } from "../../../Validation/error";
|
||||
|
||||
/**
|
||||
* CreateStatus page renders a page with tabs for general settings and contents.
|
||||
* @param {object} [props.initForm] - Specifies the initial form value if the status page exists
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
|
||||
const CreateStatus = ({ initForm }) => {
|
||||
const theme = useTheme();
|
||||
const mode = useSelector((state) => state.ui.mode);
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
const [tabIdx, setTabIdx] = useState(0);
|
||||
const [errors, setErrors] = useState({});
|
||||
const error_tab_maping = [
|
||||
["companyName", "url", "timezone", "color", "publish", "logo"],
|
||||
["monitors", "showUptimePercentage", "showBarcode"],
|
||||
];
|
||||
const tabList = ["General settings", "Contents"];
|
||||
const hasInitForm = initForm && Object.keys(initForm).length > 0;
|
||||
const STATUS_PAGE = import.meta.env.VITE_STATU_PAGE_URL?? "status-page";
|
||||
const [form, setForm] = useState(
|
||||
hasInitForm
|
||||
? initForm
|
||||
: {
|
||||
companyName: "",
|
||||
url: "/"+STATUS_PAGE,
|
||||
timezone: "America/Toronto",
|
||||
color: "#4169E1",
|
||||
//which fields matching below?
|
||||
publish: false,
|
||||
logo: null,
|
||||
monitors: [],
|
||||
showUptimePercentage: false,
|
||||
showBarcode: false,
|
||||
}
|
||||
);
|
||||
const setActiveTabOnErrors = () => {
|
||||
let newIdx = -1;
|
||||
Object.keys(errors).map((id) => {
|
||||
if (newIdx !== -1) return;
|
||||
error_tab_maping.map((val, idx) => {
|
||||
let anyMatch = val.some((vl) => vl == id);
|
||||
if (anyMatch) {
|
||||
newIdx = idx;
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (newIdx !== -1) setTabIdx(newIdx);
|
||||
};
|
||||
|
||||
const handleTabChange = (_, newTab) => {
|
||||
setTabIdx(tabList.indexOf(newTab));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
let localData = {
|
||||
...form,
|
||||
monitors:
|
||||
form.monitors[0] && Object.keys(form.monitors[0]).includes("_id")
|
||||
? form.monitors.map((m) => m._id)
|
||||
: form.monitors,
|
||||
theme: mode,
|
||||
logo: { type: form.logo?.type ?? "", size: form.logo?.size ?? "" },
|
||||
};
|
||||
if (
|
||||
hasValidationErrors(localData, publicPageSettingsValidation, setErrors)
|
||||
) {
|
||||
setActiveTabOnErrors();
|
||||
return;
|
||||
}
|
||||
|
||||
localData.logo = form.logo
|
||||
localData.url = STATUS_PAGE
|
||||
let config = { authToken: authToken, url: STATUS_PAGE, data: localData };
|
||||
try {
|
||||
const res = await networkService.createStatusPage(config);
|
||||
if (!res.status === 200) {
|
||||
throw new Error("Failed to create status page");
|
||||
}
|
||||
createToast({ body: "Status page created successfully!" });
|
||||
} catch (e) {
|
||||
createToast({ body: e.message || "Error creating status page" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (event) => {
|
||||
event.preventDefault();
|
||||
const { value, id, name } = event.target;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[id ?? name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handelCheckboxChange = (e) => {
|
||||
const { id } = e.target;
|
||||
|
||||
setForm((prev) => {
|
||||
return ({
|
||||
...prev,
|
||||
[id ]: !prev[id]
|
||||
})});
|
||||
}
|
||||
|
||||
const handleBlur = (event) => {
|
||||
event.preventDefault();
|
||||
const { value, id } = event.target;
|
||||
const { error } = publicPageSettingsValidation.validate(
|
||||
{ [id]: value },
|
||||
{
|
||||
abortEarly: false,
|
||||
}
|
||||
);
|
||||
setErrors((prev) => {
|
||||
return buildErrors(prev, id, error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="status"
|
||||
px={theme.spacing(20)}
|
||||
py={theme.spacing(12)}
|
||||
backgroundColor={theme.palette.primary.main}
|
||||
border={1}
|
||||
borderColor={theme.palette.primary.lowContrast}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
>
|
||||
<TabContext value={tabList[tabIdx]}>
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
"& .MuiTabs-root": { height: "fit-content", minHeight: "0" },
|
||||
}}
|
||||
>
|
||||
<TabList
|
||||
onChange={handleTabChange}
|
||||
aria-label="status tabs"
|
||||
>
|
||||
{tabList.map((label, index) => (
|
||||
<Tab
|
||||
label={label}
|
||||
key={index}
|
||||
value={label}
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.tertiary.contrastText,
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
textTransform: "none",
|
||||
minWidth: "fit-content",
|
||||
paddingY: theme.spacing(6),
|
||||
fontWeight: 400,
|
||||
borderBottom: "2px solid transparent",
|
||||
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
"&:first-child": { borderTopLeftRadius: "8px" },
|
||||
"&:last-child": { borderTopRightRadius: "8px", borderRight: 0 },
|
||||
"&:focus-visible": {
|
||||
color: theme.palette.primary.contrastText,
|
||||
borderColor: theme.palette.tertiary.contrastText,
|
||||
borderRightColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
color: theme.palette.secondary.contrastText,
|
||||
borderColor: theme.palette.secondary.contrastText,
|
||||
borderRightColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TabList>
|
||||
</Box>
|
||||
<StatusFormProvider
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
errors={errors}
|
||||
setErrors={setErrors}
|
||||
handleBlur={handleBlur}
|
||||
handleChange={handleChange}
|
||||
handelCheckboxChange={handelCheckboxChange}
|
||||
>
|
||||
{tabIdx == 0 ? <GeneralSettingsPanel /> : <ContentPanel />}
|
||||
</StatusFormProvider>
|
||||
</TabContext>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
CreateStatus.propTypes = {
|
||||
initForm: PropTypes.object
|
||||
};
|
||||
|
||||
export default CreateStatus;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, React } from "react";
|
||||
|
||||
const StatusFormContext = createContext({
|
||||
form: {},
|
||||
setForm: () => {},
|
||||
errors: {},
|
||||
setErrors: () => {},
|
||||
handleBlur: () => {},
|
||||
handleChange: () => {},
|
||||
handelCheckboxChange: () =>{}
|
||||
});
|
||||
|
||||
const StatusFormProvider = ({
|
||||
form,
|
||||
setForm,
|
||||
errors,
|
||||
setErrors,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
handelCheckboxChange,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<StatusFormContext.Provider
|
||||
value={{
|
||||
form,
|
||||
setForm,
|
||||
errors,
|
||||
setErrors,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
handelCheckboxChange,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StatusFormContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { StatusFormContext, StatusFormProvider };
|
||||
@@ -1,35 +1,85 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import Fallback from "../../Components/Fallback";
|
||||
import { Box, Button, Stack } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import CreateStatus from "./CreateStatus";
|
||||
import { useEffect, useState } from "react";
|
||||
import { networkService } from "../../main";
|
||||
import { useSelector } from "react-redux";
|
||||
import { logger } from "../../Utils/Logger";
|
||||
|
||||
/**
|
||||
* The configuration page for public page that contains a general settings and
|
||||
* content tabs, It will display a static page if there is no status page configured
|
||||
* or the status page if one is already configured
|
||||
*/
|
||||
const Status = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const {authToken} = useSelector((state) => state.auth);
|
||||
const [initForm, setInitForm] = useState({});
|
||||
const STATUS_PAGE = import.meta.env.VITE_STATU_PAGE_URL?? "status-page";
|
||||
useEffect(() => {
|
||||
const getStatusPage = async () => {
|
||||
let config = { authToken: authToken, url: STATUS_PAGE };
|
||||
try {
|
||||
let res = await networkService.getStatusPageByUrl(config);
|
||||
if(res && res.data)
|
||||
setInitForm( res.data.data)
|
||||
}catch (error) {
|
||||
logger.error("Failed to fetch status page", error);
|
||||
}
|
||||
|
||||
};
|
||||
getStatusPage();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="status"
|
||||
sx={{
|
||||
':has(> [class*="fallback__"])': {
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Fallback
|
||||
title="status page"
|
||||
checks={[
|
||||
"Share your uptime publicly",
|
||||
"Keep your users informed about incidents",
|
||||
"Build trust with your customers",
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
<>
|
||||
{Object.keys(initForm).length===0? (
|
||||
<Box
|
||||
className="status"
|
||||
sx={{
|
||||
':has(> [class*="fallback__"])': {
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Fallback
|
||||
title="status page"
|
||||
checks={[
|
||||
"Share your uptime publicly",
|
||||
"Keep your users informed about incidents",
|
||||
"Build trust with your customers",
|
||||
]}
|
||||
/>
|
||||
<Stack
|
||||
alignItems="center"
|
||||
mt={theme.spacing(10)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={() => {
|
||||
navigate("/status/create");
|
||||
}}
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
Let's create your status page
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<CreateStatus initForm={initForm}/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Status;
|
||||
|
||||
@@ -41,6 +41,7 @@ import Maintenance from "../Pages/Maintenance";
|
||||
import ProtectedRoute from "../Components/ProtectedRoute";
|
||||
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance";
|
||||
import withAdminCheck from "../Components/HOC/withAdminCheck";
|
||||
import CreateStatus from "../Pages/Status/CreateStatus";
|
||||
|
||||
const Routes = () => {
|
||||
const AdminCheckedRegister = withAdminCheck(AuthRegister);
|
||||
@@ -112,6 +113,11 @@ const Routes = () => {
|
||||
path="status"
|
||||
element={<Status />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="status/create"
|
||||
element={<CreateStatus/>}
|
||||
/>
|
||||
<Route
|
||||
path="integrations"
|
||||
element={<Integrations />}
|
||||
|
||||
@@ -15,3 +15,22 @@ export const capitalizeFirstLetter = (str) => {
|
||||
}
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get first letter as a lower case string
|
||||
* @param {string} str String whose first letter is to be lower cased
|
||||
* @returns A string with first letter lower cased
|
||||
*/
|
||||
|
||||
export const toLowerCaseFirstLetter = (str) => {
|
||||
if (str === null || str === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof str !== "string") {
|
||||
throw new TypeError("Input must be a string");
|
||||
}
|
||||
if (str.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return str.charAt(0).toLowerCase() + str.slice(1);
|
||||
};
|
||||
@@ -91,58 +91,61 @@ const credentials = joi.object({
|
||||
});
|
||||
|
||||
const monitorValidation = joi.object({
|
||||
url: joi.when('type', {
|
||||
is: 'docker',
|
||||
then: joi.string()
|
||||
.trim().regex(/^[a-z0-9]{64}$/),
|
||||
otherwise: joi
|
||||
.string()
|
||||
.trim()
|
||||
.custom((value, helpers) => {
|
||||
// Regex from https://gist.github.com/dperini/729294
|
||||
var urlRegex = new RegExp(
|
||||
"^" +
|
||||
// protocol identifier (optional)
|
||||
// short syntax // still required
|
||||
"(?:(?:https?|ftp):\\/\\/)?" +
|
||||
// user:pass BasicAuth (optional)
|
||||
"(?:" +
|
||||
// IP address dotted notation octets
|
||||
// excludes loopback network 0.0.0.0
|
||||
// excludes reserved space >= 224.0.0.0
|
||||
// excludes network & broadcast addresses
|
||||
// (first & last IP address of each class)
|
||||
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
|
||||
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
|
||||
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
|
||||
"|" +
|
||||
// host & domain names, may end with dot
|
||||
// can be replaced by a shortest alternative
|
||||
// (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
|
||||
"(?:" +
|
||||
"(?:" +
|
||||
"[a-z0-9\\u00a1-\\uffff]" +
|
||||
"[a-z0-9\\u00a1-\\uffff_-]{0,62}" +
|
||||
")?" +
|
||||
"[a-z0-9\\u00a1-\\uffff]\\." +
|
||||
")+" +
|
||||
// TLD identifier name, may end with dot
|
||||
"(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" +
|
||||
")" +
|
||||
// port number (optional)
|
||||
"(?::\\d{2,5})?" +
|
||||
// resource path (optional)
|
||||
"(?:[/?#]\\S*)?" +
|
||||
"$",
|
||||
"i"
|
||||
);
|
||||
if (!urlRegex.test(value)) {
|
||||
return helpers.error("string.invalidUrl");
|
||||
}
|
||||
url: joi
|
||||
.when("type", {
|
||||
is: "docker",
|
||||
then: joi
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9]{64}$/),
|
||||
otherwise: joi
|
||||
.string()
|
||||
.trim()
|
||||
.custom((value, helpers) => {
|
||||
// Regex from https://gist.github.com/dperini/729294
|
||||
var urlRegex = new RegExp(
|
||||
"^" +
|
||||
// protocol identifier (optional)
|
||||
// short syntax // still required
|
||||
"(?:(?:https?|ftp):\\/\\/)?" +
|
||||
// user:pass BasicAuth (optional)
|
||||
"(?:" +
|
||||
// IP address dotted notation octets
|
||||
// excludes loopback network 0.0.0.0
|
||||
// excludes reserved space >= 224.0.0.0
|
||||
// excludes network & broadcast addresses
|
||||
// (first & last IP address of each class)
|
||||
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
|
||||
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
|
||||
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
|
||||
"|" +
|
||||
// host & domain names, may end with dot
|
||||
// can be replaced by a shortest alternative
|
||||
// (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
|
||||
"(?:" +
|
||||
"(?:" +
|
||||
"[a-z0-9\\u00a1-\\uffff]" +
|
||||
"[a-z0-9\\u00a1-\\uffff_-]{0,62}" +
|
||||
")?" +
|
||||
"[a-z0-9\\u00a1-\\uffff]\\." +
|
||||
")+" +
|
||||
// TLD identifier name, may end with dot
|
||||
"(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" +
|
||||
")" +
|
||||
// port number (optional)
|
||||
"(?::\\d{2,5})?" +
|
||||
// resource path (optional)
|
||||
"(?:[/?#]\\S*)?" +
|
||||
"$",
|
||||
"i"
|
||||
);
|
||||
if (!urlRegex.test(value)) {
|
||||
return helpers.error("string.invalidUrl");
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
})
|
||||
return value;
|
||||
}),
|
||||
})
|
||||
.messages({
|
||||
"string.empty": "This field is required.",
|
||||
"string.uri": "The URL you provided is not valid.",
|
||||
@@ -180,17 +183,14 @@ const logoImageValidation = joi.object({
|
||||
"any.only": "Invalid file format.",
|
||||
"string.empty": "File type required.",
|
||||
}),
|
||||
size: joi
|
||||
.number()
|
||||
.max(800*800)
|
||||
.messages({
|
||||
"number.base": "File size must be a number.",
|
||||
"number.max": "File size must be less than 640000 pixels.",
|
||||
"number.empty": "File size required.",
|
||||
}),
|
||||
size: joi.number().max(3000000).messages({
|
||||
"number.base": "File size must be a number.",
|
||||
"number.max": "File size must be less than 3MB.",
|
||||
"number.empty": "File size required.",
|
||||
}),
|
||||
});
|
||||
|
||||
const publicPageGeneralSettingsValidation = joi.object({
|
||||
const publicPageSettingsValidation = joi.object({
|
||||
publish: joi.bool(),
|
||||
companyName: joi
|
||||
.string()
|
||||
@@ -199,7 +199,6 @@ const publicPageGeneralSettingsValidation = joi.object({
|
||||
url: joi.string().trim().messages({ "string.empty": "URL is required." }),
|
||||
timezone: joi.string().trim().messages({ "string.empty": "Timezone is required." }),
|
||||
color: joi.string().trim().messages({ "string.empty": "Color is required." }),
|
||||
|
||||
theme: joi.string(),
|
||||
monitors: joi.array().min(1).items(joi.string().required()).messages({
|
||||
"string.pattern.base": "Must be a valid monitor ID",
|
||||
@@ -207,6 +206,10 @@ const publicPageGeneralSettingsValidation = joi.object({
|
||||
"array.empty": "At least one monitor is required",
|
||||
"any.required": "Monitors are required",
|
||||
}),
|
||||
logo: logoImageValidation,
|
||||
showUptimePercentage: joi.boolean(),
|
||||
showBarcode: joi.boolean(),
|
||||
showBarcode: joi.boolean(),
|
||||
});
|
||||
const settingsValidation = joi.object({
|
||||
ttl: joi.number().required().messages({
|
||||
@@ -322,4 +325,6 @@ export {
|
||||
maintenanceWindowValidation,
|
||||
advancedSettingsValidation,
|
||||
infrastructureMonitorValidation,
|
||||
publicPageSettingsValidation,
|
||||
logoImageValidation,
|
||||
};
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
>
|
||||
<Router>
|
||||
<NetworkServiceProvider>
|
||||
<App />
|
||||
<App />
|
||||
</NetworkServiceProvider>
|
||||
</Router>
|
||||
</PersistGate>
|
||||
|
||||
Vendored
+1
@@ -4,6 +4,7 @@ services:
|
||||
restart: always
|
||||
environment:
|
||||
UPTIME_APP_API_BASE_URL: "http://localhost:5000/api/v1"
|
||||
UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -4,6 +4,7 @@ services:
|
||||
restart: always
|
||||
environment:
|
||||
UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1"
|
||||
UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -4,6 +4,7 @@ services:
|
||||
restart: always
|
||||
environment:
|
||||
UPTIME_APP_API_BASE_URL: "https://checkmate-test.bluewavelabs.ca/api/v1"
|
||||
UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -3,6 +3,7 @@ services:
|
||||
image: uptime_client:latest
|
||||
environment:
|
||||
UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1"
|
||||
UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
Reference in New Issue
Block a user