diff --git a/Client/.env.production b/Client/.env.production index a5bce84e1..2ae6d13cd 100644 --- a/Client/.env.production +++ b/Client/.env.production @@ -1 +1,2 @@ VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL +VITE_STATUS_PAGE_SUBDOMAIN_PREFIX=UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX diff --git a/Client/package-lock.json b/Client/package-lock.json index 5b7c891c9..7c0748fdf 100644 --- a/Client/package-lock.json +++ b/Client/package-lock.json @@ -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", diff --git a/Client/package.json b/Client/package.json index d27740361..172143a46 100644 --- a/Client/package.json +++ b/Client/package.json @@ -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", diff --git a/Client/src/App.jsx b/Client/src/App.jsx index 05822a5f5..aee0fb84b 100644 --- a/Client/src/App.jsx +++ b/Client/src/App.jsx @@ -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); diff --git a/Client/src/Components/ConfigBox/index.jsx b/Client/src/Components/ConfigBox/index.jsx index 8f7eb7b24..9b64712fc 100644 --- a/Client/src/Components/ConfigBox/index.jsx +++ b/Client/src/Components/ConfigBox/index.jsx @@ -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; diff --git a/Client/src/Components/Inputs/Checkbox/index.jsx b/Client/src/Components/Inputs/Checkbox/index.jsx index 9aac8534a..4f308e555 100644 --- a/Client/src/Components/Inputs/Checkbox/index.jsx +++ b/Client/src/Components/Inputs/Checkbox/index.jsx @@ -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, diff --git a/Client/src/Components/Inputs/ColorPicker/index.jsx b/Client/src/Components/Inputs/ColorPicker/index.jsx new file mode 100644 index 000000000..de63400bc --- /dev/null +++ b/Client/src/Components/Inputs/ColorPicker/index.jsx @@ -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: + * + * + */ +const ColorPicker = ({ id, value, error, onChange, onBlur }) => { + const theme = useTheme(); + return ( + + + {error && ( + + {error} + + )} + + ); +}; + +ColorPicker.propTypes = { + id: PropTypes.string.isRequired, + value: PropTypes.string, + error: PropTypes.string, + onChange: PropTypes.func.isRequired, + onBlur: PropTypes.func.isRequired, +}; + +export default ColorPicker; diff --git a/Client/src/Components/Inputs/Image/index.jsx b/Client/src/Components/Inputs/Image/index.jsx index 5c26d2314..0a2a2ef91 100644 --- a/Client/src/Components/Inputs/Image/index.jsx +++ b/Client/src/Components/Inputs/Image/index.jsx @@ -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 ? ( - <> + <> { 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"}) - + { > {error} - )} + )} ) : ( { const theme = useTheme(); return ( { 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) => ( }), + slotProps={{ + input: { + ...params.InputProps, + ...(isAdorned && { startAdornment: }), + ...(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; diff --git a/Client/src/Components/Inputs/TextInput/Adornments/index.jsx b/Client/src/Components/Inputs/TextInput/Adornments/index.jsx index 2dcc42b93..1a9880ab6 100644 --- a/Client/src/Components/Inputs/TextInput/Adornments/index.jsx +++ b/Client/src/Components/Inputs/TextInput/Adornments/index.jsx @@ -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 ( - + + + ); }; @@ -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 }) => { ); }; + +ServerEndAdornment.propTypes = { + id: PropTypes.string.isRequired, + removeItem: PropTypes.func.isRequired, +}; diff --git a/Client/src/Components/Sidebar/index.jsx b/Client/src/Components/Sidebar/index.jsx index 3821306ad..c4c574c8f 100644 --- a/Client/src/Components/Sidebar/index.jsx +++ b/Client/src/Components/Sidebar/index.jsx @@ -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: }, { name: "Infrastructure", path: "infrastructure", icon: }, { name: "Incidents", path: "incidents", icon: }, - // { name: "Status pages", path: "status", icon: }, + + { name: "Status pages", path: "status", icon: }, { name: "Maintenance", path: "maintenance", icon: }, // { name: "Integrations", path: "integrations", icon: }, { diff --git a/Client/src/Components/TabPanels/Status/ContentPanel.jsx b/Client/src/Components/TabPanels/Status/ContentPanel.jsx new file mode 100644 index 000000000..37833f55c --- /dev/null +++ b/Client/src/Components/TabPanels/Status/ContentPanel.jsx @@ -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 ( + + + + + + Status page servers + + You can add any number of servers that you monitor to your status page. + You can also reorder them for the best viewing experience. + + + + + + + {" "} + Servers list{" "} + + + + + + + {errors["monitors"] && ( + + {errors["monitors"]} + + )} + + + + + + + Features + Show more details on the status page + + + + + + + + + + ); +}; + +export default ContentPanel; diff --git a/Client/src/Components/TabPanels/Status/GeneralSettingsPanel.jsx b/Client/src/Components/TabPanels/Status/GeneralSettingsPanel.jsx new file mode 100644 index 000000000..af0f9c81f --- /dev/null +++ b/Client/src/Components/TabPanels/Status/GeneralSettingsPanel.jsx @@ -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 ( + + + + + + Access + + If your status page is ready, you can mark it as published. + + + + + + + + + + + + Basic Information + + Define company name and the subdomain that your status page points to. + + + + + + + + + + + + Appearance + + Define the default look and feel of your public status page. + + + + +