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.
+
+
+
+
+
+
+
+ Logo
+
+
+ {progress.isLoading || progress.value !== 0 || errors["logo"] ? (
+ }
+ label={logo?.name}
+ size={formatBytes(logo?.size)}
+ progress={progress.value}
+ onClick={removeLogo}
+ error={errors["logo"]}
+ />
+ ) : logo && logo.type ? (
+
+ ) : (
+ ""
+ )}
+
+
+
+ Color
+
+
+
+
+
+
+
+
+ );
+};
+
+export default GeneralSettingsPanel;
diff --git a/Client/src/Components/TabPanels/Status/ServersList/Server/index.jsx b/Client/src/Components/TabPanels/Status/ServersList/Server/index.jsx
new file mode 100644
index 000000000..26a200495
--- /dev/null
+++ b/Client/src/Components/TabPanels/Status/ServersList/Server/index.jsx
@@ -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 (
+
+ }
+ endAdornment={
+
+ }
+ handleInputChange={handleSearch}
+ handleChange={onChange}
+ />
+
+ );
+};
+
+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;
\ No newline at end of file
diff --git a/Client/src/Components/TabPanels/Status/ServersList/index.jsx b/Client/src/Components/TabPanels/Status/ServersList/index.jsx
new file mode 100644
index 000000000..4f73a87b4
--- /dev/null
+++ b/Client/src/Components/TabPanels/Status/ServersList/index.jsx
@@ -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 (
+
+
+ {(provided, snapshot) => (
+
+ {cards.map((item, index) => (
+
+ {(provided, snapshot) => (
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+ );
+};
+
+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;
diff --git a/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx b/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx
index cacc0e8d5..444c0fa4b 100644
--- a/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx
+++ b/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx
@@ -181,7 +181,7 @@ const CreateMaintenance = () => {
setSearch(value);
};
- const handleSelectMonitors = (monitors) => {
+ const handleSelectMonitors = (_, monitors) => {
setForm({ ...form, monitors });
const { error } = maintenanceWindowValidation.validate(
{ monitors },
diff --git a/Client/src/Pages/Status/CreateStatus/index.jsx b/Client/src/Pages/Status/CreateStatus/index.jsx
new file mode 100644
index 000000000..f7cb30e16
--- /dev/null
+++ b/Client/src/Pages/Status/CreateStatus/index.jsx
@@ -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 (
+
+
+
+
+ {tabList.map((label, index) => (
+
+ ))}
+
+
+
+ {tabIdx == 0 ? : }
+
+
+
+
+
+
+ );
+ };
+
+CreateStatus.propTypes = {
+ initForm: PropTypes.object
+};
+
+export default CreateStatus;
diff --git a/Client/src/Pages/Status/CreateStatusContext/index.jsx b/Client/src/Pages/Status/CreateStatusContext/index.jsx
new file mode 100644
index 000000000..cd7127025
--- /dev/null
+++ b/Client/src/Pages/Status/CreateStatusContext/index.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+export { StatusFormContext, StatusFormProvider };
diff --git a/Client/src/Pages/Status/index.css b/Client/src/Pages/Status/index.css
deleted file mode 100644
index e69de29bb..000000000
diff --git a/Client/src/Pages/Status/index.jsx b/Client/src/Pages/Status/index.jsx
index 7ba2c0a8d..25d5a1410 100644
--- a/Client/src/Pages/Status/index.jsx
+++ b/Client/src/Pages/Status/index.jsx
@@ -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 (
- [class*="fallback__"])': {
- position: "relative",
- border: 1,
- borderColor: theme.palette.primary.lowContrast,
- borderRadius: theme.shape.borderRadius,
- borderStyle: "dashed",
- backgroundColor: theme.palette.primary.main,
- overflow: "hidden",
- },
- }}
- >
-
-
- );
+ <>
+ {Object.keys(initForm).length===0? (
+ [class*="fallback__"])': {
+ position: "relative",
+ border: 1,
+ borderColor: theme.palette.primary.lowContrast,
+ borderRadius: theme.shape.borderRadius,
+ borderStyle: "dashed",
+ backgroundColor: theme.palette.primary.main,
+ overflow: "hidden",
+ },
+ }}
+ >
+
+
+
+
+
+ ) : (
+
+ )}
+ >
+ );
};
export default Status;
diff --git a/Client/src/Routes/index.jsx b/Client/src/Routes/index.jsx
index 555c335f7..420d18804 100644
--- a/Client/src/Routes/index.jsx
+++ b/Client/src/Routes/index.jsx
@@ -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={}
/>
+
+ }
+ />
}
diff --git a/Client/src/Utils/stringUtils.js b/Client/src/Utils/stringUtils.js
index 43ae5ed54..0bf7aaf11 100644
--- a/Client/src/Utils/stringUtils.js
+++ b/Client/src/Utils/stringUtils.js
@@ -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);
+ };
\ No newline at end of file
diff --git a/Client/src/Validation/validation.js b/Client/src/Validation/validation.js
index 5e14de16a..9c2041e9e 100644
--- a/Client/src/Validation/validation.js
+++ b/Client/src/Validation/validation.js
@@ -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,
};
diff --git a/Client/src/main.jsx b/Client/src/main.jsx
index c07cad40b..7ee52132e 100644
--- a/Client/src/main.jsx
+++ b/Client/src/main.jsx
@@ -17,7 +17,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
>
-
+
diff --git a/Docker/dist/docker-compose.yaml b/Docker/dist/docker-compose.yaml
index a73b75dc6..420941e7e 100644
--- a/Docker/dist/docker-compose.yaml
+++ b/Docker/dist/docker-compose.yaml
@@ -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"
diff --git a/Docker/prod/docker-compose.yaml b/Docker/prod/docker-compose.yaml
index 2f8907089..0da2da996 100644
--- a/Docker/prod/docker-compose.yaml
+++ b/Docker/prod/docker-compose.yaml
@@ -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"
diff --git a/Docker/test/docker-compose.yaml b/Docker/test/docker-compose.yaml
index 40890e1d9..09ea94449 100644
--- a/Docker/test/docker-compose.yaml
+++ b/Docker/test/docker-compose.yaml
@@ -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"
diff --git a/Docker/test/prod/docker-compose.yaml b/Docker/test/prod/docker-compose.yaml
index e262ef1a2..ba88f3b67 100644
--- a/Docker/test/prod/docker-compose.yaml
+++ b/Docker/test/prod/docker-compose.yaml
@@ -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"