Merge branch 'develop' into fix/language-detection

This commit is contained in:
Alexander Holliday authored and GitHub committed 2025-05-05 14:33:15 -07:00
commit a1dcd5970f
38 files changed
+1168 -14125

No files matched your search

+3 -6
View File
@@ -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
@@ -136,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.
+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
+11
View File
@@ -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) && (
@@ -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,
};
@@ -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;
+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;
+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];
};
+41 -36
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>
@@ -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,7 @@ 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 && t("authRegisterEmailRequired")}
ref={inputRef}
/>
<Stack
@@ -75,6 +75,7 @@ const InfrastructureMenu = ({ monitor, isAdmin, updateCallback }) => {
<IconButton
aria-label="monitor actions"
onClick={openMenu}
disabled={!isAdmin}
sx={{
"&:focus": {
outline: "none",
+8 -8
View File
@@ -6,10 +6,10 @@ 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 {
// WalletMultiButton,
// WalletDisconnectButton,
// } from "@solana/wallet-adapter-react-ui";
//Utils
import { useTheme } from "@emotion/react";
@@ -254,7 +254,7 @@ const Settings = () => {
></Select>
</Stack>
</ConfigBox>
{isAdmin && (
{/* {isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsDistributedUptime")}</Typography>
@@ -276,8 +276,8 @@ const Settings = () => {
: t("settingsDisabled")}
</Box>
</ConfigBox>
)}
{isAdmin && (
)} */}
{/* {isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsWallet")}</Typography>
@@ -302,7 +302,7 @@ const Settings = () => {
</Stack>
</Box>
</ConfigBox>
)}
)} */}
{isAdmin && (
<ConfigBox>
<Box>
@@ -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} />
@@ -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;
@@ -25,6 +25,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.
@@ -196,6 +197,18 @@ 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 +384,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 +437,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)"
@@ -546,6 +579,13 @@ const Configure = () => {
onConfirm={handleRemove}
isLoading={isLoading}
/>
<NotificationIntegrationModal
open={isNotificationModalOpen}
onClose={handleClosenNotificationModal}
monitor={monitor}
setMonitor={setMonitor}
/>
</Stack>
);
};
+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
+23 -17
View File
@@ -56,6 +56,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 +79,11 @@ const Routes = () => {
element={<Uptime />}
/>
<Route
path="/uptime/bulk-import"
element={<BulkImport />}
/>
<Route
path="/uptime/create/:monitorId?"
element={<UptimeCreate />}
@@ -90,16 +96,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 +120,15 @@ const Routes = () => {
<CreateDistributedUptime />
</ProtectedDistributedUptimeRoute>
}
/>
<Route
/> */}
{/* <Route
path="/distributed-uptime/:monitorId"
element={
<ProtectedDistributedUptimeRoute>
<DistributedUptimeDetails />
</ProtectedDistributedUptimeRoute>
}
/>
/> */}
<Route
path="pagespeed"
@@ -148,9 +154,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 +177,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,10 +280,10 @@ const Routes = () => {
path="/status/uptime/public/:url"
element={<Status />}
/>
<Route
{/* <Route
path="/status/distributed/public/:url"
element={<DistributedUptimeStatus />}
/>
/> */}
<Route
path="*"
+17
View File
@@ -45,6 +45,9 @@ class NetworkService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.code === "ERR_NETWORK") {
// Do error handling here
}
if (error.response && error.response.status === 401) {
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
@@ -266,6 +269,7 @@ class NetworkService {
matchMethod: monitor.matchMethod,
expectedValue: monitor.expectedValue,
jsonPath: monitor.jsonPath,
...(monitor.type === "port" && { port: monitor.port }),
};
return this.axiosInstance.put(`/monitors/${monitorId}`, payload, {
headers: {
@@ -1086,6 +1090,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
// ************************************
+17 -11
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
@@ -159,7 +152,20 @@ 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.",
}),
@@ -366,5 +372,5 @@ export {
advancedSettingsValidation,
infrastructureMonitorValidation,
statusPageValidation,
logoImageValidation,
logoImageValidation
};
+22 -5
View File
@@ -54,9 +54,9 @@
"authPasswordConfirmAndPassword": "Confirm password and password",
"authPasswordMustMatch": "must match",
"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",
@@ -396,5 +396,22 @@
"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?"
}
"DeleteWarningTitle": "Really delete this account?",
"authRegisterFirstName": "Name",
"authRegisterLastName": "Surname",
"authRegisterEmail": "Email",
"authRegisterEmailRequired": "Email is required",
"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"
}
+6 -2
View File
@@ -55,8 +55,12 @@
"authPasswordMustMatch": "должен совпадать",
"authRegisterCreateAccount": "Создайте свою учетную запись, чтобы начать",
"authRegisterCreateSuperAdminAccount": "Создайте учетную запись суперадминистратора, чтобы начать работу",
"authRegisterSignUpWithEmail": "Зарегистрироваться по электронной почте",
"authRegisterBySigningUp": "Регистрируясь, вы соглашаетесь с нашими",
"authRegisterSignUpWithEmail": "Создать учетную запись суперадминистратора",
"authRegisterBySigningUp": "Создавая учетную запись, вы соглашаетесь с нашими <a1>Условиями использования</a1> и <a2>Политикой конфиденциальности</a2>.",
"authRegisterFirstName": "Имя",
"authRegisterLastName": "Фамилия",
"authRegisterEmail": "Эл. почта",
"authRegisterEmailRequired": "Эл. почта обязательна",
"distributedStatusHeaderText": "Охват реального времени и реального устройства",
"distributedStatusSubHeaderText": "Работает на миллионах устройств по всему миру, просматривайте производительность системы по глобальному региону, стране или городу",
"settingsGeneralSettings": "Общие настройки",
+17 -2
View File
@@ -396,5 +396,20 @@
"DeleteAccount": "",
"DeleteDescriptionText": "",
"DeleteAccountWarning": "",
"DeleteWarningTitle": ""
}
"DeleteWarningTitle": "",
"authRegisterFirstName": "",
"authRegisterLastName": "",
"authRegisterEmail": "",
"authRegisterEmailRequired": "",
"bulkImport": {
"title": "",
"selectFileTips": "",
"selectFileDescription": "",
"selectFile": "",
"parsingFailed": "",
"uploadSuccess": "",
"validationFailed": "",
"noFileSelected": "",
"fallbackPage": ""
}
}
+142 -33
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
@@ -572,6 +637,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);
+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
@@ -231,7 +231,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(
+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"
}
+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")
};
/**
+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>
+1
View File
@@ -210,6 +210,7 @@ const editMonitorBodyValidation = joi.object({
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(),