Merge branch 'develop' into feat/super-simple-queue

This commit is contained in:
Alex Holliday committed 2025-07-23 10:35:11 -07:00
commit 4f186a6b49
189 files changed
+4473 -5400

No files matched your search

+15 -5
View File
@@ -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!
+17
View File
@@ -0,0 +1,17 @@
.DS_Store
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
*.swp
*.bak
*.tmp
*.orig
*~
.project
.idea/
*.tmproj
.vscode/
+6
View File
@@ -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"
+39
View File
@@ -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.
@@ -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 }}
@@ -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"
@@ -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 }}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: client
spec:
selector:
app: client
ports:
- port: {{ .Values.client.port }}
@@ -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
@@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongodb-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.mongodbSize }}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: mongodb
spec:
selector:
app: mongodb
ports:
- port: {{ .Values.mongodb.port }}
@@ -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
@@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.redisSize }}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: {{ .Values.redis.port }}
@@ -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 }}
@@ -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
@@ -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 }}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: server
spec:
selector:
app: server
ports:
- port: {{ .Values.server.port }}
+41
View File
@@ -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
@@ -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
* <CustomGauge
@@ -27,7 +31,15 @@ const MAXIMUM_VALUE = 100;
*
* @returns {React.ReactElement} Rendered CustomGauge component
*/
const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold = 50 }) => {
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 (
<Stack
className="radial-chart"
width={radius}
height={radius}
alignItems="center"
justifyContent="center"
>
<CircularProgress color="accent" />
</Stack>
);
}
return (
<Box
@@ -71,7 +97,7 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
>
<circle
className="radial-chart-base"
stroke={theme.palette.secondary.light} // CAIO_REVIEW
stroke={theme.palette.secondary.light}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2} // Center the circle
@@ -103,7 +129,7 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
fill: theme.typography.h2.color,
}}
>
{`${progressWithinRange.toFixed(1)}%`}
{`${progressWithinRange.toFixed(precision)}${unit}`}
</Typography>
</Box>
);
@@ -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,
};
+3 -4
View File
@@ -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,
@@ -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 (
<Stack
gap={theme.spacing(10)}
alignItems="center"
>
<Button
variant="contained"
color="accent"
onClick={() => navigate(link)}
>
{t(`${type}.fallback.actionButton`)}
</Button>
{type === "uptimeMonitor" && (
<Button
variant="contained"
color="accent"
sx={{ alignSelf: "center" }}
onClick={() => navigate("/uptime/bulk-import")}
>
{t("bulkImport.fallbackPage")}
</Button>
)}
</Stack>
);
};
FallbackActionButtons.propTypes = {
title: PropTypes.string.isRequired,
link: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
};
export default FallbackActionButtons;
@@ -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" ? (
<Skeleton style={{ zIndex: 1 }} />
) : (
<SkeletonDark style={{ zIndex: 1 }} />
)}
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
</>
);
};
export default FallbackBackground;
@@ -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 (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: theme.spacing(2),
alignItems: "flex-start",
maxWidth: { xs: "90%", md: "80%", lg: "75%" },
}}
>
{checks?.map((check, index) => (
<Check
text={check}
key={`${type.trim().split(" ")[0]}-${index}`}
outlined={true}
/>
))}
</Box>
);
};
FallbackCheckList.propTypes = {
checks: PropTypes.arrayOf(PropTypes.string).isRequired,
title: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
};
export default FallbackCheckList;
@@ -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 (
<Box
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.primary.main}
overflow="hidden"
sx={{
display: "flex",
borderStyle: "dashed",
height: "fit-content",
minHeight: "60vh",
width: {
sm: "90%",
md: "70%",
lg: "50%",
xl: "40%",
},
padding: `${theme.spacing(20)} ${theme.spacing(10)}`,
}}
>
<Stack
className={`fallback__${type?.trim().split(" ")[0]}`}
alignItems="center"
gap={theme.spacing(20)}
>
{children}
</Stack>
</Box>
);
};
FallbackContainer.propTypes = {
children: PropTypes.node,
type: PropTypes.string,
};
export default FallbackContainer;
@@ -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")}{" "}
<Link
component={RouterLink}
to="/settings"
sx={{
textDecoration: "underline",
color: "inherit",
fontWeight: "inherit",
"&:hover": {
textDecoration: "underline",
},
}}
>
{t("pageSpeedLearnMoreLink")}
</Link>{" "}
{t("pageSpeedAddApiKey")}
</>
);
};
const FallbackPageSpeedWarning = ({ settingsData }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Box sx={{ width: "80%", maxWidth: "600px", zIndex: 1 }}>
<Box
sx={{
"& .alert.row-stack": {
backgroundColor: theme.palette.warningSecondary.main,
borderColor: theme.palette.warningSecondary.lowContrast,
"& .MuiTypography-root": {
color: theme.palette.warningSecondary.contrastText,
},
"& .MuiBox-root > svg": {
color: theme.palette.warningSecondary.contrastText,
},
},
}}
>
{settingsData?.pagespeedKeySet === false && (
<Alert
variant="warning"
hasIcon={true}
body={renderWarningMessage(t)}
/>
)}
</Box>
</Box>
);
};
FallbackPageSpeedWarning.propTypes = {
settingsData: PropTypes.shape({
pagespeedKeySet: PropTypes.bool,
}),
};
export default FallbackPageSpeedWarning;
@@ -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 (
<Typography
alignSelf="center"
component="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{title}
</Typography>
);
};
FallbackTitle.propTypes = {
title: PropTypes.string.isRequired,
};
export default FallbackTitle;
-4
View File
@@ -38,7 +38,3 @@
background-size: cover;
background-repeat: no-repeat;
}
.fallback__status > .MuiStack-root {
margin-left: var(--env-var-spacing-2);
}
+34 -174
View File
@@ -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<string>} 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")}{" "}
<Link
component={RouterLink}
to="/settings"
sx={{
textDecoration: "underline",
color: "inherit",
fontWeight: "inherit",
"&:hover": {
textDecoration: "underline",
},
}}
>
{t("pageSpeedLearnMoreLink")}
</Link>{" "}
{t("pageSpeedAddApiKey")}
</>
);
};
return (
<Box
position="relative"
@@ -78,138 +32,44 @@ const Fallback = ({
alignItems: "center",
}}
>
<Box
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.primary.main}
overflow="hidden"
sx={{
display: "flex",
borderStyle: "dashed",
height: {
sm: "55vh",
md: "50vh",
lg: "70vh",
xl: "58vh",
},
width: {
sm: "90%",
md: "70%",
lg: "42%",
},
minHeight: {
sm: theme.spacing(280),
},
padding: theme.spacing(10),
}}
>
<FallbackContainer type={type}>
<FallbackBackground />
<Stack
className={`fallback__${title?.trim().split(" ")[0]}`}
gap={theme.spacing(5)}
zIndex={1}
alignItems="center"
gap={theme.spacing(20)}
>
{mode === "light" ? (
<Skeleton style={{ zIndex: 1 }} />
) : (
<SkeletonDark style={{ zIndex: 1 }} />
)}
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
gap={theme.spacing(4)}
maxWidth={theme.spacing(180)}
zIndex={1}
>
<Typography
alignSelf="center"
component="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{vowelStart ? "An" : "A"} {title} is used to:
</Typography>
{checks?.map((check, index) => (
<Check
text={check}
key={`${title.trim().split(" ")[0]}-${index}`}
outlined={true}
/>
))}
</Stack>
{/* TODO - display a different fallback if user is not an admin*/}
{isAdmin && (
<Stack gap={theme.spacing(10)}>
<Button
variant="contained"
color="accent"
sx={{ alignSelf: "center", mb: "2px" }}
onClick={() => navigate(link)}
>
Let's create your first {title}
</Button>
{/* Bulk create of uptime monitors */}
{title === "uptime monitor" && (
<Button
variant="contained"
color="accent"
sx={{ alignSelf: "center" }}
onClick={() => navigate("/uptime/bulk-import")}
>
{t("bulkImport.fallbackPage")}
</Button>
)}
{/* Warning box for PageSpeed monitor */}
{title === "pagespeed monitor" && showPageSpeedWarning && (
<Box sx={{ width: "80%", maxWidth: "600px", zIndex: 1 }}>
<Box
sx={{
"& .alert.row-stack": {
backgroundColor: theme.palette.warningSecondary.main,
borderColor: theme.palette.warningSecondary.lowContrast,
"& .MuiTypography-root": {
color: theme.palette.warningSecondary.contrastText,
},
"& .MuiBox-root > svg": {
color: theme.palette.warningSecondary.contrastText,
},
},
}}
>
{settingsData?.pagespeedKeySet === false && (
<Alert
variant="warning"
hasIcon={true}
body={renderWarningMessage()}
/>
)}
</Box>
</Box>
)}
</Stack>
)}
<FallbackTitle title={title} />
<FallbackCheckList
checks={checks}
title={title}
type={type}
/>
</Stack>
</Box>
{isAdmin && (
<Stack
gap={theme.spacing(10)}
alignItems="center"
>
<FallbackActionButtons
title={title}
link={link}
type={type}
/>
{children}
</Stack>
)}
</FallbackContainer>
</Box>
);
};
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;
@@ -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 (
<Stack
gap={gap ?? theme.spacing(DEFAULT_GAP)}
sx={sx}
>
{label && (
<Typography
component={labelVariant}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={labelFontWeight}
sx={{
...(labelMb !== undefined && { mb: theme.spacing(labelMb) }),
...labelSx,
}}
>
{label}
</Typography>
)}
{children}
</Stack>
);
};
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;
+19 -2
View File
@@ -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={
<>
<Typography component="p">{title}</Typography>
<Typography
component="p"
mb={
labelSpacing !== undefined ? theme.spacing(labelSpacing) : theme.spacing(2)
}
>
{title}
</Typography>
<Typography
component="h6"
mt={theme.spacing(1)}
+31 -13
View File
@@ -12,6 +12,7 @@ import { useTheme } from "@emotion/react";
import SearchIcon from "../../../assets/icons/search.svg?react";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import FieldWrapper from "../FieldWrapper";
/**
* Search component using Material UI's Autocomplete.
@@ -24,6 +25,7 @@ import { useTranslation } from "react-i18next";
* @param {Function} props.handleChange - Function to call when the input changes
* @param {Function} Prop.onBlur - Function to call when the input is blured
* @param {Object} props.sx - Additional styles to apply to the component
* @param {string} props.unit - Label to identify type of options
* @returns {JSX.Element} The rendered Search component
*/
@@ -49,7 +51,7 @@ const SearchAdornment = () => {
);
};
//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) => (
<Stack>
<Typography
component="h3"
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
>
{label}
</Typography>
<FieldWrapper
label={label}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
...sx,
}}
>
<TextField
{...params}
error={Boolean(error)}
@@ -175,7 +187,7 @@ const Search = ({
{error}
</Typography>
)}
</Stack>
</FieldWrapper>
)}
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;
+26 -15
View File
@@ -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 (
<Stack
gap={theme.spacing(labelControlSpacing)}
className="select-wrapper"
<FieldWrapper
label={label}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={labelControlSpacing}
sx={{
...fieldWrapperSx,
}}
>
{label && (
<Typography
component="h3"
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
fontSize={13}
>
{label}
</Typography>
)}
<MuiSelect
className="select-component"
value={value}
@@ -107,6 +111,13 @@ const Select = ({
"& svg path": {
fill: theme.palette.primary.contrastTextTertiary,
},
"& .MuiSelect-select": {
padding: "0 13px",
minHeight: "34px",
display: "flex",
alignItems: "center",
lineHeight: 1,
},
...sx,
}}
renderValue={(selected) => {
@@ -151,7 +162,7 @@ const Select = ({
</MenuItem>
))}
</MuiSelect>
</Stack>
</FieldWrapper>
);
};
@@ -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 && <Required />}
{isOptional && <Optional optionalLabel={optionalLabel} />}
</>
);
return (
<Stack
flex={flex}
display={hidden ? "none" : ""}
marginTop={marginTop}
marginRight={marginRight}
marginBottom={marginBottom}
marginLeft={marginLeft}
<FieldWrapper
label={labelContent}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
flex,
display: hidden ? "none" : "",
mt: marginTop,
mr: marginRight,
mb: marginBottom,
ml: marginLeft,
...sx,
}}
>
<Typography
component="h3"
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
mb={theme.spacing(2)}
>
{label}
{isRequired && <Required />}
{isOptional && <Optional optionalLabel={optionalLabel} />}
</Typography>
<TextField
id={id}
name={name}
@@ -132,7 +145,7 @@ const TextInput = forwardRef(
}}
disabled={disabled}
/>
</Stack>
</FieldWrapper>
);
}
);
+9 -4
View File
@@ -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 (
@@ -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,
}}
/>
<Stack
flex={1}
+1
View File
@@ -16,6 +16,7 @@ const CustomTabList = ({ value, onChange, children, ...props }) => {
return (
<Box
sx={{
marginBottom: theme.spacing(12),
borderBottom: `1px solid ${theme.palette.primary.lowContrast}`,
"& .MuiTabs-root": { height: "fit-content", minHeight: "0" },
}}
+34
View File
@@ -0,0 +1,34 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const ToastBody = ({ body }) => {
const theme = useTheme();
if (Array.isArray(body)) {
return (
<Stack gap={theme.spacing(2)}>
{body.map((item, idx) => (
<Typography
key={`item-${idx}`}
color={theme.palette.secondary.contrastText}
>
{item}
</Typography>
))}
</Stack>
);
} else if (typeof body === "string") {
return <Typography color={theme.palette.secondary.contrastText}>{body}</Typography>;
}
return null;
};
ToastBody.propTypes = {
body: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
};
export default ToastBody;
+96
View File
@@ -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: <InfoOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
warning: <WarningAmberOutlinedIcon />,
};
const Toast = ({ variant, title, body, onClick, hasDismiss, hasIcon }) => {
const theme = useTheme();
const icon = icons[variant];
return (
<Stack
gap={theme.spacing(2)}
paddingTop={theme.spacing(4)}
paddingRight={theme.spacing(8)}
paddingBottom={theme.spacing(4)}
paddingLeft={theme.spacing(8)}
backgroundColor={theme.palette.alert.main}
border={`solid 1px ${theme.palette.alert.contrastText}`}
borderRadius={theme.shape.borderRadius}
>
<Stack
direction="row"
gap={theme.spacing(8)}
justifyContent="space-between"
alignItems="center"
>
{hasIcon && icon}
{title && (
<Typography
fontWeight="700"
color={theme.palette.secondary.contrastText}
>
{title}
</Typography>
)}
{title && (
<IconButton onClick={onClick}>
<CloseIcon />
</IconButton>
)}
</Stack>
<Stack
direction="row"
gap={theme.spacing(2)}
alignItems="center"
>
<ToastBody body={body} />
{!title && (
<IconButton onClick={onClick}>
<CloseIcon />
</IconButton>
)}
</Stack>
{hasDismiss && (
<Button
variant="text"
color="info"
onClick={onClick}
sx={{
fontWeight: "600",
width: "fit-content",
}}
>
Dismiss
</Button>
)}
</Stack>
);
};
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,
};
+1 -5
View File
@@ -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) {
+80 -1
View File
@@ -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 };
+4
View File
@@ -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);
-57
View File
@@ -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 };
+7 -1
View File
@@ -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 };
+61
View File
@@ -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];
};
@@ -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];
};
+122
View File
@@ -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 (
<Stack gap={theme.spacing(20)}>
<Breadcrumbs list={BREADCRUMBS} />
<Typography variant="h2">{t("editUserPage.title")}</Typography>
<Stack
component="form"
onSubmit={handleSubmit}
gap={theme.spacing(12)}
maxWidth="50%"
>
<TextInput
name="firstName"
label={t("editUserPage.form.firstName")}
value={form?.firstName}
onChange={onChange}
error={errors?.firstName ? true : false}
helperText={t(errors?.firstName)}
/>
<TextInput
name="lastName"
label={t("editUserPage.form.lastName")}
value={form?.lastName}
onChange={onChange}
error={errors?.lastName ? true : false}
helperText={t(errors?.lastName)}
/>
<TextInput
name="email"
label={t("editUserPage.form.email")}
value={form?.email}
disabled={true}
error={errors?.email ? true : false}
helperText={t(errors?.email)}
/>
<Search
label={t("editUserPage.form.role")}
filteredBy="role"
inputValue={searchInput}
handleInputChange={handleSearchInput}
value={
form?.role
?.filter((role) => role !== ROLES.SUPERADMIN)
.map((role) => ({ role, _id: role })) || []
}
options={EDITABLE_ROLES}
multiple={true}
handleChange={handleRoleChange}
/>
<RoleTable
roles={form?.role}
handleDeleteRole={handleDeleteRole}
/>
<Box>
<Button
type="submit"
variant="contained"
color="accent"
loading={isLoading || isSaving}
>
{t("editUserPage.form.save")}
</Button>
</Box>
</Stack>
</Stack>
);
};
export default EditUser;
@@ -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";
@@ -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 = () => {
>
<Stack
component="form"
className="edit-profile-form"
noValidate
spellCheck="false"
gap={SPACING_GAP}
@@ -0,0 +1,42 @@
import Typography from "@mui/material/Typography";
import DataTable from "../../../../Components/Table";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import { useTranslation } from "react-i18next";
import { ROLES } from "../../../../Utils/roleUtils";
const RoleTable = ({ roles, handleDeleteRole }) => {
const { t } = useTranslation();
const HEADERS = [
{
id: "name",
content: <Typography>{t("editUserPage.table.roleHeader")}</Typography>,
render: (row) => {
return row;
},
},
{
id: "delete",
content: <Typography>{t("editUserPage.table.actionHeader")}</Typography>,
render: (row) => {
if (row === ROLES.SUPERADMIN) return null;
return (
<DeleteOutlineRoundedIcon
onClick={() => {
handleDeleteRole(row);
}}
sx={{ cursor: "pointer" }}
/>
);
},
},
];
return (
<DataTable
headers={HEADERS}
data={roles}
/>
);
};
export default RoleTable;
@@ -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 (
<TabPanel
className="team-panel table-container"
value="team"
sx={{
"& h1": {
@@ -249,7 +251,17 @@ const TeamPanel = () => {
<DataTable
headers={headers}
data={data}
config={{ emptyView: t("teamPanel.noMembers") }}
config={{
emptyView: t("teamPanel.noMembers"),
rowSX: {
cursor: isSuperAdmin ? "pointer" : "default",
},
onRowClick: (row) => {
if (isSuperAdmin) {
navigate(`/account/team/${row.id}`);
}
},
}}
/>
</Stack>
-38
View File
@@ -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);
}
+5 -5
View File
@@ -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 (
<Box
className="account"
px={theme.spacing(20)}
py={theme.spacing(12)}
>
@@ -62,14 +62,15 @@ export const CustomThreshold = ({
const theme = useTheme();
return (
<Stack
direction={"row"}
sx={{
width: "50%",
justifyContent: "space-between",
flexWrap: "wrap",
}}
direction={{ sm: "column", md: "row" }}
spacing={theme.spacing(2)}
>
<Box>
<Box
sx={{
width: { md: "45%", lg: "25%", xl: "20%" },
}}
justifyContent="flex-start"
>
<Checkbox
id={checkboxId}
name={checkboxName}
@@ -81,8 +82,10 @@ export const CustomThreshold = ({
<Stack
direction={"row"}
sx={{
justifyContent: "flex-end",
justifyContent: "flex-start",
}}
alignItems="center"
spacing={theme.spacing(4)}
>
<TextInput
maxWidth="var(--env-var-width-4)"
@@ -25,6 +25,7 @@ import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
import { createToast } from "../../../Utils/toastUtils";
import Select from "../../../Components/Inputs/Select";
import { CustomThreshold } from "./Components/CustomThreshold";
import FieldWrapper from "../../../Components/Inputs/FieldWrapper";
const SELECT_VALUES = [
{ _id: 0.25, name: "15 seconds" },
@@ -283,7 +284,7 @@ const CreateInfrastructureMonitor = () => {
/>
</Typography>
</Stack>
<Stack gap={theme.spacing(15)}>
<Stack gap={theme.spacing(8)}>
<TextInput
type="url"
id="url"
@@ -299,8 +300,10 @@ const CreateInfrastructureMonitor = () => {
disabled={!isCreate}
/>
{isCreate && (
<Box>
<Typography component="p">{t("infrastructureProtocol")}</Typography>
<FieldWrapper
label={t("infrastructureProtocol")}
labelVariant="p"
>
<ButtonGroup>
<Button
variant="group"
@@ -317,7 +320,7 @@ const CreateInfrastructureMonitor = () => {
{t("http")}
</Button>
</ButtonGroup>
</Box>
</FieldWrapper>
)}
<TextInput
type="text"
@@ -2,18 +2,19 @@
import { Stack } from "@mui/material";
import Gauge from "./Gauge";
import SkeletonLayout from "./skeleton";
import PropTypes from "prop-types";
// Utils
import { useHardwareUtils } from "../../Hooks/useHardwareUtils";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
const Gauges = ({ shouldRender, monitor }) => {
const Gauges = ({ isLoading = false, monitor }) => {
const { decimalToPercentage, formatBytes } = useHardwareUtils();
const theme = useTheme();
const { t } = useTranslation();
if (!shouldRender) {
if (isLoading) {
return <SkeletonLayout />;
}
@@ -60,6 +61,7 @@ const Gauges = ({ shouldRender, monitor }) => {
return (
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(8)}
>
{gauges.map((gauge) => {
@@ -79,4 +81,9 @@ const Gauges = ({ shouldRender, monitor }) => {
);
};
Gauges.propTypes = {
isLoading: PropTypes.bool,
monitor: PropTypes.object,
};
export default Gauges;
@@ -92,11 +92,11 @@ const InfrastructureDetails = () => {
monitor={monitor}
/>
<GaugeBoxes
shouldRender={!isLoading}
isLoading={isLoading}
monitor={monitor}
/>
<MonitorTimeFrameHeader
shouldRender={!isLoading}
isLoading={isLoading}
dateRange={dateRange}
setDateRange={setDateRange}
/>
@@ -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 (
<Fallback
vowelStart={true}
title="infrastructure monitor"
checks={[
"Track the performance of your servers",
"Identify bottlenecks and optimize usage",
"Ensure reliability with real-time monitoring",
]}
type="infrastructureMonitor"
title={t("infrastructureMonitor.fallback.title")}
checks={t("infrastructureMonitor.fallback.checks", { returnObjects: true })}
link="/infrastructure/create"
isAdmin={isAdmin}
/>
@@ -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 (
<Stack
alignItems="center"
p={theme.spacing(2)}
maxWidth={150}
width={150}
>
{children}
<Typography variant="h2">{title}</Typography>
<Typography variant="body2">{subtitle}</Typography>
</Stack>
);
};
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 (
<Stack
direction="row"
spacing={theme.spacing(4)}
flexWrap="wrap"
>
<GaugeBox
title={t("diagnosticsPage.gauges.heapAllocationTitle")}
subtitle={t("diagnosticsPage.gauges.heapAllocationSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={heapTotalSize}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.heapUsageTitle")}
subtitle={t("diagnosticsPage.gauges.heapUsageSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={heapUsedSize}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.heapUtilizationTitle")}
subtitle={t("diagnosticsPage.gauges.heapUtilizationSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={actualHeapUsed}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.instantCpuUsageTitle")}
subtitle={t("diagnosticsPage.gauges.instantCpuUsageSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={diagnostics?.cpuUsage?.usagePercentage}
precision={2}
/>
</GaugeBox>
</Stack>
);
};
Gauges.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
export default Gauges;
@@ -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 (
<Card sx={{ width: 150, maxWidth: 150, height: 80, maxHeight: 80 }}>
{isLoading ? (
<Stack
alignItems="center"
justifyContent="center"
height={80}
maxHeight={80}
>
<CircularProgress color="accent" />
</Stack>
) : (
<CardContent>
<Typography
variant="body1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Typography variant="body1">
{value} {unit}
</Typography>
</CardContent>
)}
</Card>
);
};
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 (
<Stack
direction="row"
gap={theme.spacing(4)}
flexWrap="wrap"
>
<StatsCard
title={t("diagnosticsPage.stats.eventLoopDelayTitle")}
value={getHumanReadableDuration(diagnostics?.eventLoopDelayMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.uptimeTitle")}
value={getHumanReadableDuration(diagnostics?.uptimeMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.usedHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.totalHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.osMemoryLimitTitle")}
value={formatBytes(diagnostics?.osStats?.totalMemoryBytes)}
isLoading={isLoading}
/>
</Stack>
);
};
Stats.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
export default Stats;
@@ -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 (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="h2">{t("diagnosticsPage.diagnosticDescription")}</Typography>
</Box>
<Divider color={theme.palette.accent.main} />
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(10)}
>
<Gauges
diagnostics={diagnostics}
isLoading={isLoading}
/>
<Stats
diagnostics={diagnostics}
isLoading={isLoading}
/>
<Box>
<Button
variant="contained"
color="accent"
onClick={fetchDiagnostics}
loading={isLoading}
>
Fetch Diagnostics
</Button>
</Box>
</Stack>
</Stack>
);
};
export default Diagnostics;
@@ -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`;
};
+5 -1
View File
@@ -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 (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="body">{t("logsPage.description")}</Typography>
<Typography variant="h2">{t("logsPage.description")}</Typography>
</Box>
<Divider color={theme.palette.accent.main} />
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
mt={theme.spacing(10)}
>
<Typography>{t("logsPage.logLevelSelect.title")}</Typography>
<Select
@@ -1,8 +1,6 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import StatBox from "../../../../../Components/StatBox";
import StatusBoxes from "../../../../../Components/StatusBoxes";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
@@ -24,20 +22,17 @@ const Metrics = ({ metrics = {} }) => {
});
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
</Stack>
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
);
};
export default Metrics;
+40 -31
View File
@@ -5,10 +5,12 @@ import Metrics from "./components/Metrics";
import FailedJobTable from "./components/FailedJobTable";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
// Utils
import { useState } from "react";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/queueHooks";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/logHooks";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
@@ -26,39 +28,46 @@ const QueueDetails = () => {
if (error || flushError) return <div>Error: {error.message}</div>;
return (
<Stack gap={theme.spacing(20)}>
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<FailedJobTable metrics={metrics} />
<ButtonGroup
variant="contained"
color="accent"
sx={{
position: "sticky",
bottom: 0,
zIndex: 1000,
backgroundColor: theme.palette.primary.main,
p: theme.spacing(4),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.spacing(2),
}}
<Stack gap={theme.spacing(4)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<Divider color={theme.palette.accent.main} />
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(10)}
>
<Button
onClick={() => {
setTrigger(!trigger);
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<FailedJobTable metrics={metrics} />
<ButtonGroup
variant="contained"
color="accent"
sx={{
position: "sticky",
bottom: 0,
zIndex: 1000,
backgroundColor: theme.palette.primary.main,
p: theme.spacing(4),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.spacing(2),
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
<Button
onClick={() => {
setTrigger(!trigger);
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
+4 -1
View File
@@ -4,6 +4,7 @@ import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Queue from "./Queue";
import LogsComponent from "./Logs";
import Diagnostics from "./Diagnostics";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
@@ -14,7 +15,7 @@ const Logs = () => {
const theme = useTheme();
// Local state
const [value, setValue] = useState(0);
const [value, setValue] = useState(2);
// Handlers
const handleChange = (event, newValue) => {
@@ -31,9 +32,11 @@ const Logs = () => {
>
<Tab label={t("logsPage.tabs.logs")} />
<Tab label={t("logsPage.tabs.queue")} />
<Tab label={t("logsPage.tabs.diagnostics")} />
</Tabs>
{value === 0 && <LogsComponent />}
{value === 1 && <Queue />}
{value === 2 && <Diagnostics />}
</Stack>
);
};
@@ -0,0 +1,69 @@
// Components
import { Stack, Typography } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const MonitorListItem = ({ monitor, onDelete }) => {
const theme = useTheme();
return (
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(4)}
width="100%"
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<DeleteIcon
sx={{ cursor: "pointer" }}
onClick={() => onDelete(monitor)}
/>
</Stack>
);
};
MonitorListItem.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
onDelete: PropTypes.func.isRequired,
};
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
const onDelete = (monitorToDelete) => {
const newMonitors = selectedMonitors.filter(
(monitor) => monitor._id !== monitorToDelete._id
);
setSelectedMonitors(newMonitors);
};
const theme = useTheme();
return (
<Stack
gap={theme.spacing(6)}
width="100%"
>
{selectedMonitors?.map((monitor) => (
<MonitorListItem
key={monitor._id}
monitor={monitor}
onDelete={onDelete}
/>
))}
</Stack>
);
};
MonitorList.propTypes = {
selectedMonitors: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
setSelectedMonitors: PropTypes.func.isRequired,
};
export default MonitorList;
@@ -9,6 +9,8 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
import { maintenanceWindowValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import MonitorList from "./Components/MonitorList";
import Checkbox from "../../../Components/Inputs/Checkbox";
import dayjs from "dayjs";
import Select from "../../../Components/Inputs/Select";
@@ -212,6 +214,17 @@ const CreateMaintenance = () => {
});
};
const handleMonitorsChange = (selected) => {
setForm((prev) => ({ ...prev, monitors: selected }));
const { error } = maintenanceWindowValidation.validate(
{ monitors: selected },
{ abortEarly: false }
);
setErrors((prev) => {
return buildErrors(prev, "monitors", error);
});
};
const handleSubmit = async () => {
if (hasValidationErrors(form, maintenanceWindowValidation, setErrors)) return;
// Build timestamp for maintenance window from startDate and startTime
@@ -525,19 +538,25 @@ const CreateMaintenance = () => {
id={"monitors"}
label={t("addMonitors")}
multiple={true}
isAdorned={false}
options={monitors ? monitors : []}
isAdorned={true}
options={monitors}
filteredBy="name"
secondaryLabel={"type"}
inputValue={search}
value={form.monitors}
handleInputChange={handleSearch}
handleChange={handleSelectMonitors}
handleInputChange={setSearch}
handleChange={handleMonitorsChange}
error={errors["monitors"]}
disabled={maintenanceWindowId !== undefined}
/>
<MonitorList
selectedMonitors={form.monitors}
setSelectedMonitors={(monitors) =>
setForm((prev) => ({ ...prev, monitors }))
}
/>
</Stack>
</ConfigBox>
<Box
ml="auto"
display="inline-block"
+3 -6
View File
@@ -68,12 +68,9 @@ const Maintenance = () => {
if (isDataFetched && maintenanceWindows.length === 0) {
return (
<Fallback
title="maintenance window"
checks={[
"Mark your maintenance periods",
"Eliminate any misunderstandings",
"Stop sending alerts in maintenance windows",
]}
type="maintenanceWindow"
title={t("maintenanceWindow.fallback.title")}
checks={t("maintenanceWindow.fallback.checks", { returnObjects: true })}
link="/maintenance/create"
isAdmin={isAdmin}
/>
+29 -12
View File
@@ -65,12 +65,19 @@ const CreateNotifications = () => {
const [isOpen, setIsOpen] = useState(false);
const getNotificationTypeValue = (typeId) => {
return NOTIFICATION_TYPES.find((type) => type._id === typeId)?.value || "email";
};
const extractError = (error, field) =>
error?.details.find((d) => d.path.includes(field))?.message;
// handlers
const onSubmit = (e) => {
e.preventDefault();
const form = {
...notification,
type: NOTIFICATION_TYPES.find((type) => type._id === notification.type).value,
type: getNotificationTypeValue(notification.type),
};
let error = null;
@@ -84,7 +91,7 @@ const CreateNotifications = () => {
});
console.log(JSON.stringify(newErrors));
console.log(JSON.stringify(form, null, 2));
createToast({ body: "Please check the form for errors." });
createToast({ body: Object.values(newErrors)[0] });
setErrors(newErrors);
return;
}
@@ -98,22 +105,32 @@ const CreateNotifications = () => {
const onChange = (e) => {
const { name, value } = e.target;
let rawNotification = { ...notification, [name]: value };
let newNotification = {
...rawNotification,
type: getNotificationTypeValue(rawNotification.type),
};
const newNotification = { ...notification, [name]: value };
const { error } = notificationValidation.validate(newNotification, {
abortEarly: false,
});
let validationError = { ...errors };
const { error } = notificationValidation.extract(name).validate(value);
setErrors((prev) => ({
...prev,
[name]: error?.message,
}));
if (name === "type") {
validationError["type"] = extractError(error, "type");
validationError["address"] = extractError(error, "address");
} else {
validationError[name] = extractError(error, name);
}
setNotification(newNotification);
setNotification(rawNotification);
setErrors(validationError);
};
const onTestNotification = () => {
const form = {
...notification,
type: NOTIFICATION_TYPES.find((type) => type._id === notification.type).value,
type: getNotificationTypeValue(notification.type),
};
let error = null;
@@ -125,7 +142,7 @@ const CreateNotifications = () => {
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
createToast({ body: "Please check the form for errors." });
createToast({ body: Object.values(newErrors)[0] });
setErrors(newErrors);
return;
}
@@ -139,7 +156,7 @@ const CreateNotifications = () => {
}
};
const type = NOTIFICATION_TYPES.find((type) => type._id === notification.type).value;
const type = getNotificationTypeValue(notification.type);
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
+1 -1
View File
@@ -79,7 +79,7 @@ const Notifications = () => {
if (notifications?.length === 0) {
return (
<Fallback
vowelStart={false}
type="notifications"
title={t("notifications.fallback.title")}
checks={t("notifications.fallback.checks", { returnObjects: true })}
link="/notifications/create"
@@ -1,9 +0,0 @@
.configure-pagespeed button {
height: var(--env-var-height-2);
}
.configure-pagespeed .field,
.configure-pagespeed .section-disabled,
.configure-pagespeed .select-wrapper {
flex: 1;
}
@@ -1,351 +0,0 @@
// Components
import { Box, Stack, Tooltip, Typography, Button } from "@mui/material";
import ConfigBox from "../../../Components/ConfigBox";
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";
// Utils
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useParams } from "react-router";
import { monitorValidation } from "../../../Validation/validation";
import { useTranslation } from "react-i18next";
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils";
import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications";
import {
useFetchMonitorById,
useDeleteMonitor,
useUpdateMonitor,
usePauseMonitor,
} from "../../../Hooks/monitorHooks";
const PageSpeedConfigure = () => {
// Redux state
// Local state
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const [isOpen, setIsOpen] = useState(false);
const [updateTrigger, setUpdateTrigger] = useState(false);
// Utils
const theme = useTheme();
const { t } = useTranslation();
const MS_PER_MINUTE = 60000;
const { monitorId } = useParams();
const { statusColor, pagespeedStatusMsg, determineState } = useMonitorUtils();
const [notifications, notificationsAreLoading, notificationsError] =
useGetNotificationsByTeamId();
const [isLoading] = useFetchMonitorById({ monitorId, setMonitor, updateTrigger });
const [deleteMonitor, isDeleting] = useDeleteMonitor();
const [updateMonitor, isUpdating] = useUpdateMonitor();
const [pauseMonitor, isPausing] = usePauseMonitor();
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
{ _id: 10, name: "10 minutes" },
{ _id: 20, name: "20 minutes" },
{ _id: 60, name: "1 hour" },
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
// Handlers
const triggerUpdate = () => {
setUpdateTrigger(!updateTrigger);
};
const onChange = (event) => {
let { value, name } = event.target;
if (name === "interval") {
value = value * MS_PER_MINUTE;
}
setMonitor((prev) => ({
...prev,
[name]: value,
}));
const validation = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error) updatedErrors[name] = validation.error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
};
const handlePause = async () => {
await pauseMonitor({ monitorId, triggerUpdate });
};
const onSubmit = async (event) => {
event.preventDefault();
await updateMonitor({ monitor, redirect: "/pagespeed" });
};
const handleRemove = async (event) => {
event.preventDefault();
await deleteMonitor({ monitor, redirect: "/pagespeed" });
};
return (
<Stack
className="configure-pagespeed"
gap={theme.spacing(10)}
>
{Object.keys(monitor).length === 0 ? (
<SkeletonLayout />
) : (
<>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
{ name: "details", path: `/pagespeed/${monitorId}` },
{ name: "configure", path: `/pagespeed/configure/${monitorId}` },
]}
/>
<Stack
component="form"
noValidate
spellCheck="false"
onSubmit={onSubmit}
flex={1}
gap={theme.spacing(10)}
>
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Box>
<Typography
component="h1"
variant="monitorName"
>
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems="center"
height="fit-content"
gap={theme.spacing(2)}
>
<Tooltip
title={pagespeedStatusMsg[determineState(monitor)]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
<Box>
<PulseDot color={statusColor[determineState(monitor)]} />
</Box>
</Tooltip>
<Typography
component="h2"
variant="monitorUrl"
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
position="relative"
variant="body2"
ml={theme.spacing(6)}
mt={theme.spacing(1)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.primary.contrastTextTertiary,
opacity: 0.8,
left: -10,
top: "50%",
transform: "translateY(-50%)",
},
}}
>
{t("editing")}
</Typography>
</Stack>
</Box>
<Box
alignSelf="flex-end"
ml="auto"
>
<Button
onClick={handlePause}
loading={isLoading}
variant="contained"
color="secondary"
sx={{
pl: theme.spacing(4),
pr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
strokeWidth: 0.1,
},
},
}}
>
{monitor?.isActive ? (
<>
<PauseCircleOutlineIcon />
{t("pause")}
</>
) : (
<>
<PlayCircleOutlineRoundedIcon />
{t("resume")}
</>
)}
</Button>
<Button
loading={isLoading}
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{
ml: theme.spacing(6),
}}
>
{t("remove")}
</Button>
</Box>
</Stack>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("settingsGeneralSettings")}
</Typography>
<Typography component="p">
{t("pageSpeedConfigureSettingsDescription")}
</Typography>
</Box>
<Stack
gap={theme.spacing(20)}
sx={{
".MuiInputBase-root:has(> .Mui-disabled)": {
backgroundColor: theme.palette.tertiary.main,
},
}}
>
<TextInput
name="url"
type="url"
label={t("url")}
placeholder="random.website.com"
value={monitor?.url || ""}
onChange={onChange}
error={errors.url ? true : false}
helperText={errors.url}
disabled={true}
/>
<TextInput
name="name"
type="text"
label={t("monitorDisplayName")}
placeholder="Example monitor"
isOptional={true}
value={monitor?.name || ""}
onChange={onChange}
error={errors.name ? true : false}
helperText={errors.name}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("notificationConfig.title")}</Typography>
<Typography component="p">
{t("notificationConfig.description")}
</Typography>
</Box>
<NotificationsConfig
notifications={notifications}
setMonitor={setMonitor}
setNotifications={monitor.notifications}
/>
</ConfigBox>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("distributedUptimeCreateAdvancedSettings")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
name="interval"
label={t("checkFrequency")}
items={frequencies}
value={monitor?.interval / MS_PER_MINUTE || 3}
onChange={onChange}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
mt="auto"
>
<Button
loading={isLoading || isDeleting || isUpdating || isPausing}
type="submit"
variant="contained"
color="accent"
sx={{ px: theme.spacing(12) }}
>
{t("settingsSave")}
</Button>
</Stack>
</Stack>
</>
)}
<Dialog
open={isOpen}
theme={theme}
title={t("deleteDialogTitle")}
description={t("deleteDialogDescription")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t("delete")}
onConfirm={handleRemove}
isLoading={isLoading || isDeleting || isUpdating || isPausing}
/>
</Stack>
);
};
export default PageSpeedConfigure;
+368 -171
View File
@@ -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 <SkeletonLayout />;
}
return (
<Box
className="create-monitor"
sx={{
"& h1": {
color: theme.palette.primary.contrastText,
},
"& h1": { color: theme.palette.primary.contrastText },
}}
>
<Breadcrumbs list={CRUMBS} />
<Stack
component="form"
className="create-monitor-form"
onSubmit={onSubmit}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography
component="h1"
variant="h1"
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Typography
component="span"
fontSize="inherit"
>
{t("createYour")}{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
color={theme.palette.primary.contrastTextSecondary}
>
{t("pageSpeedMonitor")}
</Typography>
</Typography>
<Box>
<Typography
component="h1"
variant="h1"
>
<Typography
component="span"
fontSize="inherit"
color={
!isCreate ? theme.palette.primary.contrastTextSecondary : undefined
}
>
{!isCreate ? monitor.name : t("createYour") + " "}
</Typography>
{isCreate ? (
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
color={theme.palette.primary.contrastTextSecondary}
>
{t("pageSpeedMonitor")}
</Typography>
) : (
<></>
)}
</Typography>
{!isCreate && (
<Stack
direction="row"
alignItems="center"
height="fit-content"
gap={theme.spacing(2)}
>
<Tooltip
title={pagespeedStatusMsg[determineState(monitor)]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: { offset: [0, -8] },
},
],
},
}}
>
<Box>
<PulseDot color={statusColor[determineState(monitor)]} />
</Box>
</Tooltip>
<Typography
component="h2"
variant="monitorUrl"
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
position="relative"
variant="body2"
ml={theme.spacing(6)}
mt={theme.spacing(1)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: theme.spacing(2),
height: theme.spacing(2),
borderRadius: "50%",
backgroundColor: theme.palette.primary.contrastTextTertiary,
opacity: 0.8,
left: theme.spacing(-5),
top: "50%",
transform: "translateY(-50%)",
},
}}
>
{t("editing")}
</Typography>
</Stack>
)}
</Box>
{!isCreate && (
<Box
alignSelf="flex-end"
ml="auto"
>
<Button
onClick={handlePause}
loading={isBusy}
variant="contained"
color="secondary"
sx={{
pl: theme.spacing(4),
pr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
strokeWidth: 0.1,
},
},
}}
>
{monitor?.isActive ? (
<>
<PauseCircleOutlineIcon />
{t("pause")}
</>
) : (
<>
<PlayCircleOutlineRoundedIcon />
{t("resume")}
</>
)}
</Button>
<Button
loading={isBusy}
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{ ml: theme.spacing(6) }}
>
{t("remove")}
</Button>
</Box>
)}
</Stack>
<ConfigBox>
<Box>
<Typography
@@ -172,90 +343,101 @@ const CreatePageSpeed = () => {
>
{t("settingsGeneralSettings")}
</Typography>
<Typography component="p">{t("distributedUptimeCreateSelectURL")}</Typography>
<Typography component="p">
{t("pageSpeedConfigureSettingsDescription")}
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Stack
gap={!isCreate ? theme.spacing(20) : theme.spacing(15)}
sx={{
".MuiInputBase-root:has(> .Mui-disabled)": {
backgroundColor: theme.palette.tertiary.main,
},
}}
>
<TextInput
type={"url"}
name="url"
id="monitor-url"
label="URL to monitor"
startAdornment={<HttpAdornment https={https} />}
placeholder="google.com"
value={monitor.url}
label={!isCreate ? t("url") : t("urlMonitor")}
startAdornment={isCreate ? <HttpAdornment https={https} /> : 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}
/>
<TextInput
type="text"
id="monitor-name"
name="name"
label="Display name"
label={t("monitorDisplayName")}
isOptional={true}
placeholder="Google"
value={monitor.name}
value={monitor.name || ""}
onChange={handleChange}
error={errors["name"] ? true : false}
error={!!errors["name"]}
helperText={errors["name"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("distributedUptimeCreateChecks")}
</Typography>
<Typography component="p">
{t("distributedUptimeCreateChecksDescription")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="PageSpeed"
desc="Use the Lighthouse PageSpeed API to monitor your website"
size="small"
value="http"
checked={monitor.type === "pagespeed"}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
{t("https")}
</Button>
<Button
variant="group" // Why does this work?
filled={(!https).toString()} // There's nothing in the docs about this either
onClick={() => setHttps(false)}
>
{t("http")}
</Button>
</ButtonGroup>
{isCreate && (
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("distributedUptimeCreateChecks")}
</Typography>
<Typography component="p">
{t("distributedUptimeCreateChecksDescription")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="PageSpeed"
desc={t("pageSpeedLighthouseAPI")}
size="small"
value="http"
checked={monitor.type === "pagespeed"}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
{t("https")}
</Button>
<Button
variant="group" // Why does this work?
filled={(!https).toString()} // There's nothing in the docs about this either
onClick={() => setHttps(false)}
>
{t("http")}
</Button>
</ButtonGroup>
</Stack>
{errors["type"] ? (
<Box>
<Typography
component="p"
color={theme.palette.error.contrastText}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.contrastText}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
</ConfigBox>
)}
<ConfigBox>
<Box>
<Typography
@@ -269,6 +451,7 @@ const CreatePageSpeed = () => {
<NotificationsConfig
notifications={notifications}
setMonitor={setMonitor}
setNotifications={isCreate ? undefined : monitor.notifications}
/>
</ConfigBox>
<ConfigBox>
@@ -280,33 +463,47 @@ const CreatePageSpeed = () => {
{t("distributedUptimeCreateAdvancedSettings")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(isCreate ? 12 : 20)}>
<Select
id="monitor-interval"
name="interval"
label="Check frequency"
value={monitor.interval || 3}
label={t("checkFrequency")}
value={monitor?.interval / MS_PER_MINUTE || 3}
onChange={handleChange}
items={SELECT_VALUES}
items={FREQUENCIES}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
mt={isCreate ? undefined : "auto"}
>
<Button
type="submit"
variant="contained"
color="accent"
loading={isBusy}
disabled={!Object.values(errors).every((value) => value === undefined)}
loading={isCreating}
sx={isCreate ? undefined : { px: theme.spacing(12) }}
>
{t("createMonitor")}
{isCreate ? t("createMonitor") : t("settingsSave")}
</Button>
</Stack>
</Stack>
{!isCreate && (
<Dialog
open={isOpen}
theme={theme}
title={t("deleteDialogTitle")}
description={t("deleteDialogDescription")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t("delete")}
onConfirm={handleRemove}
/>
)}
</Box>
);
};
export default CreatePageSpeed;
export default PageSpeedSetup;
+18 -8
View File
@@ -1,4 +1,5 @@
// Components
import { useState } from "react";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import { Stack, Typography } from "@mui/material";
import CreateMonitorHeader from "../../../Components/MonitorCreateHeader";
@@ -6,12 +7,14 @@ import MonitorCountHeader from "../../../Components/MonitorCountHeader";
import MonitorGrid from "./Components/MonitorGrid";
import Fallback from "../../../Components/Fallback";
import GenericFallback from "../../../Components/GenericFallback";
import FallbackPageSpeedWarning from "../../../Components/Fallback/FallbackPageSpeedWarning";
// Utils
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
import { useTranslation } from "react-i18next";
import { useFetchMonitorsByTeamId } from "../../../Hooks/monitorHooks";
import { useFetchSettings } from "../../../Hooks/settingsHooks";
// Constants
const BREADCRUMBS = [{ name: `pagespeed`, path: "/pagespeed" }];
const TYPES = ["pagespeed"];
@@ -30,6 +33,13 @@ const PageSpeed = () => {
order: null,
});
const [settingsData, setSettingsData] = useState(undefined);
const [isSettingsLoading, settingsError] = useFetchSettings({
setSettingsData,
setIsApiKeySet: () => {},
setIsEmailPasswordSet: () => {},
});
if (networkError === true) {
return (
<GenericFallback>
@@ -48,16 +58,16 @@ const PageSpeed = () => {
if (!isLoading && monitors?.length === 0) {
return (
<Fallback
title="pagespeed monitor"
checks={[
"Report on the user experience of a page",
"Help analyze webpage speed",
"Give suggestions on how the page can be improved",
]}
type="pageSpeed"
title={t("pageSpeed.fallback.title")}
checks={t("pageSpeed.fallback.checks", { returnObjects: true })}
link="/pagespeed/create"
isAdmin={isAdmin}
// showPageSpeedWarning={isAdmin && !pagespeedApiKey}
/>
>
{isAdmin && settingsData && !settingsData.pagespeedApiKey && (
<FallbackPageSpeedWarning settingsData={settingsData} />
)}
</Fallback>
);
}
+7 -7
View File
@@ -117,8 +117,8 @@ const SettingsEmail = ({
<Box>
<Stack gap={theme.spacing(10)}>
<Box>
<Typography>{t("settingsPage.emailSettings.labelHost")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelHost")}
name="systemEmailHost"
placeholder="smtp.gmail.com"
value={systemEmailHost}
@@ -126,8 +126,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelPort")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelPort")}
name="systemEmailPort"
placeholder="425"
type="number"
@@ -136,8 +136,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelUser")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelUser")}
name="systemEmailUser"
placeholder={t("settingsPage.emailSettings.placeholderUser")}
value={systemEmailUser}
@@ -145,8 +145,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelAddress")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelAddress")}
name="systemEmailAddress"
placeholder="uptime@bluewavelabs.ca"
value={systemEmailAddress}
@@ -155,8 +155,8 @@ const SettingsEmail = ({
</Box>
{(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && (
<Box>
<Typography>{t("settingsPage.emailSettings.labelPassword")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelPassword")}
name="systemEmailPassword"
type="password"
placeholder="123 456 789 101112"
@@ -188,8 +188,8 @@ const SettingsEmail = ({
</Box>
)}
<Box>
<Typography>{t("settingsPage.emailSettings.labelTLSServername")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelTLSServername")}
name="systemEmailTLSServername"
placeholder="bluewavelabs.ca"
value={systemEmailTLSServername}
@@ -197,8 +197,8 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsPage.emailSettings.labelConnectionHost")}</Typography>
<TextInput
label={t("settingsPage.emailSettings.labelConnectionHost")}
name="systemEmailConnectionHost"
placeholder="bluewavelabs.ca"
value={systemEmailConnectionHost}
+33 -7
View File
@@ -2,16 +2,37 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import ConfigBox from "../../Components/ConfigBox";
import Select from "../../Components/Inputs/Select";
import Search from "../../Components/Inputs/Search";
import timezones from "../../Utils/timezones.json";
// Utils
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
const theme = useTheme();
const { t } = useTranslation();
const [rawInput, setRawInput] = useState("");
const selectedTimezone = useMemo(
() => timezones.find((tz) => tz._id === timezone) ?? null,
[timezone]
);
const handleTimezoneChange = useCallback(
(newValue) => {
setRawInput("");
handleChange({
target: {
name: "timezone",
value: newValue?._id ?? "",
},
});
},
[handleChange]
);
return (
<ConfigBox>
<Box>
@@ -28,12 +49,17 @@ const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
label={t("settingsPage.timezoneSettings.label")}
name="timezone"
value={timezone}
onChange={handleChange}
items={timezones}
<Search
id="timezone"
label={t("settingsDisplayTimezone")}
options={timezones}
filteredBy="name"
value={selectedTimezone}
inputValue={rawInput}
handleInputChange={(val) => setRawInput(val)}
handleChange={handleTimezoneChange}
isAdorned={false}
unit="timezone"
/>
</Stack>
</ConfigBox>
@@ -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 (
<TabPanel value={tabValue}>
@@ -101,13 +121,17 @@ const TabSettings = ({
</Typography>
</Stack>
<Stack gap={theme.spacing(6)}>
<Select
<Search
id="timezone"
name="timezone"
label={t("settingsDisplayTimezone")}
items={timezones}
value={form.timezone}
onChange={handleFormChange}
options={timezones}
filteredBy="name"
value={selectedTimezone}
inputValue={rawInput}
handleInputChange={(newVal) => setRawInput(newVal)}
handleChange={handleTimezoneChange}
isAdorned={false}
unit="timezone"
/>
</Stack>
</ConfigBox>
@@ -42,12 +42,9 @@ const StatusPages = () => {
if (!isLoading && typeof statusPages !== "undefined" && statusPages.length === 0) {
return (
<Fallback
title="status page"
checks={[
"Monitor and display the health of your services in real time",
"Track multiple services and share their status",
"Keep users informed about outages and performance",
]}
title={t("statusPage.fallback.title")}
checks={t("statusPage.fallback.checks", { returnObjects: true })}
type="statusPage"
link="/status/uptime/create"
isAdmin={isAdmin}
/>
+10 -9
View File
@@ -21,7 +21,7 @@ import MonitorCountHeader from "../../../Components/MonitorCountHeader";
import { Stack, Box, Button, Typography } from "@mui/material";
// Utils
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
@@ -146,6 +146,12 @@ const UptimeMonitors = () => {
monitorUpdateTrigger,
});
useEffect(() => {
if (isSearching) {
setPage(undefined);
}
}, [isSearching]);
const isLoading = monitorsWithSummaryIsLoading || monitorsWithChecksIsLoading;
if (networkError) {
return (
@@ -168,14 +174,9 @@ const UptimeMonitors = () => {
) {
return (
<Fallback
vowelStart={true}
title="uptime monitor"
checks={[
"Check if websites or servers are online & responsive",
"Alert teams about downtime or performance issues",
"Monitor HTTP endpoints, pings, containers & ports",
"Track historical uptime and reliability trends",
]}
type="uptimeMonitor"
title={t("uptimeMonitor.fallback.title")}
checks={t("uptimeMonitor.fallback.checks", { returnObjects: true })}
link="/uptime/create"
isAdmin={isAdmin}
/>
+11 -3
View File
@@ -18,9 +18,8 @@ import UptimeConfigure from "../Pages/Uptime/Configure";
// PageSpeed
import PageSpeed from "../Pages/PageSpeed/Monitors";
import PageSpeedCreate from "../Pages/PageSpeed/Create";
import PageSpeedDetails from "../Pages/PageSpeed/Details";
import PageSpeedConfigure from "../Pages/PageSpeed/Configure";
import PageSpeedCreate from "../Pages/PageSpeed/Create";
// Infrastructure
import Infrastructure from "../Pages/Infrastructure/Monitors";
@@ -43,6 +42,7 @@ import CreateNotifications from "../Pages/Notifications/create";
// Settings
import Account from "../Pages/Account";
import EditUser from "../Pages/Account/EditUser";
import Settings from "../Pages/Settings";
import Maintenance from "../Pages/Maintenance";
@@ -107,7 +107,7 @@ const Routes = () => {
/>
<Route
path="pagespeed/configure/:monitorId"
element={<PageSpeedConfigure />}
element={<PageSpeedCreate />}
/>
<Route
path="infrastructure"
@@ -188,6 +188,14 @@ const Routes = () => {
path="account/team"
element={<Account open={"team"} />}
/>
<Route
path="account/team/:userId"
element={
<RoleProtectedRoute roles={["superadmin"]}>
<EditUser />
</RoleProtectedRoute>
}
/>
<Route
path="logs"
+30 -4
View File
@@ -393,7 +393,7 @@ class NetworkService {
*
*/
async updateUser(config) {
return this.axiosInstance.put(`/auth/user/${config.userId}`, config.form);
return this.axiosInstance.put(`/auth/user`, config.form);
}
/**
@@ -406,8 +406,8 @@ class NetworkService {
* @param {string} config.userId - The ID of the user to be deleted.
*
**/
async deleteUser(config) {
return this.axiosInstance.delete(`/auth/user/${config.userId}`);
async deleteUser() {
return this.axiosInstance.delete(`/auth/user`);
}
/**
@@ -977,7 +977,7 @@ class NetworkService {
});
}
return this.axiosInstance.get(`/monitors/summary/team?${params.toString()}`, {
return this.axiosInstance.get(`/monitors/team/summary?${params.toString()}`, {
headers: {
"Content-Type": "application/json",
},
@@ -1091,6 +1091,32 @@ class NetworkService {
},
});
}
async getDiagnostics() {
return this.axiosInstance.get(`/diagnostic/system`, {
headers: {
"Content-Type": "application/json",
},
});
}
async getUserById(config) {
const userId = config?.userId;
return this.axiosInstance.get(`auth/users/${userId}`, {
headers: {
"Content-Type": "application/json",
},
});
}
async editUser(config) {
const { userId, user } = config;
return this.axiosInstance.put(`auth/users/${userId}`, user, {
headers: {
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;
+5
View File
@@ -553,6 +553,11 @@ const baseTheme = (palette) => ({
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
padding: "0 13px",
minHeight: "34px",
display: "flex",
alignItems: "center",
lineHeight: 1,
}),
},
},
+13
View File
@@ -0,0 +1,13 @@
export const ROLES = {
SUPERADMIN: "superadmin",
ADMIN: "admin",
USER: "user",
DEMO: "demo",
};
export const VALID_ROLES = [ROLES.ADMIN, ROLES.USER, ROLES.DEMO];
export const EDITABLE_ROLES = [
{ role: ROLES.ADMIN, _id: ROLES.ADMIN },
{ role: ROLES.USER, _id: ROLES.USER },
];
+4
View File
@@ -85,6 +85,7 @@ export const getHumanReadableDuration = (ms) => {
hours: durationObj.hours(),
minutes: durationObj.minutes(),
seconds: durationObj.seconds(),
milliseconds: durationObj.milliseconds(),
};
const result = [];
@@ -101,6 +102,9 @@ export const getHumanReadableDuration = (ms) => {
if (result.length < 2 && parts.seconds > 0) {
result.push(`${parts.seconds}s`);
}
if (result.length < 2 && parts.milliseconds > 0 && parts.seconds < 1) {
result.push(`${parts.milliseconds.toFixed(2)}ms`);
}
if (result.length === 0) {
// fallback for durations < 1s
+6 -4
View File
@@ -1,7 +1,6 @@
import PropTypes from "prop-types";
import { toast, Slide } from "react-toastify";
import Alert from "../Components/Alert";
import Toast from "../Components/Toast";
/**
* @param {object} props
* @param {'info' | 'error' | 'warning'} - The variant of the alert (e.g., "info", "error").
@@ -15,6 +14,7 @@ export const createToast = ({
variant = "info",
title,
body,
hasDismiss = false,
hasIcon = false,
config = {},
}) => {
@@ -29,13 +29,14 @@ export const createToast = ({
toast(
({ closeToast }) => (
<Alert
<Toast
variant={variant}
title={title}
body={body}
isToast={true}
hasIcon={hasIcon}
onClick={closeToast}
hasDismiss={hasDismiss}
hasIcon={hasIcon}
/>
),
toastConfig
@@ -47,5 +48,6 @@ createToast.propTypes = {
title: PropTypes.string,
body: PropTypes.string.isRequired,
hasIcon: PropTypes.bool,
hasDismiss: PropTypes.bool,
config: PropTypes.object,
};
+75 -47
View File
@@ -1,5 +1,6 @@
import joi from "joi";
import dayjs from "dayjs";
import { ROLES } from "../Utils/roleUtils";
const THRESHOLD_COMMON_BASE_MSG = "Threshold must be a number.";
@@ -128,48 +129,37 @@ const monitorValidation = joi.object({
.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)) {
// 1. Standard URLs: must have protocol and pass canParse()
if (/^(https?:\/\/)/.test(value)) {
if (
typeof URL !== "undefined" &&
typeof URL.canParse === "function" &&
URL.canParse(value)
) {
return value;
}
// else, it's a malformed URL with protocol
return helpers.error("string.invalidUrl");
}
return value;
// 2. Docker/internal hostnames (no protocol)
if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(value)) {
if (value.includes("..")) {
return helpers.error("string.invalidUrl");
}
// Split by dot, check each label
const labels = value.split(":")[0].split(".");
for (const label of labels) {
if (!label) return helpers.error("string.invalidUrl");
if (label.startsWith("-") || label.endsWith("-")) {
return helpers.error("string.invalidUrl");
}
}
return value;
}
// 3. Everything else is invalid
return helpers.error("string.invalidUrl");
})
.messages({
"string.empty": "This field is required.",
@@ -410,17 +400,54 @@ const notificationValidation = joi.object({
"string.empty": "Notification name is required",
"any.required": "Notification name is required",
}),
address: joi.string().required().messages({
"string.empty": "This field cannot be empty",
"string.base": "This field must be a string",
"any.required": "This field is required",
}),
type: joi.string().required().messages({
"string.empty": "This field is required",
"any.required": "This field is required",
type: joi
.string()
.valid("email", "webhook", "slack", "discord", "pager_duty")
.required()
.messages({
"string.empty": "Notification type is required",
"any.required": "Notification type is required",
"any.only": "Notification type must be email, webhook, or pager_duty",
}),
address: joi.when("type", {
is: "email",
then: joi
.string()
.email({ tlds: { allow: false } })
.required()
.messages({
"string.empty": "E-mail address cannot be empty",
"any.required": "E-mail address is required",
"string.email": "Please enter a valid e-mail address",
}),
otherwise: joi.string().uri().required().messages({
"string.empty": "Webhook URL cannot be empty",
"any.required": "Webhook URL is required",
"string.uri": "Please enter a valid Webhook URL",
}),
}),
});
const editUserValidation = joi.object({
firstName: nameSchema,
lastName: lastnameSchema,
role: joi
.array()
.items(joi.string().valid(...Object.values(ROLES)))
.min(1)
.messages({
"array.min": "auth.common.fields.role.errors.min",
}),
email: joi
.string()
.required()
.trim()
.email({ tlds: { allow: false } })
.lowercase(),
});
export {
newOrChangedCredentials,
loginCredentials,
@@ -433,4 +460,5 @@ export {
statusPageValidation,
logoImageValidation,
notificationValidation,
editUserValidation,
};
+138 -17
View File
@@ -32,6 +32,11 @@
"errors": {
"incorrect": "The password you provided does not match our records"
}
},
"role": {
"errors": {
"min": "At least one role is required"
}
}
},
"inputs": {
@@ -209,7 +214,12 @@
"validationFailed": "Validation failed"
},
"cancel": "Cancel",
"checkFormError": "Please check the form for errors.",
"checkFrequency": "Check frequency",
"checkHooks": {
"failureResolveAll": "Failed to resolve all incidents.",
"failureResolveOne": "Failed to resolve incident."
},
"checkingEvery": "Checking every",
"city": "CITY",
"common": {
@@ -243,6 +253,8 @@
"createMonitor": "Create monitor",
"createNew": "Create new",
"createNotifications": {
"dialogDeleteConfirm": "Delete",
"dialogDeleteTitle": "Are you sure you want to delete this notification?",
"discordSettings": {
"description": "Configure your Discord webhook here",
"title": "Discord",
@@ -273,6 +285,7 @@
"webhookLabel": "Slack webhook URL",
"webhookPlaceholder": "https://hooks.slack.com/services/..."
},
"testNotification": "Test notification",
"title": "Create notification channel",
"typeSettings": {
"description": "Select the type of notification channel you want to create.",
@@ -284,10 +297,7 @@
"title": "Webhook",
"webhookLabel": "Webhook URL",
"webhookPlaceholder": "https://your-server.com/webhook"
},
"testNotification": "Test notification",
"dialogDeleteTitle": "Are you sure you want to delete this notification?",
"dialogDeleteConfirm": "Delete"
}
},
"createYour": "Create your",
"date&Time": "Date & Time",
@@ -297,6 +307,27 @@
"deleteStatusPage": "Do you want to delete this status page?",
"deleteStatusPageConfirm": "Yes, delete status page",
"deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.",
"diagnosticsPage": {
"diagnosticDescription": "System diagnostics",
"gauges": {
"heapAllocationSubtitle": "% of available memory",
"heapAllocationTitle": "Heap allocation",
"heapUsageSubtitle": "% of available memory",
"heapUsageTitle": "Heap usage",
"heapUtilizationSubtitle": "% of allocated",
"heapUtilizationTitle": "Heap utilization",
"instantCpuUsageSubtitle": "% of 1s used by CPU",
"instantCpuUsageTitle": "Instant CPU usage"
},
"stats": {
"eventLoopDelayTitle": "Event loop delay",
"osMemoryLimitTitle": "OS Memory Limit",
"totalHeapSizeTitle": "Total heap size",
"uptimeTitle": "Uptime",
"usedHeapSizeTitle": "Used heap size"
},
"statsDescription": "System statistics"
},
"disk": "Disk",
"diskUsage": "Disk Usage",
"displayName": "Display name",
@@ -347,6 +378,24 @@
"duration": "Duration",
"edit": "Edit",
"editMaintenance": "Edit maintenance",
"editUserPage": {
"form": {
"email": "Email",
"firstName": "First name",
"lastName": "Last name",
"role": "Roles",
"save": "Save"
},
"table": {
"actionHeader": "Action",
"roleHeader": "Role"
},
"title": "Edit user",
"toast": {
"successUserUpdate": "User updated successfully",
"validationErrors": "Validation errors"
}
},
"editing": "Editing...",
"emailSent": "Email sent successfully",
"errorInvalidFieldId": "Invalid field ID provided",
@@ -377,6 +426,9 @@
"friendlyNameInput": "Friendly name",
"friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes",
"gb": "GB",
"general": {
"noOptionsFound": "No {{unit}} found"
},
"greeting": {
"append": "The afternoon is your playground—let's make it epic!",
"overview": "Here's an overview of your {{type}} monitors.",
@@ -391,24 +443,24 @@
"incidentsOptionsHeader": "Incidents for:",
"incidentsOptionsHeaderFilterAll": "All",
"incidentsOptionsHeaderFilterBy": "Filter by:",
"incidentsOptionsHeaderTotalIncidents": "Total Incidents",
"incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve",
"incidentsTableResolvedAt": "Resolved at",
"incidentsOptionsHeaderFilterDown": "Down",
"incidentsTableActionResolve": "Resolve",
"incidentsPageActionResolve": "Resolve all incidents",
"incidentsOptionsHeaderFilterResolved": "Resolved",
"incidentsOptionsHeaderLastDay": "Last day",
"incidentsOptionsHeaderLastHour": "Last hour",
"incidentsOptionsHeaderLastWeek": "Last week",
"incidentsOptionsHeaderShow": "Show:",
"incidentsOptionsHeaderTotalIncidents": "Total Incidents",
"incidentsOptionsPlaceholderAllServers": "All servers",
"incidentsPageActionResolve": "Resolve all incidents",
"incidentsPageTitle": "Incidents",
"incidentsTableActionResolve": "Resolve",
"incidentsTableDateTime": "Date & Time",
"incidentsTableMessage": "Message",
"incidentsTableMonitorName": "Monitor Name",
"incidentsTableNoIncidents": "No incidents recorded",
"incidentsTablePaginationLabel": "incidents",
"incidentsTableResolvedAt": "Resolved at",
"incidentsTableStatus": "Status",
"incidentsTableStatusCode": "Status Code",
"infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.",
@@ -420,6 +472,17 @@
"infrastructureDisplayNameLabel": "Display name",
"infrastructureEditMonitor": "Save Infrastructure Monitor",
"infrastructureEditYour": "Edit your",
"infrastructureMonitor": {
"fallback": {
"checks": [
"Track the performance of your servers",
"Identify bottlenecks and optimize usage",
"Ensure reliability with real-time monitoring"
],
"title": "An infrastructure monitor is used to:",
"actionButton": "Let's create your first infrastructure monitor!"
}
},
"infrastructureMonitorCreated": "Infrastructure monitor created successfully!",
"infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!",
"infrastructureProtocol": "Protocol",
@@ -438,7 +501,7 @@
"inviteNoTokenFound": "No invite token found",
"loginHere": "Login here",
"logsPage": {
"description": "This page shows the latest 1000 lines of logs from the Checkmate server",
"description": "System logs - last 1000 lines",
"logLevelSelect": {
"title": "Log level",
"values": {
@@ -450,6 +513,7 @@
}
},
"tabs": {
"diagnostics": "Diagnostics",
"logs": "Server logs",
"queue": "Job queue"
},
@@ -462,6 +526,17 @@
"maintenance": "maintenance",
"maintenanceRepeat": "Maintenance Repeat",
"maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?",
"maintenanceWindow": {
"fallback": {
"checks": [
"Mark your maintenance periods",
"Eliminate any misunderstandings",
"Stop sending alerts in maintenance windows"
],
"title": "A maintenance window is used to:",
"actionButton": "Let's create your first maintenance window!"
}
},
"maintenanceWindowDescription": "Your pings won't be sent during this time frame",
"maintenanceWindowName": "Maintenance Window Name",
"maskedPageSpeedKeyPlaceholder": "*************************************",
@@ -493,8 +568,8 @@
"message": "Message",
"monitor": "monitor",
"monitorActions": {
"deleteSuccess": "Monitor deleted successfully",
"deleteFailed": "Failed to delete monitor",
"deleteSuccess": "Monitor deleted successfully",
"details": "Details",
"export": "Export Monitors",
"import": "Import Monitors",
@@ -505,7 +580,6 @@
"failureAddDemoMonitors": "Failed to add demo monitors",
"successAddDemoMonitors": "Successfully added demo monitors"
},
"monitorState": {
"active": "Active",
"paused": "Paused",
@@ -522,10 +596,6 @@
"monitorStatusUp": "Monitor {name} ({url}) is now UP and responding",
"monitors": "monitors",
"monitorsToApply": "Monitors to apply maintenance window to",
"checkHooks": {
"failureResolveOne": "Failed to resolve incident.",
"failureResolveAll": "Failed to resolve all incidents."
},
"ms": "ms",
"navControls": "Controls",
"nextWindow": "Next window",
@@ -564,7 +634,8 @@
"Let engineers know when incidents happen",
"Keep administrators informed of system changes"
],
"title": "notification channel"
"title": "A notification channel is used to:",
"actionButton": "Let's create your first notification channel!"
},
"fetch": {
"failed": "Failed to fetch notifications",
@@ -609,11 +680,23 @@
"notifySMS": "Notify via SMS (coming soon)",
"now": "Now",
"os": "OS",
"pageSpeed": {
"fallback": {
"checks": [
"Report on the user experience of a page",
"Help analyze webpage speed",
"Give suggestions on how the page can be improved"
],
"title": "A PageSpeed monitor is used to:",
"actionButton": "Let's create your first PageSpeed monitor!"
}
},
"pageSpeedAddApiKey": "to add your API key.",
"pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.",
"pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.",
"pageSpeedDetailsPerformanceReportCalculator": "See calculator",
"pageSpeedLearnMoreLink": "Click here",
"pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website",
"pageSpeedMonitor": "PageSpeed monitor",
"pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.",
"passwordPanel": {
@@ -796,6 +879,14 @@
"pending": "Pending...",
"up": "Your site is up."
},
"statusPage": {
"contents": "Contents",
"createSuccess": "Status page created successfully",
"deleteFailed": "Failed to delete status page",
"deleteSuccess": "Status page deleted successfully",
"generalSettings": "General settings",
"updateSuccess": "Status page updated successfully"
},
"statusPageCreate": {
"buttonSave": "Save"
},
@@ -821,7 +912,16 @@
"createSuccess": "Status page created successfully",
"updateSuccess": "Status page updated successfully",
"generalSettings": "General settings",
"contents": "Contents"
"contents": "Contents",
"fallback": {
"checks": [
"Monitor and display the health of your services in real time",
"Track multiple services and share their status",
"Keep users informed about outages and performance"
],
"title": "A status page is used to:",
"actionButton": "Let's create your first status page!"
}
},
"submit": "Submit",
"teamPanel": {
@@ -849,6 +949,15 @@
},
"testLocale": "testLocale",
"testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button",
"time": {
"fiveMinutes": "5 minutes",
"oneDay": "1 day",
"oneHour": "1 hour",
"oneWeek": "1 week",
"tenMinutes": "10 minutes",
"threeMinutes": "3 minutes",
"twentyMinutes": "20 minutes"
},
"timeZoneInfo": "All dates and times are in GMT+0 time zone.",
"timezone": "Timezone",
"title": "Title",
@@ -866,6 +975,18 @@
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard."
},
"uptimeMonitor": {
"fallback": {
"checks": [
"Check if websites or servers are online & responsive",
"Alert teams about downtime or performance issues",
"Monitor HTTP endpoints, pings, containers & ports",
"Track historical uptime and reliability trends"
],
"title": "An uptime monitor is used to:",
"actionButton": "Let's create your first uptime monitor!"
}
},
"url": "URL",
"urlMonitor": "URL to monitor",
"used": "Used",
-836
View File
@@ -1,836 +0,0 @@
{
"submit": "Lähetä",
"title": "Otsikko",
"distributedStatusHeaderText": "Reaaliaikainen kattavuus oikeilla laitteilla",
"distributedStatusSubHeaderText": "Miljoonien laitteiden voimin ympäri maailman näet järjestelmän suorituskyvyn maailman alueittain, maittain tai kaupungeittain",
"settingsDisabled": "Poistettu käytöstä",
"settingsSuccessSaved": "",
"settingsFailedToSave": "",
"settingsStatsCleared": "",
"settingsFailedToClearStats": "",
"settingsFailedToAddDemoMonitors": "",
"settingsMonitorsDeleted": "",
"settingsFailedToDeleteMonitors": "",
"starPromptTitle": "",
"starPromptDescription": "",
"https": "HTTPS",
"http": "HTTP",
"monitor": "Seurain",
"aboutus": "Tietoja meistä",
"now": "Nyt",
"delete": "Poista",
"configure": "Määritä",
"responseTime": "Vasteaika",
"ms": "ms",
"bar": "Palkki",
"area": "Alue",
"country": "MAA",
"city": "KAUPUNKI",
"response": "Vastaus",
"monitorStatusUp": "Valvontakohde {name} ({url}) on nyt toiminnassa ja vastaa",
"monitorStatusDown": "Valvontakohde {name} ({url}) ei ole toiminnassa, eikä vastaa",
"webhookSendSuccess": "Webhook-ilmoitus lähetettiin onnistuneesti",
"webhookSendError": "Virhe lähetettäessä webhook-ilmoitusta palveluun {platform}",
"webhookUnsupportedPlatform": "Ei tuettu palvelu: {platform}",
"distributedRightCategoryTitle": "Valvonta",
"distributedStatusServerMonitors": "Palvelin valvonta",
"distributedStatusServerMonitorsDescription": "Valvo palvelimien tilaa",
"distributedUptimeCreateSelectURL": "Tässä voit valita isäntäkohteen URL-osoitteen ja valvontatyypin.",
"distributedUptimeCreateChecks": "Suoritettavat tarkistukset",
"distributedUptimeCreateChecksDescription": "Voit aina lisätä tai poistaa tarkistuksia sivuston lisäämisen jälkeen.",
"distributedUptimeCreateIncidentNotification": "Häiriöilmoitukset",
"distributedUptimeCreateIncidentDescription": "Jos ilmenee häiriö, ilmoita käyttäjille.",
"distributedUptimeCreateAdvancedSettings": "Lisäasetukset",
"distributedUptimeDetailsNoMonitorHistory": "Tälle valvontakohteelle ei ole vielä tarkistushistoriaa.",
"distributedUptimeDetailsStatusHeaderUptime": "",
"distributedUptimeDetailsStatusHeaderLastUpdate": "Viimeksi päivitetty",
"notifications": {
"enableNotifications": "",
"testNotification": "Testi-ilmoitus",
"addOrEditNotifications": "Lisää tai muokkaa ilmoituksia",
"slack": {
"label": "Slack",
"description": "",
"webhookLabel": "Webhookin URL",
"webhookPlaceholder": "",
"webhookRequired": ""
},
"discord": {
"label": "Discord",
"description": "",
"webhookLabel": "Discord Webhookin URL-osoite",
"webhookPlaceholder": "https://discord.com/api/webhooks/...",
"webhookRequired": ""
},
"telegram": {
"label": "Telegram",
"description": "",
"tokenLabel": "",
"tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"chatIdLabel": "",
"chatIdPlaceholder": "-1001234567890",
"fieldsRequired": ""
},
"webhook": {
"label": "Webhookit",
"description": "",
"urlLabel": "Webhookin URL",
"urlPlaceholder": "https://your-server.com/webhook",
"urlRequired": ""
},
"testNotificationDevelop": "Testi-ilmoitus 2",
"integrationButton": "",
"testSuccess": "Testi ilmoituksen lähetys onnistui!",
"testFailed": "Testi ilmoituksen lähetys epäonnistui",
"unsupportedType": "",
"networkError": "",
"fallback": {
"title": "",
"checks": [""]
},
"createButton": "",
"createTitle": "",
"create": {
"success": "",
"failed": ""
},
"fetch": {
"success": "",
"failed": ""
},
"delete": {
"success": "",
"failed": ""
},
"edit": {
"success": "",
"failed": ""
},
"test": {
"success": "",
"failed": ""
}
},
"testLocale": "",
"add": "Lisää",
"monitors": "Seuraimet",
"distributedUptimeStatusCreateStatusPage": "",
"distributedUptimeStatusCreateStatusPageAccess": "",
"distributedUptimeStatusCreateStatusPageReady": "",
"distributedUptimeStatusBasicInfoHeader": "",
"distributedUptimeStatusBasicInfoDescription": "",
"distributedUptimeStatusLogoHeader": "Logo",
"distributedUptimeStatusLogoDescription": "",
"distributedUptimeStatusLogoUploadButton": "",
"distributedUptimeStatusStandardMonitorsHeader": "",
"distributedUptimeStatusStandardMonitorsDescription": "",
"distributedUptimeStatusCreateYour": "",
"distributedUptimeStatusEditYour": "",
"distributedUptimeStatusPublishedLabel": "",
"distributedUptimeStatusCompanyNameLabel": "Yrityksen nimi",
"distributedUptimeStatusPageAddressLabel": "",
"distributedUptimeStatus30Days": "30 päivää",
"distributedUptimeStatus60Days": "60 päivää",
"distributedUptimeStatus90Days": "90 päivää",
"distributedUptimeStatusPageNotSetUp": "",
"distributedUptimeStatusContactAdmin": "Ota yhteys järjestelmävalvojaasi",
"distributedUptimeStatusPageNotPublic": "",
"distributedUptimeStatusPageDeleteDialog": "",
"distributedUptimeStatusPageDeleteConfirm": "",
"distributedUptimeStatusPageDeleteDescription": "",
"distributedUptimeStatusDevices": "",
"distributedUptimeStatusUpt": "",
"distributedUptimeStatusUptBurned": "",
"distributedUptimeStatusUptLogo": "UPT logo",
"incidentsTableNoIncidents": "",
"incidentsTablePaginationLabel": "",
"incidentsTableMonitorName": "",
"incidentsTableStatus": "Tila",
"incidentsTableDateTime": "Päivämäärä ja aika",
"incidentsTableStatusCode": "",
"incidentsTableMessage": "Viesti",
"incidentsOptionsHeader": "",
"incidentsOptionsHeaderFilterBy": "",
"incidentsOptionsHeaderFilterAll": "Kaikki",
"incidentsOptionsHeaderFilterDown": "Alhaalla",
"incidentsOptionsHeaderFilterCannotResolve": "",
"incidentsOptionsHeaderShow": "Näytä:",
"incidentsOptionsHeaderLastHour": "Viime tunti",
"incidentsOptionsHeaderLastDay": "Viime päivä",
"incidentsOptionsHeaderLastWeek": "Viime viikko",
"incidentsOptionsPlaceholderAllServers": "Kaikki palvelimet",
"infrastructureCreateYour": "",
"infrastructureCreateGeneralSettingsDescription": "",
"infrastructureServerRequirement": "",
"infrastructureCustomizeAlerts": "",
"infrastructureAlertNotificationDescription": "",
"infrastructureCreateMonitor": "",
"infrastructureProtocol": "Protokolla",
"infrastructureServerUrlLabel": "Palvelimen URL-osoite",
"infrastructureDisplayNameLabel": "",
"infrastructureAuthorizationSecretLabel": "",
"gb": "",
"mb": "",
"mem": "",
"memoryUsage": "Muistinkäyttö",
"cpu": "",
"cpuUsage": "",
"cpuTemperature": "",
"diskUsage": "",
"used": "",
"total": "",
"cores": "",
"frequency": "",
"status": "Tila",
"cpuPhysical": "",
"cpuLogical": "",
"cpuFrequency": "",
"avgCpuTemperature": "",
"memory": "Muisti",
"disk": "",
"uptime": "",
"os": "",
"host": "",
"actions": "",
"integrations": "",
"integrationsPrism": "",
"integrationsSlack": "Slack",
"integrationsSlackInfo": "",
"integrationsDiscord": "",
"integrationsDiscordInfo": "",
"integrationsZapier": "Zapier",
"integrationsZapierInfo": "",
"commonSave": "Tallenna",
"createYour": "",
"createMonitor": "",
"pause": "",
"resume": "",
"editing": "",
"url": "",
"access": "",
"timezone": "Aikavyöhyke",
"features": "",
"administrator": "",
"loginHere": "",
"displayName": "",
"urlMonitor": "",
"portToMonitor": "",
"websiteMonitoring": "",
"websiteMonitoringDescription": "",
"pingMonitoring": "",
"pingMonitoringDescription": "",
"dockerContainerMonitoring": "",
"dockerContainerMonitoringDescription": "",
"portMonitoring": "",
"portMonitoringDescription": "",
"createMaintenanceWindow": "",
"createMaintenance": "",
"editMaintenance": "",
"maintenanceWindowName": "",
"friendlyNameInput": "",
"friendlyNamePlaceholder": "",
"maintenanceRepeat": "",
"maintenance": "",
"duration": "",
"addMonitors": "",
"window": "",
"cancel": "Peruuttaa",
"message": "Viesti",
"low": "",
"high": "",
"statusCode": "Tilakoodi",
"date&Time": "Päivämäärä ja aika",
"type": "",
"statusPageName": "",
"publicURL": "Julkinen URL-osoite",
"repeat": "",
"edit": "Muokkaa",
"createA": "",
"remove": "",
"maintenanceWindowDescription": "",
"startTime": "",
"timeZoneInfo": "",
"monitorsToApply": "",
"nextWindow": "",
"notFoundButton": "",
"pageSpeedConfigureSettingsDescription": "",
"monitorDisplayName": "",
"whenNewIncident": "",
"notifySMS": "Ilmoita tekstiviestillä (tulossa pian)",
"notifyEmails": "",
"seperateEmails": "",
"checkFrequency": "",
"matchMethod": "",
"expectedValue": "",
"deleteDialogTitle": "",
"deleteDialogDescription": "",
"pageSpeedMonitor": "",
"shown": "",
"ago": "sitten",
"companyName": "Yrityksen nimi",
"pageSpeedDetailsPerformanceReport": "",
"pageSpeedDetailsPerformanceReportCalculator": "",
"checkingEvery": "",
"statusPageCreateSettings": "",
"basicInformation": "",
"statusPageCreateBasicInfoDescription": "",
"statusPageCreateSelectTimeZoneDescription": "",
"statusPageCreateAppearanceDescription": "",
"statusPageCreateSettingsCheckboxLabel": "",
"statusPageCreateBasicInfoStatusPageAddress": "",
"statusPageCreateTabsContent": "",
"statusPageCreateTabsContentDescription": "",
"statusPageCreateTabsContentFeaturesDescription": "",
"showCharts": "Näytä kaaviot",
"showUptimePercentage": "",
"removeLogo": "Poista logo",
"statusPageStatus": "",
"statusPageStatusContactAdmin": "Ota yhteyttä järjestelmänvalvojaan",
"statusPageStatusNotPublic": "",
"statusPageStatusNoPage": "",
"statusPageStatusServiceStatus": "",
"deleteStatusPage": "",
"deleteStatusPageConfirm": "",
"deleteStatusPageDescription": "",
"uptimeCreate": "",
"uptimeCreateJsonPath": "",
"uptimeCreateJsonPathQuery": "",
"maintenanceTableActionMenuDialogTitle": "",
"infrastructureEditYour": "Muokkaa omaa",
"infrastructureEditMonitor": "",
"infrastructureMonitorCreated": "",
"infrastructureMonitorUpdated": "",
"errorInvalidTypeId": "",
"errorInvalidFieldId": "",
"inviteNoTokenFound": "",
"pageSpeedWarning": "",
"pageSpeedLearnMoreLink": "",
"pageSpeedAddApiKey": "",
"update": "Päivitä",
"invalidFileFormat": "",
"invalidFileSize": "Tiedoston koko on liian suuri!",
"ClickUpload": "",
"DragandDrop": "raahaa ja pudota",
"MaxSize": "Enimmäiskoko",
"SupportedFormats": "Tuetut muodot",
"FirstName": "Etunimi",
"LastName": "Sukunimi",
"EmailDescriptionText": "Tämä on nykyinen sähköpostiosoitteesi — sitä ei voi vaihtaa.",
"YourPhoto": "Profiilikuva",
"PhotoDescriptionText": "",
"save": "Tallenna",
"DeleteDescriptionText": "",
"DeleteAccountWarning": "",
"DeleteWarningTitle": "Haluatko todella poistaa tämän tilin?",
"bulkImport": {
"title": "",
"selectFileTips": "",
"selectFileDescription": "",
"selectFile": "Valitse tiedosto",
"parsingFailed": "",
"uploadSuccess": "",
"validationFailed": "",
"noFileSelected": "Ei valittua tiedostoa",
"fallbackPage": ""
},
"DeleteAccountTitle": "Poista tili",
"DeleteAccountButton": "Poista tili",
"publicLink": "Julkinen linkki",
"maskedPageSpeedKeyPlaceholder": "*************************************",
"reset": "Palauta",
"ignoreTLSError": "",
"tlsErrorIgnored": "",
"ignoreTLSErrorDescription": "",
"createNew": "Luo uusi",
"greeting": {
"prepend": "Hei",
"append": "",
"overview": ""
},
"roles": {
"superAdmin": "Pääylläpitäjä",
"admin": "Ylläpitäjä",
"teamMember": "Tiimin jäsen",
"demoUser": "Demokäyttäjä"
},
"teamPanel": {
"teamMembers": "Tiimin jäsenet",
"filter": {
"all": "Kaikki",
"member": "Jäsen"
},
"inviteTeamMember": "",
"inviteNewTeamMember": "",
"inviteDescription": "",
"email": "Sähköposti",
"selectRole": "Valitse rooli",
"inviteLink": "Kutsulinkki",
"cancel": "Peruuttaa",
"noMembers": "",
"getToken": "Hae tunniste",
"emailToken": "Sähköpostitunnus",
"table": {
"name": "Nimi",
"email": "Sähköposti",
"role": "Rooli",
"created": "Luotu"
}
},
"monitorState": {
"paused": "Tauota",
"resumed": "Jatka",
"active": ""
},
"menu": {
"uptime": "",
"pagespeed": "",
"infrastructure": "Infrastruktuuri",
"incidents": "",
"statusPages": "Tilasivut",
"maintenance": "Huolto",
"integrations": "",
"settings": "Asetukset",
"support": "Tuki",
"discussions": "Keskustelut",
"docs": "Dokumentit",
"changelog": "Muutosloki",
"profile": "Profiili",
"password": "Salasana",
"team": "Tiimi",
"logOut": "Kirjaudu ulos",
"notifications": "",
"logs": ""
},
"settingsEmailUser": "",
"state": "Tila",
"statusBreadCrumbsStatusPages": "Tilasivut",
"statusBreadCrumbsDetails": "Tiedot",
"commonSaving": "Tallennetaan...",
"navControls": "Ohjaimet",
"incidentsPageTitle": "Häiriöt",
"passwordPanel": {
"passwordChangedSuccess": "Salasanasi vaihdettiin onnistuneesti.",
"passwordInputIncorrect": "",
"currentPassword": "Nykyinen salasana",
"enterCurrentPassword": "Kirjoita nykyinen salasanasi",
"newPassword": "Uusi salasana",
"enterNewPassword": "Kirjoita uusi salasana",
"confirmNewPassword": "Vahvista uusi salasana",
"passwordRequirements": "Uuden salasanan on oltava vähintään 8 merkkiä pitkä ja sen on sisältävä vähintään yksi iso kirjain, yksi pieni kirjain, yksi numero sekä yksi erikoismerkki.",
"saving": "Tallennetaan..."
},
"emailSent": "Sähköpostin lähetys onnistui",
"failedToSendEmail": "Sähköpostin lähetys epäonnistui",
"settingsTestEmailSuccess": "Testisähköpostin lähetys onnistui",
"settingsTestEmailFailed": "Testisähköpostin lähetys epäonnistui",
"settingsTestEmailFailedWithReason": "Testisähköpostin lähetys epäonnistui: {{reason}}",
"settingsTestEmailUnknownError": "Tuntematon virhe",
"statusMsg": {
"paused": "Valvonta on keskeytetty.",
"up": "Sivusi on ylhäällä.",
"down": "Sivusi on alhaalla.",
"pending": "Odottaa..."
},
"uptimeGeneralInstructions": {
"http": "",
"ping": "",
"docker": "",
"port": ""
},
"common": {
"appName": "",
"monitoringAgentName": "",
"buttons": {
"toggleTheme": ""
},
"toasts": {
"networkError": "",
"checkConnection": "",
"unknownError": ""
}
},
"auth": {
"common": {
"navigation": {
"continue": "",
"back": ""
},
"inputs": {
"email": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"invalid": ""
}
},
"password": {
"label": "",
"rules": {
"length": {
"beginning": "",
"highlighted": ""
},
"special": {
"beginning": "",
"highlighted": ""
},
"number": {
"beginning": "",
"highlighted": ""
},
"uppercase": {
"beginning": "",
"highlighted": ""
},
"lowercase": {
"beginning": "",
"highlighted": ""
},
"match": {
"beginning": "",
"highlighted": ""
}
},
"errors": {
"empty": "",
"length": "",
"uppercase": "",
"lowercase": "",
"number": "",
"special": "",
"incorrect": ""
}
},
"passwordConfirm": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"different": ""
}
},
"firstName": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"length": "",
"pattern": ""
}
},
"lastName": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"length": "",
"pattern": ""
}
}
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
"heading": "",
"subheadings": {
"stepOne": "",
"stepTwo": ""
},
"links": {
"forgotPassword": "",
"register": "",
"forgotPasswordLink": "",
"registerLink": ""
},
"toasts": {
"success": "",
"incorrectPassword": ""
},
"errors": {
"password": {
"incorrect": ""
}
}
},
"registration": {
"heading": {
"superAdmin": "",
"user": ""
},
"subheadings": {
"stepOne": "",
"stepTwo": "",
"stepThree": ""
},
"description": {
"superAdmin": "",
"user": ""
},
"gettingStartedButton": {
"superAdmin": "",
"user": ""
},
"termsAndPolicies": "",
"links": {
"login": ""
},
"toasts": {
"success": ""
}
},
"forgotPassword": {
"heading": "",
"subheadings": {
"stepOne": "",
"stepTwo": "",
"stepThree": "",
"stepFour": ""
},
"buttons": {
"openEmail": "",
"resetPassword": ""
},
"imageAlts": {
"passwordKey": "",
"email": "",
"lock": "",
"passwordConfirm": ""
},
"links": {
"login": "",
"resend": ""
},
"toasts": {
"sent": "",
"emailNotFound": "",
"redirect": "",
"success": "",
"error": ""
}
}
},
"errorPages": {
"serverUnreachable": {
"toasts": {
"reconnected": "",
"stillUnreachable": ""
},
"alertBox": "",
"description": "",
"retryButton": {
"default": "",
"processing": ""
}
}
},
"createNotifications": {
"title": "",
"nameSettings": {
"title": "",
"description": "",
"nameLabel": "",
"namePlaceholder": ""
},
"typeSettings": {
"title": "",
"description": "",
"typeLabel": ""
},
"emailSettings": {
"title": "",
"description": "",
"emailLabel": "",
"emailPlaceholder": ""
},
"slackSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"pagerdutySettings": {
"title": "",
"description": "",
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
}
},
"notificationConfig": {
"title": "",
"description": ""
},
"monitorStatus": {
"checkingEvery": "",
"withCaptureAgent": "",
"up": "",
"down": "",
"paused": ""
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {
"title": "",
"description": "",
"tabs": {
"queue": "",
"logs": ""
},
"toast": {
"fetchLogsSuccess": ""
},
"logLevelSelect": {
"title": "",
"values": {
"all": "",
"info": "",
"warn": "",
"error": "",
"debug": ""
}
}
},
"queuePage": {
"title": "",
"refreshButton": "",
"flushButton": "",
"jobTable": {
"title": "",
"idHeader": "",
"urlHeader": "",
"typeHeader": "",
"activeHeader": "",
"lockedAtHeader": "",
"runCountHeader": "",
"failCountHeader": "",
"lastRunHeader": "",
"lastFinishedAtHeader": "",
"lastRunTookHeader": ""
},
"metricsTable": {
"title": "",
"metricHeader": "",
"valueHeader": ""
},
"failedJobTable": {
"title": "",
"monitorIdHeader": "",
"monitorUrlHeader": "",
"failCountHeader": "",
"failedAtHeader": "",
"failReasonHeader": ""
}
},
"export": {
"title": "",
"success": "",
"failed": ""
},
"monitorActions": {
"title": "",
"import": "",
"export": ""
},
"settingsPage": {
"aboutSettings": {
"labelDevelopedBy": "",
"labelVersion": "",
"title": ""
},
"demoMonitorsSettings": {
"buttonAddMonitors": "",
"description": "",
"title": ""
},
"emailSettings": {
"buttonSendTestEmail": "",
"description": "",
"descriptionTransport": "",
"labelAddress": "",
"labelConnectionHost": "",
"labelHost": "",
"labelIgnoreTLS": "",
"labelPassword": "",
"labelPasswordSet": "",
"labelPool": "",
"labelPort": "",
"labelRejectUnauthorized": "",
"labelRequireTLS": "",
"labelSecure": "",
"labelTLSServername": "",
"labelUser": "",
"linkTransport": "",
"placeholderUser": "",
"title": ""
},
"pageSpeedSettings": {
"description": "",
"labelApiKeySet": "",
"labelApiKey": "",
"title": ""
},
"saveButtonLabel": "",
"statsSettings": {
"clearAllStatsButton": "",
"clearAllStatsDescription": "",
"clearAllStatsDialogConfirm": "",
"clearAllStatsDialogDescription": "",
"clearAllStatsDialogTitle": "",
"description": "",
"labelTTL": "",
"labelTTLOptional": "",
"title": ""
},
"systemResetSettings": {
"buttonRemoveAllMonitors": "",
"description": "",
"dialogConfirm": "",
"dialogDescription": "",
"dialogTitle": "",
"title": ""
},
"timezoneSettings": {
"description": "",
"label": "",
"title": ""
},
"title": "",
"uiSettings": {
"description": "",
"labelLanguage": "",
"labelTheme": "",
"title": ""
},
"urlSettings": {
"description": "",
"label": "",
"selectDisabled": "",
"selectEnabled": "",
"title": ""
}
}
}
-836
View File
@@ -1,836 +0,0 @@
{
"submit": "Envoyer",
"title": "Titre",
"distributedStatusHeaderText": "",
"distributedStatusSubHeaderText": "",
"settingsDisabled": "Désactivé",
"settingsSuccessSaved": "",
"settingsFailedToSave": "",
"settingsStatsCleared": "",
"settingsFailedToClearStats": "",
"settingsFailedToAddDemoMonitors": "",
"settingsMonitorsDeleted": "",
"settingsFailedToDeleteMonitors": "",
"starPromptTitle": "",
"starPromptDescription": "",
"https": "",
"http": "",
"monitor": "",
"aboutus": "A propos de nous",
"now": "",
"delete": "Supprimer",
"configure": "",
"responseTime": "",
"ms": "",
"bar": "",
"area": "Zone",
"country": "PAYS",
"city": "VILLE",
"response": "",
"monitorStatusUp": "",
"monitorStatusDown": "",
"webhookSendSuccess": "",
"webhookSendError": "",
"webhookUnsupportedPlatform": "",
"distributedRightCategoryTitle": "Moniteur",
"distributedStatusServerMonitors": "",
"distributedStatusServerMonitorsDescription": "",
"distributedUptimeCreateSelectURL": "",
"distributedUptimeCreateChecks": "",
"distributedUptimeCreateChecksDescription": "",
"distributedUptimeCreateIncidentNotification": "Notifications d'incidents",
"distributedUptimeCreateIncidentDescription": "",
"distributedUptimeCreateAdvancedSettings": "Paramètres avancés",
"distributedUptimeDetailsNoMonitorHistory": "",
"distributedUptimeDetailsStatusHeaderUptime": "",
"distributedUptimeDetailsStatusHeaderLastUpdate": "",
"notifications": {
"enableNotifications": "",
"testNotification": "Notification de test",
"addOrEditNotifications": "Ajouter ou éditer des notifications",
"slack": {
"label": "",
"description": "",
"webhookLabel": "URL du webhook",
"webhookPlaceholder": "",
"webhookRequired": ""
},
"discord": {
"label": "",
"description": "",
"webhookLabel": "URL du webhook Discord",
"webhookPlaceholder": "",
"webhookRequired": ""
},
"telegram": {
"label": "",
"description": "",
"tokenLabel": "",
"tokenPlaceholder": "",
"chatIdLabel": "Votre Chat ID",
"chatIdPlaceholder": "-1001234567890",
"fieldsRequired": ""
},
"webhook": {
"label": "",
"description": "",
"urlLabel": "URL du webhook",
"urlPlaceholder": "https://votre-serveur.fr/webhook",
"urlRequired": "L'URL du webhook est nécessaire"
},
"testNotificationDevelop": "Notification de test 2",
"integrationButton": "Intégration de notification",
"testSuccess": "La notification de test a été envoyée avec succès !",
"testFailed": "Échec de l'envoi de la notification de test",
"unsupportedType": "",
"networkError": "",
"fallback": {
"title": "",
"checks": [""]
},
"createButton": "",
"createTitle": "",
"create": {
"success": "",
"failed": ""
},
"fetch": {
"success": "",
"failed": ""
},
"delete": {
"success": "",
"failed": ""
},
"edit": {
"success": "",
"failed": ""
},
"test": {
"success": "",
"failed": ""
}
},
"testLocale": "",
"add": "Ajouter",
"monitors": "",
"distributedUptimeStatusCreateStatusPage": "page de statut",
"distributedUptimeStatusCreateStatusPageAccess": "Accès",
"distributedUptimeStatusCreateStatusPageReady": "",
"distributedUptimeStatusBasicInfoHeader": "",
"distributedUptimeStatusBasicInfoDescription": "",
"distributedUptimeStatusLogoHeader": "Logo",
"distributedUptimeStatusLogoDescription": "",
"distributedUptimeStatusLogoUploadButton": "Téléverser un logo",
"distributedUptimeStatusStandardMonitorsHeader": "",
"distributedUptimeStatusStandardMonitorsDescription": "",
"distributedUptimeStatusCreateYour": "",
"distributedUptimeStatusEditYour": "",
"distributedUptimeStatusPublishedLabel": "",
"distributedUptimeStatusCompanyNameLabel": "Nom de l'entreprise",
"distributedUptimeStatusPageAddressLabel": "Adresse de votre page de statut",
"distributedUptimeStatus30Days": "30 jours",
"distributedUptimeStatus60Days": "60 jours",
"distributedUptimeStatus90Days": "90 jours",
"distributedUptimeStatusPageNotSetUp": "",
"distributedUptimeStatusContactAdmin": "Veuillez contacter votre administrateur",
"distributedUptimeStatusPageNotPublic": "Cette page de statut n'est pas publique.",
"distributedUptimeStatusPageDeleteDialog": "Voulez-vous supprimer cette page de statut ?",
"distributedUptimeStatusPageDeleteConfirm": "Oui, supprimer la page de statut",
"distributedUptimeStatusPageDeleteDescription": "Supprimer votre page de statut est irréversible.",
"distributedUptimeStatusDevices": "",
"distributedUptimeStatusUpt": "",
"distributedUptimeStatusUptBurned": "",
"distributedUptimeStatusUptLogo": "",
"incidentsTableNoIncidents": "",
"incidentsTablePaginationLabel": "incidents",
"incidentsTableMonitorName": "",
"incidentsTableStatus": "",
"incidentsTableDateTime": "Date et heure",
"incidentsTableStatusCode": "",
"incidentsTableMessage": "Message",
"incidentsOptionsHeader": "",
"incidentsOptionsHeaderFilterBy": "",
"incidentsOptionsHeaderFilterAll": "",
"incidentsOptionsHeaderFilterDown": "",
"incidentsOptionsHeaderFilterCannotResolve": "",
"incidentsOptionsHeaderShow": "",
"incidentsOptionsHeaderLastHour": "Dernière heure",
"incidentsOptionsHeaderLastDay": "Dernier jour",
"incidentsOptionsHeaderLastWeek": "Dernière semaine",
"incidentsOptionsPlaceholderAllServers": "",
"infrastructureCreateYour": "",
"infrastructureCreateGeneralSettingsDescription": "",
"infrastructureServerRequirement": "",
"infrastructureCustomizeAlerts": "",
"infrastructureAlertNotificationDescription": "",
"infrastructureCreateMonitor": "",
"infrastructureProtocol": "Protocole",
"infrastructureServerUrlLabel": "URL du serveur",
"infrastructureDisplayNameLabel": "",
"infrastructureAuthorizationSecretLabel": "",
"gb": "",
"mb": "",
"mem": "",
"memoryUsage": "",
"cpu": "CPU",
"cpuUsage": "",
"cpuTemperature": "",
"diskUsage": "Utilisation du disque",
"used": "",
"total": "",
"cores": "",
"frequency": "",
"status": "",
"cpuPhysical": "",
"cpuLogical": "",
"cpuFrequency": "Fréquence CPU",
"avgCpuTemperature": "Température moyenne du CPU",
"memory": "",
"disk": "Disque",
"uptime": "",
"os": "",
"host": "",
"actions": "Actions",
"integrations": "Intégrations",
"integrationsPrism": "Connecter Prism à votre service préféré.",
"integrationsSlack": "Slack",
"integrationsSlackInfo": "Connecter à Slack et voir les incidents dans un canal",
"integrationsDiscord": "Discord",
"integrationsDiscordInfo": "Connecter à Discord et voir les incidents dans un canal",
"integrationsZapier": "Zapier",
"integrationsZapierInfo": "Envoyer tous les incidents à Zapier",
"commonSave": "Sauvegarder",
"createYour": "Créez votre",
"createMonitor": "",
"pause": "",
"resume": "",
"editing": "",
"url": "URL",
"access": "Accès",
"timezone": "Fuseau horaire",
"features": "",
"administrator": "Administrateur ?",
"loginHere": "",
"displayName": "Nom d'affichage",
"urlMonitor": "URL à suivre",
"portToMonitor": "",
"websiteMonitoring": "",
"websiteMonitoringDescription": "",
"pingMonitoring": "",
"pingMonitoringDescription": "",
"dockerContainerMonitoring": "",
"dockerContainerMonitoringDescription": "",
"portMonitoring": "",
"portMonitoringDescription": "",
"createMaintenanceWindow": "",
"createMaintenance": "Créer une maintenance",
"editMaintenance": "",
"maintenanceWindowName": "",
"friendlyNameInput": "",
"friendlyNamePlaceholder": "",
"maintenanceRepeat": "",
"maintenance": "",
"duration": "",
"addMonitors": "Ajouter des moniteurs",
"window": "",
"cancel": "Annuler",
"message": "",
"low": "",
"high": "",
"statusCode": "",
"date&Time": "Date et heure",
"type": "",
"statusPageName": "",
"publicURL": "",
"repeat": "",
"edit": "",
"createA": "Créer un",
"remove": "",
"maintenanceWindowDescription": "",
"startTime": "",
"timeZoneInfo": "",
"monitorsToApply": "",
"nextWindow": "",
"notFoundButton": "",
"pageSpeedConfigureSettingsDescription": "",
"monitorDisplayName": "",
"whenNewIncident": "Lors d'un nouvel incident,",
"notifySMS": "",
"notifyEmails": "",
"seperateEmails": "",
"checkFrequency": "Vérifier la fréquence",
"matchMethod": "",
"expectedValue": "",
"deleteDialogTitle": "",
"deleteDialogDescription": "",
"pageSpeedMonitor": "",
"shown": "",
"ago": "depuis",
"companyName": "",
"pageSpeedDetailsPerformanceReport": "",
"pageSpeedDetailsPerformanceReportCalculator": "",
"checkingEvery": "Vérification tous les",
"statusPageCreateSettings": "",
"basicInformation": "",
"statusPageCreateBasicInfoDescription": "",
"statusPageCreateSelectTimeZoneDescription": "",
"statusPageCreateAppearanceDescription": "",
"statusPageCreateSettingsCheckboxLabel": "",
"statusPageCreateBasicInfoStatusPageAddress": "",
"statusPageCreateTabsContent": "",
"statusPageCreateTabsContentDescription": "",
"statusPageCreateTabsContentFeaturesDescription": "",
"showCharts": "",
"showUptimePercentage": "",
"removeLogo": "",
"statusPageStatus": "",
"statusPageStatusContactAdmin": "",
"statusPageStatusNotPublic": "",
"statusPageStatusNoPage": "",
"statusPageStatusServiceStatus": "",
"deleteStatusPage": "",
"deleteStatusPageConfirm": "Oui, supprimer la page de statut",
"deleteStatusPageDescription": "",
"uptimeCreate": "",
"uptimeCreateJsonPath": "",
"uptimeCreateJsonPathQuery": "",
"maintenanceTableActionMenuDialogTitle": "",
"infrastructureEditYour": "",
"infrastructureEditMonitor": "",
"infrastructureMonitorCreated": "",
"infrastructureMonitorUpdated": "",
"errorInvalidTypeId": "",
"errorInvalidFieldId": "",
"inviteNoTokenFound": "",
"pageSpeedWarning": "",
"pageSpeedLearnMoreLink": "",
"pageSpeedAddApiKey": "",
"update": "",
"invalidFileFormat": "Format de fichier non supporté !",
"invalidFileSize": "Le fichier est trop volumineux !",
"ClickUpload": "Cliquez pour téléverser",
"DragandDrop": "",
"MaxSize": "",
"SupportedFormats": "",
"FirstName": "",
"LastName": "",
"EmailDescriptionText": "",
"YourPhoto": "Photo de profil",
"PhotoDescriptionText": "",
"save": "",
"DeleteDescriptionText": "",
"DeleteAccountWarning": "",
"DeleteWarningTitle": "Vous supprimez vraiment ce compte ?",
"bulkImport": {
"title": "",
"selectFileTips": "",
"selectFileDescription": "",
"selectFile": "",
"parsingFailed": "",
"uploadSuccess": "",
"validationFailed": "Echec de la validation",
"noFileSelected": "",
"fallbackPage": ""
},
"DeleteAccountTitle": "Supprimer le compte",
"DeleteAccountButton": "Supprimer le compte",
"publicLink": "",
"maskedPageSpeedKeyPlaceholder": "",
"reset": "",
"ignoreTLSError": "",
"tlsErrorIgnored": "",
"ignoreTLSErrorDescription": "",
"createNew": "Créer un nouveau",
"greeting": {
"prepend": "",
"append": "L'après-midi est votre libre, rendons-le épique !",
"overview": ""
},
"roles": {
"superAdmin": "",
"admin": "Admin",
"teamMember": "",
"demoUser": ""
},
"teamPanel": {
"teamMembers": "Membres de l'équipe",
"filter": {
"all": "Tous",
"member": ""
},
"inviteTeamMember": "",
"inviteNewTeamMember": "",
"inviteDescription": "",
"email": "Email",
"selectRole": "",
"inviteLink": "Lien d'invitation",
"cancel": "Annuler",
"noMembers": "",
"getToken": "",
"emailToken": "",
"table": {
"name": "",
"email": "Email",
"role": "",
"created": "Créée"
}
},
"monitorState": {
"paused": "",
"resumed": "",
"active": "Actif"
},
"menu": {
"uptime": "",
"pagespeed": "",
"infrastructure": "",
"incidents": "",
"statusPages": "",
"maintenance": "",
"integrations": "Intégrations",
"settings": "",
"support": "",
"discussions": "Discussions",
"docs": "",
"changelog": "Changelog",
"profile": "",
"password": "",
"team": "",
"logOut": "Déconnexion",
"notifications": "",
"logs": ""
},
"settingsEmailUser": "",
"state": "",
"statusBreadCrumbsStatusPages": "",
"statusBreadCrumbsDetails": "",
"commonSaving": "Sauvegarde...",
"navControls": "",
"incidentsPageTitle": "Incidents",
"passwordPanel": {
"passwordChangedSuccess": "Votre mot de passe a été modifié avec succès.",
"passwordInputIncorrect": "La saisie de votre mot de passe est incorrecte.",
"currentPassword": "Mot de passe actuel",
"enterCurrentPassword": "Entrez votre mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"enterNewPassword": "Entrez votre nouveau mot de passe",
"confirmNewPassword": "Confirmer le nouveau mot de passe",
"passwordRequirements": "Le nouveau mot de passe doit contenir au moins 8 caractères et au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère spécial.",
"saving": "Sauvegarde..."
},
"emailSent": "",
"failedToSendEmail": "",
"settingsTestEmailSuccess": "",
"settingsTestEmailFailed": "",
"settingsTestEmailFailedWithReason": "",
"settingsTestEmailUnknownError": "Erreur inconnue",
"statusMsg": {
"paused": "",
"up": "",
"down": "",
"pending": ""
},
"uptimeGeneralInstructions": {
"http": "",
"ping": "",
"docker": "",
"port": ""
},
"common": {
"appName": "",
"monitoringAgentName": "",
"buttons": {
"toggleTheme": ""
},
"toasts": {
"networkError": "",
"checkConnection": "",
"unknownError": ""
}
},
"auth": {
"common": {
"navigation": {
"continue": "",
"back": ""
},
"inputs": {
"email": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"invalid": ""
}
},
"password": {
"label": "",
"rules": {
"length": {
"beginning": "",
"highlighted": ""
},
"special": {
"beginning": "",
"highlighted": ""
},
"number": {
"beginning": "",
"highlighted": ""
},
"uppercase": {
"beginning": "",
"highlighted": ""
},
"lowercase": {
"beginning": "",
"highlighted": ""
},
"match": {
"beginning": "",
"highlighted": ""
}
},
"errors": {
"empty": "",
"length": "",
"uppercase": "",
"lowercase": "",
"number": "",
"special": "",
"incorrect": ""
}
},
"passwordConfirm": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"different": ""
}
},
"firstName": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"length": "",
"pattern": ""
}
},
"lastName": {
"label": "",
"placeholder": "",
"errors": {
"empty": "",
"length": "",
"pattern": ""
}
}
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
"heading": "",
"subheadings": {
"stepOne": "",
"stepTwo": ""
},
"links": {
"forgotPassword": "",
"register": "",
"forgotPasswordLink": "",
"registerLink": ""
},
"toasts": {
"success": "",
"incorrectPassword": ""
},
"errors": {
"password": {
"incorrect": ""
}
}
},
"registration": {
"heading": {
"superAdmin": "",
"user": ""
},
"subheadings": {
"stepOne": "",
"stepTwo": "",
"stepThree": ""
},
"description": {
"superAdmin": "",
"user": ""
},
"gettingStartedButton": {
"superAdmin": "",
"user": ""
},
"termsAndPolicies": "",
"links": {
"login": ""
},
"toasts": {
"success": ""
}
},
"forgotPassword": {
"heading": "",
"subheadings": {
"stepOne": "",
"stepTwo": "",
"stepThree": "",
"stepFour": ""
},
"buttons": {
"openEmail": "",
"resetPassword": ""
},
"imageAlts": {
"passwordKey": "",
"email": "",
"lock": "",
"passwordConfirm": ""
},
"links": {
"login": "",
"resend": ""
},
"toasts": {
"sent": "",
"emailNotFound": "",
"redirect": "",
"success": "",
"error": ""
}
}
},
"errorPages": {
"serverUnreachable": {
"toasts": {
"reconnected": "",
"stillUnreachable": ""
},
"alertBox": "",
"description": "",
"retryButton": {
"default": "",
"processing": ""
}
}
},
"createNotifications": {
"title": "",
"nameSettings": {
"title": "",
"description": "",
"nameLabel": "",
"namePlaceholder": ""
},
"typeSettings": {
"title": "",
"description": "",
"typeLabel": ""
},
"emailSettings": {
"title": "",
"description": "",
"emailLabel": "",
"emailPlaceholder": ""
},
"slackSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"pagerdutySettings": {
"title": "",
"description": "",
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
}
},
"notificationConfig": {
"title": "",
"description": ""
},
"monitorStatus": {
"checkingEvery": "",
"withCaptureAgent": "",
"up": "",
"down": "",
"paused": ""
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {
"title": "",
"description": "",
"tabs": {
"queue": "",
"logs": ""
},
"toast": {
"fetchLogsSuccess": ""
},
"logLevelSelect": {
"title": "",
"values": {
"all": "",
"info": "",
"warn": "",
"error": "",
"debug": ""
}
}
},
"queuePage": {
"title": "",
"refreshButton": "",
"flushButton": "",
"jobTable": {
"title": "",
"idHeader": "",
"urlHeader": "",
"typeHeader": "",
"activeHeader": "",
"lockedAtHeader": "",
"runCountHeader": "",
"failCountHeader": "",
"lastRunHeader": "",
"lastFinishedAtHeader": "",
"lastRunTookHeader": ""
},
"metricsTable": {
"title": "",
"metricHeader": "",
"valueHeader": ""
},
"failedJobTable": {
"title": "",
"monitorIdHeader": "",
"monitorUrlHeader": "",
"failCountHeader": "",
"failedAtHeader": "",
"failReasonHeader": ""
}
},
"export": {
"title": "",
"success": "",
"failed": ""
},
"monitorActions": {
"title": "",
"import": "",
"export": ""
},
"settingsPage": {
"aboutSettings": {
"labelDevelopedBy": "",
"labelVersion": "",
"title": ""
},
"demoMonitorsSettings": {
"buttonAddMonitors": "",
"description": "",
"title": ""
},
"emailSettings": {
"buttonSendTestEmail": "",
"description": "",
"descriptionTransport": "",
"labelAddress": "",
"labelConnectionHost": "",
"labelHost": "",
"labelIgnoreTLS": "",
"labelPassword": "",
"labelPasswordSet": "",
"labelPool": "",
"labelPort": "",
"labelRejectUnauthorized": "",
"labelRequireTLS": "",
"labelSecure": "",
"labelTLSServername": "",
"labelUser": "",
"linkTransport": "",
"placeholderUser": "",
"title": ""
},
"pageSpeedSettings": {
"description": "",
"labelApiKeySet": "",
"labelApiKey": "",
"title": ""
},
"saveButtonLabel": "",
"statsSettings": {
"clearAllStatsButton": "",
"clearAllStatsDescription": "",
"clearAllStatsDialogConfirm": "",
"clearAllStatsDialogDescription": "",
"clearAllStatsDialogTitle": "",
"description": "",
"labelTTL": "",
"labelTTLOptional": "",
"title": ""
},
"systemResetSettings": {
"buttonRemoveAllMonitors": "",
"description": "",
"dialogConfirm": "",
"dialogDescription": "",
"dialogTitle": "",
"title": ""
},
"timezoneSettings": {
"description": "",
"label": "",
"title": ""
},
"title": "",
"uiSettings": {
"description": "",
"labelLanguage": "",
"labelTheme": "",
"title": ""
},
"urlSettings": {
"description": "",
"label": "",
"selectDisabled": "",
"selectEnabled": "",
"title": ""
}
}
}
File renamed without changes.
@@ -1,6 +1,6 @@
{
"submit": "Enviar",
"title": "Titulo",
"submit": "",
"title": "",
"distributedStatusHeaderText": "",
"distributedStatusSubHeaderText": "",
"settingsDisabled": "",
@@ -1,11 +1,11 @@
{
"submit": "Odeslat",
"submit": "",
"title": "",
"distributedStatusHeaderText": "",
"distributedStatusSubHeaderText": "",
"settingsDisabled": "Vypnuto",
"settingsSuccessSaved": "Nastavení bylo úspěšně uloženo",
"settingsFailedToSave": "Nepodařilo se uložit nastavení",
"settingsDisabled": "",
"settingsSuccessSaved": "",
"settingsFailedToSave": "",
"settingsStatsCleared": "",
"settingsFailedToClearStats": "",
"settingsFailedToAddDemoMonitors": "",
@@ -438,99 +438,99 @@
"port": ""
},
"common": {
"appName": "Checkmate",
"monitoringAgentName": "Capture",
"appName": "",
"monitoringAgentName": "",
"buttons": {
"toggleTheme": "Přepnout mezi světlým a tmavým motivem"
"toggleTheme": ""
},
"toasts": {
"networkError": "Chyba připojení k síti",
"checkConnection": "Zkontrolujte prosím své připojení k síti",
"unknownError": "Nastala neznámá chyba"
"networkError": "",
"checkConnection": "",
"unknownError": ""
}
},
"auth": {
"common": {
"navigation": {
"continue": "Pokračovat",
"back": "Zpět"
"continue": "",
"back": ""
},
"inputs": {
"email": {
"label": "E-mail",
"placeholder": "jan.novak@domena.cz",
"label": "",
"placeholder": "",
"errors": {
"empty": "Zadejte prosím e-mailovou adresu",
"invalid": "Překontrolujte si prosím správnost zadané e-mailové adresy"
"empty": "",
"invalid": ""
}
},
"password": {
"label": "Heslo",
"label": "",
"rules": {
"length": {
"beginning": "Heslo musí být dlouhé alespoň",
"highlighted": "8 znaků"
"beginning": "",
"highlighted": ""
},
"special": {
"beginning": "Heslo musí obsahovat alespoň",
"highlighted": "1 speciální znak"
"beginning": "",
"highlighted": ""
},
"number": {
"beginning": "Heslo musí obsahovat alespoň",
"highlighted": "1 číslo"
"beginning": "",
"highlighted": ""
},
"uppercase": {
"beginning": "Heslo musí obsahovat alespoň",
"highlighted": "1 velké písmeno"
"beginning": "",
"highlighted": ""
},
"lowercase": {
"beginning": "Heslo musí obsahovat alespoň",
"highlighted": "1 malé písmeno"
"beginning": "",
"highlighted": ""
},
"match": {
"beginning": "Obě hesla se",
"highlighted": "musí shodovat"
"beginning": "",
"highlighted": ""
}
},
"errors": {
"empty": "Zadejte prosím heslo",
"length": "Heslo musí být dlouhé alespoň 8 znaků",
"uppercase": "Heslo musí obsahovat alespoň 1 velké písmeno",
"lowercase": "Heslo musí obsahovat alespoň 1 malé písmeno",
"number": "Heslo musí obsahovat alespoň 1 číslo",
"special": "Heslo musí obsahovat alespoň 1 speciální znak",
"incorrect": "Zadané heslo neodpovídá tomu, co bylo nastaveno"
"empty": "",
"length": "",
"uppercase": "",
"lowercase": "",
"number": "",
"special": "",
"incorrect": ""
}
},
"passwordConfirm": {
"label": "Potvrzení hesla",
"placeholder": "Pro ověření správnosti zadejte heslo ještě jednou",
"label": "",
"placeholder": "",
"errors": {
"empty": "Zadejte prosím své heslo ještě jednou, aby mohla být potvrzena jeho správnost (odhalí překlepy)",
"different": "Zadaná hesla se liší, takže je nejspíše jedno z nich špatně zapsané"
"empty": "",
"different": ""
}
},
"firstName": {
"label": "Jméno",
"placeholder": "Jan",
"label": "",
"placeholder": "",
"errors": {
"empty": "Zadejte prosím své křestní jméno",
"length": "Jméno se musí vejít do 50 znaků",
"pattern": "Součástí jména mohou být pouze písmena, mezery apostrofy nebo spojovníky"
"empty": "",
"length": "",
"pattern": ""
}
},
"lastName": {
"label": "Příjmení",
"placeholder": "Novák",
"label": "",
"placeholder": "",
"errors": {
"empty": "Zadejte prosím své příjmení",
"length": "Příjmení se musí vejít do 50 znaků",
"pattern": "Součástí příjmení mohou být pouze písmena, mezery apostrofy nebo spojovníky"
"empty": "",
"length": "",
"pattern": ""
}
}
},
"errors": {
"validation": "Nastala chyba při ověřování dat."
"validation": ""
},
"fields": {
"password": {
@@ -541,20 +541,20 @@
}
},
"login": {
"heading": "Přihlášení",
"heading": "",
"subheadings": {
"stepOne": "Zadejte svůj e-mail",
"stepTwo": "Zadejte své heslo"
"stepOne": "",
"stepTwo": ""
},
"links": {
"forgotPassword": "Zapomněli jste heslo? <a>Obnovte si ho</a>",
"register": "Ještě nemáte účet? <a>Zaregistrujte se</a>",
"forgotPassword": "",
"register": "",
"forgotPasswordLink": "",
"registerLink": ""
},
"toasts": {
"success": "Vítejte zpět",
"incorrectPassword": "Nesprávné heslo"
"success": "",
"incorrectPassword": ""
},
"errors": {
"password": {
@@ -564,72 +564,72 @@
},
"registration": {
"heading": {
"superAdmin": "Vytvoření superadministrátora",
"user": "Registrace"
"superAdmin": "",
"user": ""
},
"subheadings": {
"stepOne": "Zadejte své osobní údaje",
"stepTwo": "Zadejte svůj e-mail",
"stepThree": "Nastavte si heslo"
"stepOne": "",
"stepTwo": "",
"stepThree": ""
},
"description": {
"superAdmin": "Pro začátek vytvořte účet superadministrátora",
"user": "Zaregistrujte se jako uživatel a požádejte svého superadministrátora, aby vám přidělil oprávnění k hlídačům"
"superAdmin": "",
"user": ""
},
"gettingStartedButton": {
"superAdmin": "Vytvořit účet superadministrátora",
"user": "Zaregistrovat se jako běžný uživatel"
"superAdmin": "",
"user": ""
},
"termsAndPolicies": "Vytvořením účtu souhlasíte s našimi <a1>Podmínkami použití</a1> (anglicky) a <a2>Podmínkami zpracování osobních údajů</a2> (anglicky).",
"termsAndPolicies": "",
"links": {
"login": "Máte již vytvořený účet? <a>Přihlašte se</a>"
"login": ""
},
"toasts": {
"success": "Vítejte! Váš účet byl úspěšně vytvořen."
"success": ""
}
},
"forgotPassword": {
"heading": "Zapomenuté heslo",
"heading": "",
"subheadings": {
"stepOne": "Nebojte se, zašleme vám pokyny pro obnovení hesla.",
"stepTwo": "Odkaz pro obnovení hesla byl odeslán na <email/>",
"stepThree": "Nové heslo se musí lišit od těch, které jste již použili.",
"stepFour": "Heslo bylo úspěšně obnoveno. Klepněte na tlačítko a dojde k čarovnému přihlášení."
"stepOne": "",
"stepTwo": "",
"stepThree": "",
"stepFour": ""
},
"buttons": {
"openEmail": "Otevřít e-mailovou aplikaci",
"resetPassword": "Obnovit heslo"
"openEmail": "",
"resetPassword": ""
},
"imageAlts": {
"passwordKey": "Ikonka přístupového klíče",
"email": "Ikonka e-mailu",
"lock": "Ikonka zámku",
"passwordConfirm": "Ikonka potvrzeného hesla"
"passwordKey": "",
"email": "",
"lock": "",
"passwordConfirm": ""
},
"links": {
"login": "Vrátit se k <a>Přihlášení</a>",
"resend": "Nedorazil vám e-mail? <a>Klepněte pro opětovné zaslání</a>"
"login": "",
"resend": ""
},
"toasts": {
"sent": "Instrukce vám dorazí na <email/>.",
"emailNotFound": "Nepodařilo se nalézt e-mailovou adresu.",
"redirect": "Přesměrování proběhne za <seconds/>...",
"success": "Heslo bylo úspěšně obnoveno.",
"error": "Obnova hesla se nepodařila. Zkuste to prosím znovu nebo kontaktujte podporu."
"sent": "",
"emailNotFound": "",
"redirect": "",
"success": "",
"error": ""
}
}
},
"errorPages": {
"serverUnreachable": {
"toasts": {
"reconnected": "Podařilo se úspěšně obnovit spojení se serverem",
"stillUnreachable": "Server je stále nedosažitelný. Zkuste to prosím později."
"reconnected": "",
"stillUnreachable": ""
},
"alertBox": "Chyba připojení k serveru",
"description": "Nepodařilo se připojit k serveru. Zkontrolujte si prosím internetové připojení a pokud problém přetrvává, ověřte konfiguraci aplikace Checkmate.",
"alertBox": "",
"description": "",
"retryButton": {
"default": "Zkusit se znovu připojit",
"processing": "Probíhá připojování…"
"default": "",
"processing": ""
}
}
},
+1 -1
View File
@@ -1,5 +1,5 @@
{
"printWidth": 90,
"printWidth": 150,
"useTabs": true,
"tabWidth": 2,
"singleQuote": false,
+14 -20
View File
@@ -1,5 +1,5 @@
import { createAnnouncementValidation } from "../validation/joi.js";
import { handleError } from "./controllerUtils.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "announcementController";
@@ -28,16 +28,10 @@ class AnnouncementController {
*
* @returns {Promise<void>} A promise that resolves once the response is sent.
*/
createAnnouncement = async (req, res, next) => {
try {
createAnnouncement = asyncHandler(
async (req, res, next) => {
await createAnnouncementValidation.validateAsync(req.body);
} catch (error) {
return next(handleError(error, SERVICE_NAME)); // Handle Joi validation errors
}
const { title, message } = req.body;
try {
const { title, message } = req.body;
const announcementData = {
title: title.trim(),
message: message.trim(),
@@ -49,10 +43,10 @@ class AnnouncementController {
msg: this.stringService.createAnnouncement,
data: newAnnouncement,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createAnnouncement"));
}
};
},
SERVICE_NAME,
"createAnnouncement"
);
/**
* Handles retrieving announcements with pagination.
@@ -63,17 +57,17 @@ class AnnouncementController {
* - `msg`: A message about the success of the request.
* @param {Function} next - The next middleware function in the stack for error handling.
*/
getAnnouncement = async (req, res, next) => {
try {
getAnnouncement = asyncHandler(
async (req, res, next) => {
const allAnnouncements = await this.db.getAnnouncements();
return res.success({
msg: this.stringService.getAnnouncement,
data: allAnnouncements,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAnnouncement"));
}
};
},
SERVICE_NAME,
"getAnnouncement"
);
}
export default AnnouncementController;
+149 -152
View File
@@ -1,16 +1,20 @@
import {
registrationBodyValidation,
loginValidation,
editUserParamValidation,
editUserBodyValidation,
recoveryValidation,
recoveryTokenValidation,
newPasswordValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
} from "../validation/joi.js";
import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
import crypto from "crypto";
import { handleValidationError, handleError } from "./controllerUtils.js";
import { asyncHandler, createAuthError, createError } from "../utils/errorUtils.js";
const SERVICE_NAME = "authController";
class AuthController {
@@ -32,15 +36,10 @@ class AuthController {
* @throws {Error}
*/
issueToken = (payload, appSettings) => {
try {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
} catch (error) {
throw handleError(error, SERVICE_NAME, "issueToken");
}
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
};
/**
@@ -55,19 +54,14 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the creation of the user, the created user data, and a JWT token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
registerUser = async (req, res, next) => {
try {
registerUser = asyncHandler(
async (req, res, next) => {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await registrationBodyValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
// Create a new user
try {
// Create a new user
const user = req.body;
// If superAdmin exists, a token should be attached to all further register requests
const superAdminExists = await this.db.checkSuperadmin(req, res);
@@ -100,16 +94,14 @@ class AuthController {
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
name: newUser.firstName,
});
this.emailService
.sendEmail(newUser.email, "Welcome to Uptime Monitor", html)
.catch((error) => {
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "registerUser",
stack: error.stack,
});
this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => {
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "registerUser",
stack: error.stack,
});
});
} catch (error) {
this.logger.warn({
message: error.message,
@@ -123,10 +115,10 @@ class AuthController {
msg: this.stringService.authCreateUser,
data: { user: newUser, token: token },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "registerController"));
}
};
},
SERVICE_NAME,
"registerUser"
);
/**
* Logs in a user by validating the user's credentials and issuing a JWT token.
@@ -140,18 +132,13 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the login of the user, the user data (without password and avatar image), and a JWT token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422) or the password is incorrect.
*/
loginUser = async (req, res, next) => {
try {
loginUser = asyncHandler(
async (req, res, next) => {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const { email, password } = req.body;
// Check if user exists
@@ -160,10 +147,7 @@ class AuthController {
// Compare password
const match = await user.comparePassword(password);
if (match !== true) {
const error = new Error(this.stringService.authIncorrectPassword);
error.status = 401;
next(error);
return;
throw createAuthError(this.stringService.authIncorrectPassword);
}
// Remove password from user object. Should this be abstracted to DB layer?
@@ -184,11 +168,10 @@ class AuthController {
token: token,
},
});
} catch (error) {
error.status = 401;
next(handleError(error, SERVICE_NAME, "loginUser"));
}
};
},
SERVICE_NAME,
"loginUser"
);
/**
* Edits a user's information. If the user wants to change their password, the current password is checked before updating to the new password.
@@ -204,26 +187,10 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the update of the user, and the updated user data.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422), the user is unauthorized (401), or the password is incorrect (403).
*/
editUser = async (req, res, next) => {
try {
await editUserParamValidation.validateAsync(req.params);
editUser = asyncHandler(
async (req, res, next) => {
await editUserBodyValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
// TODO is this neccessary any longer? Verify ownership middleware should handle this
if (req.params.userId !== req.user._id.toString()) {
const error = new Error(this.stringService.unauthorized);
error.status = 401;
error.service = SERVICE_NAME;
next(error);
return;
}
try {
// Change Password check
if (req.body.password && req.body.newPassword) {
// Get token from headers
@@ -240,24 +207,21 @@ class AuthController {
// If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor
if (!match) {
const error = new Error(this.stringService.authIncorrectPassword);
error.status = 403;
next(error);
return;
throw createError(this.stringService.authIncorrectPassword, 403);
}
// If a match, update the password
req.body.password = req.body.newPassword;
}
const updatedUser = await this.db.updateUser(req, res);
const updatedUser = await this.db.updateUser({ userId: req?.user?._id, user: req.body, file: req.file });
res.success({
msg: this.stringService.authUpdateUser,
data: updatedUser,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "userEditController"));
}
};
},
SERVICE_NAME,
"editUser"
);
/**
* Checks if a superadmin account exists in the database.
@@ -268,18 +232,17 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the existence of a superadmin, and a boolean indicating the existence of a superadmin.
* @throws {Error} If there is an error during the process.
*/
checkSuperadminExists = async (req, res, next) => {
try {
checkSuperadminExists = asyncHandler(
async (req, res, next) => {
const superAdminExists = await this.db.checkSuperadmin(req, res);
return res.success({
msg: this.stringService.authAdminExists,
data: superAdminExists,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "checkSuperadminController"));
}
};
},
SERVICE_NAME,
"checkSuperadminExists"
);
/**
* Requests a recovery token for a user. The user's email is validated and a recovery token is created and sent via email.
* @async
@@ -291,16 +254,9 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the creation of the recovery token, and the message ID of the sent email.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
requestRecovery = async (req, res, next) => {
try {
requestRecovery = asyncHandler(
async (req, res, next) => {
await recoveryValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const { email } = req.body;
const user = await this.db.getUserByEmail(email);
const recoveryToken = await this.db.requestRecoveryToken(req, res);
@@ -313,20 +269,16 @@ class AuthController {
email,
url,
});
const msgId = await this.emailService.sendEmail(
email,
"Checkmate Password Reset",
html
);
const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html);
return res.success({
msg: this.stringService.authCreateRecoveryToken,
data: msgId,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "recoveryRequestController"));
}
};
},
SERVICE_NAME,
"requestRecovery"
);
/**
* Validates a recovery token. The recovery token is validated and if valid, a success message is returned.
* @async
@@ -338,25 +290,17 @@ class AuthController {
* @returns {Object} The response object with a success status and a message indicating the validation of the recovery token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
validateRecovery = async (req, res, next) => {
try {
validateRecovery = asyncHandler(
async (req, res, next) => {
await recoveryTokenValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
await this.db.validateRecoveryToken(req, res);
return res.success({
msg: this.stringService.authVerifyRecoveryToken,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "validateRecoveryTokenController"));
}
};
},
SERVICE_NAME,
"validateRecovery"
);
/**
* Resets a user's password. The new password is validated and if valid, the user's password is updated in the database and a new JWT token is issued.
@@ -370,27 +314,20 @@ class AuthController {
* @returns {Object} The response object with a success status, a message indicating the reset of the password, the updated user data (without password and avatar image), and a new JWT token.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
resetPassword = async (req, res, next) => {
try {
resetPassword = asyncHandler(
async (req, res, next) => {
await newPasswordValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
next(validationError);
return;
}
try {
const user = await this.db.resetPassword(req, res);
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(user._doc, appSettings);
return res.success({
msg: this.stringService.authResetPassword,
data: { user, token },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "resetPasswordController"));
}
};
},
SERVICE_NAME,
"resetPassword"
);
/**
* Deletes a user and all associated monitors, checks, and alerts.
@@ -401,22 +338,36 @@ class AuthController {
* @returns {Object} The response object with success status and message.
* @throws {Error} If user validation fails or user is not found in the database.
*/
deleteUser = async (req, res, next) => {
try {
const token = getTokenFromHeaders(req.headers);
const decodedToken = jwt.decode(token);
const { email } = decodedToken;
deleteUser = asyncHandler(
async (req, res, next) => {
const email = req?.user?.email;
if (!email) {
throw new Error("No email in request");
}
const teamId = req?.user?.teamId;
const userId = req?.user?._id;
if (!teamId) {
throw new Error("No team ID in request");
}
if (!userId) {
throw new Error("No user ID in request");
}
const roles = req.user.role;
if (roles.includes("demo")) {
throw new Error("Demo user cannot be deleted");
}
// Check if the user exists
const user = await this.db.getUserByEmail(email);
// 1. Find all the monitors associated with the team ID if superadmin
const result = await this.db.getMonitorsByTeamId({
teamId: user.teamId.toString(),
teamId: teamId,
});
if (user.role.includes("superadmin")) {
//2. Remove all jobs, delete checks and alerts
if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts
result?.monitors.length > 0 &&
(await Promise.all(
result.monitors.map(async (monitor) => {
@@ -425,28 +376,74 @@ class AuthController {
));
}
// 6. Delete the user by id
await this.db.deleteUser(user._id);
await this.db.deleteUser(userId);
return res.success({
msg: this.stringService.authDeleteUser,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteUserController"));
}
};
},
SERVICE_NAME,
"deleteUser"
);
getAllUsers = async (req, res, next) => {
try {
getAllUsers = asyncHandler(
async (req, res, next) => {
const allUsers = await this.db.getAllUsers(req, res);
return res.success({
msg: this.stringService.authGetAllUsers,
data: allUsers,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAllUsersController"));
}
};
},
SERVICE_NAME,
"getAllUsers"
);
getUserById = asyncHandler(
async (req, res, next) => {
await getUserByIdParamValidation.validateAsync(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
if (!userId) {
throw new Error("No user ID in request");
}
if (!roles || roles.length === 0) {
throw new Error("No roles in request");
}
const user = await this.db.getUserById(roles, userId);
return res.success({ msg: "ok", data: user });
},
SERVICE_NAME,
"getUserById"
);
editUserById = asyncHandler(
async (req, res, next) => {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw createError("Unauthorized", 403);
}
const userId = req.params.userId;
const user = { ...req.body };
await editUserByIdParamValidation.validateAsync(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user._id) {
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
} else {
await editUserByIdBodyValidation.validateAsync(req.body);
}
await this.db.editUserById(userId, user);
return res.success({ msg: "ok" });
},
SERVICE_NAME,
"editUserById"
);
}
export default AuthController;
+166 -124
View File
@@ -1,6 +1,4 @@
import {
createCheckParamValidation,
createCheckBodyValidation,
getChecksParamValidation,
getChecksQueryValidation,
getTeamChecksParamValidation,
@@ -12,9 +10,7 @@ import {
ackAllChecksParamValidation,
ackAllChecksBodyValidation,
} from "../validation/joi.js";
import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
import { handleValidationError, handleError } from "./controllerUtils.js";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "checkController";
@@ -25,41 +21,41 @@ class CheckController {
this.stringService = stringService;
}
createCheck = async (req, res, next) => {
try {
await createCheckParamValidation.validateAsync(req.params);
await createCheckBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const checkData = { ...req.body };
const check = await this.db.createCheck(checkData);
return res.success({
msg: this.stringService.checkCreate,
data: check,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createCheck"));
}
};
getChecksByMonitor = async (req, res, next) => {
try {
getChecksByMonitor = asyncHandler(
async (req, res, next) => {
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorId } = req.params;
let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } =
req.query;
const monitorId = req?.params?.monitorId;
if (!monitorId) {
throw new Error("No monitor ID in request");
}
const teamId = req?.user?.teamId;
if (!teamId) {
throw new Error("No team ID in request");
}
const monitor = await this.db.getMonitorById(monitorId);
if (!monitor) {
const err = new Error("Monitor not found");
err.status = 404;
err.service = SERVICE_NAME;
err.method = "getChecksByMonitor";
throw err;
}
if (!monitor.teamId.equals(teamId)) {
const err = new Error("Unauthorized");
err.status = 403;
err.service = SERVICE_NAME;
err.method = "getChecksByMonitor";
throw err;
}
let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = req.query;
const result = await this.db.getChecksByMonitor({
monitorId,
type,
@@ -76,22 +72,22 @@ class CheckController {
msg: this.stringService.checkGet,
data: result,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getChecks"));
}
};
},
SERVICE_NAME,
"getChecksByMonitor"
);
getChecksByTeam = async (req, res, next) => {
try {
getChecksByTeam = asyncHandler(
async (req, res, next) => {
await getTeamChecksParamValidation.validateAsync(req.params);
await getTeamChecksQueryValidation.validateAsync(req.query);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
let { sortOrder, dateRange, filter, ack, page, rowsPerPage } = req.query;
const { teamId } = req.user;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new Error("No team ID in request");
}
const checkData = await this.db.getChecksByTeam({
sortOrder,
@@ -106,36 +102,44 @@ class CheckController {
msg: this.stringService.checkGet,
data: checkData,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getTeamChecks"));
}
};
},
SERVICE_NAME,
"getChecksByTeam"
);
getChecksSummaryByTeamId = asyncHandler(
async (req, res, next) => {
const teamId = req?.user?.teamId;
if (!teamId) {
throw new Error("No team ID in request");
}
getChecksSummaryByTeamId = async (req, res, next) => {
try {
const { teamId } = req.user;
const summary = await this.db.getChecksSummaryByTeamId({ teamId });
return res.success({
msg: this.stringService.checkGetSummary,
data: summary,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getChecksSummaryByTeamId"));
}
};
},
SERVICE_NAME,
"getChecksSummaryByTeamId"
);
ackCheck = async (req, res, next) => {
try {
ackCheck = asyncHandler(
async (req, res, next) => {
await ackCheckBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { checkId } = req.params;
const { ack } = req.body;
const { teamId } = req.user;
const checkId = req?.params?.checkId;
const ack = req?.body?.ack;
const teamId = req?.user?.teamId;
if (!checkId) {
throw new Error("No check ID in request");
}
if (!teamId) {
throw new Error("No team ID in request");
}
const updatedCheck = await this.db.ackCheck(checkId, teamId, ack);
@@ -143,24 +147,45 @@ class CheckController {
msg: this.stringService.checkUpdateStatus,
data: updatedCheck,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "ackCheck"));
}
};
},
SERVICE_NAME,
"ackCheck"
);
ackAllChecks = async (req, res, next) => {
try {
ackAllChecks = asyncHandler(
async (req, res, next) => {
await ackAllChecksParamValidation.validateAsync(req.params);
await ackAllChecksBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorId, path } = req.params;
const { ack } = req.body;
const { teamId } = req.user;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new Error("No team ID in request");
}
const monitorId = req?.params?.monitorId;
const path = req?.params?.path;
const ack = req?.body?.ack;
if (path === "monitor") {
if (!monitorId) {
throw new Error("No monitor ID in request");
}
const monitor = await this.db.getMonitorById(monitorId);
if (!monitor) {
throw new Error("Monitor not found");
}
if (!monitor.teamId.equals(teamId)) {
const err = new Error("Unauthorized");
err.status = 403;
err.service = SERVICE_NAME;
err.method = "ackAllChecks";
throw err;
}
}
const updatedChecks = await this.db.ackAllChecks(monitorId, teamId, ack, path);
@@ -168,64 +193,81 @@ class CheckController {
msg: this.stringService.checkUpdateStatus,
data: updatedChecks,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "ackAllChecks"));
}
};
},
SERVICE_NAME,
"ackAllChecks"
);
deleteChecks = async (req, res, next) => {
try {
deleteChecks = asyncHandler(
async (req, res, next) => {
await deleteChecksParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const monitorId = req?.params?.monitorId;
const teamId = req?.user?.teamId;
if (!monitorId) {
throw new Error("No monitor ID in request");
}
if (!teamId) {
throw new Error("No team ID in request");
}
const monitor = await this.db.getMonitorById(monitorId);
if (!monitor) {
const err = new Error("Monitor not found");
err.status = 404;
err.service = SERVICE_NAME;
err.method = "deleteChecks";
throw err;
}
if (!monitor.teamId.equals(teamId)) {
const err = new Error("Unauthorized");
err.status = 403;
err.service = SERVICE_NAME;
err.method = "deleteChecks";
throw err;
}
const deletedCount = await this.db.deleteChecks(req.params.monitorId);
return res.success({
msg: this.stringService.checkDelete,
data: { deletedCount },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteChecks"));
}
};
},
SERVICE_NAME,
"deleteChecks"
);
deleteChecksByTeamId = async (req, res, next) => {
try {
deleteChecksByTeamId = asyncHandler(
async (req, res, next) => {
await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { teamId } = req.user;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new Error("No team ID in request");
}
const deletedCount = await this.db.deleteChecksByTeamId(teamId);
return res.success({
msg: this.stringService.checkDelete,
data: { deletedCount },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "deleteChecksByTeamId"));
}
};
},
SERVICE_NAME,
"deleteChecksByTeamId"
);
updateChecksTTL = async (req, res, next) => {
const SECONDS_PER_DAY = 86400;
updateChecksTTL = asyncHandler(
async (req, res, next) => {
const SECONDS_PER_DAY = 86400;
try {
await updateChecksTTLBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
// Get user's teamId
const { teamId } = req.user;
const ttl = parseInt(req.body.ttl, 10) * SECONDS_PER_DAY;
await this.db.updateChecksTTL(teamId, ttl);
@@ -233,9 +275,9 @@ class CheckController {
return res.success({
msg: this.stringService.checkUpdateTTL,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "updateTTL"));
}
};
},
SERVICE_NAME,
"updateChecksTtl"
);
}
export default CheckController;
+3 -15
View File
@@ -1,16 +1,4 @@
const handleValidationError = (error, serviceName) => {
error.status = 422;
error.service = serviceName;
error.message = error.details?.[0]?.message || error.message || "Validation Error";
return error;
};
const handleError = (error, serviceName, method, status = 500) => {
error.status === undefined ? (error.status = status) : null;
error.service === undefined ? (error.service = serviceName) : null;
error.method === undefined ? (error.method = method) : null;
return error;
};
import { createServerError } from "../utils/errorUtils.js";
const fetchMonitorCertificate = async (sslChecker, monitor) => {
const monitorUrl = new URL(monitor.url);
@@ -18,9 +6,9 @@ const fetchMonitorCertificate = async (sslChecker, monitor) => {
const cert = await sslChecker(hostname);
// Throw an error if no cert or if cert.validTo is not present
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
throw createServerError("Certificate not found");
}
return cert;
};
export { handleValidationError, handleError, fetchMonitorCertificate };
export { fetchMonitorCertificate };
+77 -34
View File
@@ -1,48 +1,91 @@
import { handleError } from "./controllerUtils.js";
import v8 from "v8";
import os from "os";
import { asyncHandler } from "../utils/errorUtils.js";
const SERVICE_NAME = "diagnosticController";
const obs = new PerformanceObserver((items) => {
const entry = items.getEntries()[0];
performance.clearMarks();
});
obs.observe({ entryTypes: ["measure"] });
class DiagnosticController {
constructor(db) {
this.db = db;
this.getMonitorsByTeamIdExecutionStats =
this.getMonitorsByTeamIdExecutionStats.bind(this);
this.getDbStats = this.getDbStats.bind(this);
}
async getMonitorsByTeamIdExecutionStats(req, res, next) {
try {
const data = await this.db.getMonitorsByTeamIdExecutionStats(req);
return res.success({
msg: "OK",
data,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMonitorsByTeamIdExecutionStats"));
}
}
getCPUUsage = async () => {
const startUsage = process.cpuUsage();
const timingPeriod = 1000; // measured in ms
await new Promise((resolve) => setTimeout(resolve, timingPeriod));
const endUsage = process.cpuUsage(startUsage);
const cpuUsage = {
userUsageMs: endUsage.user / 1000,
systemUsageMs: endUsage.system / 1000,
usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100,
};
return cpuUsage;
};
async getDbStats(req, res, next) {
try {
const { methodName, args = [] } = req.body;
if (!methodName || !this.db[methodName]) {
return res.error({
msg: "Invalid method name or method doesn't exist",
status: 400,
});
}
const explainMethod = await this.db[methodName].apply(this.db, args);
const stats = {
methodName,
timestamp: new Date(),
explain: explainMethod,
getSystemStats = asyncHandler(
async (req, res, next) => {
// Memory Usage
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const osStats = {
freeMemoryBytes: freeMemory, // bytes
totalMemoryBytes: totalMemory, // bytes
};
const used = process.memoryUsage();
const memoryUsage = {};
for (let key in used) {
memoryUsage[`${key}Mb`] = Math.round((used[key] / 1024 / 1024) * 100) / 100; // MB
}
// CPU Usage
const cpuMetrics = await this.getCPUUsage();
// V8 Heap Statistics
const heapStats = v8.getHeapStatistics();
const v8Metrics = {
totalHeapSizeBytes: heapStats.total_heap_size, // bytes
usedHeapSizeBytes: heapStats.used_heap_size, // bytes
heapSizeLimitBytes: heapStats.heap_size_limit, // bytes
};
// Event Loop Delay
let eventLoopDelay = 0;
performance.mark("start");
await new Promise((resolve) => setTimeout(resolve, 0));
performance.mark("end");
performance.measure("eventLoopDelay", "start", "end");
const entries = performance.getEntriesByName("eventLoopDelay");
if (entries.length > 0) {
eventLoopDelay = entries[0].duration;
}
// Uptime
const uptimeMs = process.uptime() * 1000; // ms
// Combine Metrics
const diagnostics = {
osStats,
memoryUsage,
cpuUsage: cpuMetrics,
v8HeapStats: v8Metrics,
eventLoopDelayMs: eventLoopDelay,
uptimeMs,
};
return res.success({
msg: "OK",
data: stats,
data: diagnostics,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getDbStats"));
}
}
},
SERVICE_NAME,
"getSystemStats"
);
}
export default DiagnosticController;
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more