Merge pull request #2228 from bluewave-labs/develop

Demo deploy
This commit is contained in:
Alexander Holliday authored and GitHub committed 2025-05-09 09:39:55 -07:00
commit dd109c0e5c
99 files changed
+2293 -14644

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
name: Distribution deploy
on:
push:
branches: ["master"]
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:frontend-dist \
-f ./docker/dist/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Client Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-dist
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-dist \
-f ./docker/dist/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-dist
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:mongo-dist \
-f ./docker/dist/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-dist
- name: Build Redis Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:redis-dist \
-f ./docker/dist/redis.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Redis Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:redis-dist
-3
View File
@@ -13,9 +13,6 @@ on:
required: false
default: "key_value_json"
# For automatic execution at a specific time (every day at midnight)
schedule:
- cron: "0 0 * * *"
permissions:
contents: write
+3 -1
View File
@@ -87,7 +87,9 @@ jobs:
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate/docker/prod
cd checkmate
git pull
cd docker/prod
docker compose down
docker compose pull
docker compose up -d
+3 -1
View File
@@ -87,7 +87,9 @@ jobs:
username: ${{ secrets.STAGING_SERVER_USER }}
key: ${{ secrets.STAGING_SERVER_SSH_KEY }}
script: |
cd checkmate/docker/staging
cd checkmate
git pull
cd docker/staging
docker compose down
docker compose pull
docker compose up -d
+7 -8
View File
@@ -48,24 +48,23 @@ Ask anything in our [Discord server](https://discord.com/invite/NAb6H3UTjK) —
### Set up Checkmate locally?
Frontend:
Frontend & backend:
```bash
npm install
npm run dev
```
Backend:
Clone and follow instructions in [checkmate-backend](https://github.com/bluewave-labs/checkmate-backend).
By default, the frontend expects the backend on `http://localhost:3001`. Update configs if needed.
### Start contributing code?
1. Pick or open an issue (check `good-first-issue`s first)
2. Ask to be assigned. If there is alrady someone assigned and it's been more than 7 days, you can raise the flag and ask to be assigned as well.
3. Create a branch from `develop`.
4. Write your code.
5. Run and test locally.
6. Open a PR to `develop`.
2. (optional but highly suggested) Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
3. Ask to be assigned. If there is alrady someone assigned and it's been more than 7 days, you can raise the flag and ask to be assigned as well.
4. Create a branch from `develop`.
5. Write your code.
6. Run and test locally.
7. Open a PR to `develop`.
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
+9 -11
View File
@@ -25,7 +25,7 @@ We **love** what we are building here, and we continuously learn a few things ab
## 📦 Demo
See [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
## 🔗 User's guide
@@ -69,11 +69,10 @@ If you have any questions, suggestions or comments, please use our [Discord chan
**Short term roadmap:**
- Global (distributed) uptime checking on Solana network (**in progress**) https://github.com/bluewave-labs/Checkmate/issues/1593
- Status pages (**in progress**) https://github.com/bluewave-labs/Checkmate/issues/1131
- Translations (i18n) (**in progress**)
- Better notification options (Webhooks, Discord, Telegram, Slack) (**in progress**) https://github.com/bluewave-labs/Checkmate/issues/1545
- JSON query monitoring https://github.com/bluewave-labs/Checkmate/issues/1573
- JSON query monitoring (**in progress**) https://github.com/bluewave-labs/Checkmate/issues/1573
- Tagging/grouping monitors https://github.com/bluewave-labs/Checkmate/issues/1546
- More configuration options
- DNS monitoring
@@ -118,10 +117,11 @@ Here's how you can contribute:
0. Star this repo :)
1. Check [Contributor's guideline](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). First timers are encouraged to check `good-first-issue` tag.
2. Optionally, read [project structure](https://docs.checkmate.so/developers-guide/general-project-structure) and [high level overview](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
3. Open an issue if you believe you've encountered a bug.
4. Check for good-first-issue's if you are a newcomer.
5. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
2. Check [project structure](https://docs.checkmate.so/developers-guide/general-project-structure) and [high level overview](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
3. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
4. Open an issue if you believe you've encountered a bug.
5. Check for good-first-issue's if you are a newcomer.
6. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
@@ -135,8 +135,6 @@ Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their doc
Also check other developer and contributor-friendly projects of BlueWave:
- [LangRoute](https://github.com/bluewave-labs/langroute), an LLM proxy and gateway
- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom), an secure file sharing application, aka dataroom.
- [Headcount](https://github.com/bluewave-labs/bluewave-hrm), a complete Human Resource Management platform.
- [Guidefox](https://github.com/bluewave-labs/guidefox), an application that helps new users learn how to use your product via hints, tours, popups and banners.
- [VerifyWise](https://github.com/bluewave-labs/verifywise), the first open source AI governance platform.
- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom), an secure file sharing application, aka dataroom.
- [Guidefox](https://github.com/bluewave-labs/guidefox), an application that helps new users learn how to use your product via hints, tours, popups and banners.
+2 -1
View File
@@ -1,2 +1,3 @@
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
VITE_STATUS_PAGE_SUBDOMAIN_PREFIX=UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX
VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
+71 -13627
View File
File diff suppressed because it is too large. Load diff
+8 -6
View File
@@ -22,12 +22,6 @@
"@mui/lab": "6.0.0-dev.240424162023-9968b4889d",
"@mui/material": "6.4.11",
"@reduxjs/toolkit": "2.7.0",
"@solana/wallet-adapter-base": "0.9.25",
"@solana/wallet-adapter-material-ui": "0.16.35",
"@solana/wallet-adapter-react": "0.15.37",
"@solana/wallet-adapter-react-ui": "0.9.37",
"@solana/wallet-adapter-wallets": "0.19.34",
"@solana/web3.js": "1.98.0",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
@@ -52,6 +46,14 @@
"redux-persist": "6.0.0",
"vite-plugin-svgr": "^4.2.0"
},
"unusedDepencies": {
"@solana/wallet-adapter-base": "0.9.25",
"@solana/wallet-adapter-material-ui": "0.16.35",
"@solana/wallet-adapter-react": "0.15.37",
"@solana/wallet-adapter-react-ui": "0.9.37",
"@solana/wallet-adapter-wallets": "0.19.34",
"@solana/web3.js": "1.98.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
@@ -0,0 +1,6 @@
type,name,url,interval,port,expectedValue,jsonPath,matchMethod
http,HTTP Monitor,https://www.google.com,60000,,,,
http,API Monitor,http://reqres.in/api/users/2,120000,,Janet,data.first_name,equal
ping,Ping monitor,127.0.0.1,60000,,,,
docker,Docker monitor,bbdd20b4e3757d63e070bc16e04d3f770c4a8f6e62e813de380f23b141e2ca01,60000,,,,
port,Port monitor,127.0.0.1,60000,5000,,,
1 type name url interval port expectedValue jsonPath matchMethod
2 http HTTP Monitor https://www.google.com 60000
3 http API Monitor http://reqres.in/api/users/2 120000 Janet data.first_name equal
4 ping Ping monitor 127.0.0.1 60000
5 docker Docker monitor bbdd20b4e3757d63e070bc16e04d3f770c4a8f6e62e813de380f23b141e2ca01 60000
6 port Port monitor 127.0.0.1 60000 5000
@@ -0,0 +1 @@
type,name,url,interval,port,expectedValue,jsonPath,matchMethod
1 type name url interval port expectedValue jsonPath matchMethod
-25
View File
@@ -1,43 +1,18 @@
import { useEffect } from "react";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react";
import lightTheme from "./Utils/Theme/lightTheme";
import darkTheme from "./Utils/Theme/darkTheme";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { getAppSettings } from "./Features/Settings/settingsSlice";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Routes } from "./Routes";
import WalletProvider from "./Components/WalletProvider";
import { useTranslation } from "react-i18next";
import { setLanguage } from "./Features/UI/uiSlice";
function App() {
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const { i18n } = useTranslation();
useEffect(() => {
if (authToken) {
dispatch(getAppSettings({ authToken })).then((action) => {
if (action.payload && action.payload.success) {
const { language } = action.payload.data;
const availableLanguages = Object.keys(i18n.options.resources || {});
if (language && availableLanguages.includes(language)) {
dispatch(setLanguage(language));
i18n.changeLanguage(language);
} else {
dispatch(setLanguage(availableLanguages[0]));
i18n.changeLanguage(availableLanguages[0]);
}
}
});
}
}, [dispatch, authToken, i18n]);
// Cleanup
useEffect(() => {
+14 -3
View File
@@ -1,6 +1,7 @@
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";
@@ -35,9 +36,8 @@ const Fallback = ({ title, checks, link = "/", isAdmin, vowelStart = false, show
<>
{t("pageSpeedWarning")} {" "}
<Link
href="https://docs.checkmate.so/users-guide/quickstart#env-vars-server"
target="_blank"
rel="noopener noreferrer"
component={RouterLink}
to="/settings"
sx={{
textDecoration: "underline",
color: "inherit",
@@ -118,6 +118,17 @@ const Fallback = ({ title, checks, link = "/", isAdmin, vowelStart = false, show
>
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) && (
@@ -6,18 +6,6 @@ import PropTypes from "prop-types";
const getSx = (theme, type, maxWidth) => {
const sx = {
maxWidth: maxWidth,
"& .MuiOutlinedInput-root ": {
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.contrastText, // Adjust hover border color
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.accent.main, // Adjust focus border color
},
"&.Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.contrastText, // CAIO_REVIEW
opacity: 0.5,
},
},
"& .MuiFormHelperText-root": {
position: "absolute",
@@ -116,6 +104,7 @@ const TextInput = forwardRef(
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
mb={theme.spacing(2)}
>
{label}
{isRequired && <Required />}
@@ -1,30 +1,49 @@
import { Stack, Button } from "@mui/material";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import SkeletonLayout from "./skeleton";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
const CreateMonitorHeader = ({
isAdmin,
label = "Create new",
shouldRender = true,
isLoading = true,
path,
bulkPath,
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
if (!isAdmin) return null;
if (!shouldRender) return <SkeletonLayout />;
return (
<Stack
direction="row"
justifyContent="end"
alignItems="center"
gap={theme.spacing(6)}
>
<Button
loading={isLoading}
variant="contained"
color="accent"
onClick={() => navigate(path)}
>
{label}
</Button>
{bulkPath && (
<Button
loading={isLoading}
variant="contained"
color="accent"
onClick={() => {
navigate(`${bulkPath}`);
}}
>
{t("bulkImport.title")}
</Button>
)}
</Stack>
);
};
@@ -33,7 +52,8 @@ export default CreateMonitorHeader;
CreateMonitorHeader.propTypes = {
isAdmin: PropTypes.bool.isRequired,
shouldRender: PropTypes.bool,
isLoading: PropTypes.bool,
path: PropTypes.string.isRequired,
label: PropTypes.string,
bulkPath: PropTypes.string,
};
@@ -25,10 +25,12 @@ const MonitorStatusHeader = ({ path, isLoading = false, isAdmin, monitor }) => {
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(2)}
gap={theme.spacing(4)}
>
<PulseDot color={statusColor[determineState(monitor)]} />
<Typography variant="h2">
<Typography variant="h2"
style={{fontFamily : "monospace" , fontWeight : 'bolder'}}
>
{monitor?.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Dot />
@@ -55,10 +55,9 @@ const MonitorTimeFrameHeader = ({
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
justifyContent="flex-end"
alignItems="center"
gap={theme.spacing(4)}
mb={theme.spacing(8)}
>
<Typography variant="body2">
Showing statistics for past{" "}
@@ -1,16 +1,17 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
import {
Dialog,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Tabs,
Tab,
import {
Dialog,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Tabs,
Tab,
Stack,
} from "@mui/material";
import { useTheme } from "@emotion/react";
import TabPanel from "./TabPanel";
@@ -19,335 +20,400 @@ import useNotifications from "../Hooks/useNotification";
// Define constants for notification types to avoid magic values
const NOTIFICATION_TYPES = {
SLACK: 'slack',
DISCORD: 'discord',
TELEGRAM: 'telegram',
WEBHOOK: 'webhook'
SLACK: "slack",
DISCORD: "discord",
TELEGRAM: "telegram",
WEBHOOK: "webhook",
};
// Define constants for field IDs
const FIELD_IDS = {
WEBHOOK: 'webhook',
TOKEN: 'token',
CHAT_ID: 'chatId',
URL: 'url'
WEBHOOK: "webhook",
TOKEN: "token",
CHAT_ID: "chatId",
URL: "url",
};
const NotificationIntegrationModal = ({
open,
onClose,
monitor,
setMonitor,
// Optional prop to configure available notification types
notificationTypes = null
const NotificationIntegrationModal = ({
open,
onClose,
monitor,
setMonitor,
// Optional prop to configure available notification types
notificationTypes = null,
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [tabValue, setTabValue] = useState(0);
const [loading, _, sendTestNotification] = useNotifications();
// Helper to get the field state key with error handling
const getFieldKey = (typeId, fieldId) => {
if (typeof typeId !== 'string' || typeId === '') {
throw new Error(t('errorInvalidTypeId'));
}
if (typeof fieldId !== 'string' || fieldId === '') {
throw new Error(t('errorInvalidFieldId'));
}
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
};
// Define notification types
const DEFAULT_NOTIFICATION_TYPES = [
{
id: NOTIFICATION_TYPES.SLACK,
label: t('notifications.slack.label'),
description: t('notifications.slack.description'),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t('notifications.slack.webhookLabel'),
placeholder: t('notifications.slack.webhookPlaceholder'),
type: 'text'
}
]
},
{
id: NOTIFICATION_TYPES.DISCORD,
label: t('notifications.discord.label'),
description: t('notifications.discord.description'),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t('notifications.discord.webhookLabel'),
placeholder: t('notifications.discord.webhookPlaceholder'),
type: 'text'
}
]
},
{
id: NOTIFICATION_TYPES.TELEGRAM,
label: t('notifications.telegram.label'),
description: t('notifications.telegram.description'),
fields: [
{
id: FIELD_IDS.TOKEN,
label: t('notifications.telegram.tokenLabel'),
placeholder: t('notifications.telegram.tokenPlaceholder'),
type: 'text'
},
{
id: FIELD_IDS.CHAT_ID,
label: t('notifications.telegram.chatIdLabel'),
placeholder: t('notifications.telegram.chatIdPlaceholder'),
type: 'text'
}
]
},
{
id: NOTIFICATION_TYPES.WEBHOOK,
label: t('notifications.webhook.label'),
description: t('notifications.webhook.description'),
fields: [
{
id: FIELD_IDS.URL,
label: t('notifications.webhook.urlLabel'),
placeholder: t('notifications.webhook.urlPlaceholder'),
type: 'text'
}
]
}
];
const { t } = useTranslation();
const theme = useTheme();
const [tabValue, setTabValue] = useState(0);
// Use provided notification types or default to our translated ones
const activeNotificationTypes = notificationTypes || DEFAULT_NOTIFICATION_TYPES;
// Memoized function to initialize integrations state
const initialIntegrationsState = useMemo(() => {
const state = {};
activeNotificationTypes.forEach(type => {
// Add enabled flag for each notification type
state[type.id] = monitor?.notifications?.some(n => n.type === type.id) || false;
// Add state for each field in the notification type
type.fields.forEach(field => {
const fieldKey = getFieldKey(type.id, field.id);
state[fieldKey] = monitor?.notifications?.find(n => n.type === type.id)?.[field.id] || "";
});
});
return state;
}, [monitor, activeNotificationTypes]); // Only recompute when these dependencies change
const [integrations, setIntegrations] = useState(initialIntegrationsState);
const [loading, _, sendTestNotification] = useNotifications();
const handleChangeTab = (event, newValue) => {
setTabValue(newValue);
};
// Helper to get the field state key with error handling
const getFieldKey = useCallback(
(typeId, fieldId) => {
if (typeof typeId !== "string" || typeId === "") {
throw new Error(t("errorInvalidTypeId"));
}
const handleIntegrationChange = (type, checked) => {
setIntegrations(prev => ({
...prev,
[type]: checked
}));
};
if (typeof fieldId !== "string" || fieldId === "") {
throw new Error(t("errorInvalidFieldId"));
}
const handleInputChange = (type, value) => {
setIntegrations(prev => ({
...prev,
[type]: value
}));
};
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
},
[t]
);
const handleTestNotification = async (type) => {
// Get the notification type details
const notificationType = activeNotificationTypes.find(t => t.id === type);
if (typeof notificationType === "undefined") {
return;
}
// Prepare config object based on notification type
const config = {};
// Add each field value to the config object
notificationType.fields.forEach(field => {
const fieldKey = getFieldKey(type, field.id);
config[field.id] = integrations[fieldKey];
});
await sendTestNotification(type, config);
};
// In NotificationIntegrationModal.jsx, update the handleSave function:
// Define notification types
const DEFAULT_NOTIFICATION_TYPES = [
{
id: NOTIFICATION_TYPES.SLACK,
label: t("notifications.slack.label"),
description: t("notifications.slack.description"),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t("notifications.slack.webhookLabel"),
placeholder: t("notifications.slack.webhookPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.DISCORD,
label: t("notifications.discord.label"),
description: t("notifications.discord.description"),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t("notifications.discord.webhookLabel"),
placeholder: t("notifications.discord.webhookPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.TELEGRAM,
label: t("notifications.telegram.label"),
description: t("notifications.telegram.description"),
fields: [
{
id: FIELD_IDS.TOKEN,
label: t("notifications.telegram.tokenLabel"),
placeholder: t("notifications.telegram.tokenPlaceholder"),
type: "text",
},
{
id: FIELD_IDS.CHAT_ID,
label: t("notifications.telegram.chatIdLabel"),
placeholder: t("notifications.telegram.chatIdPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.WEBHOOK,
label: t("notifications.webhook.label"),
description: t("notifications.webhook.description"),
fields: [
{
id: FIELD_IDS.URL,
label: t("notifications.webhook.urlLabel"),
placeholder: t("notifications.webhook.urlPlaceholder"),
type: "text",
},
],
},
];
const handleSave = () => {
// Get existing notifications
const notifications = [...(monitor?.notifications || [])];
// Get all notification types IDs
const existingTypes = activeNotificationTypes.map(type => type.id);
// Filter out notifications that are configurable in this modal
const filteredNotifications = notifications.filter(
notification => {
if (notification.platform) {
return !existingTypes.includes(notification.platform);
}
return !existingTypes.includes(notification.type);
}
);
// Use provided notification types or default to our translated ones
const activeNotificationTypes = notificationTypes || DEFAULT_NOTIFICATION_TYPES;
// Add each enabled notification with its configured fields
activeNotificationTypes.forEach(type => {
if (integrations[type.id]) {
// Memoized function to initialize integrations state
const initialIntegrationsState = useMemo(() => {
const state = {};
let notificationObject = {
type: "webhook",
platform: type.id, // Set platform to identify the specific service
config: {}
};
// Configure based on notification type
switch(type.id) {
case "slack":
case "discord":
notificationObject.config.webhookUrl = integrations[getFieldKey(type.id, 'webhook')];
break;
case "telegram":
notificationObject.config.botToken = integrations[getFieldKey(type.id, 'token')];
notificationObject.config.chatId = integrations[getFieldKey(type.id, 'chatId')];
break;
case "webhook":
notificationObject.config.webhookUrl = integrations[getFieldKey(type.id, 'url')];
break;
}
filteredNotifications.push(notificationObject);
}
});
activeNotificationTypes.forEach((type) => {
// Add enabled flag for each notification type
state[type.id] = false;
// Update monitor with new notifications
setMonitor(prev => ({
...prev,
notifications: filteredNotifications
}));
onClose();
};
// Add state for each field in the notification type
type.fields.forEach((field) => {
const fieldKey = getFieldKey(type.id, field.id);
state[fieldKey] = "";
});
});
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
sx={{
'& .MuiDialog-paper': {
width: `calc(80% - ${theme.spacing(40)})`,
maxWidth: `${theme.breakpoints.values.md - 70}px`
}
}}
>
<DialogContent>
<Box sx={{
display: 'flex',
height: `calc(26vh - ${theme.spacing(20)})`
}}>
{/* Left sidebar with tabs */}
<Box sx={{
borderRight: 1,
borderColor: theme.palette.primary.lowContrast,
width: '30%',
maxWidth: theme.spacing(120),
pr: theme.spacing(10)
}}>
<Typography variant="subtitle1" sx={{
my: theme.spacing(1),
fontWeight: 'bold',
fontSize: theme.typography.fontSize * 0.9,
color: theme.palette.primary.contrastTextSecondary,
pl: theme.spacing(4)
}}>
{t('notifications.addOrEditNotifications')}
</Typography>
<Tabs
orientation="vertical"
variant="scrollable"
value={tabValue}
onChange={handleChangeTab}
aria-label="Notification tabs"
>
{activeNotificationTypes.map((type) => (
<Tab
key={type.id}
label={type.label}
orientation="vertical"
disableRipple
/>
))}
</Tabs>
</Box>
return state;
}, [activeNotificationTypes, getFieldKey]); // Only recompute when these dependencies change
{/* Right side content */}
<Box sx={{
flex: 1,
pl: theme.spacing(7.5),
overflowY: 'auto'
}}>
{activeNotificationTypes.map((type, index) => (
<TabPanel key={type.id} value={tabValue} index={index}>
<TabComponent
type={type}
integrations={integrations}
handleIntegrationChange={handleIntegrationChange}
handleInputChange={handleInputChange}
handleTestNotification={handleTestNotification}
isLoading={loading}
/>
</TabPanel>
))}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{
p: theme.spacing(4),
display: 'flex',
justifyContent: 'flex-end',
mb: theme.spacing(5),
mr: theme.spacing(5)
}}>
<Button
variant="contained"
color="accent"
onClick={handleSave}
loading={loading}
sx={{
width: 'auto',
minWidth: theme.spacing(60),
px: theme.spacing(8)
}}
>
{t('commonSave')}
</Button>
</DialogActions>
</Dialog>
);
const [integrations, setIntegrations] = useState(initialIntegrationsState);
useEffect(() => {
if (open) {
const extractNotificationValues = () => {
const values = {};
if (!monitor?.notifications || !Array.isArray(monitor.notifications)) {
return values;
}
monitor.notifications.forEach((notification) => {
// Handle notification based on its structure
if (notification.type === "webhook" && notification.platform) {
if (typeof notification.config === "undefined") return;
const platform = notification.platform;
values[platform] = true; // Set platform as enabled
// Extract configuration based on platform
switch (platform) {
case NOTIFICATION_TYPES.SLACK:
case NOTIFICATION_TYPES.DISCORD:
if (notification.config.webhookUrl) {
values[getFieldKey(platform, FIELD_IDS.WEBHOOK)] =
notification.config.webhookUrl;
}
break;
case NOTIFICATION_TYPES.TELEGRAM:
if (notification.config.botToken) {
values[getFieldKey(platform, FIELD_IDS.TOKEN)] =
notification.config.botToken;
}
if (notification.config.chatId) {
values[getFieldKey(platform, FIELD_IDS.CHAT_ID)] =
notification.config.chatId;
}
break;
case NOTIFICATION_TYPES.WEBHOOK:
if (notification.config.webhookUrl) {
values[getFieldKey(platform, FIELD_IDS.URL)] =
notification.config.webhookUrl;
}
break;
}
}
});
return values;
};
const extractedValues = extractNotificationValues();
setIntegrations((prev) => ({
...prev,
...extractedValues,
}));
}
}, [open, monitor, getFieldKey]);
const handleChangeTab = (event, newValue) => {
setTabValue(newValue);
};
const handleIntegrationChange = (type, checked) => {
setIntegrations((prev) => ({
...prev,
[type]: checked,
}));
};
const handleInputChange = (type, value) => {
setIntegrations((prev) => ({
...prev,
[type]: value,
}));
};
const handleTestNotification = async (type) => {
// Get the notification type details
const notificationType = activeNotificationTypes.find((t) => t.id === type);
if (typeof notificationType === "undefined") {
return;
}
// Prepare config object based on notification type
const config = {};
// Add each field value to the config object
notificationType.fields.forEach((field) => {
const fieldKey = getFieldKey(type, field.id);
config[field.id] = integrations[fieldKey];
});
await sendTestNotification(type, config);
};
// In NotificationIntegrationModal.jsx, update the handleSave function:
const handleSave = () => {
// Get existing notifications
const notifications = [...(monitor?.notifications || [])];
// Get all notification types IDs
const existingTypes = activeNotificationTypes.map((type) => type.id);
// Filter out notifications that are configurable in this modal
const filteredNotifications = notifications.filter((notification) => {
if (notification.platform) {
return !existingTypes.includes(notification.platform);
}
return !existingTypes.includes(notification.type);
});
// Add each enabled notification with its configured fields
activeNotificationTypes.forEach((type) => {
if (integrations[type.id]) {
let notificationObject = {
type: "webhook",
platform: type.id, // Set platform to identify the specific service
config: {},
};
// Configure based on notification type
switch (type.id) {
case "slack":
case "discord":
notificationObject.config.webhookUrl =
integrations[getFieldKey(type.id, "webhook")];
break;
case "telegram":
notificationObject.config.botToken =
integrations[getFieldKey(type.id, "token")];
notificationObject.config.chatId =
integrations[getFieldKey(type.id, "chatId")];
break;
case "webhook":
notificationObject.config.webhookUrl =
integrations[getFieldKey(type.id, "url")];
break;
}
filteredNotifications.push(notificationObject);
}
});
// Update monitor with new notifications
setMonitor((prev) => ({
...prev,
notifications: filteredNotifications,
}));
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
sx={{
"& .MuiDialog-paper": {
width: `calc(80% - ${theme.spacing(40)})`,
maxWidth: `${theme.breakpoints.values.md - 70}px`,
},
}}
>
<DialogContent>
<Stack
direction="row"
sx={{
height: `calc(30vh - ${theme.spacing(20)})`,
}}
>
{/* Left sidebar with tabs */}
<Box
sx={{
borderRight: 1,
borderColor: theme.palette.primary.lowContrast,
width: "30%",
maxWidth: theme.spacing(120),
pr: theme.spacing(10),
}}
>
<Typography variant="h2">
{t("notifications.addOrEditNotifications")}
</Typography>
<Tabs
orientation="vertical"
variant="scrollable"
value={tabValue}
onChange={handleChangeTab}
aria-label="Notification tabs"
>
{activeNotificationTypes.map((type) => (
<Tab
key={type.id}
label={type.label}
orientation="vertical"
disableRipple
/>
))}
</Tabs>
</Box>
{/* Right side content */}
<Box
sx={{
flex: 1,
pl: theme.spacing(7.5),
overflowY: "auto",
}}
>
{activeNotificationTypes.map((type, index) => (
<TabPanel
key={type.id}
value={tabValue}
index={index}
>
<TabComponent
type={type}
integrations={integrations}
handleIntegrationChange={handleIntegrationChange}
handleInputChange={handleInputChange}
handleTestNotification={handleTestNotification}
isLoading={loading}
/>
</TabPanel>
))}
</Box>
</Stack>
</DialogContent>
<DialogActions
sx={{
p: theme.spacing(4),
display: "flex",
justifyContent: "flex-end",
mb: theme.spacing(5),
mr: theme.spacing(5),
}}
>
<Button
variant="contained"
color="accent"
onClick={handleSave}
loading={loading}
sx={{
width: "auto",
minWidth: theme.spacing(60),
px: theme.spacing(8),
}}
>
{t("commonSave")}
</Button>
</DialogActions>
</Dialog>
);
};
NotificationIntegrationModal.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
monitor: PropTypes.object.isRequired,
setMonitor: PropTypes.func.isRequired,
notificationTypes: PropTypes.array
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
monitor: PropTypes.object.isRequired,
setMonitor: PropTypes.func.isRequired,
notificationTypes: PropTypes.array,
};
export default NotificationIntegrationModal;
export default NotificationIntegrationModal;
+3 -2
View File
@@ -20,8 +20,8 @@ const StatusBoxes = ({ shouldRender, flexWrap = "nowrap", children }) => {
direction="row"
flexWrap={flexWrap}
gap={theme.spacing(8)}
justifyContent="space-between"
display="flex"
justifyContent="flex-start"
display="flex"
>
{children}
</Stack>
@@ -30,6 +30,7 @@ const StatusBoxes = ({ shouldRender, flexWrap = "nowrap", children }) => {
StatusBoxes.propTypes = {
shouldRender: PropTypes.bool,
flexWrap: PropTypes.string,
children: PropTypes.node,
};
@@ -10,6 +10,7 @@ import { update } from "../../../Features/Auth/authSlice";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../../Utils/toastUtils";
import { getTouchedFieldErrors } from "../../../Validation/error";
import { useTranslation } from "react-i18next";
const defaultPasswordsState = {
password: "",
@@ -26,6 +27,7 @@ const defaultPasswordsState = {
const PasswordPanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
const SPACING_GAP = theme.spacing(12);
@@ -203,7 +205,7 @@ const PasswordPanel = () => {
<TextInput
type="password"
id="edit-confirm-password"
placeholder="Reenter your new password"
placeholder={t("confirmPassword")}
autoComplete="new-password"
value={localData.confirm}
onChange={handleChange}
@@ -344,7 +344,7 @@ const ProfilePanel = () => {
spellCheck="false"
>
<Box mb={theme.spacing(6)}>
<Typography component="h1">{t('DeleteAccount')}</Typography>
<Typography component="h1">{t('DeleteAccountTitle')}</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
@@ -357,7 +357,7 @@ const ProfilePanel = () => {
color="error"
onClick={() => setIsOpen("delete")}
>
{t('DeleteAccount')}
{t('DeleteAccountButton')}
</Button>
</Box>
)}
@@ -367,7 +367,7 @@ const ProfilePanel = () => {
title={t('DeleteWarningTitle')}
description={t('DeleteAccountWarning')}
onCancel={() => setIsOpen("")}
confirmationButtonLabel={t('DeleteAccount')}
confirmationButtonLabel={t('DeleteAccountButton')}
onConfirm={handleDeleteAccount}
isLoading={isLoading}
/>
@@ -25,7 +25,7 @@ const TeamPanel = () => {
const [toInvite, setToInvite] = useState({
email: "",
role: ["0"],
role: ["user"],
});
const [data, setData] = useState([]);
const [members, setMembers] = useState([]);
@@ -107,7 +107,7 @@ const TeamPanel = () => {
const handleChange = (event) => {
const { value } = event.target;
const newEmail = value?.toLowerCase() || value
const newEmail = value?.toLowerCase() || value;
setToInvite((prev) => ({
...prev,
email: newEmail,
+40 -36
View File
@@ -1,46 +1,50 @@
import { useMemo } from "react";
import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react";
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
import {
UnsafeBurnerWalletAdapter,
PhantomWalletAdapter,
} from "@solana/wallet-adapter-wallets";
// import { useMemo } from "react";
// import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react";
// import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
// import {
// UnsafeBurnerWalletAdapter,
// PhantomWalletAdapter,
// } from "@solana/wallet-adapter-wallets";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { clusterApiUrl } from "@solana/web3.js";
import PropTypes from "prop-types";
import "./index.css";
// import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
// import { clusterApiUrl } from "@solana/web3.js";
// import PropTypes from "prop-types";
// import "./index.css";
// Default styles that can be overridden by your app
import "@solana/wallet-adapter-react-ui/styles.css";
// // Default styles that can be overridden by your app
// import "@solana/wallet-adapter-react-ui/styles.css";
export const Wallet = ({ children }) => {
// The network can be set to 'devnet', 'testnet', or 'mainnet-beta'.
const network = WalletAdapterNetwork.Mainnet;
// export const Wallet = ({ children }) => {
// // The network can be set to 'devnet', 'testnet', or 'mainnet-beta'.
// const network = WalletAdapterNetwork.Mainnet;
// You can also provide a custom RPC endpoint.
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
// // You can also provide a custom RPC endpoint.
// const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(
() => [new PhantomWalletAdapter()],
// eslint-disable-next-line react-hooks/exhaustive-deps
[network]
);
// const wallets = useMemo(
// () => [new PhantomWalletAdapter()],
// // eslint-disable-next-line react-hooks/exhaustive-deps
// [network]
// );
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider
wallets={wallets}
autoConnect
>
<WalletModalProvider>{children}</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
};
// return (
// <ConnectionProvider endpoint={endpoint}>
// <WalletProvider
// wallets={wallets}
// autoConnect
// >
// <WalletModalProvider>{children}</WalletModalProvider>
// </WalletProvider>
// </ConnectionProvider>
// );
// };
Wallet.propTypes = {
children: PropTypes.node,
// Wallet.propTypes = {
// children: PropTypes.node,
// };
const Wallet = ({ children }) => {
return children;
};
export default Wallet;
@@ -101,7 +101,7 @@ export const updatePageSpeed = createAsyncThunk(
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
// notifications: monitor.notifications,
notifications: monitor.notifications,
};
const res = await networkService.updateMonitor({
monitorId: monitor._id,
@@ -5,7 +5,8 @@ const initialState = {
isLoading: false,
apiBaseUrl: "",
logLevel: "debug",
language: "",
language: "gb",
pagespeedApiKey: "",
};
export const getAppSettings = createAsyncThunk(
@@ -33,6 +34,7 @@ export const updateAppSettings = createAsyncThunk(
try {
const parsedSettings = {
language: settings.language,
pagespeedApiKey: settings.pagespeedApiKey,
};
const res = await networkService.updateAppSettings({ settings: parsedSettings });
return res.data;
@@ -56,6 +58,7 @@ const handleGetSettingsFulfilled = (state, action) => {
state.apiBaseUrl = action.payload.data.apiBaseUrl;
state.logLevel = action.payload.data.logLevel;
state.language = action.payload.data.language;
state.pagespeedApiKey = action.payload.data.pagespeedApiKey;
};
const handleGetSettingsRejected = (state, action) => {
state.isLoading = false;
@@ -81,11 +81,13 @@ export const updateUptimeMonitor = createAsyncThunk(
notifications: monitor.notifications,
matchMethod: monitor.matchMethod,
expectedValue: monitor.expectedValue,
ignoreTlsErrors: monitor.ignoreTlsErrors,
jsonPath: monitor.jsonPath,
...(monitor.type === "port" && { port: monitor.port }),
};
const res = await networkService.updateMonitor({
monitorId: monitor._id,
monitor,
updatedFields,
});
return res.data;
} catch (error) {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { networkService } from "../main";
import { useTranslation } from "react-i18next";
const CLIENT_HOST = import.meta.env.VITE_CLIENT_HOST;
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
const useGetInviteToken = () => {
const { t } = useTranslation();
+27
View File
@@ -0,0 +1,27 @@
import { useState } from "react";
import { networkService } from "../main";
export const useBulkMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const createBulkMonitors = async (file, user) => {
setIsLoading(true);
const formData = new FormData();
formData.append("csvFile", file);
formData.append("userId", user._id);
formData.append("teamId", user.teamId);
try {
const response = await networkService.createBulkMonitors(formData);
return [true, response.data, null]; // [success, data, error]
} catch (err) {
const errorMessage = err?.response?.data?.msg || err.message;
return [false, null, errorMessage];
} finally {
setIsLoading(false);
}
};
return [createBulkMonitors, isLoading];
};
@@ -58,7 +58,7 @@ const EmailStep = ({ form, errors, onSubmit, onChange }) => {
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email ? true : false}
helperText={errors.email}
helperText={errors.email ? t(errors.email) : ""}
ref={inputRef}
/>
<Stack
@@ -70,8 +70,17 @@ const EmailStep = ({ form, errors, onSubmit, onChange }) => {
color="accent"
type="submit"
disabled={errors.email && true}
className="dashboard-style-button"
sx={{
width: "30%",
px: theme.spacing(6),
borderRadius: `${theme.shape.borderRadius}px !important`,
'&.MuiButtonBase-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
},
'&.MuiButton-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
},
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
@@ -75,8 +75,16 @@ const PasswordStep = ({ form, errors, onSubmit, onChange, onBack }) => {
variant="outlined"
color="info"
onClick={onBack}
className="dashboard-style-button"
sx={{
px: theme.spacing(5),
borderRadius: `${theme.shape.borderRadius}px !important`,
'&.MuiButtonBase-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
},
'&.MuiButton-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
},
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(3),
},
@@ -95,13 +103,22 @@ const PasswordStep = ({ form, errors, onSubmit, onChange, onBack }) => {
type="submit"
loading={authState.isLoading}
disabled={errors.password && true}
className="dashboard-style-button"
sx={{
width: "30%",
px: theme.spacing(4),
borderRadius: `${theme.shape.borderRadius}px !important`,
'&.MuiButtonBase-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
},
'&.MuiButton-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
},
"&.Mui-focusVisible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
boxShadow: `none`,
},
boxShadow: `none`,
}}
>
{t("continue")}
+44 -22
View File
@@ -9,13 +9,13 @@ import { createToast } from "../../../Utils/toastUtils";
import { networkService } from "../../../main";
import Background from "../../../assets/Images/background-grid.svg?react";
import Logo from "../../../assets/icons/checkmate-icon.svg?react";
import { logger } from "../../../Utils/Logger";
import "../index.css";
import EmailStep from "./Components/EmailStep";
import PasswordStep from "./Components/PasswordStep";
import ThemeSwitch from "../../../Components/ThemeSwitch";
import ForgotPasswordLabel from "./Components/ForgotPasswordLabel";
import LanguageSelector from "../../../Components/LanguageSelector";
import { useTranslation } from "react-i18next";
const DEMO = import.meta.env.VITE_APP_DEMO;
@@ -24,10 +24,10 @@ const DEMO = import.meta.env.VITE_APP_DEMO;
*/
const Login = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const authState = useSelector((state) => state.auth);
const { authToken } = authState;
@@ -43,33 +43,28 @@ const Login = () => {
const [errors, setErrors] = useState({});
const [step, setStep] = useState(0);
useEffect(() => {
if (authToken) {
navigate("/uptime");
return;
}
networkService
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === false) {
navigate("/register");
}
})
.catch((error) => {
logger.error(error);
});
}, [authToken, navigate]);
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
const lowerCasedValue = name === idMap["login-email-input"]? value?.toLowerCase()||value : value
const lowerCasedValue =
name === idMap["login-email-input"] ? value?.toLowerCase() || value : value;
setForm((prev) => ({
...prev,
[name]: lowerCasedValue,
}));
const { error } = credentials.validate({ [name]: lowerCasedValue }, { abortEarly: false });
const { error } = credentials.validate(
{ [name]: lowerCasedValue },
{ abortEarly: false }
);
setErrors((prev) => {
const prevErrors = { ...prev };
@@ -88,8 +83,10 @@ const Login = () => {
{ abortEarly: false }
);
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
createToast({ body: error.details[0].message });
const errorMessage = error.details[0].message;
const translatedMessage = errorMessage.startsWith('auth') ? t(errorMessage) : errorMessage;
setErrors((prev) => ({ ...prev, email: translatedMessage }));
createToast({ body: translatedMessage });
} else {
setStep(1);
}
@@ -106,15 +103,15 @@ const Login = () => {
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Error validating data.",
? (error.details[0].message.startsWith('auth') ? t(error.details[0].message) : error.details[0].message)
: t("Error validating data."),
});
} else {
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/uptime");
createToast({
body: "Welcome back! You're successfully logged in.",
body: t("welcomeBack"),
});
} else {
if (action.payload) {
@@ -230,6 +227,31 @@ const Login = () => {
email={form.email}
errorEmail={errors.email}
/>
{/* Registration link */}
<Box textAlign="center" >
<Typography
className="forgot-p"
display="inline-block"
color={theme.palette.primary.main}
>
{t("doNotHaveAccount")}
</Typography>
<Typography
component="span"
color={theme.palette.accent.main}
ml={theme.spacing(2)}
sx={{
cursor: 'pointer',
'&:hover': {
color: theme.palette.accent.darker
}
}}
onClick={() => navigate("/register")}
>
{t("registerHere")}
</Typography>
</Box>
</Stack>
</Stack>
);
+46 -40
View File
@@ -15,7 +15,7 @@ import Background from "../../../assets/Images/background-grid.svg?react";
import Logo from "../../../assets/icons/checkmate-icon.svg?react";
import Mail from "../../../assets/icons/mail.svg?react";
import "../index.css";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
/**
* Displays the initial landing page.
*
@@ -67,42 +67,47 @@ const LandingPage = ({ isSuperAdmin, onSignup }) => {
</Box>
<Box maxWidth={400}>
<Typography className="tos-p">
{t("authRegisterBySigningUp")}{" "}
<Typography
component="span"
onClick={() => {
window.open(
"https://bluewavelabs.ca/terms-of-service-open-source",
"_blank",
"noreferrer"
);
<Trans
i18nKey="authRegisterBySigningUp"
components={{
a1: (
<Typography
component="span"
sx={{
cursor: "pointer",
"&:hover": {
color: theme.palette.primary.contrastTextTertiary,
},
}}
onClick={() => {
window.open(
"https://bluewavelabs.ca/terms-of-service-open-source",
"_blank",
"noreferrer"
);
}}
/>
),
a2: (
<Typography
component="span"
sx={{
cursor: "pointer",
"&:hover": {
color: theme.palette.primary.contrastTextTertiary,
},
}}
onClick={() => {
window.open(
"https://bluewavelabs.ca/privacy-policy-open-source",
"_blank",
"noreferrer"
);
}}
/>
)
}}
sx={{
"&:hover": {
color: theme.palette.primary.contrastTextTertiary,
},
}}
>
{t("authRegisterTerms")}
</Typography>{" "}
{t("and")}{" "}
<Typography
component="span"
onClick={() => {
window.open(
"https://bluewavelabs.ca/privacy-policy-open-source",
"_blank",
"noreferrer"
);
}}
sx={{
"&:hover": {
color: theme.palette.primary.contrastTextTertiary,
},
}}
>
{t("authRegisterPrivacy")}
</Typography>
/>
</Typography>
</Box>
</Stack>
@@ -148,8 +153,8 @@ const Register = ({ isSuperAdmin }) => {
try {
const res = await networkService.verifyInvitationToken(token);
const invite = res.data.data;
const { role, email, teamId } = invite;
setForm({ ...form, email, role, teamId });
const { email } = invite;
setForm({ ...form, email });
} catch (error) {
navigate("/register", { replace: true });
}
@@ -259,7 +264,8 @@ const Register = ({ isSuperAdmin }) => {
const handleChange = (event) => {
const { value, id } = event.target;
const name = idMap[id];
const lowerCasedValue = name === idMap["register-email-input"]? value?.toLowerCase() || value : value
const lowerCasedValue =
name === idMap["register-email-input"] ? value?.toLowerCase() || value : value;
setForm((prev) => ({
...prev,
[name]: lowerCasedValue,
@@ -381,7 +387,7 @@ const Register = ({ isSuperAdmin }) => {
}}
sx={{ userSelect: "none", color: theme.palette.accent.main }}
>
{t("authLoginTitle")}
{t("authRegisterLoginLink")}
</Typography>
</Box>
</Stack>
@@ -64,7 +64,7 @@ function StepOne({ form, errors, onSubmit, onChange, onBack }) {
>
<TextInput
id="register-firstname-input"
label="Name"
label={t("authRegisterFirstName")}
isRequired={true}
placeholder="Jordan"
autoComplete="given-name"
@@ -76,7 +76,7 @@ function StepOne({ form, errors, onSubmit, onChange, onBack }) {
/>
<TextInput
id="register-lastname-input"
label="Surname"
label={t("authRegisterLastName")}
isRequired={true}
placeholder="Ellis"
autoComplete="family-name"
@@ -64,7 +64,7 @@ function StepThree({ onSubmit, onBack }) {
type="password"
id="register-password-input"
name="password"
label={t("commonPassword")}
label={t("authLoginEnterPassword")}
isRequired={true}
placeholder={t("createAPassword")}
autoComplete="current-password"
@@ -60,7 +60,7 @@ function StepTwo({ form, errors, onSubmit, onChange, onBack }) {
<TextInput
type="email"
id="register-email-input"
label={t("commonEmail")}
label={t("authRegisterEmail")}
isRequired={true}
placeholder="jordan.ellis@domain.com"
autoComplete="email"
@@ -68,7 +68,11 @@ function StepTwo({ form, errors, onSubmit, onChange, onBack }) {
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email ? true : false}
helperText={errors.email}
helperText={errors.email && (
errors.email.includes("required") ? t("authRegisterEmailRequired") :
errors.email.includes("valid email") ? t("authRegisterEmailInvalid") :
errors.email
)}
ref={inputRef}
/>
<Stack
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
const InfraStatBoxes = ({ shouldRender, monitor }) => {
// Utils
const { formatBytes } = useHardwareUtils();
const { statusStyles, determineState } = useUtils();
const { determineState } = useUtils();
const { t } = useTranslation();
const { stats, uptimePercentage } = monitor ?? {};
@@ -41,7 +41,8 @@ const InfraStatBoxes = ({ shouldRender, monitor }) => {
flexWrap="wrap"
>
<StatBox
sx={statusStyles[determineState(monitor)]}
gradient={true}
status={determineState(monitor)}
heading={t("status")}
subHeading={determineState(monitor)}
/>
@@ -50,7 +51,9 @@ const InfraStatBoxes = ({ shouldRender, monitor }) => {
subHeading={
<>
{physicalCores}
<Typography component="span">cores</Typography>
<Typography component="span">
{physicalCores === 1 ? "core" : "cores"}
</Typography>
</>
}
/>
@@ -60,7 +63,9 @@ const InfraStatBoxes = ({ shouldRender, monitor }) => {
subHeading={
<>
{logicalCores}
<Typography component="span">cores</Typography>
<Typography component="span">
{logicalCores === 1 ? "core" : "cores"}
</Typography>
</>
}
/>
@@ -78,7 +83,7 @@ const InfraStatBoxes = ({ shouldRender, monitor }) => {
subHeading={
<>
{cpuTemperature.toFixed(2)}
<Typography component="span">C</Typography>
<Typography component="span">°C</Typography>
</>
}
/>
@@ -52,7 +52,12 @@ const Filter = ({
}, [selectedStatus]);
return (
<Box>
<Box
sx={{
m: theme.spacing(2),
ml: theme.spacing(4),
}}
>
<FilterHeader
header={t("status")}
options={statusOptions}
@@ -75,6 +75,7 @@ const InfrastructureMenu = ({ monitor, isAdmin, updateCallback }) => {
<IconButton
aria-label="monitor actions"
onClick={openMenu}
disabled={!isAdmin}
sx={{
"&:focus": {
outline: "none",
@@ -94,19 +94,21 @@ const InfrastructureMonitors = () => {
<Breadcrumbs list={BREADCRUMBS} />
<MonitorCreateHeader
isAdmin={isAdmin}
shouldRender={!isLoading}
isLoading={isLoading}
path="/infrastructure/create"
/>
<MonitorCountHeader
shouldRender={!isLoading}
monitorCount={summary?.totalMonitors ?? 0}
/>
<Filter
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
setToFilterStatus={setToFilterStatus}
handleReset={handleReset}
/>
<Stack direction={"row"}>
<MonitorCountHeader
shouldRender={!isLoading}
monitorCount={summary?.totalMonitors ?? 0}
/>
<Filter
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
setToFilterStatus={setToFilterStatus}
handleReset={handleReset}
/>
</Stack>
<MonitorsTable
shouldRender={!isLoading}
monitors={monitors}
@@ -21,6 +21,7 @@ const PageSpeed = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const { user } = useSelector((state) => state.auth);
const { pagespeedApiKey } = useSelector((state) => state.settings);
const { isLoading, monitors, summary, networkError } = useMonitorsFetch({
teamId: user.teamId,
@@ -52,7 +53,7 @@ const PageSpeed = () => {
]}
link="/pagespeed/create"
isAdmin={isAdmin}
showPageSpeedWarning={true}
showPageSpeedWarning={isAdmin && !pagespeedApiKey}
/>
);
}
@@ -62,7 +63,7 @@ const PageSpeed = () => {
<Breadcrumbs list={BREADCRUMBS} />
<CreateMonitorHeader
isAdmin={isAdmin}
shouldRender={!isLoading}
isLoading={isLoading}
path="/pagespeed/create"
/>
<MonitorCountHeader
+161
View File
@@ -0,0 +1,161 @@
import React, { useState } from "react";
import { Box, Typography, Button, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router";
import { networkService } from "../Utils/NetworkService";
import Alert from "../Components/Alert";
import { createToast } from "../Utils/toastUtils";
import { useTranslation } from "react-i18next";
import Background from "../assets/Images/background-grid.svg?react";
import Logo from "../assets/icons/checkmate-icon.svg?react";
import ThemeSwitch from "../Components/ThemeSwitch";
import LanguageSelector from "../Components/LanguageSelector";
const ServerUnreachable = () => {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useTranslation();
// State for tracking connection check status
const [isCheckingConnection, setIsCheckingConnection] = useState(false);
const handleRetry = React.useCallback(async () => {
setIsCheckingConnection(true);
try {
// Try to connect to the backend with a simple API call
// We'll use any lightweight endpoint that doesn't require authentication
await networkService.axiosInstance.get('/health', { timeout: 5000 });
// If successful, show toast and navigate to login page
createToast({
body: t("backendReconnected", "Connection to server restored"),
});
navigate("/login");
} catch (error) {
// If still unreachable, stay on this page and show toast
createToast({
body: t("backendStillUnreachable", "Server is still unreachable"),
});
} finally {
setIsCheckingConnection(false);
}
}, [navigate, t]);
return (
<Stack
className="login-page auth"
overflow="hidden"
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
{/* Header with logo */}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>Checkmate</Typography>
</Stack>
<Stack
direction="row"
spacing={2}
alignItems="center"
>
<LanguageSelector />
<ThemeSwitch />
</Stack>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
rowGap={theme.spacing(8)}
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.primary.lowContrast,
backgroundColor: theme.palette.primary.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack spacing={theme.spacing(6)} alignItems="center">
<Box sx={{
width: theme.spacing(220),
mx: 'auto',
'& .alert.row-stack': {
width: '100%',
alignItems: 'center',
gap: theme.spacing(3)
}
}}>
<Alert
variant="error"
body={t("backendUnreachable", "Server Unreachable")}
hasIcon={true}
/>
</Box>
<Box mt={theme.spacing(2)}>
<Typography
variant="body1"
align="center"
color={theme.palette.primary.contrastTextSecondary}
>
{t("backendUnreachableMessage", "The Checkmate server is not responding. Please check your deployment configuration or try again later.")}
</Typography>
</Box>
<Box sx={{ mt: theme.spacing(4) }}>
<Button
variant="contained"
color="accent"
onClick={handleRetry}
disabled={isCheckingConnection}
className="dashboard-style-button"
sx={{
px: theme.spacing(6),
borderRadius: `${theme.shape.borderRadius}px !important`,
'&.MuiButtonBase-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
},
'&.MuiButton-root': {
borderRadius: `${theme.shape.borderRadius}px !important`
}
}}
>
{isCheckingConnection ?
t("retryingConnection", "Retrying Connection...") :
t("retryConnection", "Retry Connection")}
</Button>
</Box>
</Stack>
</Stack>
</Stack>
);
};
export default ServerUnreachable;
+125 -47
View File
@@ -1,15 +1,17 @@
// Components
import { Box, Stack, Typography, Button, Switch } from "@mui/material";
import { Box, Stack, Typography, Button } from "@mui/material";
import TextInput from "../../Components/Inputs/TextInput";
import Link from "../../Components/Link";
import Select from "../../Components/Inputs/Select";
import { useIsAdmin } from "../../Hooks/useIsAdmin";
import Dialog from "../../Components/Dialog";
import ConfigBox from "../../Components/ConfigBox";
import {
WalletMultiButton,
WalletDisconnectButton,
} from "@solana/wallet-adapter-react-ui";
import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornments";
import { getAppSettings } from "../../Features/Settings/settingsSlice";
// import {
// WalletMultiButton,
// WalletDisconnectButton,
// } from "@solana/wallet-adapter-react-ui";
//Utils
import { useTheme } from "@emotion/react";
@@ -23,12 +25,7 @@ import {
} from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { update } from "../../Features/Auth/authSlice";
import PropTypes from "prop-types";
import {
setTimezone,
setMode,
setDistributedUptimeEnabled,
setLanguage,
} from "../../Features/UI/uiSlice";
import { setTimezone, setMode, setLanguage } from "../../Features/UI/uiSlice";
import timezones from "../../Utils/timezones.json";
import { useState, useEffect } from "react";
import { networkService } from "../../main";
@@ -50,12 +47,18 @@ const Settings = () => {
const { isLoading: authIsLoading } = useSelector((state) => state.auth);
const { timezone, distributedUptimeEnabled } = useSelector((state) => state.ui);
const { mode } = useSelector((state) => state.ui);
const { pagespeedApiKey } = useSelector((state) => state.settings);
const [checksIsLoading, setChecksIsLoading] = useState(false);
const [form, setForm] = useState({
enableDistributedUptime: distributedUptimeEnabled,
ttl: checkTTL ? (checkTTL / SECONDS_PER_DAY).toString() : 0,
pagespeedApiKey: pagespeedApiKey,
});
const [version, setVersion] = useState("unknown");
const [apiKeyFieldType, setApiKeyFieldType] = useState("password");
const [isApiKeySet, setIsApiKeySet] = useState(pagespeedApiKey ? true : false);
const [tempPSKey, setTempPSKey] = useState("");
const [errors, setErrors] = useState({});
const deleteStatsMonitorsInitState = { deleteMonitors: false, deleteStats: false };
const [isOpen, setIsOpen] = useState(deleteStatsMonitorsInitState);
@@ -80,6 +83,10 @@ const Settings = () => {
fetchLatestVersion();
}, []);
useEffect(() => {
dispatch(getAppSettings());
}, [dispatch]);
const handleChange = (event) => {
const { type, checked, value, id } = event.target;
@@ -91,12 +98,19 @@ const Settings = () => {
return;
}
const { error } = settingsValidation.validate(
{ [id]: value },
{
abortEarly: false,
}
);
if (id === "pagespeedApiKey") {
setTempPSKey(value);
return;
}
let inputValue = value;
if (id === "ttl") {
inputValue = value.replace(/[^0-9]/g, "");
}
const updatedForm = { ...form, [id]: inputValue };
const { error } = settingsValidation.validate(updatedForm, { abortEarly: false });
if (!error || error.details.length === 0) {
setErrors({});
} else {
@@ -107,12 +121,8 @@ const Settings = () => {
setErrors(newErrors);
logger.error("Validation errors:", error.details);
}
let inputValue = value;
id === "ttl" && (inputValue = value.replace(/[^0-9]/g, ""));
setForm((prev) => ({
...prev,
[id]: inputValue,
}));
setForm(updatedForm);
};
// TODO Handle saving
@@ -125,7 +135,14 @@ const Settings = () => {
const updatedUser = { ...user, checkTTL: form.ttl };
const [userAction, settingsAction] = await Promise.all([
dispatch(update({ localData: updatedUser })),
dispatch(updateAppSettings({ settings: { language: language } })),
dispatch(
updateAppSettings({
settings: {
language: language,
pagespeedApiKey: tempPSKey ? tempPSKey : form.pagespeedApiKey,
},
})
),
]);
if (userAction.payload.success && settingsAction.payload.success) {
@@ -187,6 +204,14 @@ const Settings = () => {
}
};
const handleResetApiKey = () => {
setIsApiKeySet(false);
setForm((prev) => ({
...prev,
pagespeedApiKey: "",
}));
};
const languages = Object.keys(i18n.options.resources || {});
return (
@@ -254,7 +279,7 @@ const Settings = () => {
></Select>
</Stack>
</ConfigBox>
{isAdmin && (
{/* {isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsDistributedUptime")}</Typography>
@@ -276,8 +301,50 @@ const Settings = () => {
: t("settingsDisabled")}
</Box>
</ConfigBox>
)}
{isAdmin && (
)} */}
<ConfigBox>
<Box>
<Typography component="h1">{t("pageSpeedApiKeyFieldTitle")}</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(2) }}>
{t("pageSpeedApiKeyFieldDescription")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<TextInput
id="pagespeedApiKey"
label={t("pageSpeedApiKeyFieldLabel")}
value={form.pagespeedApiKey ? form.pagespeedApiKey : tempPSKey}
type={apiKeyFieldType}
onChange={handleChange}
disabled={isApiKeySet}
optionalLabel="(Optional)"
error={!!errors.pagespeedApiKey}
helperText={errors.pagespeedApiKey}
endAdornment={
!isApiKeySet && (
<PasswordEndAdornment
fieldType={apiKeyFieldType}
setFieldType={setApiKeyFieldType}
/>
)
}
/>
{isApiKeySet && (
<Box>
<Typography>{t("pageSpeedApiKeyFieldResetLabel")}</Typography>
<Button
onClick={handleResetApiKey}
variant="contained"
color="error"
sx={{ mt: theme.spacing(4) }}
>
{t("reset")}
</Button>
</Box>
)}
</Stack>
</ConfigBox>
{/* {isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsWallet")}</Typography>
@@ -302,7 +369,7 @@ const Settings = () => {
</Stack>
</Box>
</ConfigBox>
)}
)} */}
{isAdmin && (
<ConfigBox>
<Box>
@@ -348,14 +415,15 @@ const Settings = () => {
</ConfigBox>
)}
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsDemoMonitors")}</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsDemoMonitorsDescription")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<>
{/* Demo Monitors Section */}
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsDemoMonitors")}</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsDemoMonitorsDescription")}
</Typography>
</Box>
<Box>
<Typography>{t("settingsAddDemoMonitors")}</Typography>
<Button
@@ -368,6 +436,16 @@ const Settings = () => {
{t("settingsAddDemoMonitorsButton")}
</Button>
</Box>
</ConfigBox>
{/* System Reset Section */}
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsSystemReset")}</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsSystemResetDescription")}
</Typography>
</Box>
<Box>
<Typography>{t("settingsRemoveAllMonitors")}</Typography>
<Button
@@ -382,17 +460,17 @@ const Settings = () => {
{t("settingsRemoveAllMonitorsButton")}
</Button>
</Box>
</Stack>
<Dialog
open={isOpen.deleteMonitors}
theme={theme}
title={t("settingsRemoveAllMonitorsDialogTitle")}
onCancel={() => setIsOpen(deleteStatsMonitorsInitState)}
confirmationButtonLabel={t("settingsRemoveAllMonitorsDialogConfirm")}
onConfirm={handleDeleteAllMonitors}
isLoading={isLoading || authIsLoading || checksIsLoading}
/>
</ConfigBox>
<Dialog
open={isOpen.deleteMonitors}
theme={theme}
title={t("settingsRemoveAllMonitorsDialogTitle")}
onCancel={() => setIsOpen(deleteStatsMonitorsInitState)}
confirmationButtonLabel={t("settingsRemoveAllMonitorsDialogConfirm")}
onConfirm={handleDeleteAllMonitors}
isLoading={isLoading || authIsLoading || checksIsLoading}
/>
</ConfigBox>
</>
)}
<ConfigBox>
@@ -3,6 +3,7 @@ import { Box, Stack, Typography, Button } from "@mui/material";
import Image from "../../../../../Components/Image";
import SettingsIcon from "../../../../../assets/icons/settings-bold.svg?react";
import ThemeSwitch from "../../../../../Components/ThemeSwitch";
import ArrowOutwardIcon from "@mui/icons-material/ArrowOutward";
//Utils
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
@@ -86,6 +87,8 @@ const ControlsHeader = ({
type = "uptime",
}) => {
const theme = useTheme();
const { t } = useTranslation();
const publicUrl = `/status/uptime/public/${url}`;
return (
<Stack
@@ -118,6 +121,26 @@ const ControlsHeader = ({
>
{statusPage?.companyName}
</Typography>
{statusPage?.isPublished && !isPublic && (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
onClick={() => {
window.open(publicUrl, "_blank", "noopener,noreferrer");
}}
sx={{
display: "inline-flex",
":hover": {
cursor: "pointer",
borderBottom: 1,
},
}}
>
<Typography>{t("publicLink")}</Typography>
<ArrowOutwardIcon />
</Stack>
)}
</Stack>
<Controls
isDeleting={isDeleting}
@@ -139,6 +139,7 @@ const PublicStatus = () => {
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
url={url}
isPublic={isPublic}
/>
<Typography variant="h2">{t("statusPageStatusServiceStatus")}</Typography>
<StatusBar monitors={monitors} />
@@ -61,6 +61,7 @@ const StatusPages = () => {
label="Create status page"
isAdmin={isAdmin}
path="/status/uptime/create"
isLoading={isLoading}
/>
<StatusPagesTable data={statusPages} />
</Stack>
@@ -0,0 +1,73 @@
import { useTheme } from "@emotion/react";
import { useState, useRef } from "react";
import { Button, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
const UploadFile = ({ onFileSelect }) => { // Changed prop to onFileSelect
const theme = useTheme();
const [file, setFile] = useState();
const [error, setError] = useState("");
const inputRef = useRef();
const { t } = useTranslation();
const handleSelectFile = () => {
inputRef.current.click();
};
const handleFileChange = (e) => {
setError("");
const selectedFile = e.target.files[0];
// Basic file validation
if (!selectedFile) return;
if (!selectedFile.name.endsWith('.csv')) {
setError(t("bulkImport.invalidFileType"));
return;
}
setFile(selectedFile);
onFileSelect(selectedFile); // Pass the file directly to parent
};
return (
<div>
<input
ref={inputRef}
type="file"
accept=".csv"
style={{ display: "none" }}
onChange={handleFileChange}
/>
<Typography
component="h2"
mb={theme.spacing(1.5)}
sx={{ wordBreak: "break-all" }}
>
{file?.name || t("bulkImport.noFileSelected")}
</Typography>
<Typography
component="div"
mb={theme.spacing(1.5)}
color={theme.palette.error.main}
>
{error}
</Typography>
<Button
variant="contained"
color="accent"
onClick={handleSelectFile}
>
{t("bulkImport.selectFile")}
</Button>
</div>
);
}
UploadFile.prototype = {
onFileSelect: PropTypes.func.isRequired,
};
export default UploadFile;
@@ -0,0 +1,112 @@
// React, Redux, Router
import { useTheme } from "@emotion/react";
import { useState } from "react";
// MUI
import { Box, Stack, Typography, Button, Link } from "@mui/material";
//Components
import { createToast } from "../../../Utils/toastUtils";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import ConfigBox from "../../../Components/ConfigBox";
import UploadFile from "./Upload";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router";
import { Trans, useTranslation } from "react-i18next";
import { useBulkMonitors } from "../../../Hooks/useBulkMonitors";
const BulkImport = () => {
const theme = useTheme();
const { user } = useSelector((state) => state.auth);
const navigate = useNavigate();
const { t } = useTranslation();
const [selectedFile, setSelectedFile] = useState(null);
const crumbs = [
{ name: t("uptime"), path: "/uptime" },
{ name: t("bulkImport.title"), path: `/uptime/bulk-import` },
];
const [createBulkMonitors, hookLoading] = useBulkMonitors();
const handleSubmit = async () => {
if (!selectedFile) {
createToast({ body: t("bulkImport.noFileSelected") });
return;
}
const [success, data, error] = await createBulkMonitors(selectedFile, user);
if (success) {
// You can use `data` here if needed
createToast({ body: t("bulkImport.uploadSuccess") });
navigate("/uptime");
} else {
createToast({ body: error || t("bulkImport.uploadFailed") });
}
};
return (
<Box className="bulk-import-monitor">
<Breadcrumbs list={crumbs} />
<Stack
component="form"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Typography
component="h1"
variant="h1"
>
{t("bulkImport.title")}
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">{t("bulkImport.selectFileTips")}</Typography>
<Typography component="p">
<Trans
i18nKey="bulkImport.selectFileDescription"
components={{
template: (
<Link
color="info"
download
href="/bulk_import_monitors_template.csv"
/>
),
sample: (
<Link
color="info"
download
href="/bulk_import_monitors_sample.csv"
/>
),
}}
/>
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<UploadFile onFileSelect={(file) => setSelectedFile(file)} />
</Stack>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="accent"
onClick={handleSubmit}
disabled={hookLoading}
loading={hookLoading}
>
{t("submit")}
</Button>
</Stack>
</Stack>
</Box>
);
};
export default BulkImport;
+74 -3
View File
@@ -2,7 +2,15 @@ import { useNavigate, useParams } from "react-router";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { useEffect, useState } from "react";
import { Box, Stack, Tooltip, Typography, Button } from "@mui/material";
import {
Box,
Stack,
Tooltip,
Typography,
Button,
FormControlLabel,
Switch,
} from "@mui/material";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
@@ -25,6 +33,7 @@ import PulseDot from "../../../Components/Animated/PulseDot";
import SkeletonLayout from "./skeleton";
import "./index.css";
import Dialog from "../../../Components/Dialog";
import NotificationIntegrationModal from "../../../Components/NotificationIntegrationModal/Components/NotificationIntegrationModal";
/**
* Parses a URL string and returns a URL object.
@@ -94,7 +103,7 @@ const Configure = () => {
}, [monitorId, navigate]);
const handleChange = (event, name) => {
let { value, id } = event.target;
let { checked, value, id } = event.target;
if (!name) name = idMap[id];
if (name.includes("notification-")) {
@@ -126,6 +135,9 @@ const Configure = () => {
if (name === "interval") {
value = value * MS_PER_MINUTE;
}
if (name === "ignoreTlsErrors") {
value = checked;
}
setMonitor((prev) => ({
...prev,
[name]: value,
@@ -196,6 +208,17 @@ const Configure = () => {
const parsedUrl = parseUrl(monitor?.url);
const protocol = parsedUrl?.protocol?.replace(":", "") || "";
// Notification modal state
const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false);
const handleOpenNotificationModal = () => {
setIsNotificationModalOpen(true);
};
const handleClosenNotificationModal = () => {
setIsNotificationModalOpen(false);
};
const statusColor = {
true: theme.palette.success.main,
false: theme.palette.error.main,
@@ -371,6 +394,17 @@ const Configure = () => {
value={parsedUrl?.host || monitor?.url || ""}
disabled={true}
/>
<TextInput
type="number"
id="monitor-port"
label={t("portToMonitor")}
placeholder="5173"
value={monitor.port || ""}
onChange={(event) => handleChange(event, "port")}
error={errors["port"] ? true : false}
helperText={errors["port"]}
hidden={monitor.type !== "port"}
/>
<TextInput
type="text"
id="monitor-name"
@@ -413,6 +447,15 @@ const Configure = () => {
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Box mt={theme.spacing(2)}>
<Button
variant="contained"
color="accent"
onClick={handleOpenNotificationModal}
>
{t("notifications.integrationButton")}
</Button>
</Box>
{/* <Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
@@ -443,7 +486,28 @@ const Configure = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateAdvancedSettings")}</Typography>
<Typography component="h2">{t("ignoreTLSError")}</Typography>
<Typography component="p">{t("ignoreTLSErrorDescription")}</Typography>
</Box>
<Stack>
<FormControlLabel
control={
<Switch
name="ignore-error"
checked={monitor.ignoreTlsErrors}
onChange={(event) => handleChange(event, "ignoreTlsErrors")}
sx={{ mr: theme.spacing(2) }}
/>
}
label={t("tlsErrorIgnored")}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">
{t("distributedUptimeCreateAdvancedSettings")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
@@ -546,6 +610,13 @@ const Configure = () => {
onConfirm={handleRemove}
isLoading={isLoading}
/>
<NotificationIntegrationModal
open={isNotificationModalOpen}
onClose={handleClosenNotificationModal}
monitor={monitor}
setMonitor={setMonitor}
/>
</Stack>
);
};
+28 -16
View File
@@ -13,6 +13,8 @@ import { getUptimeMonitorById } from "../../../Features/UptimeMonitors/uptimeMon
import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
// MUI
import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material";
import Switch from "@mui/material/Switch";
import FormControlLabel from "@mui/material/FormControlLabel";
//Components
import Breadcrumbs from "../../../Components/Breadcrumbs";
@@ -92,6 +94,7 @@ const CreateMonitor = () => {
url: "",
name: "",
type: "http",
ignoreTlsErrors: false,
notifications: [],
interval: 1,
});
@@ -107,6 +110,7 @@ const CreateMonitor = () => {
port: monitor.type === "port" ? monitor.port : undefined,
name: monitor.name || monitor.url.substring(0, 50),
type: monitor.type,
ignoreTlsErrors: monitor.ignoreTlsErrors,
interval: monitor.interval * MS_PER_MINUTE,
};
@@ -130,19 +134,6 @@ const CreateMonitor = () => {
return;
}
if (monitor.type === "http") {
// const checkEndpointAction = await dispatch(
// checkEndpointResolution({ monitorURL: form.url })
// );
// if (checkEndpointAction.meta.requestStatus === "rejected") {
// createToast({
// body: "The endpoint you entered doesn't resolve. Check the URL again.",
// });
// setErrors({ url: "The entered URL is not reachable." });
// return;
// }
}
form = {
...form,
description: monitor.name || monitor.url,
@@ -160,11 +151,13 @@ const CreateMonitor = () => {
};
const handleChange = (event, formName) => {
const { value } = event.target;
const { type, checked, value } = event.target;
const newVal = type === "checkbox" ? checked : value;
const newMonitor = {
...monitor,
[formName]: value,
[formName]: newVal,
};
if (formName === "type") {
newMonitor.url = "";
@@ -172,7 +165,7 @@ const CreateMonitor = () => {
setMonitor(newMonitor);
const { error } = monitorValidation.validate(
{ type: monitor.type, [formName]: value },
{ type: monitor.type, [formName]: newVal },
{ abortEarly: false }
);
setErrors((prev) => ({
@@ -422,6 +415,25 @@ const CreateMonitor = () => {
</Box>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("ignoreTLSError")}</Typography>
<Typography component="p">{t("ignoreTLSErrorDescription")}</Typography>
</Box>
<Stack>
<FormControlLabel
control={
<Switch
name="ignore-error"
checked={monitor.ignoreTlsErrors}
onChange={(event) => handleChange(event, "ignoreTlsErrors")}
sx={{ mr: theme.spacing(2) }}
/>
}
label={t("tlsErrorIgnored")}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">
@@ -61,18 +61,20 @@ const UptimeStatusBoxes = ({
</>
}
/>
<StatBox
heading="certificate expiry"
subHeading={
<Typography
component="span"
fontSize={13}
color={theme.palette.primary.contrastText}
>
{certificateExpiry}
</Typography>
}
/>
{monitor?.type === "http" && (
<StatBox
heading="certificate expiry"
subHeading={
<Typography
component="span"
fontSize={13}
color={theme.palette.primary.contrastText}
>
{certificateExpiry}
</Typography>
}
/>
)}
</StatusBoxes>
);
};
+2 -1
View File
@@ -189,8 +189,9 @@ const UptimeMonitors = () => {
<Breadcrumbs list={BREADCRUMBS} />
<CreateMonitorHeader
isAdmin={isAdmin}
shouldRender={!isLoading}
isLoading={isLoading}
path="/uptime/create"
bulkPath="/uptime/bulk-import"
/>
<Greeting type="uptime" />
<StatusBoxes
+30 -17
View File
@@ -36,6 +36,9 @@ import DistributedUptimeDetails from "../Pages/DistributedUptime/Details";
import CreateDistributedUptimeStatus from "../Pages/DistributedUptimeStatus/Create";
import DistributedUptimeStatus from "../Pages/DistributedUptimeStatus/Status";
// Server Status
import ServerUnreachable from "../Pages/ServerUnreachable";
// Incidents
import Incidents from "../Pages/Incidents";
@@ -56,6 +59,7 @@ import ProtectedRoute from "../Components/ProtectedRoute";
import ProtectedDistributedUptimeRoute from "../Components/ProtectedDistributedUptimeRoute";
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance";
import withAdminCheck from "../Components/HOC/withAdminCheck";
import BulkImport from "../Pages/Uptime/BulkImport";
const Routes = () => {
const AdminCheckedRegister = withAdminCheck(AuthRegister);
@@ -78,6 +82,11 @@ const Routes = () => {
element={<Uptime />}
/>
<Route
path="/uptime/bulk-import"
element={<BulkImport />}
/>
<Route
path="/uptime/create/:monitorId?"
element={<UptimeCreate />}
@@ -90,16 +99,16 @@ const Routes = () => {
path="/uptime/configure/:monitorId/"
element={<UptimeConfigure />}
/>
<Route
{/* <Route
path="/distributed-uptime"
element={
<ProtectedDistributedUptimeRoute>
<DistributedUptimeMonitors />{" "}
</ProtectedDistributedUptimeRoute>
}
/>
/> */}
<Route
{/* <Route
path="/distributed-uptime/create"
element={
<ProtectedDistributedUptimeRoute>
@@ -114,15 +123,15 @@ const Routes = () => {
<CreateDistributedUptime />
</ProtectedDistributedUptimeRoute>
}
/>
<Route
/> */}
{/* <Route
path="/distributed-uptime/:monitorId"
element={
<ProtectedDistributedUptimeRoute>
<DistributedUptimeDetails />
</ProtectedDistributedUptimeRoute>
}
/>
/> */}
<Route
path="pagespeed"
@@ -148,9 +157,9 @@ const Routes = () => {
path="infrastructure/create"
element={<InfrastructureCreate />}
/>
<Route
path="/infrastructure/configure/:monitorId"
element={<InfrastructureCreate />}
<Route
path="/infrastructure/configure/:monitorId"
element={<InfrastructureCreate />}
/>
<Route
path="infrastructure/:monitorId"
@@ -171,42 +180,42 @@ const Routes = () => {
element={<Status />}
/>
<Route
{/* <Route
path="/status/distributed/:url"
element={
<ProtectedDistributedUptimeRoute>
<DistributedUptimeStatus />
</ProtectedDistributedUptimeRoute>
}
/>
/> */}
<Route
path="status/uptime/create"
element={<CreateStatus />}
/>
<Route
{/* <Route
path="/status/distributed/create/:monitorId"
element={
<ProtectedDistributedUptimeRoute>
<CreateDistributedUptimeStatus />
</ProtectedDistributedUptimeRoute>
}
/>
/> */}
<Route
path="status/uptime/configure/:url"
element={<CreateStatus />}
/>
<Route
{/* <Route
path="/status/distributed/configure/:url"
element={
<ProtectedDistributedUptimeRoute>
<CreateDistributedUptimeStatus />
</ProtectedDistributedUptimeRoute>
}
/>
/> */}
<Route
path="integrations"
@@ -274,11 +283,15 @@ const Routes = () => {
path="/status/uptime/public/:url"
element={<Status />}
/>
<Route
{/* <Route
path="/status/distributed/public/:url"
element={<DistributedUptimeStatus />}
/>
/> */}
<Route
path="/server-unreachable"
element={<ServerUnreachable />}
/>
<Route
path="*"
element={<NotFound />}
+26 -11
View File
@@ -32,7 +32,7 @@ class NetworkService {
config.headers = {
Authorization: `Bearer ${authToken}`,
"Accept-Language": currentLanguage,
"Accept-Language": currentLanguage === "gb" ? "en" : currentLanguage,
...config.headers,
};
@@ -45,6 +45,16 @@ class NetworkService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
// Handle network errors (server unreachable)
if (error.code === "ERR_NETWORK") {
// Navigate to server unreachable page
navigate("/server-unreachable");
// Return an empty resolved promise to stop the error propagation
return Promise.reject(error);
}
// Handle authentication errors
if (error.response && error.response.status === 401) {
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
@@ -257,16 +267,8 @@ class NetworkService {
* @returns {Promise<AxiosResponse>} The response from the axios PUT request.
*/
async updateMonitor(config) {
const { monitorId, monitor, updatedFields } = config;
const payload = updatedFields ?? {
name: monitor.name,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications,
matchMethod: monitor.matchMethod,
expectedValue: monitor.expectedValue,
jsonPath: monitor.jsonPath,
};
const { monitorId, updatedFields } = config;
const payload = updatedFields;
return this.axiosInstance.put(`/monitors/${monitorId}`, payload, {
headers: {
"Content-Type": "application/json",
@@ -1086,6 +1088,19 @@ class NetworkService {
return this.axiosInstance.delete(`/status-page/${encodedUrl}`, {});
}
// ************************************
// Create bulk monitors
// ************************************
async createBulkMonitors(formData) {
const response = await this.axiosInstance.post(`/monitors/bulk`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data;
}
// ************************************
// Fetch monitors with summary by TeamID
// ************************************
+9 -3
View File
@@ -63,7 +63,6 @@ const paletteColors = {
orange500: "#E88C30",
orange600: "#DC6803",
orange800: "#624711",
};
const semanticColors = {
@@ -155,7 +154,6 @@ const newSemanticColors = {
light: newColors.blueGray600,
dark: newColors.gray200,
},
// CAIO_REVIEW, need a brighter color for dark bg
contrastTextSecondaryDarkBg: {
light: newColors.gray200,
dark: newColors.gray200,
@@ -164,6 +162,14 @@ const newSemanticColors = {
light: newColors.blueGray500,
dark: newColors.gray500,
},
contrastBorder: {
light: newColors.gray500,
dark: newColors.blueGray600,
},
contrastBorderDisabled: {
light: newColors.gray100,
dark: newColors.blueGray800,
},
lowContrast: {
light: newColors.gray200,
dark: newColors.blueGray600,
@@ -264,7 +270,7 @@ const newSemanticColors = {
dark: newColors.red600,
},
},
/* These are temporary, just for everything not to break */
gradient: {
color1: {
+41 -7
View File
@@ -1,5 +1,4 @@
import { typographyLevels } from "./constants";
const fontFamilyPrimary = '"Inter" , sans-serif';
// const fontFamilySecondary = '"Avenir", sans-serif';
@@ -300,13 +299,15 @@ const baseTheme = (palette) => ({
}),
},
},
MuiTextField: {
styleOverrides: {
root: ({ theme }) => ({
"& fieldset": {
borderColor: theme.palette.primary.lowContrast,
borderColor: theme.palette.primary.contrastBorder,
borderRadius: theme.shape.borderRadius,
},
"& .MuiInputBase-input": {
padding: ".75em",
minHeight: "var(--env-var-height-2)",
@@ -356,10 +357,19 @@ const baseTheme = (palette) => ({
MuiOutlinedInput: {
styleOverrides: {
root: {
color: palette.primary.contrastTextTertiary,
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: palette.primary.contrastTextTertiary,
"&.Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: palette.primary.contrastBorderDisabled,
},
"&.Mui-disabled:hover .MuiOutlinedInput-notchedOutline": {
borderColor: palette.primary.contrastBorderDisabled,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: palette.primary.contrastText, // Adjust hover border color
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: palette.accent.main, // Adjust focus border color
},
color: palette.primary.contrastTextTertiary,
},
},
},
@@ -453,8 +463,32 @@ const baseTheme = (palette) => ({
MuiSwitch: {
styleOverrides: {
root: ({ theme }) => ({
width: 42,
height: 26,
padding: 0,
"& .MuiSwitch-switchBase": {
padding: 0,
margin: 2,
transitionDuration: "300ms",
"&.Mui-checked": {
transform: "translateX(16px)",
color: "#fff",
"& + .MuiSwitch-track": {
backgroundColor: theme.palette.accent.main,
opacity: 1,
border: 0,
},
},
},
"& .MuiSwitch-thumb": {
boxSizing: "border-box",
width: 22,
height: 22,
},
"& .MuiSwitch-track": {
backgroundColor: theme.palette.primary.contrastText,
borderRadius: 13,
backgroundColor: theme.palette.secondary.light,
opacity: 1,
},
}),
},
@@ -486,7 +520,7 @@ const baseTheme = (palette) => ({
root: ({ theme }) => ({
ml: "auto",
"& .MuiButtonBase-root, & .MuiButtonBase-root:hover": {
borderColor: theme.palette.primary.lowContrast,
borderColor: theme.palette.primary.contrastBorder,
width: "auto",
whiteSpace: "nowrap",
},
+1 -1
View File
@@ -16,7 +16,7 @@ Object.keys(translations).forEach((path) => {
});
const savedLanguage = store.getState()?.ui?.language;
const initialLanguage = savedLanguage || primaryLanguage;
const initialLanguage = savedLanguage;
i18n.use(initReactI18next).init({
resources,
+26 -15
View File
@@ -7,18 +7,11 @@ const nameSchema = joi
.string()
.max(50)
.trim()
.pattern(/^(?=.*[\p{L}\p{Sc}])[\p{L}\p{Sc}\s']+$/u, {
name: "name.containsLetterOrSymbol",
})
.pattern(/^[\p{L}\p{Sc}\s']+$/u, {
name: "name.validCharacters",
})
.pattern(/^[\p{L}\p{M}''\- ]+$/u)
.messages({
"string.empty": "Name is required",
"string.max": "Name must be less than 50 characters",
"string.pattern.name": "Name must contain at least 1 letter or currency symbol",
"string.pattern.base.validCharacters":
"Can only contain letters, spaces, apostrophes, and currency symbols",
"string.pattern.base": "Name must contain only letters, spaces, apostrophes, or hyphens"
});
const passwordSchema = joi
@@ -73,8 +66,8 @@ const credentials = joi.object({
return lowercasedValue;
})
.messages({
"string.empty": "Email is required",
"string.email": "Must be a valid email address",
"string.empty": "authRegisterEmailRequired",
"string.email": "authRegisterEmailInvalid",
}),
password: passwordSchema,
newPassword: passwordSchema,
@@ -159,11 +152,25 @@ const monitorValidation = joi.object({
"string.invalidUrl": "Please enter a valid URL with optional port",
"string.pattern.base": "Please enter a valid container ID.",
}),
port: joi.number(),
port: joi.number()
.integer()
.min(1)
.max(65535)
.when("type", {
is: "port",
then: joi.required().messages({
"number.base": "Port must be a number.",
"number.min": "Port must be at least 1.",
"number.max": "Port must be at most 65535.",
"any.required": "Port is required for port monitors.",
}),
otherwise: joi.optional(),
}),
name: joi.string().trim().max(50).allow("").messages({
"string.max": "This field should not exceed the 50 characters limit.",
}),
type: joi.string().trim().messages({ "string.empty": "This field is required." }),
ignoreTlsErrors: joi.boolean(),
interval: joi.number().messages({
"number.base": "Frequency must be a number.",
"any.required": "Frequency is required.",
@@ -247,9 +254,13 @@ const statusPageValidation = joi.object({
});
const settingsValidation = joi.object({
ttl: joi.number().required().messages({
"string.empty": "TTL is required",
"string.empty": "Please enter a value",
"number.base": "Please enter a valid number",
"any.required": "Please enter a value"
}),
});
pagespeedApiKey: joi.string().allow("").optional(),
})
.unknown(true);
const dayjsValidator = (value, helpers) => {
if (!dayjs(value).isValid()) {
@@ -366,5 +377,5 @@ export {
advancedSettingsValidation,
infrastructureMonitorValidation,
statusPageValidation,
logoImageValidation,
logoImageValidation
};
+71 -26
View File
@@ -1,9 +1,11 @@
{
"dontHaveAccount": "Don't have account",
"doNotHaveAccount": "Do not have an account?",
"registerHere": "Register here",
"email": "E-mail",
"forgotPassword": "Forgot Password",
"password": "password",
"signUp": "Sign up",
"password": "Password",
"signUp": "Sign Up",
"submit": "Submit",
"title": "Title",
"continue": "Continue",
@@ -15,9 +17,11 @@
"authForgotPasswordTitle": "Forgot password?",
"authForgotPasswordResetPassword": "Reset password",
"createPassword": "Create your password",
"createAPassword": "Create a password",
"createAPassword": "Password",
"authRegisterAlreadyHaveAccount": "Already have an account?",
"authRegisterLoginLink": "Log In",
"commonAppName": "Checkmate",
"welcomeBack": "Welcome back! You're successfully logged in.",
"authLoginEnterEmail": "Enter your email",
"authRegisterTitle": "Create an account",
"authRegisterStepOneTitle": "Create your account",
@@ -40,7 +44,7 @@
"authSetNewPasswordDescription": "Your new password must be different from previously used passwords.",
"authSetNewPasswordNewPassword": "New password",
"authSetNewPasswordConfirmPassword": "Confirm password",
"confirmPassword": "Confirm your password",
"confirmPassword": "Re-enter password to confirm",
"authSetNewPasswordResetPassword": "Reset password",
"authSetNewPasswordBackTo": "Back to",
"authPasswordMustBeAtLeast": "Must be at least",
@@ -51,16 +55,19 @@
"authPasswordUpperCharacter": "one upper character",
"authPasswordLowerCharacter": "one lower character",
"authPasswordConfirmAndPassword": "Confirm password and password",
"authPasswordMustMatch": "must match",
"authPasswordMustMatch": "Passwords must match",
"validationNameRequired": "Please enter your name",
"validationNameTooLong": "Name should be less than 50 characters",
"validationNameInvalidCharacters": "Please use only letters, spaces, apostrophes, or hyphens",
"authRegisterCreateAccount": "Create your account to get started",
"authRegisterCreateSuperAdminAccount": "Create your Super admin account to get started",
"authRegisterSignUpWithEmail": "Sign up with Email",
"authRegisterBySigningUp": "By signing up, you agree to our",
"authRegisterCreateSuperAdminAccount": "Create your super admin account to get started",
"authRegisterSignUpWithEmail": "Create super admin account",
"authRegisterBySigningUp": "By creating an account, you agree to our <a1>Terms of Service</a1> and <a2>Privacy Policy</a2>.",
"distributedStatusHeaderText": "Real-time, real-device coverage",
"distributedStatusSubHeaderText": "Powered by millions devices worldwide, view a system performance by global region, country or city",
"settingsGeneralSettings": "General settings",
"settingsDisplayTimezone": "Display timezone",
"settingsDisplayTimezoneDescription": "The timezone of the dashboard you publicly display.",
"settingsDisplayTimezoneDescription": "Select the timezone used to display dates and times throughout the application.",
"settingsAppearance": "Appearance",
"settingsAppearanceDescription": "Switch between light and dark mode, or change user interface language",
"settingsThemeMode": "Theme Mode",
@@ -69,23 +76,25 @@
"settingsDistributedUptimeDescription": "Enable/disable distributed uptime monitoring.",
"settingsEnabled": "Enabled",
"settingsDisabled": "Disabled",
"settingsHistoryAndMonitoring": "History and monitoring",
"settingsHistoryAndMonitoringDescription": "Define here for how long you want to keep the data. You can also remove all past data.",
"settingsHistoryAndMonitoring": "History of monitoring",
"settingsHistoryAndMonitoringDescription": "Define how long you want to retain historical data. You can also clear all existing data.",
"settingsTTLLabel": "The days you want to keep monitoring history.",
"settingsTTLOptionalLabel": "0 for infinite",
"settingsClearAllStats": "Clear all stats. This is irreversible.",
"settingsClearAllStatsButton": "Clear all stats",
"settingsClearAllStatsDialogTitle": "Do you want to clear all stats?",
"settingsClearAllStatsDialogDescription": "Once deleted, your monitors cannot be retrieved.",
"settingsClearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.",
"settingsClearAllStatsDialogConfirm": "Yes, clear all stats",
"settingsDemoMonitors": "Demo monitors",
"settingsDemoMonitorsDescription": "Here you can add and remove demo monitors.",
"settingsAddDemoMonitors": "Add demo monitors",
"settingsDemoMonitorsDescription": "Add sample monitors for demonstration purposes.",
"settingsAddDemoMonitors": "Adding demo monitors",
"settingsAddDemoMonitorsButton": "Add demo monitors",
"settingsRemoveAllMonitors": "Remove all monitors",
"settingsSystemReset": "System reset",
"settingsSystemResetDescription": "Remove all monitors from your system.",
"settingsRemoveAllMonitors": "Removing all monitors",
"settingsRemoveAllMonitorsButton": "Remove all monitors",
"settingsRemoveAllMonitorsDialogTitle": "Do you want to remove all monitors?",
"settingsRemoveAllMonitorsDialogConfirm": "Yes, clear all monitors",
"settingsRemoveAllMonitorsDialogConfirm": "Yes, remove all monitors",
"settingsWallet": "Wallet",
"settingsWalletDescription": "Connect your wallet here. This is required for the Distributed Uptime monitor to connect to multiple nodes globally.",
"settingsAbout": "About",
@@ -99,18 +108,26 @@
"settingsFailedToAddDemoMonitors": "Failed to add demo monitors",
"settingsMonitorsDeleted": "Successfully deleted all monitors",
"settingsFailedToDeleteMonitors": "Failed to delete all monitors",
"backendUnreachable": "Server Connection Error",
"backendUnreachableMessage": "We're unable to connect to the server. Please check your internet connection or verify your deployment configuration if the problem persists.",
"backendUnreachableError": "Cannot connect to the server. Please try again later.",
"retryConnection": "Retry connection",
"retryingConnection": "Connecting...",
"backendReconnected": "Successfully reconnected to the server.",
"backendStillUnreachable": "Server is still unreachable. Please try again later.",
"backendConnectionError": "Error connecting to the server. Please check your network connection.",
"starPromptTitle": "Star Checkmate",
"starPromptDescription": "See the latest releases and help grow the community on GitHub",
"https": "HTTPS",
"http": "HTTP",
"monitor": "monitor",
"aboutus": "About Us",
"signUP": "Sign Up",
"now": "Now",
"delete": "Delete",
"configure": "Configure",
"networkError": "Network error",
"responseTime": "Response time:",
"responseTime": "Response time",
"ms": "ms",
"bar": "Bar",
"area": "Area",
@@ -376,9 +393,9 @@
"errorInvalidTypeId": "Invalid notification type provided",
"errorInvalidFieldId": "Invalid field ID provided",
"inviteNoTokenFound": "No invite token found",
"pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key. Without it, the PageSpeed monitor won't function.",
"pageSpeedLearnMoreLink": "Click here to learn",
"pageSpeedAddApiKey": "how to add your API key.",
"pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.",
"pageSpeedLearnMoreLink": "Click here",
"pageSpeedAddApiKey": "to add your API key.",
"update": "Update",
"invalidFileFormat": "Unsupported file format!",
"invalidFileSize": "File size is too large!",
@@ -389,11 +406,39 @@
"FirstName": "First name",
"LastName": "Last name",
"EmailDescriptionText": "This is your current email address — it cannot be changed.",
"YourPhoto": "Your photo",
"ignoreTLSError": "Ignore TLS/SSL error",
"tlsErrorIgnored": "TLS/SSL errors ignored",
"ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability",
"YourPhoto": "Profile photo",
"PhotoDescriptionText": "This photo will be displayed in your profile page.",
"save": "Save",
"DeleteAccount": "Delete account",
"DeleteDescriptionText": "Note that deleting your account will remove all data from the server. This is permanent and non-recoverable.",
"DeleteAccountWarning": "If you delete your account, you will no longer be able to sign in, and all of your data will be deleted. Deleting your account is permanent and non-recoverable action.",
"DeleteWarningTitle": "Really delete this account?"
"DeleteAccountTitle": "Remove account",
"DeleteAccountButton": "Remove account",
"DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.",
"DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.",
"DeleteWarningTitle": "Really remove this account?",
"authRegisterFirstName": "Name",
"authRegisterLastName": "Surname",
"authRegisterEmail": "Email",
"authRegisterEmailRequired": "To continue, please enter your email address",
"authRegisterEmailInvalid": "Please enter a valid email address",
"bulkImport": {
"title": "Bulk Import",
"selectFileTips": "Select CSV file to upload",
"selectFileDescription": "You can download our <template>template</template> or <sample>sample</sample>",
"selectFile": "Select File",
"parsingFailed": "Parsing failed",
"uploadSuccess": "Monitors created successfully!",
"validationFailed": "Validation failed",
"noFileSelected": "No file selected",
"fallbackPage": "Import a file to upload a list of servers in bulk"
},
"publicLink": "Public link",
"maskedPageSpeedKeyPlaceholder": "*************************************",
"pageSpeedApiKeyFieldTitle": "Google PageSpeed API key",
"pageSpeedApiKeyFieldLabel": "PageSpeed API key",
"pageSpeedApiKeyFieldDescription": "Enter your Google PageSpeed API key to enable pagespeed monitoring. Click Reset to update the key.",
"pageSpeedApiKeyFieldResetLabel": "API key is set. Click Reset to change it.",
"reset": "Reset"
}
+48 -18
View File
@@ -1,8 +1,10 @@
{
"dontHaveAccount": "Нет аккаунта",
"doNotHaveAccount": "У вас нет учетной записи?",
"registerHere": "Зарегистрироваться здесь",
"email": "Почта",
"forgotPassword": "Забыли пароль",
"password": "пароль",
"password": "Пароль",
"signUp": "Зарегистрироваться",
"submit": "Подтвердить",
"title": "Название",
@@ -15,9 +17,11 @@
"authForgotPasswordTitle": "Забыли пароль?",
"authForgotPasswordResetPassword": "Сбросить пароль",
"createPassword": "Создайте свой пароль",
"createAPassword": "Создайте пароль",
"createAPassword": "Пароль",
"authRegisterAlreadyHaveAccount": "Уже есть аккаунт?",
"authRegisterLoginLink": "Войти",
"commonAppName": "Checkmate",
"welcomeBack": "Добро пожаловать обратно! Вы успешно вошли в систему.",
"authLoginEnterEmail": "Введите свой email",
"authRegisterTitle": "Создать аккаунт",
"authRegisterStepOneTitle": "Создайте свой аккаут",
@@ -40,7 +44,8 @@
"authSetNewPasswordDescription": "Ваш новый пароль должен отличаться от ранее использованных паролей.",
"authSetNewPasswordNewPassword": "Новый пароль",
"authSetNewPasswordConfirmPassword": "Подтвердите пароль",
"confirmPassword": "Подтвердите ваш пароль",
"confirmPassword": "Введите пароль еще раз для подтверждения",
"confirmNewPasswordPlaceholder": "Подтвердите ваш новый пароль",
"authSetNewPasswordResetPassword": "Сбросить пароль",
"authSetNewPasswordBackTo": "Назад к",
"authPasswordMustBeAtLeast": "Должно быть как минимум",
@@ -51,16 +56,21 @@
"authPasswordUpperCharacter": "один верхний символ",
"authPasswordLowerCharacter": "один нижний символ",
"authPasswordConfirmAndPassword": "Подтвердите пароль и пароль",
"authPasswordMustMatch": "должен совпадать",
"authPasswordMustMatch": "Пароли должны совпадать",
"authRegisterCreateAccount": "Создайте свою учетную запись, чтобы начать",
"authRegisterCreateSuperAdminAccount": "Создайте учетную запись суперадминистратора, чтобы начать работу",
"authRegisterSignUpWithEmail": "Зарегистрироваться по электронной почте",
"authRegisterBySigningUp": "Регистрируясь, вы соглашаетесь с нашими",
"authRegisterSignUpWithEmail": "Создать учетную запись суперадминистратора",
"authRegisterBySigningUp": "Создавая учетную запись, вы соглашаетесь с нашими <a1>Условиями использования</a1> и <a2>Политикой конфиденциальности</a2>.",
"authRegisterFirstName": "Имя",
"authRegisterLastName": "Фамилия",
"authRegisterEmail": "Эл. почта",
"authRegisterEmailRequired": "Чтобы продолжить, пожалуйста, введите ваш адрес электронной почты",
"authRegisterEmailInvalid": "Пожалуйста, введите корректный адрес электронной почты",
"distributedStatusHeaderText": "Охват реального времени и реального устройства",
"distributedStatusSubHeaderText": "Работает на миллионах устройств по всему миру, просматривайте производительность системы по глобальному региону, стране или городу",
"settingsGeneralSettings": "Общие настройки",
"settingsDisplayTimezone": "Отображать часовой пояс",
"settingsDisplayTimezoneDescription": "Часовой пояс панели мониторинга, которую вы публично отображаете.",
"settingsDisplayTimezoneDescription": "Выберите часовой пояс, используемый для отображения дат и времени в приложении.",
"settingsAppearance": "Внешний вид",
"settingsAppearanceDescription": "Переключение между светлым и темным режимом или изменение языка пользовательского интерфейса",
"settingsThemeMode": "Тема",
@@ -69,23 +79,25 @@
"settingsDistributedUptimeDescription": "Включить/выключить distributed uptime monitoring.",
"settingsEnabled": "Включено",
"settingsDisabled": "Выключено",
"settingsHistoryAndMonitoring": "История и мониторинг",
"settingsHistoryAndMonitoringDescription": "Определите здесь, как долго вы хотите хранить данные. Вы также можете удалить все прошлые данные.",
"settingsHistoryAndMonitoring": "История мониторинга",
"settingsHistoryAndMonitoringDescription": "Определите, как долго вы хотите хранить исторические данные. Вы также можете очистить все существующие данные.",
"settingsTTLLabel": "Дни, за которыми вы хотите следить.",
"settingsTTLOptionalLabel": "0 для бесконечности",
"settingsClearAllStats": "Очистить всю статистику. Это необратимо.",
"settingsClearAllStatsButton": "Очистить всю статистику",
"settingsClearAllStatsDialogTitle": "Хотите очистить всю статистику?",
"settingsClearAllStatsDialogDescription": "После удаления ваши мониторы не могут быть восстановлены.",
"settingsClearAllStatsDialogDescription": "После удаления история мониторинга и статистика не могут быть восстановлены.",
"settingsClearAllStatsDialogConfirm": "Да, очистить всю статистику",
"settingsDemoMonitors": "Демо мониторы",
"settingsDemoMonitorsDescription": "Здесь вы можете добавлять и удалять демонстрационные мониторы.",
"settingsAddDemoMonitors": "Добавьте демонстрационные мониторы",
"settingsDemoMonitorsDescription": "Добавьте примеры мониторов для демонстрации.",
"settingsAddDemoMonitors": "Добавление демонстрационных мониторов",
"settingsAddDemoMonitorsButton": "Добавьте демонстрационные мониторы",
"settingsRemoveAllMonitors": "Удалить все демонстрационные мониторы",
"settingsRemoveAllMonitorsButton": "Удалить все демонстрационные мониторы",
"settingsSystemReset": "Сброс системы",
"settingsSystemResetDescription": "Удалить все мониторы из вашей системы.",
"settingsRemoveAllMonitors": "Удаление всех мониторов",
"settingsRemoveAllMonitorsButton": "Удалить все мониторы",
"settingsRemoveAllMonitorsDialogTitle": "Хотите удалить все мониторы?",
"settingsRemoveAllMonitorsDialogConfirm": "Да, очистить все мониторы",
"settingsRemoveAllMonitorsDialogConfirm": "Да, удалить все мониторы",
"settingsWallet": "Кошелёк",
"settingsWalletDescription": "Подключите свой кошелек здесь. Это необходимо для того, чтобы монитор Distributed Uptime мог подключиться к нескольким узлам по всему миру.",
"settingsAbout": "О",
@@ -95,22 +107,40 @@
"settingsFailedToSave": "Не удалось сохранить настройки",
"settingsStatsCleared": "Статистика успешно очищена",
"settingsFailedToClearStats": "Не удалось очистить статистику",
"FirstName": "Имя",
"LastName": "Фамилия",
"YourPhoto": "Фото профиля",
"PhotoDescriptionText": "Это фото будет отображаться на странице вашего профиля.",
"EmailDescriptionText": "Ваш текущий email—его нельзя изменить.",
"DeleteAccountTitle": "Удалить аккаунт",
"DeleteAccountButton": "Удалить аккаунт",
"DeleteDescriptionText": "Это удалит аккаунт и все связанные данные с сервера. Это необратимо.",
"DeleteAccountWarning": "Удаление аккаунта означает, что вы не сможете снова войти в систему, и все ваши данные будут удалены. Это необратимо.",
"DeleteWarningTitle": "Действительно удалить этот аккаунт?",
"settingsDemoMonitorsAdded": "Успешно добавлены демонстрационные мониторы",
"settingsFailedToAddDemoMonitors": "Не удалось добавить демонстрационные мониторы",
"settingsMonitorsDeleted": "Успешно удалены все мониторы",
"settingsFailedToDeleteMonitors": "Не удалось удалить все мониторы",
"backendUnreachable": "Ошибка подключения к серверу",
"backendUnreachableMessage": "Мы не можем подключиться к серверу. Пожалуйста, проверьте ваше интернет-соединение или проверьте конфигурацию развертывания, если проблема не устраняется.",
"backendUnreachableError": "Невозможно подключиться к серверу. Пожалуйста, повторите попытку позже.",
"retryConnection": "Повторить подключение",
"retryingConnection": "Подключение...",
"backendReconnected": "Успешно восстановлено подключение к серверу.",
"backendStillUnreachable": "Сервер по-прежнему недоступен. Пожалуйста, повторите попытку позже.",
"backendConnectionError": "Ошибка подключения к серверу. Пожалуйста, проверьте ваше сетевое подключение.",
"starPromptTitle": "Star Checkmate",
"starPromptDescription": "Ознакомьтесь с последними релизами и помогите развить сообщество на GitHub",
"https": "HTTPS",
"http": "HTTP",
"monitor": "монитор",
"aboutus": "О Нас",
"signUP": "Зарегистрироваться",
"now": "Сейчас",
"delete": "Удалить",
"configure": "Настроить",
"networkError": "Ошибка сети",
"responseTime": "Время ответа:",
"responseTime": "Время ответа",
"ms": "мс",
"bar": "Bar",
"area": "Area",
@@ -364,4 +394,4 @@
"pageSpeedWarning": "Предупреждение: Вы не добавили ключ API Google PageSpeed. Без него монитор PageSpeed не будет работать.",
"pageSpeedLearnMoreLink": "Нажмите здесь, чтобы узнать",
"pageSpeedAddApiKey": "как добавить ваш ключ API."
}
}
+54 -22
View File
@@ -1,5 +1,7 @@
{
"dontHaveAccount": "Hesabınız yok mu",
"doNotHaveAccount": "Hesabınız yok mu?",
"registerHere": "Buradan kayıt olun",
"email": "E-posta",
"forgotPassword": "Parolamı unuttum",
"password": "Parola",
@@ -15,9 +17,11 @@
"authForgotPasswordTitle": "Parolanızı mı unuttunuz?",
"authForgotPasswordResetPassword": "Parola sıfırla",
"createPassword": "Parolanızı oluşturun",
"createAPassword": "Bir parola oluşturun",
"createAPassword": "Parola",
"authRegisterAlreadyHaveAccount": "Zaten hesabınız var mı?",
"authRegisterLoginLink": "Giriş Yap",
"commonAppName": "Checkmate",
"welcomeBack": "Tekrar hoş geldiniz! Başarıyla giriş yaptınız.",
"authLoginEnterEmail": "E-posta adresinizi girin",
"authRegisterTitle": "Hesap oluştur",
"authRegisterStepOneTitle": "Hesabınızı oluşturun",
@@ -40,7 +44,8 @@
"authSetNewPasswordDescription": "Yeni şifreniz daha önce kullanılan şifrelerden farklı olmalıdır.",
"authSetNewPasswordNewPassword": "Yeni şifre",
"authSetNewPasswordConfirmPassword": "Parolayı onayla",
"confirmPassword": "Parolanızı onaylayın",
"confirmPassword": "Onaylamak için parolayı tekrar girin",
"confirmNewPasswordPlaceholder": "Yeni parolanızı onaylayın",
"authSetNewPasswordResetPassword": "Parola sıfırla",
"authSetNewPasswordBackTo": "Geri dön",
"authPasswordMustBeAtLeast": "En az",
@@ -51,7 +56,7 @@
"authPasswordUpperCharacter": "bir büyük harf",
"authPasswordLowerCharacter": "bir küçük harf",
"authPasswordConfirmAndPassword": "Onay şifresi ve şifre",
"authPasswordMustMatch": "eşleşmelidir",
"authPasswordMustMatch": "Parolalar eşleşmelidir",
"authRegisterCreateAccount": "Hesap oluşturmak için devam et",
"authRegisterCreateSuperAdminAccount": "Super admin hesabınızı oluşturmak için devam edin",
"authRegisterSignUpWithEmail": "E-posta ile kayıt ol",
@@ -60,7 +65,7 @@
"distributedStatusSubHeaderText": "Dünya çapında milyonlarca cihaz tarafından desteklenen sistem performansını küresel bölgeye, ülkeye veya şehre göre görüntüleyin",
"settingsGeneralSettings": "Genel ayarlar",
"settingsDisplayTimezone": "Görüntüleme saat dilimi",
"settingsDisplayTimezoneDescription": "Herkese açık olarak görüntülediğiniz kontrol panelinin saat dilimi.",
"settingsDisplayTimezoneDescription": "Uygulama genelinde tarih ve saatlerin görüntülenmesi için kullanılacak saat dilimini seçin.",
"settingsAppearance": "Görünüm",
"settingsAppearanceDescription": "Açık ve koyu mod arasında geçiş yapın veya kullanıcı arayüzü dilini değiştirin",
"settingsThemeMode": "Tema",
@@ -69,23 +74,25 @@
"settingsDistributedUptimeDescription": "Dağıtılmış çalışma süresi izlemeyi etkinleştirin/devre dışı bırakın.",
"settingsEnabled": "Etkin",
"settingsDisabled": "Devre dışı",
"settingsHistoryAndMonitoring": "Geçmiş ve izleme",
"settingsHistoryAndMonitoringDescription": "Verileri ne kadar süreyle saklamak istediğinizi burada tanımlayın. Ayrıca tüm geçmiş verileri kaldırabilirsiniz.",
"settingsHistoryAndMonitoring": "İzleme geçmişi",
"settingsHistoryAndMonitoringDescription": "Geçmiş verileri ne kadar süre saklamak istediğinizi tanımlayın. Ayrıca mevcut tüm verileri temizleyebilirsiniz.",
"settingsTTLLabel": "İzleme geçmişini saklamak istediğiniz gün sayısı.",
"settingsTTLOptionalLabel": "Sınırsız için 0",
"settingsClearAllStats": "Tüm istatistikleri temizle. Bu geri alınamaz.",
"settingsClearAllStatsButton": "Tüm istatistikleri temizle",
"settingsClearAllStatsDialogTitle": "Tüm istatistikleri temizlemek istiyor musunuz?",
"settingsClearAllStatsDialogDescription": "Silindikten sonra, monitörleriniz geri alınamaz.",
"settingsClearAllStatsDialogDescription": "Kaldırıldıktan sonra, izleme geçmişi ve istatistikleri geri alınamaz.",
"settingsClearAllStatsDialogConfirm": "Evet, tüm istatistikleri temizle",
"settingsDemoMonitors": "Demo monitörler",
"settingsDemoMonitorsDescription": "Burada demo monitörler ekleyebilir ve kaldırabilirsiniz.",
"settingsAddDemoMonitors": "Demo monitörler ekle",
"settingsDemoMonitorsDescription": "Gösterim amaçlı örnek monitörler ekleyin.",
"settingsAddDemoMonitors": "Demo monitörler ekleniyor",
"settingsAddDemoMonitorsButton": "Demo monitörler ekle",
"settingsRemoveAllMonitors": "Tüm monitörleri kaldır",
"settingsSystemReset": "Sistem sıfırlama",
"settingsSystemResetDescription": "Sisteminizden tüm monitörleri kaldırın.",
"settingsRemoveAllMonitors": "Tüm monitörler kaldırılıyor",
"settingsRemoveAllMonitorsButton": "Tüm monitörleri kaldır",
"settingsRemoveAllMonitorsDialogTitle": "Tüm monitörleri kaldırmak istiyor musunuz?",
"settingsRemoveAllMonitorsDialogConfirm": "Evet, tüm monitörleri temizle",
"settingsRemoveAllMonitorsDialogConfirm": "Evet, tüm monitörleri kaldır",
"settingsWallet": "Cüzdan",
"settingsWalletDescription": "Cüzdanınızı buradan bağlayın. Bu, Dağıtılmış Çalışma Süresi monitörünün küresel olarak birden çok düğüme bağlanması için gereklidir.",
"settingsAbout": "Hakkında",
@@ -99,18 +106,26 @@
"settingsFailedToAddDemoMonitors": "Demo monitörler eklenemedi",
"settingsMonitorsDeleted": "Tüm monitörler başarıyla silindi",
"settingsFailedToDeleteMonitors": "Monitörler silinemedi",
"backendUnreachable": "Sunucu Bağlantı Hatası",
"backendUnreachableMessage": "Sunucuya bağlanamıyoruz. Lütfen internet bağlantınızı kontrol edin veya sorun devam ederse dağıtım yapılandırmanızı doğrulayın.",
"backendUnreachableError": "Sunucuya bağlanılamıyor. Lütfen daha sonra tekrar deneyin.",
"retryConnection": "Bağlantıyı Yeniden Dene",
"retryingConnection": "Bağlanıyor...",
"backendReconnected": "Sunucuya başarıyla yeniden bağlandı.",
"backendStillUnreachable": "Sunucu hala erişilemez durumda. Lütfen daha sonra tekrar deneyin.",
"backendConnectionError": "Sunucuya bağlanırken hata oluştu. Lütfen ağ bağlantınızı kontrol edin.",
"starPromptTitle": "Checkmate yıldızla değerlendirin",
"starPromptDescription": "En son sürümleri görün ve GitHub'daki topluluğun büyümesine yardımcı olun",
"https": "HTTPS",
"http": "HTTP",
"monitor": "monitör",
"aboutus": "Hakkımızda",
"signUP": "Hesap Oluştur",
"now": "Şimdi",
"delete": "Sil",
"configure": "Yapılandır",
"networkError": "Ağ hatası",
"responseTime": "Yanıt süresi:",
"responseTime": "Yanıt süresi",
"ms": "ms",
"bar": "Çubuk",
"area": "Alan",
@@ -386,14 +401,31 @@
"DragandDrop": "",
"MaxSize": "",
"SupportedFormats": "",
"FirstName": "",
"LastName": "",
"EmailDescriptionText": "",
"YourPhoto": "",
"PhotoDescriptionText": "",
"FirstName": "Ad",
"LastName": "Soyad",
"EmailDescriptionText": "Mevcut e-postanız—değiştirilemez.",
"YourPhoto": "Profil fotoğrafı",
"PhotoDescriptionText": "Bu fotoğraf profil sayfanızda görüntülenecektir.",
"save": "",
"DeleteAccount": "",
"DeleteDescriptionText": "",
"DeleteAccountWarning": "",
"DeleteWarningTitle": ""
"DeleteAccountTitle": "Hesabı kaldır",
"DeleteAccountButton": "Hesabı kaldır",
"DeleteDescriptionText": "Bu, hesabı ve tüm ilişkili verileri sunucudan kaldıracaktır. Bu geri alınamaz.",
"DeleteAccountWarning": "Hesabınızı kaldırmak, tekrar oturum açamayacağınız ve tüm verilerinizin kaldırılacağı anlamına gelir. Bu geri alınamaz.",
"DeleteWarningTitle": "Bu hesabı gerçekten kaldırmak istiyor musunuz?",
"authRegisterFirstName": "Ad",
"authRegisterLastName": "Soyad",
"authRegisterEmail": "E-posta",
"authRegisterEmailRequired": "Devam etmek için lütfen e-posta adresinizi girin",
"authRegisterEmailInvalid": "Lütfen geçerli bir e-posta adresi girin",
"bulkImport": {
"title": "",
"selectFileTips": "",
"selectFileDescription": "",
"selectFile": "",
"parsingFailed": "",
"uploadSuccess": "",
"validationFailed": "",
"noFileSelected": "",
"fallbackPage": ""
}
}
+1
View File
@@ -7,5 +7,6 @@ dist/mongo/data/*
dist/redis/data/*
prod/mongo/data/*
prod/redis/data/*
dist/docker-compose-test.yaml
*.env
prod/certbot/*
+14 -10
View File
@@ -4,23 +4,27 @@
cd "$(dirname "$0")"
cd ../..
# Define an array of services and their Dockerfiles
declare -A services=(
["uptime_client"]="./docker/dev/client.Dockerfile"
["uptime_database_mongo"]="./docker/dev/mongoDB.Dockerfile"
["uptime_redis"]="./docker/dev/redis.Dockerfile"
["uptime_server"]="./docker/dev/server.Dockerfile"
# Define service names and their corresponding Dockerfiles in parallel arrays
services=("uptime_client" "uptime_mongo" "uptime_redis" "uptime_server")
dockerfiles=(
"./docker/dev/client.Dockerfile"
"./docker/dev/mongoDB.Dockerfile"
"./docker/dev/redis.Dockerfile"
"./docker/dev/server.Dockerfile"
)
# Loop through each service and build the corresponding image
for service in "${!services[@]}"; do
docker build -f "${services[$service]}" -t "$service" .
for i in "${!services[@]}"; do
service="${services[$i]}"
dockerfile="${dockerfiles[$i]}"
## Check if the build succeeded
docker build -f "$dockerfile" -t "$service" .
# Check if the build succeeded
if [ $? -ne 0 ]; then
echo "Error building $service image. Exiting..."
exit 1
fi
done
echo "All images built successfully"
echo "All images built successfully"
+7 -4
View File
@@ -18,10 +18,13 @@ COPY ./client/package*.json ./
RUN npm install
COPY ./client .
COPY ./client ./
RUN npm run build-dev
RUN npm run build
RUN npm install -g serve
FROM nginx:1.27.1-alpine
CMD ["serve","-s", "dist", "-l", "5173"]
COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/env.sh /docker-entrypoint.d/env.sh
RUN chmod +x /docker-entrypoint.d/env.sh
CMD ["nginx", "-g", "daemon off;"]
+16 -5
View File
@@ -3,15 +3,19 @@ services:
image: uptime_client:latest
restart: always
ports:
- "5173:5173"
- "80:80"
environment:
UPTIME_APP_API_BASE_URL: "http://localhost:52345/api/v1"
UPTIME_APP_CLIENT_HOST: "http://localhost"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d/
depends_on:
- server
server:
image: uptime_server:latest
restart: always
ports:
- "5000:5000"
- "52345:52345"
env_file:
- server.env
depends_on:
@@ -31,10 +35,17 @@ services:
retries: 5
start_period: 5s
mongodb:
image: uptime_database_mongo:latest
image: uptime_mongo:latest
restart: always
command: ["mongod", "--quiet"]
command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"]
ports:
- "27017:27017"
volumes:
- ./mongo/data:/data/db
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name localhost; # Set server name to localhost
server_tokens off;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# location /api/ {
# proxy_pass http://server:52345/api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Connection '';
# chunked_transfer_encoding off;
# proxy_buffering off;
# proxy_cache off;
# }
# location /api-docs/ {
# proxy_pass http://server:52345/api-docs/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
}
+1 -1
View File
@@ -8,6 +8,6 @@ RUN npm install
COPY ./server/ ./
EXPOSE 5000
EXPOSE 52345
CMD ["node", "index.js"]
+14 -6
View File
@@ -1,16 +1,17 @@
services:
client:
image: bluewaveuptime/uptime_client:latest
image: ghcr.io/bluewave-labs/checkmate:frontend-dist
restart: always
environment:
UPTIME_APP_API_BASE_URL: "http://localhost:5000/api/v1"
UPTIME_APP_CLIENT_HOST: "http://localhost"
ports:
- "80:80"
- "443:443"
depends_on:
- server
server:
image: bluewaveuptime/uptime_server:latest
image: ghcr.io/bluewave-labs/checkmate:backend-dist
restart: always
ports:
- "5000:5000"
@@ -18,13 +19,13 @@ services:
- redis
- mongodb
environment:
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db?replicaSet=rs0
- REDIS_URL=redis://redis:6379
- CLIENT_HOST=http://localhost
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock:ro
redis:
image: bluewaveuptime/uptime_redis:latest
image: ghcr.io/bluewave-labs/checkmate:redis-dist
restart: always
ports:
- "6379:6379"
@@ -37,10 +38,17 @@ services:
retries: 5
start_period: 5s
mongodb:
image: bluewaveuptime/uptime_database_mongo:latest
image: ghcr.io/bluewave-labs/checkmate:mongo-dist
restart: always
volumes:
- ./mongo/data:/data/db
command: ["mongod", "--quiet"]
command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"]
ports:
- "27017:27017"
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
-16
View File
@@ -1,16 +0,0 @@
var username = process.env.USERNAME_ENV_VAR;
var password = process.env.PASSWORD_ENV_VAR;
db = db.getSiblingDB("uptime_db");
db.createUser({
user: username,
pwd: password,
roles: [
{
role: "readWrite",
db: "uptime_db",
},
],
});
print("User uptime_user created successfully");
-15
View File
@@ -1,15 +0,0 @@
try {
const status = rs.status();
printjson(status);
} catch (e) {
if (e.codeName === "NotYetInitialized") {
print("Replica set not initialized. Initiating...");
rs.initiate({
_id: "rs0",
members: [{ _id: 0, host: "mongodb:27017" }],
});
} else {
print("Unexpected error during rs.status():");
printjson(e);
}
}
+8 -8
View File
@@ -15,14 +15,14 @@ server {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://server:5000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# location /api/ {
# proxy_pass http://server:5000/api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
location /api-docs/ {
proxy_pass http://server:5000/api-docs/;
+1 -1
View File
@@ -8,6 +8,6 @@ RUN npm install
COPY ./server/ ./
EXPOSE 5000
EXPOSE 52345
CMD ["node", "index.js"]
+4 -2
View File
@@ -4,7 +4,7 @@ services:
restart: always
environment:
UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1"
UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
UPTIME_APP_CLIENT_HOST: "https://checkmate-demo.bluewavelabs.ca"
ports:
- "80:80"
- "443:443"
@@ -25,12 +25,14 @@ services:
image: ghcr.io/bluewave-labs/checkmate:backend-demo
restart: always
ports:
- "5000:5000"
- "52345:52345"
env_file:
- server.env
depends_on:
- redis
- mongodb
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
redis:
image: ghcr.io/bluewave-labs/checkmate:redis-demo
restart: always
+4 -4
View File
@@ -16,7 +16,7 @@ server {
}
location /api/ {
proxy_pass http://server:5000/api/;
proxy_pass http://server:52345/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -25,7 +25,7 @@ server {
}
location /api-docs/ {
proxy_pass http://server:5000/api-docs/;
proxy_pass http://server:52345/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -50,7 +50,7 @@ server {
}
location /api/ {
proxy_pass http://server:5000/api/;
proxy_pass http://server:52345/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -59,7 +59,7 @@ server {
}
location /api-docs/ {
proxy_pass http://server:5000/api-docs/;
proxy_pass http://server:52345/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+1 -1
View File
@@ -10,6 +10,6 @@ RUN npm install
COPY ./server ./
EXPOSE 5000
EXPOSE 52345
CMD ["node", "index.js"]
+2 -2
View File
@@ -4,7 +4,7 @@ services:
restart: always
environment:
UPTIME_APP_API_BASE_URL: "https://checkmate-test.bluewavelabs.ca/api/v1"
UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
UPTIME_APP_CLIENT_HOST: "https://checkmate-test.bluewavelabs.ca"
ports:
- "80:80"
- "443:443"
@@ -27,7 +27,7 @@ services:
image: ghcr.io/bluewave-labs/checkmate:backend-staging
restart: always
ports:
- "5000:5000"
- "52345:52345"
env_file:
- server.env
depends_on:
+4 -4
View File
@@ -16,7 +16,7 @@ server {
}
location /api/ {
proxy_pass http://server:5000/api/;
proxy_pass http://server:52345/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -29,7 +29,7 @@ server {
}
location /api-docs/ {
proxy_pass http://server:5000/api-docs/;
proxy_pass http://server:52345/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -54,7 +54,7 @@ server {
}
location /api/ {
proxy_pass http://server:5000/api/;
proxy_pass http://server:52345/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -67,7 +67,7 @@ server {
}
location /api-docs/ {
proxy_pass http://server:5000/api-docs/;
proxy_pass http://server:52345/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+1 -1
View File
@@ -10,6 +10,6 @@ RUN npm install
COPY ./server ./
EXPOSE 5000
EXPOSE 52345
CMD ["node", "index.js"]
+8 -6
View File
@@ -57,9 +57,9 @@ class AuthController {
*/
registerUser = async (req, res, next) => {
try {
if(req.body?.email){
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
}
await registrationBodyValidation.validateAsync(req.body);
} catch (error) {
const validationError = handleValidationError(error, SERVICE_NAME);
@@ -68,11 +68,13 @@ class AuthController {
}
// Create a new user
try {
const { inviteToken } = req.body;
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);
if (superAdminExists) {
await this.db.getInviteTokenAndDelete(inviteToken);
const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken);
user.role = invitedUser.role;
user.teamId = invitedUser.teamId;
} else {
// This is the first account, create JWT secret to use if one is not supplied by env
const jwtSecret = crypto.randomBytes(64).toString("hex");
@@ -133,8 +135,8 @@ class AuthController {
*/
loginUser = async (req, res, next) => {
try {
if(req.body?.email){
req.body.email = req.body.email?.toLowerCase();
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
} catch (error) {
+147 -40
View File
@@ -24,13 +24,15 @@ import axios from "axios";
import seedDb from "../db/mongo/utils/seedDb.js";
import { seedDistributedTest } from "../db/mongo/utils/seedDb.js";
const SERVICE_NAME = "monitorController";
import pkg from "papaparse";
class MonitorController {
constructor(db, settingsService, jobQueue, stringService) {
constructor(db, settingsService, jobQueue, stringService, emailService) {
this.db = db;
this.settingsService = settingsService;
this.jobQueue = jobQueue;
this.stringService = stringService;
this.emailService = emailService;
}
/**
@@ -243,56 +245,119 @@ class MonitorController {
};
/**
* Creates bulk monitors and adds them to the job queue.
* Creates bulk monitors and adds them to the job queue after parsing CSV.
* @async
* @param {Object} req - The Express request object.
* @property {Object} req.body - The body of the request.
* @property {Object} req.file - The uploaded CSV file.
* @param {Object} res - The Express response object.
* @param {function} next - The next middleware function.
* @returns {Object} The response object with a success status, a message indicating the creation of the monitor, and the created monitor data.
* @returns {Object} The response object with a success status and message.
* @throws {Error} If there is an error during the process, especially if there is a validation error (422).
*/
createBulkMonitors = async (req, res, next) => {
try {
await createMonitorsBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
const { parse } = pkg;
try {
// create monitors
const monitors = await this.db.createBulkMonitors(req);
// validate the file
if (!req.file) {
throw new Error("No file uploaded");
}
// create notifications for each monitor
await Promise.all(
monitors.map(async (monitor, index) => {
const notifications = req.body[index].notifications;
// Check if the file is a CSV
if (!req.file.mimetype.includes("csv")) {
throw new Error("File is not a CSV");
}
if (notifications?.length) {
monitor.notifications = await Promise.all(
notifications.map(async (notification) => {
notification.monitorId = monitor._id;
return await this.db.createNotification(notification);
})
);
await monitor.save();
// Validate if the file is empty
if (req.file.size === 0) {
throw new Error("File is empty");
}
const { userId, teamId } = req.body;
if (!userId || !teamId) {
throw new Error("Missing userId or teamId in form data");
}
// Get file buffer from memory and convert to string
const fileData = req.file.buffer.toString("utf-8");
// Parse the CSV data
parse(fileData, {
header: true,
skipEmptyLines: true,
transform: (value, header) => {
if (value === "") return undefined; // Empty fields become undefined
// Handle 'port' and 'interval' fields, check if they're valid numbers
if (["port", "interval"].includes(header)) {
const num = parseInt(value, 10);
if (isNaN(num)) {
throw new Error(`${header} should be a valid number, got: ${value}`);
}
return num;
}
// Add monitor to job queue
this.jobQueue.addJob(monitor._id, monitor);
})
);
return value;
},
complete: async ({ data, errors }) => {
try {
if (errors.length > 0) {
throw new Error("Error parsing CSV");
}
return res.success({
msg: this.stringService.bulkMonitorsCreate,
data: monitors,
if (!data || data.length === 0) {
throw new Error("CSV file contains no data rows");
}
const enrichedData = data.map((monitor) => ({
userId,
teamId,
...monitor,
description: monitor.description || monitor.name || monitor.url,
name: monitor.name || monitor.url,
type: monitor.type || "http",
}));
await createMonitorsBodyValidation.validateAsync(enrichedData);
try {
const monitors = await this.db.createBulkMonitors(enrichedData);
await Promise.all(
monitors.map(async (monitor, index) => {
const notifications = enrichedData[index].notifications;
if (notifications?.length) {
monitor.notifications = await Promise.all(
notifications.map(async (notification) => {
notification.monitorId = monitor._id;
return await this.db.createNotification(notification);
})
);
await monitor.save();
}
this.jobQueue.addJob(monitor._id, monitor);
})
);
return res.success({
msg: this.stringService.bulkMonitorsCreate,
data: monitors,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createBulkMonitors"));
}
} catch (error) {
next(handleError(error, SERVICE_NAME, "createBulkMonitors"));
}
},
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "createBulkMonitors"));
return next(handleError(error, SERVICE_NAME, "createBulkMonitors"));
}
};
/**
* Checks if the endpoint can be resolved
* @async
@@ -473,18 +538,16 @@ class MonitorController {
const monitorBeforeEdit = await this.db.getMonitorById(monitorId);
// Get notifications from the request body
const notifications = req.body.notifications;
const notifications = req.body.notifications ?? [];
const editedMonitor = await this.db.editMonitor(monitorId, req.body);
await this.db.deleteNotificationsByMonitorId(editedMonitor._id);
await Promise.all(
notifications &&
notifications.map(async (notification) => {
notification.monitorId = editedMonitor._id;
await this.db.createNotification(notification);
})
notifications.map(async (notification) => {
notification.monitorId = editedMonitor._id;
await this.db.createNotification(notification);
})
);
// Delete the old job(editedMonitor has the same ID as the old monitor)
@@ -572,6 +635,50 @@ class MonitorController {
}
};
/**
* Sends a test email to verify email delivery functionality.
* @async
* @param {Object} req - The Express request object.
* @property {Object} req.body - The body of the request.
* @property {string} req.body.to - The email address to send the test email to.
* @param {Object} res - The Express response object.
* @param {function} next - The next middleware function.
* @returns {Object} The response object with a success status and the email delivery message ID.
* @throws {Error} If there is an error while sending the test email.
*/
sendTestEmail = async (req, res, next) => {
try {
const { to } = req.body;
if (!to || typeof to !== "string") {
return res.error({ msg: this.stringService.errorForValidEmailAddress });
}
const subject = this.stringService.testEmailSubject;
const context = { testName: "Monitoring System" };
const messageId = await this.emailService.buildAndSendEmail(
"testEmailTemplate",
context,
to,
subject
);
if (!messageId) {
return res.error({
msg: "Failed to send test email.",
});
}
return res.success({
msg: this.stringService.sendTestEmail,
data: { messageId },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "sendTestEmail"));
}
};
getMonitorsByTeamId = async (req, res, next) => {
try {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
+8 -9
View File
@@ -11,16 +11,15 @@ class SettingsController {
}
getAppSettings = async (req, res, next) => {
try {
const settings = { ...(await this.settingsService.getSettings()) };
delete settings.jwtSecret;
return res.success({
msg: this.stringService.getAppSettings,
data: settings,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAppSettings"));
const dbSettings = await this.settingsService.getDBSettings();
const sanitizedSettings = { ...dbSettings };
if (typeof sanitizedSettings.pagespeedApiKey !== "undefined") {
sanitizedSettings.pagespeedApiKey = "********";
}
return res.success({
msg: this.stringService.getAppSettings,
data: sanitizedSettings,
});
};
updateAppSettings = async (req, res, next) => {
+2 -45
View File
@@ -2,48 +2,9 @@ import mongoose from "mongoose";
const AppSettingsSchema = mongoose.Schema(
{
apiBaseUrl: {
type: String,
required: true,
default: "http://localhost:5000/api/v1",
},
language: {
type: String,
default: "en",
},
logLevel: {
type: String,
default: "debug",
enum: ["debug", "none", "error", "warn"],
},
clientHost: {
type: String,
required: true,
default: "http://localhost:5173",
},
jwtSecret: {
type: String,
required: true,
default: "my_secret",
},
dbType: {
type: String,
required: true,
default: "MongoDB",
},
dbConnectionString: {
type: String,
required: true,
default: "mongodb://localhost:27017/uptime_db",
},
redisUrl: {
type: String,
default: "redis://127.0.0.1:6379",
},
jwtTTL: {
type: String,
required: true,
default: "2h",
default: "gb",
},
pagespeedApiKey: {
type: String,
@@ -51,19 +12,15 @@ const AppSettingsSchema = mongoose.Schema(
},
systemEmailHost: {
type: String,
default: "smtp.gmail.com",
},
systemEmailPort: {
type: Number,
default: 465,
},
systemEmailAddress: {
type: String,
default: "",
},
systemEmailPassword: {
type: String,
default: "",
},
singleton: {
type: Boolean,
@@ -77,4 +34,4 @@ const AppSettingsSchema = mongoose.Schema(
}
);
export default mongoose.model("AppSettings", AppSettingsSchema);
export default mongoose.model("AppSettings", AppSettingsSchema);
+1
View File
@@ -15,6 +15,7 @@ const InviteTokenSchema = mongoose.Schema(
role: {
type: Array,
required: true,
default: ["user"],
},
token: {
type: String,
+4
View File
@@ -39,6 +39,10 @@ const MonitorSchema = mongoose.Schema(
"distributed_test",
],
},
ignoreTlsErrors: {
type: Boolean,
default: false,
},
jsonPath: {
type: String,
},
+3 -3
View File
@@ -76,7 +76,8 @@ import * as diagnosticModule from "./modules/diagnosticModule.js";
class MongoDB {
static SERVICE_NAME = "MongoDB";
constructor() {
constructor({ appSettings }) {
this.appSettings = appSettings;
Object.assign(this, userModule);
Object.assign(this, inviteModule);
Object.assign(this, recoveryModule);
@@ -95,8 +96,7 @@ class MongoDB {
connect = async () => {
try {
const connectionString =
process.env.DB_CONNECTION_STRING || "mongodb://localhost:27017/uptime_db";
console.log("Connecting to MongoDB with connection string:", connectionString);
this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db";
await mongoose.connect(connectionString);
// If there are no AppSettings, create one
await AppSettings.findOneAndUpdate(
+1 -1
View File
@@ -751,7 +751,7 @@ const createMonitor = async (req, res) => {
*/
const createBulkMonitors = async (req) => {
try {
const monitors = req.body.map(
const monitors = req.map(
(item) => new Monitor({ ...item, notifications: undefined })
);
await Monitor.bulkSave(monitors);
+2 -1
View File
@@ -14,10 +14,11 @@ const getAppSettings = async () => {
const updateAppSettings = async (newSettings) => {
try {
console.log(newSettings);
const settings = await AppSettings.findOneAndUpdate(
{},
{ $set: newSettings },
{ new: true }
{ new: true, upsert: true }
);
return settings;
} catch (error) {
+30 -23
View File
@@ -81,7 +81,9 @@ import ServiceRegistry from "./service/serviceRegistry.js";
import MongoDB from "./db/mongo/MongoDB.js";
// Redis Service and dependencies
import IORedis from "ioredis";
import RedisService from "./service/redisService.js";
import TranslationService from "./service/translationService.js";
import languageMiddleware from "./middleware/languageMiddleware.js";
@@ -98,8 +100,6 @@ const openApiSpec = JSON.parse(
let server;
const PORT = 5000;
const shutdown = async () => {
if (isShuttingDown) {
return;
@@ -113,21 +113,16 @@ const shutdown = async () => {
method: "shutdown",
});
// flush Redis
const settings =
ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings() || {};
const redisService = ServiceRegistry.get(RedisService.SERVICE_NAME);
await redisService.flushall();
const { redisUrl } = settings;
const redis = new IORedis(redisUrl, { maxRetriesPerRequest: null });
logger.info({ message: "Flushing Redis" });
await redis.flushall();
logger.info({ message: "Redis flushed" });
process.exit(1);
}, SHUTDOWN_TIMEOUT);
try {
server.close();
await ServiceRegistry.get(JobQueue.SERVICE_NAME).obliterate();
await ServiceRegistry.get(MongoDB.SERVICE_NAME).disconnect();
await ServiceRegistry.get(RedisService.SERVICE_NAME).flushall();
logger.info({ message: "Graceful shutdown complete" });
process.exit(0);
} catch (error) {
@@ -142,19 +137,21 @@ const shutdown = async () => {
// Need to wrap server setup in a function to handle async nature of JobQueue
const startApp = async () => {
const app = express();
const allowedOrigin = process.env.CLIENT_HOST;
// Create and Register Primary services
const translationService = new TranslationService(logger);
const stringService = new StringService(translationService);
ServiceRegistry.register(StringService.SERVICE_NAME, stringService);
// Create DB
const db = new MongoDB();
await db.connect();
// Create services
const settingsService = new SettingsService(AppSettings);
await settingsService.loadSettings();
const appSettings = settingsService.loadSettings();
// Create DB
const db = new MongoDB({ appSettings });
await db.connect();
// Set allowed origin
const allowedOrigin = appSettings.clientHost;
const networkService = new NetworkService(
axios,
@@ -185,7 +182,13 @@ const startApp = async () => {
stringService
);
const jobQueue = new JobQueue(
const redisService = await RedisService.createInstance({
logger,
IORedis,
SettingsService: settingsService,
});
const jobQueue = new JobQueue({
db,
statusService,
networkService,
@@ -194,8 +197,9 @@ const startApp = async () => {
stringService,
logger,
Queue,
Worker
);
Worker,
redisService,
});
// Register services
ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue);
@@ -207,11 +211,13 @@ const startApp = async () => {
ServiceRegistry.register(StatusService.SERVICE_NAME, statusService);
ServiceRegistry.register(NotificationService.SERVICE_NAME, notificationService);
ServiceRegistry.register(TranslationService.SERVICE_NAME, translationService);
ServiceRegistry.register(RedisService.SERVICE_NAME, redisService);
await translationService.initialize();
server = app.listen(PORT, () => {
logger.info({ message: `server started on port:${PORT}` });
const port = appSettings.port || 52345;
server = app.listen(port, () => {
logger.info({ message: `Server started on port:${port}` });
});
process.on("SIGUSR2", shutdown);
@@ -231,7 +237,8 @@ const startApp = async () => {
ServiceRegistry.get(MongoDB.SERVICE_NAME),
ServiceRegistry.get(SettingsService.SERVICE_NAME),
ServiceRegistry.get(JobQueue.SERVICE_NAME),
ServiceRegistry.get(StringService.SERVICE_NAME)
ServiceRegistry.get(StringService.SERVICE_NAME),
ServiceRegistry.get(EmailService.SERVICE_NAME)
);
const settingsController = new SettingsController(
@@ -343,7 +350,7 @@ const startApp = async () => {
app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter());
app.use("/api/v1/distributed-uptime", distributedUptimeRoutes.getRouter());
app.use("/api/v1/status-page", statusPageRoutes.getRouter());
app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter());
app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter());
app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter());
app.use("/api/v1/health", (req, res) => {
res.json({
+4 -1
View File
@@ -158,5 +158,8 @@
"platformRequired": "Platform is required",
"testNotificationFailed": "Failed to send test notification",
"monitorUpAlert": "Uptime Alert: One of your monitors is back online.\n📌 Monitor: {monitorName}\n📅 Time: {time}\n⚠️ Status: UP\n📟 Status Code: {code}\n\u200B\n",
"monitorDownAlert": "Downtime Alert: One of your monitors went offline.\n📌 Monitor: {monitorName}\n📅 Time: {time}\n⚠️ Status: DOWN\n📟 Status Code: {code}\n\u200B\n"
"monitorDownAlert": "Downtime Alert: One of your monitors went offline.\n📌 Monitor: {monitorName}\n📅 Time: {time}\n⚠️ Status: DOWN\n📟 Status Code: {code}\n\u200B\n",
"sendTestEmail": "Test email sent successfully",
"errorForValidEmailAddress": "A valid recipient email address is required.",
"testEmailSubject": "Test Email from Monitoring System"
}
+3 -9
View File
@@ -1,16 +1,10 @@
import logger from "../utils/logger.js";
const languageMiddleware =
(stringService, translationService, settingsService) => async (req, res, next) => {
(stringService, translationService) => async (req, res, next) => {
try {
const settings = await settingsService.getSettings();
let language = settings && settings.language ? settings.language : null;
if (!language) {
const acceptLanguage = req.headers["accept-language"] || "en";
language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase();
}
const acceptLanguage = req.headers["accept-language"] || "en";
const language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase();
translationService.setLanguage(language);
stringService.setLanguage(language);
+2 -1
View File
@@ -33,8 +33,9 @@
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.14",
"papaparse": "^5.5.2",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
+12
View File
@@ -1,7 +1,12 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import multer from "multer";
import { fetchMonitorCertificate } from "../controllers/controllerUtils.js";
const upload = multer({
storage: multer.memoryStorage() // Store file in memory as Buffer
});
class MonitorRoutes {
constructor(monitorController) {
this.router = Router();
@@ -89,10 +94,17 @@ class MonitorRoutes {
this.router.post(
"/bulk",
isAllowed(["admin", "superadmin"]),
upload.single("csvFile"),
this.monitorController.createBulkMonitors
);
this.router.post("/seed", isAllowed(["superadmin"]), this.monitorController.seedDb);
this.router.post(
"/test-email",
isAllowed(["admin", "superadmin"]),
this.monitorController.sendTestEmail
);
}
getRouter() {
+1
View File
@@ -67,6 +67,7 @@ class EmailService {
serverIsUpTemplate: this.loadTemplate("serverIsUp"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"),
testEmailTemplate: this.loadTemplate("testEmailTemplate")
};
/**
+6 -10
View File
@@ -1,5 +1,3 @@
import IORedis from "ioredis";
const QUEUE_NAMES = ["uptime", "pagespeed", "hardware", "distributed"];
const SERVICE_NAME = "JobQueue";
const JOBS_PER_WORKER = 5;
@@ -18,7 +16,7 @@ const getSchedulerId = (monitor) => `scheduler:${monitor.type}:${monitor._id}`;
class NewJobQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor(
constructor({
db,
statusService,
networkService,
@@ -27,16 +25,14 @@ class NewJobQueue {
stringService,
logger,
Queue,
Worker
) {
const settings = settingsService.getSettings() || {};
const { redisUrl } = settings;
const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null });
Worker,
redisService,
}) {
this.connection = redisService.getConnection();
this.queues = {};
this.workers = {};
this.lastJobProcessedTime = {};
this.connection = connection;
this.db = db;
this.networkService = networkService;
this.statusService = statusService;
@@ -47,7 +43,7 @@ class NewJobQueue {
this.stringService = stringService;
QUEUE_NAMES.forEach((name) => {
const q = new Queue(name, { connection });
const q = new Queue(name, { connection: this.connection });
this.lastJobProcessedTime[q.name] = Date.now();
q.on("error", (error) => {
this.logger.error({
+9
View File
@@ -1,4 +1,6 @@
import jmespath from "jmespath";
import https from "https";
const SERVICE_NAME = "NetworkService";
const UPROCK_ENDPOINT = "https://api.uprock.com/checkmate/push";
@@ -133,6 +135,7 @@ class NetworkService {
name,
teamId,
type,
ignoreTlsErrors,
jsonPath,
matchMethod,
expectedValue,
@@ -141,6 +144,12 @@ class NetworkService {
secret !== undefined && (config.headers = { Authorization: `Bearer ${secret}` });
if (ignoreTlsErrors === true) {
config.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
const { response, responseTime, error } = await this.timeRequest(() =>
this.axios.get(url, config)
);
+72
View File
@@ -0,0 +1,72 @@
class RedisService {
static SERVICE_NAME = "RedisService";
constructor({ logger, IORedis, SettingsService }) {
this.logger = logger;
this.IORedis = IORedis;
this.SettingsService = SettingsService;
this.connection = null;
}
static async createInstance({ logger, IORedis, SettingsService }) {
const instance = new RedisService({ logger, IORedis, SettingsService });
await instance.connect();
return instance;
}
async connect() {
const settings = this.SettingsService.getSettings();
const { redisUrl } = settings;
this.connection = new this.IORedis(redisUrl, {
maxRetriesPerRequest: null,
retryStrategy: (times) => {
if (times >= 5) {
throw new Error("Failed to connect to Redis");
}
this.logger.debug({
message: "Retrying Redis connection",
service: RedisService.SERVICE_NAME,
details: { times },
});
return Math.min(times * 100, 2000);
},
});
await new Promise((resolve, reject) => {
let errorOccurred = false;
this.connection.on("ready", () => {
if (!errorOccurred) {
this.logger.info({
message: "Redis connection established",
service: RedisService.SERVICE_NAME,
});
resolve();
}
});
this.connection.on("error", (err) => {
errorOccurred = true;
this.logger.error({
message: "Redis connection error",
service: RedisService.SERVICE_NAME,
error: err,
});
setTimeout(() => reject(err), 5000);
});
});
}
async flushall() {
this.logger.debug({
message: "Flushing all Redis data",
service: RedisService.SERVICE_NAME,
});
await this.connection.flushall();
}
getConnection() {
return this.connection;
}
}
export default RedisService;
+21 -39
View File
@@ -1,28 +1,26 @@
const SERVICE_NAME = "SettingsService";
import dotenv from "dotenv";
dotenv.config();
const envConfig = {
nodeEnv: process.env.NODE_ENV,
logLevel: process.env.LOG_LEVEL,
language: process.env.LANGUAGE,
clientHost: process.env.CLIENT_HOST,
jwtSecret: process.env.JWT_SECRET,
dbType: process.env.DB_TYPE,
dbConnectionString: process.env.DB_CONNECTION_STRING,
redisUrl: process.env.REDIS_URL,
jwtTTL: process.env.TOKEN_TTL,
pagespeedApiKey: process.env.PAGESPEED_API_KEY,
systemEmailHost: process.env.SYSTEM_EMAIL_HOST,
systemEmailPort: process.env.SYSTEM_EMAIL_PORT,
systemEmailUser: process.env.SYSTEM_EMAIL_USER,
systemEmailAddress: process.env.SYSTEM_EMAIL_ADDRESS,
systemEmailPassword: process.env.SYSTEM_EMAIL_PASSWORD,
jwtSecret: process.env.JWT_SECRET,
jwtTTL: process.env.TOKEN_TTL,
clientHost: process.env.CLIENT_HOST,
dbConnectionString: process.env.DB_CONNECTION_STRING,
redisUrl: process.env.REDIS_URL,
callbackUrl: process.env.CALLBACK_URL,
port: process.env.PORT,
pagespeedApiKey: process.env.PAGESPEED_API_KEY,
uprockApiKey: process.env.UPROCK_API_KEY,
};
/**
* SettingsService
*
* This service is responsible for loading and managing the application settings.
* It gives priority to environment variables and will only load settings
* from the database if they are not set in the environment.
*/
class SettingsService {
static SERVICE_NAME = SERVICE_NAME;
@@ -35,38 +33,17 @@ class SettingsService {
this.settings = { ...envConfig };
}
/**
* Load settings from the database and merge with environment settings.
* If there are any settings that weren't set by environment variables, use user settings from the database.
* @returns {Promise<Object>} The merged settings.
* @throws Will throw an error if settings are not found in the database or if settings have not been loaded.
*/ async loadSettings() {
try {
const dbSettings = await this.appSettings.findOne();
if (!this.settings) {
throw new Error("Settings not found");
}
// If there are any settings that weren't set by environment variables, use user settings from DB
for (const key in envConfig) {
if (
typeof envConfig?.[key] === "undefined" &&
typeof dbSettings?.[key] !== "undefined"
) {
this.settings[key] = dbSettings[key];
}
}
return this.settings;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "loadSettings") : null;
throw error;
}
* Load settings from env settings
* @returns {Object>} The settings.
*/
loadSettings() {
return this.settings;
}
/**
* Reload settings by calling loadSettings.
* @returns {Promise<Object>} The reloaded settings.
*/
async reloadSettings() {
reloadSettings() {
return this.loadSettings();
}
/**
@@ -80,6 +57,11 @@ class SettingsService {
}
return this.settings;
}
async getDBSettings() {
const settings = await this.appSettings.findOne({ singleton: true }).lean();
return settings;
}
}
export default SettingsService;
+17
View File
@@ -0,0 +1,17 @@
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="20px" font-family="Helvetica Neue">
Hello!
</mj-text>
<mj-text font-size="16px" font-family="Helvetica Neue">
This is a test email from the Monitoring System.
</mj-text>
<mj-text font-size="14px" font-family="Helvetica Neue">
If you're receiving this, your email configuration is working correctly.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
+21 -1
View File
@@ -1,4 +1,6 @@
import { createLogger, format, transports } from "winston";
import dotenv from "dotenv";
dotenv.config();
class Logger {
constructor() {
@@ -40,8 +42,10 @@ class Logger {
}
);
const logLevel = process.env.LOG_LEVEL || "info";
this.logger = createLogger({
level: "info",
level: logLevel,
format: format.combine(format.timestamp()),
transports: [
new transports.Console({
@@ -107,6 +111,22 @@ class Logger {
stack: config.stack,
});
}
/**
* Logs a debug message.
* @param {Object} config - The configuration object.
* @param {string} config.message - The message to log.
* @param {string} config.service - The service name.
* @param {string} config.method - The method name.
* @param {Object} config.details - Additional details.
*/
debug(config) {
this.logger.debug(config.message, {
service: config.service,
method: config.method,
details: config.details,
stack: config.stack,
});
}
}
const logger = new Logger();
+22 -14
View File
@@ -63,11 +63,7 @@ const registrationBodyValidation = joi.object({
}),
password: joi.string().min(8).required().pattern(passwordPattern),
profileImage: joi.any(),
role: joi
.array()
.items(joi.string().valid("superadmin", "admin", "user", "demo"))
.min(1)
.required(),
role: joi.array().items(joi.string().valid("superadmin", "admin", "user", "demo")),
teamId: joi.string().allow("").required(),
inviteToken: joi.string().allow("").required(),
});
@@ -83,7 +79,6 @@ const editUserBodyValidation = joi.object({
newPassword: joi.string().min(8).pattern(passwordPattern),
password: joi.string().min(8).pattern(passwordPattern),
deleteProfileImage: joi.boolean(),
role: joi.array(),
});
const recoveryValidation = joi.object({
@@ -188,6 +183,7 @@ const createMonitorBodyValidation = joi.object({
description: joi.string().required(),
type: joi.string().required(),
url: joi.string().required(),
ignoreTlsErrors: joi.boolean().default(false),
port: joi.number(),
isActive: joi.boolean(),
interval: joi.number(),
@@ -212,9 +208,11 @@ const editMonitorBodyValidation = joi.object({
interval: joi.number(),
notifications: joi.array().items(joi.object()),
secret: joi.string(),
ignoreTlsErrors: joi.boolean(),
jsonPath: joi.string().allow(""),
expectedValue: joi.string().allow(""),
matchMethod: joi.string(),
port: joi.number().min(1).max(65535),
thresholds: joi.object().keys({
usage_cpu: joi.number(),
usage_memory: joi.number(),
@@ -305,7 +303,18 @@ const getChecksParamValidation = joi.object({
});
const getChecksQueryValidation = joi.object({
type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "distributed_http", "distributed_test"),
type: joi
.string()
.valid(
"http",
"ping",
"pagespeed",
"hardware",
"docker",
"port",
"distributed_http",
"distributed_test"
),
sortOrder: joi.string().valid("asc", "desc"),
limit: joi.number(),
dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
@@ -578,16 +587,15 @@ const triggerNotificationBodyValidation = joi.object({
const createAnnouncementValidation = joi.object({
title: joi.string().required().messages({
'string.empty': 'Title cannot be empty',
'any.required': 'Title is required',
"string.empty": "Title cannot be empty",
"any.required": "Title is required",
}),
message: joi.string().required().messages({
'string.empty': 'Message cannot be empty',
'any.required': 'Message is required',
"string.empty": "Message cannot be empty",
"any.required": "Message is required",
}),
userId: joi.string().required(),
});
});
export {
roleValidatior,
@@ -651,5 +659,5 @@ export {
imageValidation,
triggerNotificationBodyValidation,
webhookConfigValidation,
createAnnouncementValidation
createAnnouncementValidation,
};