diff --git a/README.md b/README.md index 7f7f0520d..4be16fef0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul - [๐Ÿ“ฆ Demo](#-demo) - [๐Ÿ”— User's guide](#-users-guide) -- [๐Ÿ› ๏ธ Installation](#๏ธ-installation) +- [๐Ÿ› ๏ธ Installation](#๏ธ-installation) +- [๐Ÿš€ Deploying Checkmate with Helm](#-deploying-checkmate-with-helm) - [๐Ÿ Translations](#-translations) - [๐Ÿš€ Performance](#-performance) - [๐Ÿ’š Questions & Ideas](#-questions--ideas) @@ -50,7 +51,10 @@ Usage instructions can be found [here](https://docs.checkmate.so/checkmate-2.1). ## ๐Ÿ› ๏ธ Installation -See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart). Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. +See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart). + +Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. + ## ๐Ÿ Translations @@ -68,14 +72,20 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb ## ๐Ÿ’š Questions & Ideas -If you have any questions, suggestions or comments, please use our [Discord channel](https://discord.gg/NAb6H3UTjK). We've also launched our [Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) page! Feel free to ask questions or share your ideasโ€”we'd love to hear from you! +If you have any questions, suggestions or comments, you have several options: + +- [Discord channel](https://discord.gg/NAb6H3UTjK) +- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) +- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/) + +Feel free to ask questions or share your ideas - we'd love to hear from you! ## ๐Ÿงฉ Features - Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5) - Website monitoring - Page speed monitoring -- Infrastructure monitoring (memory, disk usage, CPU performance etc) - requires [Capture](https://github.com/bluewave-labs/capture) +- Infrastructure monitoring (memory, disk usage, CPU performance etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent - Docker monitoring - Ping monitoring - SSL monitoring @@ -130,7 +140,7 @@ If you have any questions, suggestions or comments, please use our [Discord chan We are [Alex](http://github.com/ajhollid) (team lead), [Vishnu](http://github.com/vishnusn77), [Mohadeseh](http://github.com/mohicody), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. -We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 6000+ stars and attracted 80+ contributors from around the globe. +We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe. Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systรจmes and NEC**, so donโ€™t hold back โ€” jump in, contribute and learn with us! diff --git a/charts/helm/checkmate/.helmignore b/charts/helm/checkmate/.helmignore new file mode 100644 index 000000000..21846e965 --- /dev/null +++ b/charts/helm/checkmate/.helmignore @@ -0,0 +1,17 @@ +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/helm/checkmate/Chart.yaml b/charts/helm/checkmate/Chart.yaml new file mode 100644 index 000000000..97f320e68 --- /dev/null +++ b/charts/helm/checkmate/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: checkmate-chart +description: A Helm chart for Checkmate App +type: application +version: 0.1.0 +appVersion: "2.3" diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md new file mode 100644 index 000000000..9ca321ad3 --- /dev/null +++ b/charts/helm/checkmate/INSTALLATION.md @@ -0,0 +1,39 @@ +# Kubernetes Installation Guide for Checkmate + +This guide walks you through deploying Checkmate on your Kubernetes cluster using Helm. + +## Prerequisites + +- A running Kubernetes cluster +- Helm CLI installed and configured +- `kubectl` configured to access your cluster + +## Steps + +### 1. Clone the repo and navigate to the Helm chart + +```bash +git clone https://github.com/bluewave-labs/checkmate.git +cd checkmate/charts/helm/checkmate +``` + +### 2. Customize values.yaml +Edit `values.yaml` to update: +- `client.ingress.host` and `server.ingress.host` with your domain names +- `server.protocol` (usually http or https) +- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) โ€” replace all change_me values + +### 3. Deploy the Helm chart +```bash +helm install checkmate ./charts/helm/checkmate +``` +This will deploy the client, server, MongoDB, and Redis components. + +### 4. Verify the deployment +Check pods and services: +```bash +kubectl get pods +kubectl get svc +``` + +Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts. diff --git a/charts/helm/checkmate/templates/_prechecks.yaml b/charts/helm/checkmate/templates/_prechecks.yaml new file mode 100644 index 000000000..870ab5c77 --- /dev/null +++ b/charts/helm/checkmate/templates/_prechecks.yaml @@ -0,0 +1,12 @@ +{{- if eq .Values.client.ingress.host "change_me" }} + {{- fail "client.ingress.host must be overridden and not set to 'change_me'" }} +{{- end }} + +{{- if eq .Values.server.ingress.host "change_me" }} + {{- fail "server.ingress.host must be overridden and not set to 'change_me'" }} +{{- end }} + +{{- $protocol := .Values.server.protocol }} +{{- if not (or (eq $protocol "http") (eq $protocol "https")) }} + {{- fail "server.protocol must be either 'http' or 'https'" }} +{{- end }} diff --git a/charts/helm/checkmate/templates/client-deployment.yaml b/charts/helm/checkmate/templates/client-deployment.yaml new file mode 100644 index 000000000..6c1f70fd7 --- /dev/null +++ b/charts/helm/checkmate/templates/client-deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: client +spec: + replicas: 1 + selector: + matchLabels: + app: client + template: + metadata: + labels: + app: client + spec: + containers: + - name: client + image: {{ .Values.client.image }} + ports: + - containerPort: {{ .Values.client.port }} + env: + - name: UPTIME_APP_API_BASE_URL + value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1" diff --git a/charts/helm/checkmate/templates/client-ingress.yaml b/charts/helm/checkmate/templates/client-ingress.yaml new file mode 100644 index 000000000..da059a863 --- /dev/null +++ b/charts/helm/checkmate/templates/client-ingress.yaml @@ -0,0 +1,20 @@ +{{- if .Values.client.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: client-ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + rules: + - host: {{ .Values.client.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: client + port: + number: {{ .Values.client.port }} +{{- end }} diff --git a/charts/helm/checkmate/templates/client-service.yaml b/charts/helm/checkmate/templates/client-service.yaml new file mode 100644 index 000000000..afb88188c --- /dev/null +++ b/charts/helm/checkmate/templates/client-service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: client +spec: + selector: + app: client + ports: + - port: {{ .Values.client.port }} diff --git a/charts/helm/checkmate/templates/mongodb-deployment.yaml b/charts/helm/checkmate/templates/mongodb-deployment.yaml new file mode 100644 index 000000000..4c906fe09 --- /dev/null +++ b/charts/helm/checkmate/templates/mongodb-deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mongodb +spec: + replicas: 1 + selector: + matchLabels: + app: mongodb + template: + metadata: + labels: + app: mongodb + spec: + containers: + - name: mongodb + image: {{ .Values.mongodb.image }} + ports: + - containerPort: {{ .Values.mongodb.port }} + command: ["mongod", "--quiet", "--bind_ip_all"] + volumeMounts: + - name: mongodb-data + mountPath: /data/db + volumes: + - name: mongodb-data + persistentVolumeClaim: + claimName: mongodb-pvc \ No newline at end of file diff --git a/charts/helm/checkmate/templates/mongodb-pvc.yaml b/charts/helm/checkmate/templates/mongodb-pvc.yaml new file mode 100644 index 000000000..435b662e6 --- /dev/null +++ b/charts/helm/checkmate/templates/mongodb-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mongodb-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.mongodbSize }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/mongodb-service.yaml b/charts/helm/checkmate/templates/mongodb-service.yaml new file mode 100644 index 000000000..2911dc3eb --- /dev/null +++ b/charts/helm/checkmate/templates/mongodb-service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: mongodb +spec: + selector: + app: mongodb + ports: + - port: {{ .Values.mongodb.port }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/redis-deployment.yaml b/charts/helm/checkmate/templates/redis-deployment.yaml new file mode 100644 index 000000000..cc9ffdaf9 --- /dev/null +++ b/charts/helm/checkmate/templates/redis-deployment.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: {{ .Values.redis.image }} + ports: + - containerPort: {{ .Values.redis.port }} + volumeMounts: + - name: redis-data + mountPath: /data + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: redis-pvc diff --git a/charts/helm/checkmate/templates/redis-pvc.yaml b/charts/helm/checkmate/templates/redis-pvc.yaml new file mode 100644 index 000000000..2f0d81960 --- /dev/null +++ b/charts/helm/checkmate/templates/redis-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: redis-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.redisSize }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/redis-service.yaml b/charts/helm/checkmate/templates/redis-service.yaml new file mode 100644 index 000000000..f29fa46de --- /dev/null +++ b/charts/helm/checkmate/templates/redis-service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: redis +spec: + selector: + app: redis + ports: + - port: {{ .Values.redis.port }} diff --git a/charts/helm/checkmate/templates/secrets.yaml b/charts/helm/checkmate/templates/secrets.yaml new file mode 100644 index 000000000..0a3f1ace5 --- /dev/null +++ b/charts/helm/checkmate/templates/secrets.yaml @@ -0,0 +1,19 @@ +{{- $secrets := .Values.secrets }} + +{{- if or (not $secrets.JWT_SECRET) (eq $secrets.JWT_SECRET "change_me") }} + {{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }} +{{- end }} + +{{- if or (not $secrets.REFRESH_TOKEN_SECRET) (eq $secrets.REFRESH_TOKEN_SECRET "change_me") }} + {{- fail "secrets.REFRESH_TOKEN_SECRET must be overridden and cannot be 'change_me'" }} +{{- end }} + +apiVersion: v1 +kind: Secret +metadata: + name: checkmate-secrets +type: Opaque +stringData: +{{- range $key, $value := $secrets }} + {{ $key }}: {{ $value | quote }} +{{- end }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/server-deployment.yaml b/charts/helm/checkmate/templates/server-deployment.yaml new file mode 100644 index 000000000..6f21f86f3 --- /dev/null +++ b/charts/helm/checkmate/templates/server-deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: server +spec: + replicas: 1 + selector: + matchLabels: + app: server + template: + metadata: + labels: + app: server + spec: + containers: + - name: server + image: {{ .Values.server.image }} + ports: + - containerPort: {{ .Values.server.port }} + envFrom: + - secretRef: + name: checkmate-secrets diff --git a/charts/helm/checkmate/templates/server-ingress.yaml b/charts/helm/checkmate/templates/server-ingress.yaml new file mode 100644 index 000000000..c1982d451 --- /dev/null +++ b/charts/helm/checkmate/templates/server-ingress.yaml @@ -0,0 +1,25 @@ +{{- if .Values.server.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: server-ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" +spec: + rules: + - host: {{ .Values.server.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: server + port: + number: {{ .Values.server.port }} +{{- end }} diff --git a/charts/helm/checkmate/templates/server-service.yaml b/charts/helm/checkmate/templates/server-service.yaml new file mode 100644 index 000000000..64494ff90 --- /dev/null +++ b/charts/helm/checkmate/templates/server-service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: server +spec: + selector: + app: server + ports: + - port: {{ .Values.server.port }} diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml new file mode 100644 index 000000000..79c77a49e --- /dev/null +++ b/charts/helm/checkmate/values.yaml @@ -0,0 +1,41 @@ +client: + image: ghcr.io/bluewave-labs/checkmate-client:v2.3 + port: 80 + ingress: + enabled: true + host: change_me + +server: + image: ghcr.io/bluewave-labs/checkmate-backend:v2.3 + port: 52345 + protocol: change_me + ingress: + enabled: true + host: change_me + +redis: + image: redis:7.2 + port: 6379 + +mongodb: + image: mongo:7.0 + port: 27017 + +secrets: + JWT_SECRET: change_me + REFRESH_TOKEN_SECRET: change_me + SYSTEM_EMAIL_ADDRESS: test@example.com + SYSTEM_EMAIL_PASSWORD: change_me + SYSTEM_EMAIL_HOST: smtp.example.com + SYSTEM_EMAIL_PORT: "587" + PAGESPEED_API_KEY: change_me + DB_CONNECTION_STRING: mongodb://mongodb:27017/uptime_db + REDIS_HOST: redis + REDIS_PORT: "6379" + DB_TYPE: MongoDB + TOKEN_TTL: 99d + REFRESH_TOKEN_TTL: 99d + +persistence: + mongodbSize: 5Gi + redisSize: 1Gi \ No newline at end of file diff --git a/client/src/Components/Charts/CustomGauge/index.jsx b/client/src/Components/Charts/CustomGauge/index.jsx index 3c7e13610..d9718d72c 100644 --- a/client/src/Components/Charts/CustomGauge/index.jsx +++ b/client/src/Components/Charts/CustomGauge/index.jsx @@ -1,8 +1,10 @@ import { useTheme } from "@emotion/react"; import { useEffect, useState, useMemo } from "react"; +import Stack from "@mui/material/Stack"; import { Box, Typography } from "@mui/material"; import PropTypes from "prop-types"; import "./index.css"; +import CircularProgress from "@mui/material/CircularProgress"; const MINIMUM_VALUE = 0; const MAXIMUM_VALUE = 100; @@ -16,6 +18,8 @@ const MAXIMUM_VALUE = 100; * @param {number} [props.radius=60] - Radius of the gauge circle * @param {number} [props.strokeWidth=15] - Width of the gauge stroke * @param {number} [props.threshold=50] - Threshold for color change + * @param {number} [props.precision=1] - Precision of the progress percentage + * @param {string} [props.unit="%"] - Unit of progress * * @example * { +const CustomGauge = ({ + isLoading = false, + progress = 0, + radius = 70, + strokeWidth = 15, + threshold = 50, + precision = 1, + unit = "%", +}) => { const theme = useTheme(); // Calculate the length of the stroke for the circle const { circumference, totalSize, strokeLength } = useMemo( @@ -54,8 +66,22 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold = const fillColor = progressWithinRange > threshold - ? theme.palette.error.lowContrast // CAIO_REVIEW - : theme.palette.accent.main; // CAIO_REVIEW + ? theme.palette.error.lowContrast + : theme.palette.accent.main; + + if (isLoading) { + return ( + + + + ); + } return ( - {`${progressWithinRange.toFixed(1)}%`} + {`${progressWithinRange.toFixed(precision)}${unit}`} ); @@ -112,8 +138,11 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold = export default CustomGauge; CustomGauge.propTypes = { + isLoading: PropTypes.bool, progress: PropTypes.number, radius: PropTypes.number, strokeWidth: PropTypes.number, threshold: PropTypes.number, + precision: PropTypes.number, + unit: PropTypes.string, }; diff --git a/client/src/Components/ConfigBox/index.jsx b/client/src/Components/ConfigBox/index.jsx index 44ae38472..e26e49e8d 100644 --- a/client/src/Components/ConfigBox/index.jsx +++ b/client/src/Components/ConfigBox/index.jsx @@ -4,15 +4,14 @@ const ConfigBox = styled(Stack)(({ theme }) => ({ display: "flex", flexDirection: "row", justifyContent: "space-between", - gap: theme.spacing(20), backgroundColor: theme.palette.primary.main, border: 1, borderStyle: "solid", borderColor: theme.palette.primary.lowContrast, borderRadius: theme.spacing(2), "& > *": { - paddingTop: theme.spacing(12), - paddingBottom: theme.spacing(18), + paddingTop: theme.spacing(15), + paddingBottom: theme.spacing(15), }, "& > div:first-of-type": { flex: 0.7, @@ -25,7 +24,7 @@ const ConfigBox = styled(Stack)(({ theme }) => ({ "& > div:last-of-type": { flex: 1, paddingRight: theme.spacing(20), - paddingLeft: theme.spacing(18), + paddingLeft: theme.spacing(20), }, "& h1, & h2": { color: theme.palette.primary.contrastTextSecondary, diff --git a/client/src/Components/Fallback/FallBackActionButtons.jsx b/client/src/Components/Fallback/FallBackActionButtons.jsx new file mode 100644 index 000000000..fbe8fe807 --- /dev/null +++ b/client/src/Components/Fallback/FallBackActionButtons.jsx @@ -0,0 +1,43 @@ +import { Button, Stack } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; + +const FallbackActionButtons = ({ link, type }) => { + const theme = useTheme(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + return ( + + + {type === "uptimeMonitor" && ( + + )} + + ); +}; +FallbackActionButtons.propTypes = { + title: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, +}; + +export default FallbackActionButtons; diff --git a/client/src/Components/Fallback/FallbackBackground.jsx b/client/src/Components/Fallback/FallbackBackground.jsx new file mode 100644 index 000000000..e01659f49 --- /dev/null +++ b/client/src/Components/Fallback/FallbackBackground.jsx @@ -0,0 +1,31 @@ +import { useTheme } from "@emotion/react"; +import Box from "@mui/material/Box"; +import Skeleton from "../../assets/Images/create-placeholder.svg?react"; +import Background from "../../assets/Images/background-grid.svg?react"; +import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react"; +import { useSelector } from "react-redux"; +const FallbackBackground = () => { + const theme = useTheme(); + const mode = useSelector((state) => state.ui.mode); + return ( + <> + {mode === "light" ? ( + + ) : ( + + )} + + + + + ); +}; + +export default FallbackBackground; diff --git a/client/src/Components/Fallback/FallbackCheckList.jsx b/client/src/Components/Fallback/FallbackCheckList.jsx new file mode 100644 index 000000000..4d13dead4 --- /dev/null +++ b/client/src/Components/Fallback/FallbackCheckList.jsx @@ -0,0 +1,35 @@ +import PropTypes from "prop-types"; +import { useTheme } from "@mui/material/styles"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Check from "../Check/Check"; +const FallbackCheckList = ({ checks, type }) => { + const theme = useTheme(); + return ( + + {checks?.map((check, index) => ( + + ))} + + ); +}; + +FallbackCheckList.propTypes = { + checks: PropTypes.arrayOf(PropTypes.string).isRequired, + title: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, +}; + +export default FallbackCheckList; diff --git a/client/src/Components/Fallback/FallbackContainer.jsx b/client/src/Components/Fallback/FallbackContainer.jsx new file mode 100644 index 000000000..65d927190 --- /dev/null +++ b/client/src/Components/Fallback/FallbackContainer.jsx @@ -0,0 +1,44 @@ +import { useTheme } from "@emotion/react"; +import { Box, Stack } from "@mui/material"; +import PropTypes from "prop-types"; + +const FallbackContainer = ({ children, type }) => { + const theme = useTheme(); + return ( + + + {children} + + + ); +}; + +FallbackContainer.propTypes = { + children: PropTypes.node, + type: PropTypes.string, +}; + +export default FallbackContainer; diff --git a/client/src/Components/Fallback/FallbackPageSpeedWarning.jsx b/client/src/Components/Fallback/FallbackPageSpeedWarning.jsx new file mode 100644 index 000000000..bcec7300d --- /dev/null +++ b/client/src/Components/Fallback/FallbackPageSpeedWarning.jsx @@ -0,0 +1,69 @@ +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import Link from "@mui/material/Link"; +import { Link as RouterLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import Alert from "../Alert"; +import PropTypes from "prop-types"; + +const renderWarningMessage = (t) => { + return ( + <> + {t("pageSpeedWarning")}{" "} + + {t("pageSpeedLearnMoreLink")} + {" "} + {t("pageSpeedAddApiKey")} + + ); +}; + +const FallbackPageSpeedWarning = ({ settingsData }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + svg": { + color: theme.palette.warningSecondary.contrastText, + }, + }, + }} + > + {settingsData?.pagespeedKeySet === false && ( + + )} + + + ); +}; + +FallbackPageSpeedWarning.propTypes = { + settingsData: PropTypes.shape({ + pagespeedKeySet: PropTypes.bool, + }), +}; + +export default FallbackPageSpeedWarning; diff --git a/client/src/Components/Fallback/FallbackTitle.jsx b/client/src/Components/Fallback/FallbackTitle.jsx new file mode 100644 index 000000000..f7f42f2ca --- /dev/null +++ b/client/src/Components/Fallback/FallbackTitle.jsx @@ -0,0 +1,21 @@ +import { useTheme } from "@mui/material/styles"; +import { Typography } from "@mui/material"; +import PropTypes from "prop-types"; + +const FallbackTitle = ({ title }) => { + const theme = useTheme(); + return ( + + {title} + + ); +}; +FallbackTitle.propTypes = { + title: PropTypes.string.isRequired, +}; +export default FallbackTitle; diff --git a/client/src/Components/Fallback/index.css b/client/src/Components/Fallback/index.css index 357988b07..6988fa5bf 100644 --- a/client/src/Components/Fallback/index.css +++ b/client/src/Components/Fallback/index.css @@ -38,7 +38,3 @@ background-size: cover; background-repeat: no-repeat; } - -.fallback__status > .MuiStack-root { - margin-left: var(--env-var-spacing-2); -} diff --git a/client/src/Components/Fallback/index.jsx b/client/src/Components/Fallback/index.jsx index dd6043697..5fbb958e7 100644 --- a/client/src/Components/Fallback/index.jsx +++ b/client/src/Components/Fallback/index.jsx @@ -1,72 +1,26 @@ import PropTypes from "prop-types"; import { useTheme } from "@emotion/react"; -import { Box, Button, Stack, Typography, Link } from "@mui/material"; -import { Link as RouterLink } from "react-router-dom"; -import Skeleton from "../../assets/Images/create-placeholder.svg?react"; -import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react"; -import Background from "../../assets/Images/background-grid.svg?react"; -import Check from "../Check/Check"; -import { useNavigate } from "react-router-dom"; -import { useSelector } from "react-redux"; -import Alert from "../Alert"; -import { useTranslation } from "react-i18next"; +import { Box, Stack } from "@mui/material"; import "./index.css"; -import { useFetchSettings } from "../../Hooks/settingsHooks"; -import { useState } from "react"; +import FallbackTitle from "./FallbackTitle"; +import FallbackCheckList from "./FallbackCheckList"; +import FallbackActionButtons from "./FallBackActionButtons"; +import FallbackBackground from "./FallbackBackground"; +import FallbackContainer from "./FallbackContainer"; /** * Fallback component to display a fallback UI with a title, a list of checks, and a navigation button. * * @param {Object} props - The component props. * @param {string} props.title - The title to be displayed in the fallback UI. + * @param {string} props.type - The type of the fallback (e.g., "pageSpeed", "notifications"). * @param {Array} props.checks - An array of strings representing the checks to display. * @param {string} [props.link="/"] - The link to navigate to. - * @param {boolean} [props.vowelStart=false] - Whether the title starts with a vowel. * @param {boolean} [props.showPageSpeedWarning=false] - Whether to show the PageSpeed API warning. * @returns {JSX.Element} The rendered fallback UI. */ -const Fallback = ({ - title, - checks, - link = "/", - isAdmin, - vowelStart = false, - showPageSpeedWarning = false, -}) => { +const Fallback = ({ title, checks, link = "/", isAdmin, type, children }) => { const theme = useTheme(); - const navigate = useNavigate(); - const mode = useSelector((state) => state.ui.mode); - const { t } = useTranslation(); - const [settingsData, setSettingsData] = useState(undefined); - - const [isLoading, error] = useFetchSettings({ - setSettingsData, - setIsApiKeySet: () => {}, - setIsEmailPasswordSet: () => {}, - }); - // Custom warning message with clickable link - const renderWarningMessage = () => { - return ( - <> - {t("pageSpeedWarning")}{" "} - - {t("pageSpeedLearnMoreLink")} - {" "} - {t("pageSpeedAddApiKey")} - - ); - }; return ( - + + - {mode === "light" ? ( - - ) : ( - - )} - - - - - - {vowelStart ? "An" : "A"} {title} is used to: - - {checks?.map((check, index) => ( - - ))} - - {/* TODO - display a different fallback if user is not an admin*/} - {isAdmin && ( - - - {/* Bulk create of uptime monitors */} - {title === "uptime monitor" && ( - - )} - - {/* Warning box for PageSpeed monitor */} - {title === "pagespeed monitor" && showPageSpeedWarning && ( - - svg": { - color: theme.palette.warningSecondary.contrastText, - }, - }, - }} - > - {settingsData?.pagespeedKeySet === false && ( - - )} - - - )} - - )} + + - + {isAdmin && ( + + + {children} + + )} + ); }; - Fallback.propTypes = { title: PropTypes.string.isRequired, checks: PropTypes.arrayOf(PropTypes.string).isRequired, link: PropTypes.string, isAdmin: PropTypes.bool, - vowelStart: PropTypes.bool, showPageSpeedWarning: PropTypes.bool, + type: PropTypes.string.isRequired, + children: PropTypes.node, }; - export default Fallback; diff --git a/client/src/Components/Inputs/FieldWrapper/index.jsx b/client/src/Components/Inputs/FieldWrapper/index.jsx new file mode 100644 index 000000000..cc5061af8 --- /dev/null +++ b/client/src/Components/Inputs/FieldWrapper/index.jsx @@ -0,0 +1,51 @@ +import { Stack, Typography } from "@mui/material"; +import PropTypes from "prop-types"; +import { useTheme } from "@emotion/react"; + +const DEFAULT_GAP = 6; +const FieldWrapper = ({ + label, + children, + gap, + labelMb, + labelFontWeight = 500, + labelVariant = "h3", + labelSx = {}, + sx = {}, +}) => { + const theme = useTheme(); + return ( + + {label && ( + + {label} + + )} + {children} + + ); +}; + +FieldWrapper.propTypes = { + label: PropTypes.node, + children: PropTypes.node.isRequired, + gap: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.object]), + labelMb: PropTypes.number, + labelFontWeight: PropTypes.number, + labelVariant: PropTypes.string, + labelSx: PropTypes.object, + sx: PropTypes.object, +}; + +export default FieldWrapper; diff --git a/client/src/Components/Inputs/Radio/index.jsx b/client/src/Components/Inputs/Radio/index.jsx index 646b38e93..4c687de1e 100644 --- a/client/src/Components/Inputs/Radio/index.jsx +++ b/client/src/Components/Inputs/Radio/index.jsx @@ -24,7 +24,17 @@ import "./index.css"; * @returns {JSX.Element} - The rendered Radio component. */ -const Radio = ({ name, checked, value, id, size, title, desc, onChange }) => { +const Radio = ({ + name, + checked, + value, + id, + size, + title, + desc, + onChange, + labelSpacing, +}) => { const theme = useTheme(); return ( @@ -53,7 +63,14 @@ const Radio = ({ name, checked, value, id, size, title, desc, onChange }) => { onChange={onChange} label={ <> - {title} + + {title} + { ); }; -//TODO keep search state inside of component +//TODO keep search state inside of component. const Search = ({ label, id, @@ -68,6 +70,14 @@ const Search = ({ startAdornment, endAdornment, onBlur, + //FieldWrapper's props + gap, + labelMb, + labelFontWeight, + labelVariant, + labelSx = {}, + unit = "option", + maxWidth = "100%", }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -139,15 +149,17 @@ const Search = ({ getOptionLabel={(option) => option[filteredBy]} isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier renderInput={(params) => ( - - - {label} - + )} - + )} filterOptions={(options, { inputValue }) => { if (inputValue.trim() === "" && multiple && isAdorned) { @@ -186,7 +198,12 @@ const Search = ({ ); if (filtered.length === 0) { - return [{ [filteredBy]: "No monitors found", noOptions: true }]; + return [ + { + [filteredBy]: t("general.noOptionsFound", { unit: unit }), + noOptions: true, + }, + ]; } return filtered; }} @@ -281,7 +298,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, @@ -292,6 +309,7 @@ Search.propTypes = { startAdornment: PropTypes.object, endAdornment: PropTypes.object, onBlur: PropTypes.func, + unit: PropTypes.string, }; export default Search; diff --git a/client/src/Components/Inputs/Select/index.jsx b/client/src/Components/Inputs/Select/index.jsx index 679a5b3b5..a438b6bb2 100644 --- a/client/src/Components/Inputs/Select/index.jsx +++ b/client/src/Components/Inputs/Select/index.jsx @@ -2,6 +2,7 @@ import PropTypes from "prop-types"; import { useTheme } from "@emotion/react"; import { MenuItem, Select as MuiSelect, Stack, Typography } from "@mui/material"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import FieldWrapper from "../FieldWrapper"; import "./index.css"; @@ -50,8 +51,14 @@ const Select = ({ onBlur, sx, name = "", - labelControlSpacing = 2, + labelControlSpacing = 6, maxWidth, + //FieldWrapper's props + labelMb, + labelFontWeight, + labelVariant, + labelSx = {}, + fieldWrapperSx = {}, }) => { const theme = useTheme(); const itemStyles = { @@ -69,20 +76,17 @@ const Select = ({ }; return ( - - {label && ( - - {label} - - )} { @@ -151,7 +162,7 @@ const Select = ({ ))} - + ); }; diff --git a/client/src/Components/Inputs/TextInput/index.jsx b/client/src/Components/Inputs/TextInput/index.jsx index bb2df5d21..469791704 100644 --- a/client/src/Components/Inputs/TextInput/index.jsx +++ b/client/src/Components/Inputs/TextInput/index.jsx @@ -2,6 +2,7 @@ import { Stack, TextField, Typography } from "@mui/material"; import { useTheme } from "@emotion/react"; import { forwardRef, useState, cloneElement } from "react"; import PropTypes from "prop-types"; +import FieldWrapper from "../FieldWrapper"; const getSx = (theme, type, maxWidth) => { const sx = { @@ -85,31 +86,43 @@ const TextInput = forwardRef( marginLeft, disabled = false, hidden = false, + //FieldWrapper's props + gap, + labelMb, + labelFontWeight, + labelVariant, + labelSx = {}, + sx = {}, }, ref ) => { const [fieldType, setFieldType] = useState(type); const theme = useTheme(); + const labelContent = label && ( + <> + {label} + {isRequired && } + {isOptional && } + + ); return ( - + ); } ); diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 284d0db05..56a9a898d 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,6 +6,13 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; +const langMap = { + cs: "cz", + ja: "jp", + uk: "ua", + vi: "vn", +}; + const LanguageSelector = () => { const { i18n } = useTranslation(); const theme = useTheme(); @@ -28,14 +35,12 @@ const LanguageSelector = () => { {languages.map((lang) => { let parsedLang = lang === "en" ? "gb" : lang; - // Fix for Czech - if (parsedLang === "cs") { - parsedLang = "cz"; - } if (parsedLang.includes("-")) { parsedLang = parsedLang.split("-")[1].toLowerCase(); } + parsedLang = langMap[parsedLang] || parsedLang; + const flag = parsedLang ? `fi fi-${parsedLang}` : null; return ( diff --git a/client/src/Components/NotificationConfig/index.jsx b/client/src/Components/NotificationConfig/index.jsx index d3707eae4..c37889eff 100644 --- a/client/src/Components/NotificationConfig/index.jsx +++ b/client/src/Components/NotificationConfig/index.jsx @@ -10,7 +10,18 @@ import { useState, useEffect } from "react"; import { useTheme } from "@mui/material/styles"; import PropTypes from "prop-types"; -const NotificationConfig = ({ notifications, setMonitor, setNotifications }) => { +const NotificationConfig = ({ + notifications, + setMonitor, + setNotifications, + //FieldWrapper's props + gap, + labelMb, + labelFontWeight, + labelVariant, + labelSx = {}, + sx = {}, +}) => { // Local state const [notificationsSearch, setNotificationsSearch] = useState(""); const [selectedNotifications, setSelectedNotifications] = useState([]); @@ -66,6 +77,14 @@ const NotificationConfig = ({ notifications, setMonitor, setNotifications }) => handleChange={(value) => { handleSearch(value); }} + labelMb={labelMb} + labelVariant={labelVariant} + labelFontWeight={labelFontWeight} + labelSx={labelSx} + gap={gap} + sx={{ + ...sx, + }} /> { return ( { + const theme = useTheme(); + + if (Array.isArray(body)) { + return ( + + {body.map((item, idx) => ( + + {item} + + ))} + + ); + } else if (typeof body === "string") { + return {body}; + } + + return null; +}; + +ToastBody.propTypes = { + body: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), +}; + +export default ToastBody; diff --git a/client/src/Components/Toast/index.jsx b/client/src/Components/Toast/index.jsx new file mode 100644 index 000000000..36df0e631 --- /dev/null +++ b/client/src/Components/Toast/index.jsx @@ -0,0 +1,96 @@ +import Stack from "@mui/material/Stack"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import ToastBody from "./body"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined"; +import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined"; +import CloseIcon from "@mui/icons-material/Close"; + +// Utils +import { useTheme } from "@emotion/react"; +import PropTypes from "prop-types"; + +const icons = { + info: , + error: , + warning: , +}; + +const Toast = ({ variant, title, body, onClick, hasDismiss, hasIcon }) => { + const theme = useTheme(); + const icon = icons[variant]; + + return ( + + + {hasIcon && icon} + {title && ( + + {title} + + )} + {title && ( + + + + )} + + + + + {!title && ( + + + + )} + + {hasDismiss && ( + + )} + + ); +}; + +export default Toast; + +Toast.propTypes = { + variant: PropTypes.string.isRequired, + title: PropTypes.string, + body: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + hasDismiss: PropTypes.bool, + hasIcon: PropTypes.bool, + onClick: PropTypes.func, +}; diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js index 2fde49202..5ee31944a 100644 --- a/client/src/Features/Auth/authSlice.js +++ b/client/src/Features/Auth/authSlice.js @@ -44,7 +44,6 @@ export const login = createAsyncThunk("auth/login", async (form, thunkApi) => { export const update = createAsyncThunk("auth/update", async (data, thunkApi) => { const { localData: form } = data; - const user = thunkApi.getState().auth.user; try { const fd = new FormData(); form.firstName && fd.append("firstName", form.firstName); @@ -60,7 +59,6 @@ export const update = createAsyncThunk("auth/update", async (data, thunkApi) => form.deleteProfileImage && fd.append("deleteProfileImage", form.deleteProfileImage); const res = await networkService.updateUser({ - userId: user._id, form: fd, }); return res.data; @@ -77,10 +75,8 @@ export const update = createAsyncThunk("auth/update", async (data, thunkApi) => }); export const deleteUser = createAsyncThunk("auth/delete", async (_, thunkApi) => { - const user = thunkApi.getState().auth.user; - try { - const res = await networkService.deleteUser({ userId: user._id }); + const res = await networkService.deleteUser(); return res.data; } catch (error) { if (error.response && error.response.data) { diff --git a/client/src/Hooks/logHooks.js b/client/src/Hooks/logHooks.js index 000e4d0f0..b954efb4b 100644 --- a/client/src/Hooks/logHooks.js +++ b/client/src/Hooks/logHooks.js @@ -32,4 +32,83 @@ const useFetchLogs = () => { return [logs, isLoading, error]; }; -export { useFetchLogs }; +const useFetchQueueData = (trigger) => { + const [jobs, setJobs] = useState(undefined); + const [metrics, setMetrics] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + + useEffect(() => { + const fetchJobs = async () => { + try { + setIsLoading(true); + const response = await networkService.getQueueData(); + if (response.status === 200) { + setJobs(response.data.data.jobs); + setMetrics(response.data.data.metrics); + } + } catch (error) { + setError(error); + } finally { + setIsLoading(false); + } + }; + + fetchJobs(); + }, [trigger]); + + return [jobs, metrics, isLoading, error]; +}; + +const useFlushQueue = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + + const flushQueue = async (trigger, setTrigger) => { + try { + setIsLoading(true); + await networkService.flushQueue(); + createToast({ + body: "Queue flushed", + }); + } catch (error) { + setError(error); + createToast({ + body: error.message, + }); + } finally { + setIsLoading(false); + setTrigger(!trigger); + } + }; + return [flushQueue, isLoading, error]; +}; + +const useFetchDiagnostics = () => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const [diagnostics, setDiagnostics] = useState(undefined); + + const fetchDiagnostics = async () => { + try { + setIsLoading(true); + const response = await networkService.getDiagnostics(); + setDiagnostics(response.data.data); + } catch (error) { + createToast({ + body: error.message, + }); + setError(error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchDiagnostics(); + }, []); + + return [diagnostics, fetchDiagnostics, isLoading, error]; +}; + +export { useFetchLogs, useFetchQueueData, useFlushQueue, useFetchDiagnostics }; diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index d59d88c74..4f0e9c049 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -205,6 +205,10 @@ const useFetchStatsByMonitorId = ({ const useFetchMonitorById = ({ monitorId, setMonitor, updateTrigger }) => { const [isLoading, setIsLoading] = useState(true); useEffect(() => { + if (typeof monitorId === "undefined") { + setIsLoading(false); + return; + } const fetchMonitor = async () => { try { setIsLoading(true); diff --git a/client/src/Hooks/queueHooks.js b/client/src/Hooks/queueHooks.js deleted file mode 100644 index f7a25b3d3..000000000 --- a/client/src/Hooks/queueHooks.js +++ /dev/null @@ -1,57 +0,0 @@ -import { useState, useEffect } from "react"; -import { networkService } from "../main"; -import { createToast } from "../Utils/toastUtils"; - -const useFetchQueueData = (trigger) => { - const [jobs, setJobs] = useState(undefined); - const [metrics, setMetrics] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - - useEffect(() => { - const fetchJobs = async () => { - try { - setIsLoading(true); - const response = await networkService.getQueueData(); - if (response.status === 200) { - setJobs(response.data.data.jobs); - setMetrics(response.data.data.metrics); - } - } catch (error) { - setError(error); - } finally { - setIsLoading(false); - } - }; - - fetchJobs(); - }, [trigger]); - - return [jobs, metrics, isLoading, error]; -}; - -const useFlushQueue = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - - const flushQueue = async (trigger, setTrigger) => { - try { - setIsLoading(true); - await networkService.flushQueue(); - createToast({ - body: "Queue flushed", - }); - } catch (error) { - setError(error); - createToast({ - body: error.message, - }); - } finally { - setIsLoading(false); - setTrigger(!trigger); - } - }; - return [flushQueue, isLoading, error]; -}; - -export { useFetchQueueData, useFlushQueue }; diff --git a/client/src/Hooks/useIsAdmin.js b/client/src/Hooks/useIsAdmin.js index 7d593b874..8abf78ac9 100644 --- a/client/src/Hooks/useIsAdmin.js +++ b/client/src/Hooks/useIsAdmin.js @@ -8,4 +8,10 @@ const useIsAdmin = () => { return isAdmin; }; -export { useIsAdmin }; +const useIsSuperAdmin = () => { + const { user } = useSelector((state) => state.auth); + const isSuperAdmin = user?.role?.includes("superadmin"); + return isSuperAdmin; +}; + +export { useIsAdmin, useIsSuperAdmin }; diff --git a/client/src/Hooks/userHooks.js b/client/src/Hooks/userHooks.js new file mode 100644 index 000000000..8be32a082 --- /dev/null +++ b/client/src/Hooks/userHooks.js @@ -0,0 +1,61 @@ +import { useEffect, useState, useCallback } from "react"; +import { networkService } from "../main"; +import { createToast } from "../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; + +export const useGetUser = (userId) => { + const [user, setUser] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchUser = useCallback(async () => { + try { + setIsLoading(true); + const response = await networkService.getUserById({ userId }); + setUser(response?.data?.data); + } catch (error) { + createToast({ + body: error.message, + }); + setError(error); + } finally { + setIsLoading(false); + } + }, [userId]); + + useEffect(() => { + if (userId) { + fetchUser(); + } + }, [userId, fetchUser]); + + return [user, isLoading, error]; +}; + +export const useEditUser = (userId) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { t } = useTranslation(); + + const editUser = useCallback( + async (user) => { + try { + setIsLoading(true); + await networkService.editUser({ userId, user }); + createToast({ + body: t("editUserPage.toast.successUserUpdate"), + }); + } catch (error) { + createToast({ + body: error.message, + }); + setError(error); + } finally { + setIsLoading(false); + } + }, + [userId] + ); + + return [editUser, isLoading, error]; +}; diff --git a/client/src/Pages/Account/EditUser/hooks/editUser.js b/client/src/Pages/Account/EditUser/hooks/editUser.js new file mode 100644 index 000000000..2ffa914a0 --- /dev/null +++ b/client/src/Pages/Account/EditUser/hooks/editUser.js @@ -0,0 +1,102 @@ +import { useState, useEffect } from "react"; +import { ROLES } from "../../../../Utils/roleUtils"; +import { editUserValidation } from "../../../../Validation/validation"; +import Joi from "joi"; +import { createToast } from "../../../../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; +export const useEditUserForm = (user) => { + const [searchInput, setSearchInput] = useState(""); + const [form, setForm] = useState({ + firstName: "", + lastName: "", + email: "", + role: [], + }); + + // Effect to set user + useEffect(() => { + if (user) { + setForm({ + firstName: user?.firstName, + lastName: user?.lastName, + email: user?.email, + role: user?.role, + }); + } + }, [user]); + + const handleRoleChange = (value) => { + const hasSuperAdmin = form.role.includes(ROLES.SUPERADMIN); + const newRoles = value.map((item) => item.role); + if (hasSuperAdmin && !newRoles.includes(ROLES.SUPERADMIN)) { + newRoles.push(ROLES.SUPERADMIN); + } + + setForm({ + ...form, + role: newRoles, + }); + }; + + const handleDeleteRole = (role) => { + if (role === ROLES.SUPERADMIN) return; + setForm({ ...form, role: form?.role?.filter((r) => r !== role) }); + }; + + const handleSearchInput = (value) => { + setSearchInput(value); + }; + + return [ + form, + setForm, + handleRoleChange, + handleDeleteRole, + searchInput, + handleSearchInput, + ]; +}; + +export const useValidateEditUserForm = () => { + const [errors, setErrors] = useState({}); + const { t } = useTranslation(); + const validateForm = (form) => { + const { error } = editUserValidation.validate(form, { + abortEarly: false, + }); + const errs = error?.details; + const errsObj = errs?.reduce((acc, curr) => { + acc[curr.path[0]] = curr.message; + return acc; + }, {}); + setErrors(errsObj); + + if (errs?.length > 0) { + createToast({ + variant: "warning", + hasIcon: true, + title: t("editUserPage.toast.validationErrors"), + body: errs.map((err) => t(err.message)), + }); + return false; + } + return true; + }; + + const validateField = (name, value) => { + const fieldSchema = Joi.object({ + [name]: editUserValidation.extract(name), + }); + + const { error } = fieldSchema.validate({ [name]: value }); + + setErrors((prev) => { + const prevErrors = { ...prev }; + if (error) prevErrors[name] = error.details[0].message; + else delete prevErrors[name]; + return prevErrors; + }); + }; + + return [errors, validateForm, validateField]; +}; diff --git a/client/src/Pages/Account/EditUser/index.jsx b/client/src/Pages/Account/EditUser/index.jsx new file mode 100644 index 000000000..e1eeead9d --- /dev/null +++ b/client/src/Pages/Account/EditUser/index.jsx @@ -0,0 +1,122 @@ +// Components +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +import TextInput from "../../../Components/Inputs/TextInput"; +import Search from "../../../Components/Inputs/Search"; +import Button from "@mui/material/Button"; +import RoleTable from "../components/RoleTable"; + +// Utils +import { useParams } from "react-router-dom"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { useGetUser, useEditUser } from "../../../Hooks/userHooks"; +import { EDITABLE_ROLES, ROLES } from "../../../Utils/roleUtils"; +import { useEditUserForm, useValidateEditUserForm } from "./hooks/editUser"; + +const EditUser = () => { + const { userId } = useParams(); + const theme = useTheme(); + const { t } = useTranslation(); + const BREADCRUMBS = [ + { name: t("menu.team"), path: "/account/team" }, + { name: t("editUserPage.title"), path: "" }, + ]; + + const [user, isLoading, error] = useGetUser(userId); + const [editUser, isSaving, saveError] = useEditUser(userId); + const [ + form, + setForm, + handleRoleChange, + handleDeleteRole, + searchInput, + handleSearchInput, + ] = useEditUserForm(user); + const [errors, validateForm, validateField] = useValidateEditUserForm(); + + const onChange = (e) => { + const name = e.target.name; + const value = e.target.value; + validateField(name, value); + setForm({ ...form, [name]: value }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const valid = validateForm(form); + if (valid) { + editUser(form); + } + }; + + return ( + + + {t("editUserPage.title")} + + + + + role !== ROLES.SUPERADMIN) + .map((role) => ({ role, _id: role })) || [] + } + options={EDITABLE_ROLES} + multiple={true} + handleChange={handleRoleChange} + /> + + + + + + + ); +}; + +export default EditUser; diff --git a/client/src/Components/TabPanels/Account/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx similarity index 97% rename from client/src/Components/TabPanels/Account/PasswordPanel.jsx rename to client/src/Pages/Account/components/PasswordPanel.jsx index 81ce93946..dabd67706 100644 --- a/client/src/Components/TabPanels/Account/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -2,10 +2,10 @@ import TabPanel from "@mui/lab/TabPanel"; import { useState } from "react"; import { useTheme } from "@emotion/react"; import { Box, Stack, Typography, Button } from "@mui/material"; -import { PasswordEndAdornment } from "../../Inputs/TextInput/Adornments"; -import TextInput from "../../Inputs/TextInput"; +import { PasswordEndAdornment } from "../../../Components/Inputs/TextInput/Adornments"; +import TextInput from "../../../Components/Inputs/TextInput"; import { newOrChangedCredentials } from "../../../Validation/validation"; -import Alert from "../../Alert"; +import Alert from "../../../Components/Alert"; import { update } from "../../../Features/Auth/authSlice"; import { useDispatch, useSelector } from "react-redux"; import { createToast } from "../../../Utils/toastUtils"; diff --git a/client/src/Components/TabPanels/Account/ProfilePanel.jsx b/client/src/Pages/Account/components/ProfilePanel.jsx similarity index 97% rename from client/src/Components/TabPanels/Account/ProfilePanel.jsx rename to client/src/Pages/Account/components/ProfilePanel.jsx index 9b209bf3e..7c73e7aa3 100644 --- a/client/src/Components/TabPanels/Account/ProfilePanel.jsx +++ b/client/src/Pages/Account/components/ProfilePanel.jsx @@ -2,16 +2,16 @@ import { useTheme } from "@emotion/react"; import { useState } from "react"; import TabPanel from "@mui/lab/TabPanel"; import { Box, Button, Divider, Stack, Typography } from "@mui/material"; -import Avatar from "../../Avatar"; -import TextInput from "../../Inputs/TextInput"; -import ImageUpload from "../../Inputs/ImageUpload"; +import Avatar from "../../../Components/Avatar"; +import TextInput from "../../../Components/Inputs/TextInput"; +import ImageUpload from "../../../Components/Inputs/ImageUpload"; import { newOrChangedCredentials } from "../../../Validation/validation"; import { useDispatch, useSelector } from "react-redux"; import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice"; import { createToast } from "../../../Utils/toastUtils"; import { logger } from "../../../Utils/Logger"; -import { GenericDialog } from "../../Dialog/genericDialog"; -import Dialog from "../../Dialog"; +import { GenericDialog } from "../../../Components/Dialog/genericDialog"; +import Dialog from "../../../Components/Dialog"; import { useTranslation } from "react-i18next"; /** @@ -191,7 +191,6 @@ const ProfilePanel = () => { > { + const { t } = useTranslation(); + const HEADERS = [ + { + id: "name", + content: {t("editUserPage.table.roleHeader")}, + render: (row) => { + return row; + }, + }, + { + id: "delete", + content: {t("editUserPage.table.actionHeader")}, + render: (row) => { + if (row === ROLES.SUPERADMIN) return null; + return ( + { + handleDeleteRole(row); + }} + sx={{ cursor: "pointer" }} + /> + ); + }, + }, + ]; + return ( + + ); +}; + +export default RoleTable; diff --git a/client/src/Components/TabPanels/Account/TeamPanel.jsx b/client/src/Pages/Account/components/TeamPanel.jsx similarity index 92% rename from client/src/Components/TabPanels/Account/TeamPanel.jsx rename to client/src/Pages/Account/components/TeamPanel.jsx index 45578cc6c..95ece219f 100644 --- a/client/src/Components/TabPanels/Account/TeamPanel.jsx +++ b/client/src/Pages/Account/components/TeamPanel.jsx @@ -3,14 +3,16 @@ import TabPanel from "@mui/lab/TabPanel"; import { Button, ButtonGroup, Stack, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import TextInput from "../../Inputs/TextInput"; +import TextInput from "../../../Components/Inputs/TextInput"; import { newOrChangedCredentials } from "../../../Validation/validation"; import { networkService } from "../../../main"; import { createToast } from "../../../Utils/toastUtils"; -import Select from "../../Inputs/Select"; -import { GenericDialog } from "../../Dialog/genericDialog"; -import DataTable from "../../Table/"; +import Select from "../../../Components/Inputs/Select"; +import { GenericDialog } from "../../../Components/Dialog/genericDialog"; +import DataTable from "../../../Components/Table"; import { useGetInviteToken } from "../../../Hooks/inviteHooks"; +import { useNavigate } from "react-router-dom"; +import { useIsSuperAdmin } from "../../../Hooks/useIsAdmin"; /** * TeamPanel component manages the organization and team members, * providing functionalities like renaming the organization, managing team members, @@ -34,8 +36,9 @@ const TeamPanel = () => { const [isDisabled, setIsDisabled] = useState(true); const [errors, setErrors] = useState({}); const [isSendingInvite, setIsSendingInvite] = useState(false); - + const navigate = useNavigate(); const [getInviteToken, clearToken, isLoading, error, token] = useGetInviteToken(); + const isSuperAdmin = useIsSuperAdmin(); const headers = [ { @@ -184,7 +187,6 @@ const TeamPanel = () => { return ( { { + if (isSuperAdmin) { + navigate(`/account/team/${row.id}`); + } + }, + }} /> diff --git a/client/src/Pages/Account/index.css b/client/src/Pages/Account/index.css deleted file mode 100644 index 41e25fffa..000000000 --- a/client/src/Pages/Account/index.css +++ /dev/null @@ -1,38 +0,0 @@ -.account h1.MuiTypography-root, -.account p.MuiTypography-root, -.account input, -.account button, -.account td, -.account .MuiSelect-select { - font-size: var(--env-var-font-size-medium); -} -.account h1.MuiTypography-root { - font-weight: 600; -} -.account .MuiTabPanel-root { - padding: 0; - margin-top: 50px; -} -.account button:not(.MuiIconButton-root) { - min-height: 34px; -} -.account .field { - flex: 1; -} -#modal-delete-account, -#modal-edit-org-name, -#modal-invite-member { - font-size: var(--env-var-font-size-large); -} - -.account .MuiStack-root:has(span.MuiTypography-root.input-error) { - position: relative; -} -.account:not(:has(#modal-invite-member)) span.MuiTypography-root.input-error { - position: absolute; - top: 100%; -} - -.account .MuiTableBody-root .MuiTableCell-root { - padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2); -} diff --git a/client/src/Pages/Account/index.jsx b/client/src/Pages/Account/index.jsx index d8672f51c..37c64aca6 100644 --- a/client/src/Pages/Account/index.jsx +++ b/client/src/Pages/Account/index.jsx @@ -5,11 +5,10 @@ import { useSelector } from "react-redux"; import { Box, Tab, useTheme } from "@mui/material"; import CustomTabList from "../../Components/Tab"; import TabContext from "@mui/lab/TabContext"; -import ProfilePanel from "../../Components/TabPanels/Account/ProfilePanel"; -import PasswordPanel from "../../Components/TabPanels/Account/PasswordPanel"; -import TeamPanel from "../../Components/TabPanels/Account/TeamPanel"; +import ProfilePanel from "./components/ProfilePanel"; +import PasswordPanel from "./components/PasswordPanel"; +import TeamPanel from "./components/TeamPanel"; import { useTranslation } from "react-i18next"; -import "./index.css"; /** * Account component renders a settings page with tabs for Profile, Password, and Team settings. @@ -33,7 +32,9 @@ const Account = ({ open = "profile" }) => { { name: t("menu.password"), value: "password" }, { name: t("menu.team"), value: "team" }, ]; + const hideTeams = !requiredRoles.some((role) => user.role.includes(role)); + if (hideTeams) { tabList = [ { name: t("menu.profile"), value: "profile" }, @@ -63,7 +64,6 @@ const Account = ({ open = "profile" }) => { return ( diff --git a/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx b/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx index d91e899c5..865698f44 100644 --- a/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx +++ b/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx @@ -62,14 +62,15 @@ export const CustomThreshold = ({ const theme = useTheme(); return ( - + { /> - + { disabled={!isCreate} /> {isCreate && ( - - {t("infrastructureProtocol")} + - + )} { +const Gauges = ({ isLoading = false, monitor }) => { const { decimalToPercentage, formatBytes } = useHardwareUtils(); const theme = useTheme(); const { t } = useTranslation(); - if (!shouldRender) { + if (isLoading) { return ; } @@ -60,6 +61,7 @@ const Gauges = ({ shouldRender, monitor }) => { return ( {gauges.map((gauge) => { @@ -79,4 +81,9 @@ const Gauges = ({ shouldRender, monitor }) => { ); }; +Gauges.propTypes = { + isLoading: PropTypes.bool, + monitor: PropTypes.object, +}; + export default Gauges; diff --git a/client/src/Pages/Infrastructure/Details/index.jsx b/client/src/Pages/Infrastructure/Details/index.jsx index b57e0b91e..30840851f 100644 --- a/client/src/Pages/Infrastructure/Details/index.jsx +++ b/client/src/Pages/Infrastructure/Details/index.jsx @@ -92,11 +92,11 @@ const InfrastructureDetails = () => { monitor={monitor} /> diff --git a/client/src/Pages/Infrastructure/Monitors/index.jsx b/client/src/Pages/Infrastructure/Monitors/index.jsx index e0f5c642b..f7ca892c2 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.jsx +++ b/client/src/Pages/Infrastructure/Monitors/index.jsx @@ -11,7 +11,7 @@ import Filter from "./Components/Filters"; import SearchComponent from "../../Uptime/Monitors/Components/SearchComponent"; // Utils import { useTheme } from "@emotion/react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useIsAdmin } from "../../../Hooks/useIsAdmin"; import { useTranslation } from "react-i18next"; import { useFetchMonitorsByTeamId } from "../../../Hooks/monitorHooks"; @@ -58,6 +58,12 @@ const InfrastructureMonitors = () => { setPage(0); }; + useEffect(() => { + if (isSearching) { + setPage(0); + } + }, [isSearching]); + const handleReset = () => { setSelectedStatus(undefined); setToFilterStatus(undefined); @@ -93,13 +99,9 @@ const InfrastructureMonitors = () => { if (!isLoading && typeof summary?.totalMonitors === "undefined") { return ( diff --git a/client/src/Pages/Logs/Diagnostics/components/gauges/index.jsx b/client/src/Pages/Logs/Diagnostics/components/gauges/index.jsx new file mode 100644 index 000000000..e9f235112 --- /dev/null +++ b/client/src/Pages/Logs/Diagnostics/components/gauges/index.jsx @@ -0,0 +1,109 @@ +import Stack from "@mui/material/Stack"; +import Gauge from "../../../../../Components/Charts/CustomGauge"; +import Typography from "@mui/material/Typography"; + +// Utils +import { useTheme } from "@emotion/react"; +import PropTypes from "prop-types"; +import { getPercentage } from "../../utils/utils"; +import { useTranslation } from "react-i18next"; + +const GaugeBox = ({ title, subtitle, children }) => { + const theme = useTheme(); + return ( + + {children} + + {title} + {subtitle} + + ); +}; + +GaugeBox.propTypes = { + title: PropTypes.string.isRequired, + subtitle: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +const Gauges = ({ diagnostics, isLoading }) => { + const heapTotalSize = getPercentage( + diagnostics?.v8HeapStats?.totalHeapSizeBytes, + diagnostics?.v8HeapStats?.heapSizeLimitBytes + ); + + const heapUsedSize = getPercentage( + diagnostics?.v8HeapStats?.usedHeapSizeBytes, + diagnostics?.v8HeapStats?.heapSizeLimitBytes + ); + + const actualHeapUsed = getPercentage( + diagnostics?.v8HeapStats?.usedHeapSizeBytes, + diagnostics?.v8HeapStats?.totalHeapSizeBytes + ); + + const theme = useTheme(); + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + ); +}; + +Gauges.propTypes = { + diagnostics: PropTypes.object, + isLoading: PropTypes.bool, +}; + +export default Gauges; diff --git a/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx b/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx new file mode 100644 index 000000000..103d1c698 --- /dev/null +++ b/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx @@ -0,0 +1,97 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import CircularProgress from "@mui/material/CircularProgress"; + +import { useTheme } from "@emotion/react"; +import PropTypes from "prop-types"; + +import { getHumanReadableDuration } from "../../../../../Utils/timeUtils"; +import { formatBytes } from "../../utils/utils"; +import { useTranslation } from "react-i18next"; + +const StatsCard = ({ title, value, unit = "", isLoading }) => { + const theme = useTheme(); + return ( + + {isLoading ? ( + + + + ) : ( + + + {title} + + + {value} {unit} + + + )} + + ); +}; + +StatsCard.propTypes = { + title: PropTypes.string, + value: PropTypes.string, + unit: PropTypes.string, + isLoading: PropTypes.bool, +}; + +const Stats = ({ diagnostics, isLoading }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + + + + + + + + + + ); +}; + +Stats.propTypes = { + diagnostics: PropTypes.object, + isLoading: PropTypes.bool, +}; + +export default Stats; diff --git a/client/src/Pages/Logs/Diagnostics/index.jsx b/client/src/Pages/Logs/Diagnostics/index.jsx new file mode 100644 index 000000000..7c5d1f5a9 --- /dev/null +++ b/client/src/Pages/Logs/Diagnostics/index.jsx @@ -0,0 +1,54 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Gauges from "./components/gauges"; +import Stats from "./components/stats"; +import Divider from "@mui/material/Divider"; +import Button from "@mui/material/Button"; + +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { useFetchDiagnostics } from "../../../Hooks/logHooks"; + +const Diagnostics = () => { + // Local state + + // Hooks + const theme = useTheme(); + const { t } = useTranslation(); + const [diagnostics, fetchDiagnostics, isLoading, error] = useFetchDiagnostics(); + // Setup + return ( + + + {t("diagnosticsPage.diagnosticDescription")} + + + + + + + + + + + ); +}; + +export default Diagnostics; diff --git a/client/src/Pages/Logs/Diagnostics/utils/utils.js b/client/src/Pages/Logs/Diagnostics/utils/utils.js new file mode 100644 index 000000000..a3b75631f --- /dev/null +++ b/client/src/Pages/Logs/Diagnostics/utils/utils.js @@ -0,0 +1,16 @@ +export const getPercentage = (value, total) => { + if (!value || !total) return 0; + return (value / total) * 100; +}; + +export const formatBytes = (bytes) => { + if (!bytes) return "N/A"; + + if (bytes === 0) return "0 Bytes"; + + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +}; diff --git a/client/src/Pages/Logs/Logs/index.jsx b/client/src/Pages/Logs/Logs/index.jsx index 6c5e1a301..b65f9c0fd 100644 --- a/client/src/Pages/Logs/Logs/index.jsx +++ b/client/src/Pages/Logs/Logs/index.jsx @@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Select from "../../../Components/Inputs/Select"; import Typography from "@mui/material/Typography"; +import Divider from "@mui/material/Divider"; import { useFetchLogs } from "../../../Hooks/logHooks"; import { useTheme } from "@emotion/react"; @@ -51,12 +52,15 @@ const Logs = () => { return ( - {t("logsPage.description")} + {t("logsPage.description")} + + {t("logsPage.logLevelSelect.title")} - - - - - - - - )} - setIsOpen(false)} - confirmationButtonLabel={t("delete")} - onConfirm={handleRemove} - isLoading={isLoading || isDeleting || isUpdating || isPausing} - /> - - ); -}; - -export default PageSpeedConfigure; diff --git a/client/src/Pages/PageSpeed/Create/index.jsx b/client/src/Pages/PageSpeed/Create/index.jsx index 5cc4a1a54..78709093a 100644 --- a/client/src/Pages/PageSpeed/Create/index.jsx +++ b/client/src/Pages/PageSpeed/Create/index.jsx @@ -1,19 +1,20 @@ -//Components -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import ButtonGroup from "@mui/material/ButtonGroup"; -import Breadcrumbs from "../../../Components/Breadcrumbs"; -import TextInput from "../../../Components/Inputs/TextInput"; -import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments"; +// Components +import { Box, Stack, Tooltip, Typography, Button, ButtonGroup } from "@mui/material"; import ConfigBox from "../../../Components/ConfigBox"; -import Radio from "../../../Components/Inputs/Radio"; import Select from "../../../Components/Inputs/Select"; +import TextInput from "../../../Components/Inputs/TextInput"; +import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +import PulseDot from "../../../Components/Animated/PulseDot"; +import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded"; +import SkeletonLayout from "./skeleton"; import NotificationsConfig from "../../../Components/NotificationConfig"; +import Dialog from "../../../Components/Dialog"; +import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments"; +import Radio from "../../../Components/Inputs/Radio"; // Utils -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useSelector } from "react-redux"; import { monitorValidation } from "../../../Validation/validation"; import { parseDomainName } from "../../../Utils/monitorUtils"; @@ -21,79 +22,130 @@ import { useTranslation } from "react-i18next"; import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; import { useTheme } from "@emotion/react"; import { createToast } from "../../../Utils/toastUtils"; -import { useCreateMonitor } from "../../../Hooks/monitorHooks"; -const MS_PER_MINUTE = 60000; +import { useParams } from "react-router"; +import { useMonitorUtils } from "../../../Hooks/useMonitorUtils"; +import { + useCreateMonitor, + useFetchMonitorById, + useDeleteMonitor, + useUpdateMonitor, + usePauseMonitor, +} from "../../../Hooks/monitorHooks"; -const CRUMBS = [ - { name: "pagespeed", path: "/pagespeed" }, - { name: "create", path: `/pagespeed/create` }, -]; - -const SELECT_VALUES = [ - { _id: 3, name: "3 minutes" }, - { _id: 5, name: "5 minutes" }, - { _id: 10, name: "10 minutes" }, - { _id: 20, name: "20 minutes" }, - { _id: 60, name: "1 hour" }, - { _id: 1440, name: "1 day" }, - { _id: 10080, name: "1 week" }, -]; - -const CreatePageSpeed = () => { - // State - const [monitor, setMonitor] = useState({ - url: "", - name: "", - type: "pagespeed", - notifications: [], - interval: 3, - }); +const PageSpeedSetup = () => { + const { monitorId } = useParams(); + const isCreate = typeof monitorId === "undefined"; + const CRUMBS = [ + { name: "pagespeed", path: "/pagespeed" }, + ...(isCreate + ? [{ name: "create", path: `/pagespeed/create` }] + : [ + { name: "details", path: `/pagespeed/${monitorId}` }, + { name: "configure", path: `/pagespeed/configure/${monitorId}` }, + ]), + ]; + // States + const [monitor, setMonitor] = useState( + isCreate + ? { + url: "", + name: "", + type: "pagespeed", + notifications: [], + interval: 180000, + } + : {} + ); const [https, setHttps] = useState(true); const [errors, setErrors] = useState({}); - const { user } = useSelector((state) => state.auth); - const [notifications, notificationsAreLoading, error] = useGetNotificationsByTeamId(); + const [isOpen, setIsOpen] = useState(false); + const [updateTrigger, setUpdateTrigger] = useState(false); // Setup + const { t } = useTranslation(); const theme = useTheme(); + // Constants + const MS_PER_MINUTE = 60000; + const FREQUENCIES = [ + { _id: 3, name: t("time.threeMinutes") }, + { _id: 5, name: t("time.fiveMinutes") }, + { _id: 10, name: t("time.tenMinutes") }, + { _id: 20, name: t("time.twentyMinutes") }, + { _id: 60, name: t("time.oneHour") }, + { _id: 1440, name: t("time.oneDay") }, + { _id: 10080, name: t("time.oneWeek") }, + ]; + + const { user } = useSelector((state) => state.auth); + const { statusColor, pagespeedStatusMsg, determineState } = useMonitorUtils(); + const [notifications, notificationsAreLoading, notificationsError] = + useGetNotificationsByTeamId(); + + // Hooks for API actions + const [isLoading] = useFetchMonitorById({ monitorId, setMonitor, updateTrigger }); const [createMonitor, isCreating] = useCreateMonitor(); + const [deleteMonitor, isDeleting] = useDeleteMonitor(); + const [updateMonitor, isUpdating] = useUpdateMonitor(); + const [pauseMonitor, isPausing] = usePauseMonitor(); // Handlers const onSubmit = async (event) => { event.preventDefault(); - let form = { - url: `http${https ? "s" : ""}://` + monitor.url, - name: monitor.name === "" ? monitor.url : monitor.name, - type: monitor.type, - interval: monitor.interval * MS_PER_MINUTE, - }; + if (isCreate) { + let form = { + url: `http${https ? "s" : ""}://` + monitor.url, + name: monitor.name === "" ? monitor.url : monitor.name, + type: monitor.type, + interval: monitor.interval, + }; - const { error } = monitorValidation.validate(form, { - abortEarly: false, - }); + const { error } = monitorValidation.validate(form, { abortEarly: false }); + if (error) { + const newErrors = {}; + error.details.forEach((err) => { + newErrors[err.path[0]] = err.message; + }); + setErrors(newErrors); + createToast({ body: t("checkFormError") }); + return; + } - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: "Please check the form for errors." }); - return; + form = { + ...form, + description: form.name, + notifications: monitor.notifications, + }; + await createMonitor({ monitor: form, redirect: "/pagespeed" }); + } else { + const monitorParams = { + url: monitor.url, + name: monitor.name === "" ? monitor.url : monitor.name, + type: monitor.type, + interval: monitor.interval, + }; + const { error } = monitorValidation.validate(monitorParams, { abortEarly: false }); + if (error) { + const newErrors = {}; + error.details.forEach((err) => { + newErrors[err.path[0]] = err.message; + }); + setErrors(newErrors); + createToast({ body: t("checkFormError") }); + return; + } + await updateMonitor({ monitor, redirect: "/pagespeed" }); } - - form = { - ...form, - description: form.name, - notifications: monitor.notifications, - }; - - await createMonitor({ monitor: form, redirect: "/pagespeed" }); }; const handleChange = (event) => { - const { value, name } = event.target; + let { value, name } = event.target; + + if (name === "interval") { + value = value * MS_PER_MINUTE; + } + setMonitor({ ...monitor, [name]: value, @@ -103,7 +155,6 @@ const CreatePageSpeed = () => { { [name]: value }, { abortEarly: false } ); - setErrors((prev) => ({ ...prev, ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), @@ -111,59 +162,179 @@ const CreatePageSpeed = () => { }; const handleBlur = (event) => { - const { name } = event.target; - if (name === "url") { - const { value } = event.target; - if (monitor.name !== "") { - return; - } - setMonitor((prev) => ({ - ...prev, - name: parseDomainName(value), - })); + const { name, value } = event.target; + if (name === "url" && monitor.name === "") { + setMonitor((prev) => ({ ...prev, name: parseDomainName(value) })); } }; - const { t } = useTranslation(); + const triggerUpdate = () => { + setUpdateTrigger(!updateTrigger); + }; + + const handlePause = async () => { + await pauseMonitor({ monitorId, triggerUpdate }); + }; + + const handleRemove = async (event) => { + event.preventDefault(); + await deleteMonitor({ monitor, redirect: "/pagespeed" }); + }; + + const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; + + if (Object.keys(monitor).length === 0) { + return ; + } return ( - - - {t("createYour")}{" "} - - - {t("pageSpeedMonitor")} - - + + + + {!isCreate ? monitor.name : t("createYour") + " "} + + {isCreate ? ( + + {t("pageSpeedMonitor")} + + ) : ( + <> + )} + + {!isCreate && ( + + + + + + + + {monitor.url?.replace(/^https?:\/\//, "") || "..."} + + + {t("editing")} + + + )} + + {!isCreate && ( + + + + + )} + { > {t("settingsGeneralSettings")} - {t("distributedUptimeCreateSelectURL")} + + {t("pageSpeedConfigureSettingsDescription")} + - + .Mui-disabled)": { + backgroundColor: theme.palette.tertiary.main, + }, + }} + > } - placeholder="google.com" - value={monitor.url} + label={!isCreate ? t("url") : t("urlMonitor")} + startAdornment={isCreate ? : undefined} + placeholder="random.website.com" + value={monitor.url || ""} onChange={handleChange} - onBlur={handleBlur} - error={errors["url"] ? true : false} + onBlur={isCreate ? handleBlur : undefined} + error={!!errors["url"]} helperText={errors["url"]} + disabled={!isCreate} /> - - - - {t("distributedUptimeCreateChecks")} - - - {t("distributedUptimeCreateChecksDescription")} - - - - - - - - - + {isCreate && ( + + + + {t("distributedUptimeCreateChecks")} + + + {t("distributedUptimeCreateChecksDescription")} + + + + + + + + + + + {errors["type"] ? ( + + + {errors["type"]} + + + ) : ( + "" + )} - {errors["type"] ? ( - - - {errors["type"]} - - - ) : ( - "" - )} - - + + )} { @@ -280,33 +463,47 @@ const CreatePageSpeed = () => { {t("distributedUptimeCreateAdvancedSettings")} - + setRawInput(val)} + handleChange={handleTimezoneChange} + isAdorned={false} + unit="timezone" /> diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx index a1ba117b0..4c2842b71 100644 --- a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx +++ b/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx @@ -4,7 +4,7 @@ import { TabPanel } from "@mui/lab"; import ConfigBox from "../../../../../Components/ConfigBox"; import Checkbox from "../../../../../Components/Inputs/Checkbox"; import TextInput from "../../../../../Components/Inputs/TextInput"; -import Select from "../../../../../Components/Inputs/Select"; +import Search from "../../../../../Components/Inputs/Search"; import ImageUpload from "../../../../../Components/Inputs/ImageUpload"; import ColorPicker from "../../../../../Components/Inputs/ColorPicker"; import Progress from "../Progress"; @@ -14,6 +14,7 @@ import { useTheme } from "@emotion/react"; import timezones from "../../../../../Utils/timezones.json"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; +import { useMemo, useState, useCallback } from "react"; const TabSettings = ({ isCreate, @@ -28,6 +29,25 @@ const TabSettings = ({ // Utils const theme = useTheme(); const { t } = useTranslation(); + const [rawInput, setRawInput] = useState(""); + + const selectedTimezone = useMemo( + () => timezones.find((tz) => tz._id === form.timezone) ?? null, + [form.timezone] + ); + + const handleTimezoneChange = useCallback( + (newValue) => { + setRawInput(""); + handleFormChange({ + target: { + name: "timezone", + value: newValue?._id ?? "", + }, + }); + }, + [handleFormChange] + ); return ( @@ -101,13 +121,17 @@ const TabSettings = ({ -