diff --git a/package-lock.json b/package-lock.json index 6072cb650..b496af154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9152,9 +9152,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "version": "18.3.19", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.19.tgz", + "integrity": "sha512-fcdJqaHOMDbiAwJnXv6XCzX0jDW77yI3tJqYh1Byn8EL5/S628WRx9b/y3DnNe55zTukUQKrfYxiZls2dHcUMw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", diff --git a/src/Components/MonitorStatusHeader/index.jsx b/src/Components/MonitorStatusHeader/index.jsx index cbbba52b2..e1007bbf5 100644 --- a/src/Components/MonitorStatusHeader/index.jsx +++ b/src/Components/MonitorStatusHeader/index.jsx @@ -8,10 +8,10 @@ import ConfigButton from "./ConfigButton"; import SkeletonLayout from "./skeleton"; import PropTypes from "prop-types"; -const MonitorStatusHeader = ({ path, shouldRender = true, isAdmin, monitor }) => { +const MonitorStatusHeader = ({ path, isLoading = false, isAdmin, monitor }) => { const theme = useTheme(); - const { statusColor, statusMsg, determineState } = useUtils(); - if (!shouldRender) { + const { statusColor, determineState } = useUtils(); + if (isLoading) { return ; } @@ -48,7 +48,7 @@ const MonitorStatusHeader = ({ path, shouldRender = true, isAdmin, monitor }) => MonitorStatusHeader.propTypes = { path: PropTypes.string.isRequired, - shouldRender: PropTypes.bool, + isLoading: PropTypes.bool, isAdmin: PropTypes.bool, monitor: PropTypes.object, }; diff --git a/src/Components/MonitorTimeFrameHeader/index.jsx b/src/Components/MonitorTimeFrameHeader/index.jsx index 9ac8b8b6a..97f113897 100644 --- a/src/Components/MonitorTimeFrameHeader/index.jsx +++ b/src/Components/MonitorTimeFrameHeader/index.jsx @@ -4,14 +4,14 @@ import SkeletonLayout from "./skeleton"; import PropTypes from "prop-types"; const MonitorTimeFrameHeader = ({ - shouldRender = true, + isLoading = false, hasDateRange = true, dateRange, setDateRange, }) => { const theme = useTheme(); - if (!shouldRender) { + if (isLoading) { return ; } @@ -77,7 +77,7 @@ const MonitorTimeFrameHeader = ({ }; MonitorTimeFrameHeader.propTypes = { - shouldRender: PropTypes.bool, + isLoading: PropTypes.bool, hasDateRange: PropTypes.bool, dateRange: PropTypes.string, setDateRange: PropTypes.func, diff --git a/src/Components/NotificationIntegrationModal/NotificationIntegrationModal.jsx b/src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx similarity index 78% rename from src/Components/NotificationIntegrationModal/NotificationIntegrationModal.jsx rename to src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx index 30587833a..8077aa3a6 100644 --- a/src/Components/NotificationIntegrationModal/NotificationIntegrationModal.jsx +++ b/src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx @@ -1,5 +1,6 @@ import { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; + import { Dialog, DialogContent, @@ -8,11 +9,29 @@ import { Typography, Box, Tabs, - Tab + Tab, + CircularProgress } from "@mui/material"; import { useTheme } from "@emotion/react"; import TabPanel from "./TabPanel"; import TabComponent from "./TabComponent"; +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' +}; + +// Define constants for field IDs +const FIELD_IDS = { + WEBHOOK: 'webhook', + TOKEN: 'token', + CHAT_ID: 'chatId', + URL: 'url' +}; const NotificationIntegrationModal = ({ open, @@ -26,15 +45,30 @@ const NotificationIntegrationModal = ({ 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('Invalid typeId provided to getFieldKey'); + } + + if (typeof fieldId !== 'string' || fieldId === '') { + throw new Error('Invalid fieldId provided to getFieldKey'); + } + + return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`; + }; + // Define notification types const DEFAULT_NOTIFICATION_TYPES = [ { - id: 'slack', + id: NOTIFICATION_TYPES.SLACK, label: t('notifications.slack.label'), description: t('notifications.slack.description'), fields: [ { - id: 'webhook', + id: FIELD_IDS.WEBHOOK, label: t('notifications.slack.webhookLabel'), placeholder: t('notifications.slack.webhookPlaceholder'), type: 'text' @@ -42,12 +76,12 @@ const NotificationIntegrationModal = ({ ] }, { - id: 'discord', + id: NOTIFICATION_TYPES.DISCORD, label: t('notifications.discord.label'), description: t('notifications.discord.description'), fields: [ { - id: 'webhook', + id: FIELD_IDS.WEBHOOK, label: t('notifications.discord.webhookLabel'), placeholder: t('notifications.discord.webhookPlaceholder'), type: 'text' @@ -55,18 +89,18 @@ const NotificationIntegrationModal = ({ ] }, { - id: 'telegram', + id: NOTIFICATION_TYPES.TELEGRAM, label: t('notifications.telegram.label'), description: t('notifications.telegram.description'), fields: [ { - id: 'token', + id: FIELD_IDS.TOKEN, label: t('notifications.telegram.tokenLabel'), placeholder: t('notifications.telegram.tokenPlaceholder'), type: 'text' }, { - id: 'chatId', + id: FIELD_IDS.CHAT_ID, label: t('notifications.telegram.chatIdLabel'), placeholder: t('notifications.telegram.chatIdPlaceholder'), type: 'text' @@ -74,12 +108,12 @@ const NotificationIntegrationModal = ({ ] }, { - id: 'webhook', + id: NOTIFICATION_TYPES.WEBHOOK, label: t('notifications.webhook.label'), description: t('notifications.webhook.description'), fields: [ { - id: 'url', + id: FIELD_IDS.URL, label: t('notifications.webhook.urlLabel'), placeholder: t('notifications.webhook.urlPlaceholder'), type: 'text' @@ -101,7 +135,7 @@ const NotificationIntegrationModal = ({ // Add state for each field in the notification type type.fields.forEach(field => { - const fieldKey = `${type.id}${field.id.charAt(0).toUpperCase() + field.id.slice(1)}`; + const fieldKey = getFieldKey(type.id, field.id); state[fieldKey] = monitor?.notifications?.find(n => n.type === type.id)?.[field.id] || ""; }); }); @@ -129,11 +163,26 @@ const NotificationIntegrationModal = ({ })); }; - const handleTestNotification = (type) => { - console.log(`Testing ${type} notification`); - //implement the test notification functionality + 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); }; - + const handleSave = () => { //notifications array for selected integrations const notifications = [...(monitor?.notifications || [])]; @@ -155,7 +204,7 @@ const NotificationIntegrationModal = ({ // Add each field value to the notification object type.fields.forEach(field => { - const fieldKey = `${type.id}${field.id.charAt(0).toUpperCase() + field.id.slice(1)}`; + const fieldKey = getFieldKey(type.id, field.id); notificationObject[field.id] = integrations[fieldKey]; }); @@ -240,6 +289,7 @@ const NotificationIntegrationModal = ({ handleIntegrationChange={handleIntegrationChange} handleInputChange={handleInputChange} handleTestNotification={handleTestNotification} + isLoading={loading} /> ))} @@ -257,13 +307,14 @@ const NotificationIntegrationModal = ({ variant="contained" color="accent" onClick={handleSave} + loading={loading} sx={{ width: 'auto', minWidth: theme.spacing(60), px: theme.spacing(8) }} > - {t('common.save', 'Save')} + {t('commonSave')} diff --git a/src/Components/NotificationIntegrationModal/TabComponent.jsx b/src/Components/NotificationIntegrationModal/Components/TabComponent.jsx similarity index 84% rename from src/Components/NotificationIntegrationModal/TabComponent.jsx rename to src/Components/NotificationIntegrationModal/Components/TabComponent.jsx index 89216a8c1..e46a8cdd5 100644 --- a/src/Components/NotificationIntegrationModal/TabComponent.jsx +++ b/src/Components/NotificationIntegrationModal/Components/TabComponent.jsx @@ -2,19 +2,21 @@ import React from "react"; import { Typography, Box, - Button + Button, + CircularProgress } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useTheme } from "@emotion/react"; -import TextInput from "../../../src/Components/Inputs/TextInput"; -import Checkbox from "../../../src/Components/Inputs/Checkbox"; +import TextInput from "../../../Components/Inputs/TextInput"; +import Checkbox from "../../../Components/Inputs/Checkbox"; const TabComponent = ({ type, integrations, handleIntegrationChange, handleInputChange, - handleTestNotification + handleTestNotification, + isLoading }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -55,6 +57,7 @@ const TabComponent = ({ label={t('notifications.enableNotifications', { platform: type.label })} isChecked={integrations[type.id]} onChange={(e) => handleIntegrationChange(type.id, e.target.checked)} + disabled={isLoading} /> @@ -77,7 +80,7 @@ const TabComponent = ({ placeholder={field.placeholder} value={integrations[fieldKey]} onChange={(e) => handleInputChange(fieldKey, e.target.value)} - disabled={!integrations[type.id]} + disabled={!integrations[type.id] || isLoading} /> ); @@ -88,8 +91,14 @@ const TabComponent = ({ variant="text" color="info" onClick={() => handleTestNotification(type.id)} - disabled={!integrations[type.id] || !areAllFieldsFilled()} + disabled={!integrations[type.id] || !areAllFieldsFilled() || isLoading} > + {isLoading ? ( + + ) : null} {t('notifications.testNotification')} diff --git a/src/Components/NotificationIntegrationModal/TabPanel.jsx b/src/Components/NotificationIntegrationModal/Components/TabPanel.jsx similarity index 100% rename from src/Components/NotificationIntegrationModal/TabPanel.jsx rename to src/Components/NotificationIntegrationModal/Components/TabPanel.jsx diff --git a/src/Components/NotificationIntegrationModal/Hooks/useNotification.js b/src/Components/NotificationIntegrationModal/Hooks/useNotification.js new file mode 100644 index 000000000..08343760b --- /dev/null +++ b/src/Components/NotificationIntegrationModal/Hooks/useNotification.js @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { useTranslation } from "react-i18next"; +import { networkService } from '../../../Utils/NetworkService'; + +// Define constants for notification types to avoid magic values +const NOTIFICATION_TYPES = { + 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' +}; + +/** + * Custom hook for notification-related operations + */ +const useNotifications = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + const { t } = useTranslation(); + + /** + * Send a test notification + * @param {string} type - The notification type (slack, discord, telegram, webhook) + * @param {object} config - Configuration object with necessary params + */ + const sendTestNotification = async (type, config) => { + setLoading(true); + setError(undefined); + + // Validation based on notification type + let payload = { platform: type }; + let isValid = true; + let errorMessage = ''; + + switch(type) { + case NOTIFICATION_TYPES.SLACK: + payload.webhookUrl = config.webhook; + if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') { + isValid = false; + errorMessage = t('notifications.slack.webhookRequired'); + } + break; + + case NOTIFICATION_TYPES.DISCORD: + payload.webhookUrl = config.webhook; + if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') { + isValid = false; + errorMessage = t('notifications.discord.webhookRequired'); + } + break; + + case NOTIFICATION_TYPES.TELEGRAM: + payload.botToken = config.token; + payload.chatId = config.chatId; + if (typeof payload.botToken === 'undefined' || payload.botToken === '' || + typeof payload.chatId === 'undefined' || payload.chatId === '') { + isValid = false; + errorMessage = t('notifications.telegram.fieldsRequired'); + } + break; + + case NOTIFICATION_TYPES.WEBHOOK: + payload.webhookUrl = config.url; + payload.platform = NOTIFICATION_TYPES.SLACK; + if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') { + isValid = false; + errorMessage = t('notifications.webhook.urlRequired'); + } + break; + + default: + isValid = false; + errorMessage = t('notifications.unsupportedType'); + } + + // If validation fails, show error and return + if (isValid === false) { + toast.error(errorMessage); + setLoading(false); + return; + } + + try { + const response = await networkService.testNotification({ + platform: type, + payload: payload + }); + + if (response.data.success === true) { + toast.success(t('notifications.testSuccess')); + } else { + throw new Error(response.data.msg || t('notifications.testFailed')); + } + } catch (error) { + const errorMsg = error.response?.data?.msg || error.message || t('notifications.networkError'); + toast.error(`${t('notifications.testFailed')}: ${errorMsg}`); + setError(errorMsg); + } finally { + setLoading(false); + } + }; + + return [ + loading, + error, + sendTestNotification + ]; +}; + +export default useNotifications; \ No newline at end of file diff --git a/src/Hooks/useFetchUptimeMonitorDetails.js b/src/Hooks/useFetchUptimeMonitorDetails.js new file mode 100644 index 000000000..c8cb81e65 --- /dev/null +++ b/src/Hooks/useFetchUptimeMonitorDetails.js @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { networkService } from "../main"; +import { useNavigate } from "react-router-dom"; +import { createToast } from "../Utils/toastUtils"; + +export const useFetchUptimeMonitorDetails = ({ monitorId, dateRange }) => { + const [networkError, setNetworkError] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [monitor, setMonitor] = useState(undefined); + const [monitorStats, setMonitorStats] = useState(undefined); + const navigate = useNavigate(); + + useEffect(() => { + const fetchMonitors = async () => { + try { + const res = await networkService.getUptimeDetailsById({ + monitorId: monitorId, + dateRange: dateRange, + normalize: true, + }); + const { monitorData, monitorStats } = res?.data?.data ?? {}; + setMonitor(monitorData); + setMonitorStats(monitorStats); + } catch (error) { + setNetworkError(true); + createToast({ body: error.message }); + } finally { + setIsLoading(false); + } + }; + fetchMonitors(); + }, [dateRange, monitorId, navigate]); + return [monitor, monitorStats, isLoading, networkError]; +}; + +export default useFetchUptimeMonitorDetails; diff --git a/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx b/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx index 20226d6d3..a7b69ded0 100644 --- a/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx +++ b/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx @@ -27,7 +27,7 @@ const StatusPagesTable = ({ data }) => { row.type === "distributed" ? `/status/distributed/public/${row.url}` : `/status/uptime/public/${row.url}`; - window.open(url, "_blank", "noopener,noreferrer") + window.open(url, "_blank", "noopener,noreferrer"); } }, render: (row) => { @@ -40,13 +40,12 @@ const StatusPagesTable = ({ data }) => { gap={theme.spacing(2)} paddingLeft={theme.spacing(2)} paddingRight={theme.spacing(2)} - borderRadius={theme.spacing(4)} sx={{ ...(row.isPublished && { + display: "inline-flex", ":hover": { - backgroundColor: `${theme.palette.primary.light}`, cursor: "pointer", - borderRadius: 1, + borderBottom: 1, }, }), }} diff --git a/src/Pages/Uptime/Create/index.jsx b/src/Pages/Uptime/Create/index.jsx index 4338bb76e..bc757984d 100644 --- a/src/Pages/Uptime/Create/index.jsx +++ b/src/Pages/Uptime/Create/index.jsx @@ -4,13 +4,13 @@ import { useNavigate, useParams } from "react-router-dom"; import { useEffect } from "react"; import { useState } from "react"; import { useSelector, useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; // Utility and Network import { checkEndpointResolution } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"; import { monitorValidation } from "../../../Validation/validation"; import { getUptimeMonitorById } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"; import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice"; -import { useTranslation } from "react-i18next"; // MUI import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material"; @@ -23,7 +23,7 @@ import Radio from "../../../Components/Inputs/Radio"; import Checkbox from "../../../Components/Inputs/Checkbox"; import Select from "../../../Components/Inputs/Select"; import ConfigBox from "../../../Components/ConfigBox"; -import NotificationIntegrationModal from "../../../Components/NotificationIntegrationModal/NotificationIntegrationModal"; +import NotificationIntegrationModal from "../../../Components/NotificationIntegrationModal/Components/NotificationIntegrationModal"; const CreateMonitor = () => { const MS_PER_MINUTE = 60000; const SELECT_VALUES = [ @@ -84,8 +84,8 @@ const CreateMonitor = () => { const [isNotificationModalOpen, setIsNotificationModalOpen] = useState(false); const handleOpenNotificationModal = () => { - setIsNotificationModalOpen(true); - }; + setIsNotificationModalOpen(true); + }; const [errors, setErrors] = useState({}); const [https, setHttps] = useState(true); const [monitor, setMonitor] = useState({ @@ -210,7 +210,7 @@ const CreateMonitor = () => { const handleAddNotification = () => { console.log("Add notification clicked"); - }; + }; useEffect(() => { const fetchMonitor = async () => { @@ -354,9 +354,7 @@ const CreateMonitor = () => { {t("settingsGeneralSettings")} - - {t("distributedUptimeCreateSelectURL")} - + {t("distributedUptimeCreateSelectURL")} { - {t("distributedUptimeCreateIncidentNotification")} + + {t("distributedUptimeCreateIncidentNotification")} + {t("distributedUptimeCreateIncidentDescription")} @@ -415,21 +415,22 @@ const CreateMonitor = () => { onChange={(event) => handleNotifications(event, "email")} /> - {/* - - */} - + + + - {t("distributedUptimeCreateAdvancedSettings")} + + {t("distributedUptimeCreateAdvancedSettings")} +