diff --git a/README.md b/README.md index 129953fb9..30b406684 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,8 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb If you have any questions, suggestions or comments, you have several options: -- [Discord channel](https://discord.gg/NAb6H3UTjK) -- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) -- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/) +- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred) +- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time) Feel free to ask questions or share your ideas - we'd love to hear from you! diff --git a/client/package-lock.json b/client/package-lock.json index 282ab2611..d860ae844 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -26,10 +26,10 @@ "joi": "17.13.3", "lucide-react": "^0.562.0", "mui-color-input": "^6.0.0", - "pretty-ms": "9.3.0", + "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", - "react-hook-form": "7.63.0", + "react-hook-form": "^7.63.0", "react-i18next": "^15.4.0", "react-icons": "5.5.0", "react-redux": "9.2.0", @@ -40,7 +40,7 @@ "redux-persist": "6.0.0", "swr": "^2.3.6", "vite-plugin-svgr": "^4.2.0", - "zod": "4.1.11" + "zod": "^4.1.11" }, "devDependencies": { "@types/node": "24.5.2", diff --git a/client/package.json b/client/package.json index 7c5acb441..09599143a 100644 --- a/client/package.json +++ b/client/package.json @@ -31,10 +31,10 @@ "joi": "17.13.3", "lucide-react": "^0.562.0", "mui-color-input": "^6.0.0", - "pretty-ms": "9.3.0", + "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", - "react-hook-form": "7.63.0", + "react-hook-form": "^7.63.0", "react-i18next": "^15.4.0", "react-icons": "5.5.0", "react-redux": "9.2.0", @@ -45,7 +45,7 @@ "redux-persist": "6.0.0", "swr": "^2.3.6", "vite-plugin-svgr": "^4.2.0", - "zod": "4.1.11" + "zod": "^4.1.11" }, "devDependencies": { "@types/node": "24.5.2", diff --git a/client/src/App.jsx b/client/src/App.jsx index af2f6c11c..da83f9061 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -37,7 +37,14 @@ function App() { - + ); } diff --git a/client/src/Components/v1/Charts/BarChart/index.jsx b/client/src/Components/v1/Charts/BarChart/index.jsx index 9f0ed8aa6..77b836cae 100644 --- a/client/src/Components/v1/Charts/BarChart/index.jsx +++ b/client/src/Components/v1/Charts/BarChart/index.jsx @@ -1,6 +1,6 @@ import { useTheme } from "@emotion/react"; import { Box, Stack, Tooltip, Typography } from "@mui/material"; -import { formatDateWithTz } from "../../../../Utils/timeUtils.js"; +import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; import { useEffect, useState } from "react"; import "./index.css"; import { useSelector } from "react-redux"; diff --git a/client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx b/client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx index b37990e46..8b6c2b165 100644 --- a/client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx +++ b/client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx @@ -5,7 +5,7 @@ import { Stack, Box, Tooltip, Typography } from "@mui/material"; import { useTheme } from "@emotion/react"; import { useState, useEffect } from "react"; import PropTypes from "prop-types"; -import { formatDateWithTz } from "../../../../Utils/timeUtils.js"; +import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; import { useSelector } from "react-redux"; const PlaceholderCheck = ({ daysToShow }) => { diff --git a/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx b/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx index a9ef04a53..dafca7b40 100644 --- a/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx +++ b/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx @@ -12,7 +12,7 @@ import { Box, Stack, Typography } from "@mui/material"; import { useTheme } from "@emotion/react"; import { useMemo, useState } from "react"; import { useSelector } from "react-redux"; -import { formatDateWithTz } from "../../../../Utils/timeUtils.js"; +import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; import { tooltipDateFormatLookup, tickDateFormatLookup, diff --git a/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx b/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx index 309201b84..2dc5e61f0 100644 --- a/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx +++ b/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx @@ -1,6 +1,6 @@ import { useTheme } from "@emotion/react"; import { Box, Stack, Tooltip, Typography } from "@mui/material"; -import { formatDateWithTz } from "../../../../Utils/timeUtils.js"; +import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; import { useEffect, useState, forwardRef } from "react"; import { useSelector } from "react-redux"; import PropTypes from "prop-types"; diff --git a/client/src/Components/v1/Charts/Utils/chartUtils.jsx b/client/src/Components/v1/Charts/Utils/chartUtils.jsx index 1bab80595..7797ba462 100644 --- a/client/src/Components/v1/Charts/Utils/chartUtils.jsx +++ b/client/src/Components/v1/Charts/Utils/chartUtils.jsx @@ -2,7 +2,7 @@ import PropTypes from "prop-types"; import { useSelector } from "react-redux"; import { useTheme } from "@mui/material"; import { Text } from "recharts"; -import { formatDateWithTz } from "../../../../Utils/timeUtils.js"; +import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; import { Box, Stack, Typography } from "@mui/material"; import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions.js"; /** diff --git a/client/src/Components/v1/FilterHeader/index.jsx b/client/src/Components/v1/FilterHeader/index.jsx deleted file mode 100644 index de2c7f0e9..000000000 --- a/client/src/Components/v1/FilterHeader/index.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import { Checkbox, FormControl, ListItemText, MenuItem, Select } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -import Icon from "../Icon"; - -/** - * A reusable filter header component that displays a dropdown menu with selectable options. - * - * @component - * @param {Object} props - The component props. - * @param {string} props.header - The header text to display when no options are selected. - * @param {Array} props.options - An array of options to display in the dropdown menu. Each option should have a `value` and `label`. - * @param {Array} [props.value] - The currently selected values. - * @param {Function} props.onChange - The callback function to handle changes in the selected values. - * @param {boolean} [props.multiple=true] - Whether multiple options can be selected. - * @returns {JSX.Element} The rendered FilterHeader component. - */ - -const FilterHeader = ({ header, options, value, onChange, multiple = true }) => { - const theme = useTheme(); - - const controlledValue = value === undefined ? [] : value; // Ensure value is always treated as an array for controlled component purposes - - return ( - - - - ); -}; - -FilterHeader.propTypes = { - header: PropTypes.string.isRequired, - options: PropTypes.arrayOf( - PropTypes.shape({ - value: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - }) - ).isRequired, - value: PropTypes.arrayOf(PropTypes.string), - onChange: PropTypes.func.isRequired, - multiple: PropTypes.bool, -}; - -export default FilterHeader; diff --git a/client/src/Components/v1/MonitorActions/index.jsx b/client/src/Components/v1/MonitorActions/index.jsx deleted file mode 100644 index f63fc23fe..000000000 --- a/client/src/Components/v1/MonitorActions/index.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import * as React from "react"; -import Button from "@mui/material/Button"; -import ButtonGroup from "@mui/material/ButtonGroup"; -import Icon from "../Icon"; -import ClickAwayListener from "@mui/material/ClickAwayListener"; -import Grow from "@mui/material/Grow"; -import Paper from "@mui/material/Paper"; -import Popper from "@mui/material/Popper"; -import MenuItem from "@mui/material/MenuItem"; -import MenuList from "@mui/material/MenuList"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import { useExportMonitors } from "../../../Hooks/monitorHooks.js"; - -const MonitorActions = ({ isLoading }) => { - const [open, setOpen] = React.useState(false); - const anchorRef = React.useRef(null); - const [selectedIndex, setSelectedIndex] = React.useState(0); - const navigate = useNavigate(); - const { t } = useTranslation(); - const [exportMonitors, isExporting] = useExportMonitors(); - - const options = [t("monitorActions.import"), t("monitorActions.export")]; - - const handleClick = async () => { - if (selectedIndex === 0) { - // Import - navigate("/uptime/bulk-import"); - } else { - // Export - const [success, error] = await exportMonitors(); - if (!success) { - createToast({ body: error || t("export.failed") }); - } - } - }; - - const handleMenuItemClick = (event, index) => { - setSelectedIndex(index); - setOpen(false); - }; - - const handleToggle = () => { - setOpen((prevOpen) => !prevOpen); - }; - - const handleClose = (event) => { - if (anchorRef.current && anchorRef.current.contains(event.target)) { - return; - } - setOpen(false); - }; - - return ( - - - - - - - {({ TransitionProps, placement }) => ( - - - - - {options.map((option, index) => ( - handleMenuItemClick(event, index)} - > - {option} - - ))} - - - - - )} - - - ); -}; - -export default MonitorActions; diff --git a/client/src/Components/v1/MonitorCountHeader/index.jsx b/client/src/Components/v1/MonitorCountHeader/index.jsx deleted file mode 100644 index eba644d26..000000000 --- a/client/src/Components/v1/MonitorCountHeader/index.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Stack, Typography } from "@mui/material"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import SkeletonLayout from "./skeleton.jsx"; - -const MonitorCountHeader = ({ - isLoading = false, - monitorCount, - heading = "monitors", - sx, - children, -}) => { - const theme = useTheme(); - if (isLoading) return ; - - if (monitorCount === 1) { - heading = "monitor"; - } - - return ( - - {monitorCount} {heading} - {children} - - ); -}; - -MonitorCountHeader.propTypes = { - isLoading: PropTypes.bool, - monitorCount: PropTypes.number, - heading: PropTypes.string, - children: PropTypes.node, - sx: PropTypes.object, -}; - -export default MonitorCountHeader; diff --git a/client/src/Components/v1/MonitorCountHeader/skeleton.jsx b/client/src/Components/v1/MonitorCountHeader/skeleton.jsx deleted file mode 100644 index 9b4971c32..000000000 --- a/client/src/Components/v1/MonitorCountHeader/skeleton.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -const SkeletonLayout = () => { - const theme = useTheme(); - return ( - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Components/v1/MonitorCreateHeader/index.jsx b/client/src/Components/v1/MonitorCreateHeader/index.jsx index 90c12f5ec..c3a979eb9 100644 --- a/client/src/Components/v1/MonitorCreateHeader/index.jsx +++ b/client/src/Components/v1/MonitorCreateHeader/index.jsx @@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; import { useTheme } from "@emotion/react"; -import MonitorActions from "../MonitorActions/index.jsx"; const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => { const navigate = useNavigate(); diff --git a/client/src/Components/v1/MonitorDetailsControlHeader/index.jsx b/client/src/Components/v1/MonitorDetailsControlHeader/index.jsx deleted file mode 100644 index 0793e7149..000000000 --- a/client/src/Components/v1/MonitorDetailsControlHeader/index.jsx +++ /dev/null @@ -1,158 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Status from "./status.jsx"; -import Skeleton from "./skeleton.jsx"; -import Button from "@mui/material/Button"; -import { Tooltip } from "@mui/material"; -import Icon from "../Icon"; - -// Utils -import PropTypes from "prop-types"; -import { useNavigate } from "react-router-dom"; -import { useTheme } from "@mui/material/styles"; -import { usePauseMonitor } from "../../../Hooks/monitorHooks.js"; -import { useSendTestEmail } from "../../../Hooks/useSendTestEmail.js"; -import { useTranslation } from "react-i18next"; -import { useTestAllNotifications } from "../../../Hooks/useNotifications.js"; -/** - * MonitorDetailsControlHeader component displays the control header for monitor details. - * It includes status display, pause/resume button, and a configure button for admins. - * - * @component - * @param {Object} props - Component props - * @param {string} props.path - The base path for navigation - * @param {boolean} [props.isLoading=false] - Flag indicating if the data is loading - * @param {boolean} [props.isAdmin=false] - Flag indicating if the user is an admin - * @param {Object} props.monitor - The monitor object containing details - * @param {Function} props.triggerUpdate - Function to trigger an update - * @returns {JSX.Element} The rendered component - */ -const MonitorDetailsControlHeader = ({ - path, - isLoading = false, - isAdmin = false, - monitor, - triggerUpdate, -}) => { - const navigate = useNavigate(); - const theme = useTheme(); - const { t } = useTranslation(); - const [pauseMonitor, isPausing, error] = usePauseMonitor(); - - const isTestNotificationsDisabled = monitor?.notifications?.length === 0; - - const tooltipTitle = isTestNotificationsDisabled ? t("testNotificationsDisabled") : ""; - - // const [isSending, emailError, sendTestEmail] = useSendTestEmail(); - - const [testAllNotifications, isSending, errorAllNotifications] = - useTestAllNotifications(); - - if (isLoading) { - return ; - } - - return ( - - - - - - - - - - - {isAdmin && ( - - )} - {isAdmin && ( - - )} - - - ); -}; - -MonitorDetailsControlHeader.propTypes = { - path: PropTypes.string, - isLoading: PropTypes.bool, - isAdmin: PropTypes.bool, - monitor: PropTypes.object, - triggerUpdate: PropTypes.func, -}; - -export default MonitorDetailsControlHeader; diff --git a/client/src/Components/v1/MonitorDetailsControlHeader/skeleton.jsx b/client/src/Components/v1/MonitorDetailsControlHeader/skeleton.jsx deleted file mode 100644 index 64dc0547f..000000000 --- a/client/src/Components/v1/MonitorDetailsControlHeader/skeleton.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Components/v1/MonitorDetailsControlHeader/status.jsx b/client/src/Components/v1/MonitorDetailsControlHeader/status.jsx deleted file mode 100644 index febf449d4..000000000 --- a/client/src/Components/v1/MonitorDetailsControlHeader/status.jsx +++ /dev/null @@ -1,51 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import PulseDot from "../Animated/PulseDot.jsx"; -import Typography from "@mui/material/Typography"; -import Dot from "../Dot/index.jsx"; -// Utils -import { formatDurationRounded } from "../../../Utils/timeUtils.js"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js"; -import { formatMonitorUrl } from "../../../Utils/utils.js"; -/** - * Status component displays the status information of a monitor. - * It includes the monitor's name, URL, and check interval. - * - * @component - * @param {Object} props - Component props - * @param {Object} props.monitor - The monitor object containing details - * @param {string} props.monitor.name - The name of the monitor - * @param {string} props.monitor.url - The URL of the monitor - * @param {number} props.monitor.interval - The interval at which the monitor checks - * @returns {JSX.Element} The rendered component - */ -const Status = ({ monitor }) => { - const theme = useTheme(); - const { statusColor, determineState } = useMonitorUtils(); - - return ( - - {monitor?.name} - - - {formatMonitorUrl(monitor?.url)} - - - Checking every {formatDurationRounded(monitor?.interval)}. - - - - ); -}; - -Status.propTypes = { - monitor: PropTypes.object, -}; - -export default Status; diff --git a/client/src/Components/v1/NotificationConfig/index.jsx b/client/src/Components/v1/NotificationConfig/index.jsx deleted file mode 100644 index f1ed30ad1..000000000 --- a/client/src/Components/v1/NotificationConfig/index.jsx +++ /dev/null @@ -1,127 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Divider from "@mui/material/Divider"; -import Icon from "../Icon"; -import Search from "../Inputs/Search/index.jsx"; - -// Utils -import { useState, useEffect } from "react"; -import { useTheme } from "@mui/material/styles"; -import PropTypes from "prop-types"; - -const NotificationConfig = ({ - notifications, - setMonitor, - setNotifications, - //FieldWrapper's props - gap, - labelMb, - labelFontWeight, - labelVariant, - labelSx = {}, - sx = {}, -}) => { - // Local state - const [notificationsSearch, setNotificationsSearch] = useState(""); - const [selectedNotifications, setSelectedNotifications] = useState([]); - - const handleSearch = (value) => { - setSelectedNotifications(value); - setMonitor((prev) => { - return { - ...prev, - notifications: value.map((notification) => notification.id), - }; - }); - }; - - // Handlers - const handleDelete = (id) => { - const updatedNotifications = selectedNotifications.filter( - (notification) => notification.id !== id - ); - - setSelectedNotifications(updatedNotifications); - setMonitor((prev) => { - return { - ...prev, - notifications: updatedNotifications.map((notification) => notification.id), - }; - }); - }; - - // Setup - const theme = useTheme(); - - useEffect(() => { - if (setNotifications) { - const toSet = setNotifications.map((notification) => { - return notifications.find((n) => n.id === notification); - }); - setSelectedNotifications(toSet); - } - }, [setNotifications, notifications]); - - return ( - - { - handleSearch(value); - }} - labelMb={labelMb} - labelVariant={labelVariant} - labelFontWeight={labelFontWeight} - labelSx={labelSx} - gap={gap} - sx={{ - ...sx, - }} - /> - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - handleDelete(notification.id); - }} - style={{ cursor: "pointer" }} - /> - {index < selectedNotifications.length - 1 && } - - ))} - - - ); -}; - -NotificationConfig.propTypes = { - notifications: PropTypes.array, - setMonitor: PropTypes.func, - setNotifications: PropTypes.array, -}; - -export default NotificationConfig; diff --git a/client/src/Components/v1/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx b/client/src/Components/v1/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx deleted file mode 100644 index 2aae996f6..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx +++ /dev/null @@ -1,419 +0,0 @@ -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, - Stack, -} from "@mui/material"; -import { useTheme } from "@emotion/react"; -import TabPanel from "./TabPanel.jsx"; -import TabComponent from "./TabComponent.jsx"; -import useNotifications from "../Hooks/useNotification.js"; - -// 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, - 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 = useCallback( - (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)}`; - }, - [t] - ); - - // 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", - }, - ], - }, - ]; - - // 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] = false; - - // Add state for each field in the notification type - type.fields.forEach((field) => { - const fieldKey = getFieldKey(type.id, field.id); - state[fieldKey] = ""; - }); - }); - - return state; - }, [activeNotificationTypes, getFieldKey]); // Only recompute when these dependencies change - - 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 ( - - - - {/* Left sidebar with tabs */} - - - {t("notifications.addOrEditNotifications")} - - - - {activeNotificationTypes.map((type) => ( - - ))} - - - - {/* Right side content */} - - {activeNotificationTypes.map((type, index) => ( - - - - ))} - - - - - - - - ); -}; - -NotificationIntegrationModal.propTypes = { - open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - monitor: PropTypes.object.isRequired, - setMonitor: PropTypes.func.isRequired, - notificationTypes: PropTypes.array, -}; - -export default NotificationIntegrationModal; diff --git a/client/src/Components/v1/NotificationIntegrationModal/Components/TabComponent.jsx b/client/src/Components/v1/NotificationIntegrationModal/Components/TabComponent.jsx deleted file mode 100644 index 87fb2c301..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Components/TabComponent.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from "react"; -import { Typography, Box, Button, CircularProgress } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "@emotion/react"; -import TextInput from "../../Inputs/TextInput/index.jsx"; -import Checkbox from "../../Inputs/Checkbox/index.jsx"; - -const TabComponent = ({ - type, - integrations, - handleIntegrationChange, - handleInputChange, - handleTestNotification, - isLoading, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - // Helper to get the field state key (e.g., slackWebhook, telegramToken) - const getFieldKey = (typeId, fieldId) => { - return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`; - }; - - // Check if all fields have values to enable test button - const areAllFieldsFilled = () => { - return type.fields.every((field) => { - const fieldKey = getFieldKey(type.id, field.id); - return integrations[fieldKey]; - }); - }; - - return ( - <> - - {type.label} - - - - {type.description} - - - - handleIntegrationChange(type.id, e.target.checked)} - disabled={isLoading} - /> - - - {type.fields.map((field) => { - const fieldKey = getFieldKey(type.id, field.id); - - return ( - - - {field.label} - - - handleInputChange(fieldKey, e.target.value)} - disabled={!integrations[type.id] || isLoading} - /> - - ); - })} - - - - - - ); -}; - -export default TabComponent; diff --git a/client/src/Components/v1/NotificationIntegrationModal/Components/TabPanel.jsx b/client/src/Components/v1/NotificationIntegrationModal/Components/TabPanel.jsx deleted file mode 100644 index 93546e7bc..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Components/TabPanel.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { Box } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -/** - * TabPanel component that displays content for the selected tab. - * - * @component - * @param {Object} props - The component props. - * @param {React.ReactNode} props.children - The content to be displayed when this tab panel is selected. - * @param {number} props.value - The currently selected tab value. - * @param {number} props.index - The index of this specific tab panel. - * @param {Object} props.other - Any additional props to be spread to the root element. - * @returns {React.ReactElement|null} The rendered tab panel or null if not selected. - */ -function TabPanel({ children, value, index, ...other }) { - const theme = useTheme(); - - return ( - - ); -} - -TabPanel.propTypes = { - children: PropTypes.node, - - index: PropTypes.number.isRequired, - - value: PropTypes.number.isRequired, -}; - -export default TabPanel; diff --git a/client/src/Components/v1/NotificationIntegrationModal/Hooks/useNotification.js b/client/src/Components/v1/NotificationIntegrationModal/Hooks/useNotification.js deleted file mode 100644 index c0712fa5f..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Hooks/useNotification.js +++ /dev/null @@ -1,129 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { networkService } from "../../../../Utils/NetworkService.js"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; - -// 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) { - createToast({ - body: errorMessage, - variant: "error", - }); - setLoading(false); - return; - } - - try { - const response = await networkService.testNotification({ - platform: type, - payload: payload, - }); - - if (response.data.success === true) { - createToast({ - body: t("notifications.testSuccess"), - variant: "info", - }); - } else { - throw new Error(response.data.msg || t("notifications.testFailed")); - } - } catch (error) { - const errorMsg = - error.response?.data?.msg || error.message || t("notifications.networkError"); - createToast({ - body: `${t("notifications.testFailed")}: ${errorMsg}`, - variant: "error", - }); - setError(errorMsg); - } finally { - setLoading(false); - } - }; - - return [loading, error, sendTestNotification]; -}; - -export default useNotifications; diff --git a/client/src/Components/v2/common/charts/HeatmapResponseTime.tsx b/client/src/Components/v2/common/charts/HeatmapResponseTime.tsx index 97289ba43..972291f57 100644 --- a/client/src/Components/v2/common/charts/HeatmapResponseTime.tsx +++ b/client/src/Components/v2/common/charts/HeatmapResponseTime.tsx @@ -1,6 +1,6 @@ import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; -import type { Check } from "@/Types/Check"; +import type { CheckSnapshot } from "@/Types/Check"; import { getResponseColor } from "@/Utils/DataUtils"; import { HeatmapResponseTimeTooltip } from "@/Components/v2/common/charts/HeatmapResponseTimeTooltip"; import type { SxProps } from "@mui/material/styles"; @@ -13,7 +13,7 @@ interface PlaceholderCheck { } interface HeatmapResponseTimeProps { - checks: Check[]; + checks: CheckSnapshot[]; gap?: ResponsiveStyleValue; availabilityCellSx?: SxProps; responseCellSx?: SxProps; @@ -34,7 +34,7 @@ export const HeatmapResponseTime = ({ const latestChecks = checks.slice(-25).reverse(); - let data: Array; + let data: Array; if (latestChecks.length !== 25) { const placeholders = Array(25 - latestChecks.length).fill({ status: "placeholder" as const, diff --git a/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx index 448a60e49..ec9ad4d26 100644 --- a/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx +++ b/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx @@ -2,12 +2,14 @@ import Stack from "@mui/material/Stack"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import type { Check } from "@/Types/Check"; +import type { CheckSnapshot } from "@/Types/Check"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; - -type HeatmapCheck = Check | { status: "placeholder"; responseTime: 0; createdAt: "" }; +import { useTranslation } from "react-i18next"; +type HeatmapCheck = + | CheckSnapshot + | { status: "placeholder"; responseTime: 0; createdAt: "" }; export const HeatmapResponseTimeTooltip = ({ children, @@ -16,6 +18,7 @@ export const HeatmapResponseTimeTooltip = ({ children: React.ReactElement; check: HeatmapCheck; }) => { + const { t } = useTranslation(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const theme = useTheme(); @@ -52,15 +55,19 @@ export const HeatmapResponseTimeTooltip = ({ {formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)} - {check?.originalResponseTime && ( + {check?.responseTime && ( - Response Time: {check.originalResponseTime.toFixed()} ms + {t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms )} Status:{" "} - {check?.status} + + {check?.status === true + ? t("pages.common.monitors.status.up") + : t("pages.common.monitors.status.down")} + } diff --git a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx index bbe3bd9d4..e3b82cd50 100644 --- a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx @@ -1,11 +1,11 @@ import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; import type { ResponsiveStyleValue } from "@mui/system"; -import type { Check } from "@/Types/Check"; +import type { CheckSnapshot } from "@/Types/Check"; import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; interface HistogramResponseTimeProps { - checks: Check[]; + checks: CheckSnapshot[]; height?: ResponsiveStyleValue; gap?: ResponsiveStyleValue; } diff --git a/client/src/Components/v2/common/controls/HeaderTimeRange.tsx b/client/src/Components/v2/common/controls/HeaderTimeRange.tsx new file mode 100644 index 000000000..c66ee9ff6 --- /dev/null +++ b/client/src/Components/v2/common/controls/HeaderTimeRange.tsx @@ -0,0 +1,82 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs"; +import { useTheme } from "@mui/material/styles"; +import CircularProgress from "@mui/material/CircularProgress"; +import { useTranslation } from "react-i18next"; +import { useMediaQuery } from "@mui/material"; + +interface MonitorTimeFrameHeaderProps { + isLoading?: boolean; + hasDateRange?: boolean; + dateRange: string; + setDateRange: (dateRange: string) => void; +} + +export const HeaderTimeRange = ({ + isLoading = false, + hasDateRange = true, + dateRange, + setDateRange, +}: MonitorTimeFrameHeaderProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down("md")); + const handleChange = ( + _event: React.MouseEvent, + newValue: string | null + ) => { + if (newValue !== null) { + setDateRange(newValue); + } + }; + + let timeFramePicker = null; + + if (hasDateRange) { + timeFramePicker = ( + + + {t("components.headerTimeRange.labels.recent")} + + + {t("components.headerTimeRange.labels.day")} + + + {t("components.headerTimeRange.labels.week")} + + + {t("components.headerTimeRange.labels.month")} + + + ); + } + + return ( + + + {isLoading && } + + + {t(`components.headerTimeRange.description.${dateRange}`)} + + {timeFramePicker} + + ); +}; diff --git a/client/src/Components/v2/common/index.ts b/client/src/Components/v2/common/index.ts index e354983eb..2ea3bf3f7 100644 --- a/client/src/Components/v2/common/index.ts +++ b/client/src/Components/v2/common/index.ts @@ -2,3 +2,4 @@ export * from "../common/charts/HistogramResponseTime"; export * from "../common/charts/HeatmapResponseTime"; export * from "../common/charts/HeatmapResponseTimeTooltip"; export * from "../common/controls/HeaderCreate"; +export * from "../common/controls/HeaderTimeRange"; diff --git a/client/src/Components/v2/design-elements/BaseChart.tsx b/client/src/Components/v2/design-elements/BaseChart.tsx new file mode 100644 index 000000000..fd9251915 --- /dev/null +++ b/client/src/Components/v2/design-elements/BaseChart.tsx @@ -0,0 +1,72 @@ +import { BaseBox } from "../design-elements"; +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import type { ResponsiveStyleValue } from "@mui/system"; + +import { useTheme } from "@mui/material/styles"; + +type BaseChartProps = React.PropsWithChildren<{ + icon: React.ReactNode; + title: string; + width?: number | string; + maxWidth?: number | string; + padding?: number | string | ResponsiveStyleValue; +}>; + +export const BaseChart = ({ + children, + icon, + title, + width = "100%", + maxWidth = "100%", + padding, +}: BaseChartProps) => { + const theme = useTheme(); + return ( + + + + {icon && ( + + {icon} + + )} + {title} + + {children} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index c853a8d30..de20fc005 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/v2/design-elements/BasePage.tsx @@ -1,15 +1,51 @@ import Stack from "@mui/material/Stack"; +import Alert from "@mui/material/Alert"; +import Link from "@mui/material/Link"; import { ErrorFallback, EmptyFallback, EmptyMonitorFallback, + BaseFallback, } from "@/Components/v2/design-elements/Fallback"; import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb"; import CircularProgress from "@mui/material/CircularProgress"; import type { StackProps } from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; +import { Link as RouterLink } from "react-router-dom"; +import { Typography } from "@mui/material"; + +export const PageSpeedKeyPriorityFallback = () => { + return ( + + + + + ), + }} + /> + + + + ); +}; + interface BasePageProps extends StackProps { loading?: boolean; error?: boolean; @@ -55,7 +91,7 @@ export const BasePage = ({ spacing={theme.spacing(10)} {...props} > - + {children} ); @@ -114,6 +150,7 @@ interface MonitorBasePageWithStatesProps extends StackProps { page: string; actionLink?: string; children: React.ReactNode; + priorityFallback?: React.ReactNode; } const isEmpty = (items: any[]) => { @@ -129,12 +166,25 @@ export const MonitorBasePageWithStates = ({ page, actionLink, children, + priorityFallback, ...props }: MonitorBasePageWithStatesProps) => { const { t } = useTranslation(); const showLoading = loading && (!items || items.length === 0); + if (priorityFallback) { + return ( + + {priorityFallback} + + ); + } + if (!loading && isEmpty(items)) { return ( { +const isId = (segment: string): boolean => { + return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment); +}; + +const actionSegments = ["create", "configure"]; + +export const Breadcrumb = () => { const { t } = useTranslation(); const theme = useTheme(); const location = useLocation(); - // Don't show breadcrumb on dashboard - if (location.pathname === "/" || location.pathname === "/uptime") { - return null; - } + const segments = location.pathname.split("/").filter((x) => x); - const pathnames = breadcrumbOverride || location.pathname.split("/").filter((x) => x); + // Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create" + const basePage = segments[0] || t("common.breadcrumbs.home"); - // Don't show if only one level (e.g., /uptime) - if (pathnames.length === 0) { - return null; - } + const secondSegment = segments[1]; + const isActionPage = secondSegment && actionSegments.includes(secondSegment); // create/config + const isDetailsPage = secondSegment && isId(secondSegment); // details + const hasSubPage = isActionPage || isDetailsPage; + + const getSubPageLabel = (): string => { + if (isActionPage) { + return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1); + } + return t("common.breadcrumbs.details"); + }; return ( - - + {hasSubPage ? ( + + + {basePage.charAt(0).toUpperCase() + basePage.slice(1)} + + + ) : ( - Home + {basePage.charAt(0).toUpperCase() + basePage.slice(1)} - - {pathnames.map((value, index) => { - const last = index === pathnames.length - 1; - const to = `/${pathnames.slice(0, index + 1).join("/")}`; - - let displayName = ""; - - // Paths that are IDs should be detail pages - if (value.length === 24 || value.match(/^[a-f0-9-]{36}$/)) { - displayName = "Details"; - } else { - displayName = - t(`common.breadcrumbs.${value}`) || - value.charAt(0).toUpperCase() + value.slice(1); - } - - return last ? ( - - {displayName} - - ) : ( - - - {displayName} - - - ); - })} + )} + {hasSubPage && ( + + {getSubPageLabel()} + + )} ); }; diff --git a/client/src/Components/v2/design-elements/Dot.tsx b/client/src/Components/v2/design-elements/Dot.tsx new file mode 100644 index 000000000..541e90812 --- /dev/null +++ b/client/src/Components/v2/design-elements/Dot.tsx @@ -0,0 +1,23 @@ +import type { CSSProperties } from "react"; + +interface DotProps { + color?: string; + size?: string; + style?: CSSProperties; +} + +export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => { + return ( + + ); +}; diff --git a/client/src/Components/v2/design-elements/Gauge.tsx b/client/src/Components/v2/design-elements/Gauge.tsx new file mode 100644 index 000000000..34c57487d --- /dev/null +++ b/client/src/Components/v2/design-elements/Gauge.tsx @@ -0,0 +1,106 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; + +import { useTheme } from "@mui/material/styles"; +import { useMemo, useState, useEffect } from "react"; +import { getInfraGaugeColor } from "@/Utils/MonitorUtils"; + +const MINIMUM_VALUE = 0; +const MAXIMUM_VALUE = 100; + +export const Gauge = ({ + isLoading = false, + progress = 0, + radius = 70, + strokeWidth = 15, + precision = 1, + unit = "%", +}: { + isLoading?: boolean; + progress?: number; + radius?: number; + strokeWidth?: number; + precision?: number; + unit?: string; +}) => { + const theme = useTheme(); + const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE)); + + // Calculate the length of the stroke for the circle + const { circumference, totalSize, strokeLength } = useMemo( + () => ({ + circumference: 2 * Math.PI * radius, + totalSize: radius * 2 + strokeWidth * 2, + strokeLength: (progress / 100) * (2 * Math.PI * radius), + }), + [radius, strokeWidth, progress] + ); + + const [offset, setOffset] = useState(circumference); + useEffect(() => { + setOffset(circumference); + const timer = setTimeout(() => { + setOffset(circumference - strokeLength); + }, 100); + + return () => clearTimeout(timer); + }, [progress, circumference, strokeLength]); + + const fillColor = getInfraGaugeColor(progressWithinRange, theme); + + if (isLoading) { + return; + } + + return ( + + + + + + + + {`${progressWithinRange.toFixed(precision)}${unit}`} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/Icon.tsx b/client/src/Components/v2/design-elements/Icon.tsx new file mode 100644 index 000000000..b32cd84b2 --- /dev/null +++ b/client/src/Components/v2/design-elements/Icon.tsx @@ -0,0 +1,18 @@ +import type { LucideIcon } from "lucide-react"; + +interface IconProps { + icon: LucideIcon; + size?: number; + strokeWidth?: number; +} + +const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => { + return ( + + ); +}; + +export default Icon; diff --git a/client/src/Components/v2/design-elements/MonitorStatus.tsx b/client/src/Components/v2/design-elements/MonitorStatus.tsx new file mode 100644 index 000000000..3d5e81845 --- /dev/null +++ b/client/src/Components/v2/design-elements/MonitorStatus.tsx @@ -0,0 +1,58 @@ +import type { Monitor } from "@/Types/Monitor"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { PulseDot, Dot } from "@/Components/v2/design-elements"; +import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; +import { useTheme } from "@mui/material/styles"; +import prettyMilliseconds from "pretty-ms"; +import { typographyLevels } from "@/Utils/Theme/v2Palette"; +import useMediaQuery from "@mui/material/useMediaQuery"; +export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + if (!monitor) { + return null; + } + return ( + + + {monitor.name} + + + + + {formatUrl(monitor?.url)} + + {!isSmall && ( + <> + + + Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })} + + + )} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/PulseDot.tsx b/client/src/Components/v2/design-elements/PulseDot.tsx new file mode 100644 index 000000000..ab792b628 --- /dev/null +++ b/client/src/Components/v2/design-elements/PulseDot.tsx @@ -0,0 +1,48 @@ +import { Box, Stack, useTheme } from "@mui/material"; + +interface PulseDotProps { + color: string; +} + +export const PulseDot = ({ color }: PulseDotProps) => { + const theme = useTheme(); + + return ( + + + + ); +}; diff --git a/client/src/Components/v2/design-elements/SplitBox.tsx b/client/src/Components/v2/design-elements/SplitBox.tsx new file mode 100644 index 000000000..3a778dbcf --- /dev/null +++ b/client/src/Components/v2/design-elements/SplitBox.tsx @@ -0,0 +1,78 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +export const SplitBox = ({ + left, + right, +}: { + left: React.ReactNode; + right: React.ReactNode; +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + return ( + + + {left} + + + {right} + + + ); +}; + +export const ConfigBox = ({ + title, + subtitle, + leftContent, + rightContent, +}: { + title: string; + subtitle: string; + leftContent?: React.ReactNode; + rightContent: React.ReactNode; +}) => { + const theme = useTheme(); + return ( + + + {title} + + {subtitle} + {leftContent} + + } + right={rightContent} + /> + ); +}; diff --git a/client/src/Components/v2/design-elements/StatBox.tsx b/client/src/Components/v2/design-elements/StatBox.tsx new file mode 100644 index 000000000..b78a003dc --- /dev/null +++ b/client/src/Components/v2/design-elements/StatBox.tsx @@ -0,0 +1,86 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import { lighten } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import type { PaletteKey } from "@/Utils/Theme/v2Theme"; +import { BaseBox, TooltipWithInfo } from "@/Components/v2/design-elements"; +import type { SxProps } from "@mui/material"; + +type GradientBox = React.PropsWithChildren<{ + palette?: PaletteKey; + sx?: SxProps; +}>; + +export const GradientBox = ({ children, palette, sx }: GradientBox) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const isLight = theme.palette.mode === "light"; + const paper = theme.palette.background.paper; + const paperStart = lighten(paper, isLight ? 0.06 : 0.08); + const paperEnd = paper; + const bg = palette + ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)` + : `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`; + + return ( + + {children} + + ); +}; + +type StatBoxProps = React.PropsWithChildren<{ + title: string; + subtitle: string; + palette?: PaletteKey; + sx?: SxProps; + tooltip?: string; + onClick?: () => void; +}>; + +export const StatBox = ({ + title, + subtitle, + palette, + children, + sx, + tooltip, + onClick, +}: StatBoxProps) => { + const theme = useTheme(); + const textColor = palette ? theme.palette[palette].contrastText : "inherit"; + + return ( + + + + {title} + {tooltip && ( + + )} + + {subtitle} + {children} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/v2/design-elements/StatusBox.tsx index 6b1c1920c..bf7979cca 100644 --- a/client/src/Components/v2/design-elements/StatusBox.tsx +++ b/client/src/Components/v2/design-elements/StatusBox.tsx @@ -4,12 +4,16 @@ import Box from "@mui/material/Box"; import { BaseBox } from "@/Components/v2/design-elements"; import Background from "@/assets/Images/background-grid.svg?react"; import { useTranslation } from "react-i18next"; +import type { SxProps } from "@mui/material"; import { useTheme } from "@mui/material/styles"; -type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>; +type StatusBoxProps = React.PropsWithChildren<{ + children: React.ReactNode; + sx?: SxProps; +}>; -export const BGBox = ({ children }: StatusBoxProps) => { +export const BGBox = ({ children, sx }: StatusBoxProps) => { const theme = useTheme(); return ( { position: "relative", flex: 1, padding: theme.spacing(4), + ...sx, }} > { const theme = useTheme(); return ( - + { /> ); }; +export const TotalChecksBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; +export const DownChecksBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; +export const UpChecksBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; export const InitializingStatusBox = ({ n }: { n: number }) => { const theme = useTheme(); diff --git a/client/src/Components/v2/design-elements/StatusLabel.tsx b/client/src/Components/v2/design-elements/StatusLabel.tsx index 30adf5917..c9991f0d4 100644 --- a/client/src/Components/v2/design-elements/StatusLabel.tsx +++ b/client/src/Components/v2/design-elements/StatusLabel.tsx @@ -17,14 +17,17 @@ export const StatusLabel = ({ sx, }: { status: MonitorStatus; - isActive: boolean; + isActive?: boolean; sx?: SxProps; }) => { const { t } = useTranslation(); const theme = useTheme(); const palette = getStatusPalette(status); - const determineStatus = (isActive: boolean, status: MonitorStatus): string => { + const determineStatus = ( + isActive: boolean | undefined, + status: MonitorStatus + ): string => { if (isActive === false) { return t("pages.common.monitors.status.paused"); } diff --git a/client/src/Components/v2/design-elements/Table.tsx b/client/src/Components/v2/design-elements/Table.tsx index c87fa41b8..0516f1ddd 100644 --- a/client/src/Components/v2/design-elements/Table.tsx +++ b/client/src/Components/v2/design-elements/Table.tsx @@ -147,7 +147,7 @@ export function DataTable< { + const theme = useTheme(); + return ( + + {props.children} + + ); +}; + +import MuiTab from "@mui/material/Tab"; +import type { TabProps } from "@mui/material/Tab"; +interface CustomTabProps extends TabProps {} + +export const Tab = (props: CustomTabProps) => { + const theme = useTheme(); + return ( + + ); +}; diff --git a/client/src/Components/v2/design-elements/Tooltip.tsx b/client/src/Components/v2/design-elements/Tooltip.tsx new file mode 100644 index 000000000..f83b1331c --- /dev/null +++ b/client/src/Components/v2/design-elements/Tooltip.tsx @@ -0,0 +1,79 @@ +import MuiTooltip from "@mui/material/Tooltip"; +import { useTheme } from "@mui/material/styles"; +import { Info } from "lucide-react"; + +import type { TooltipProps } from "@mui/material/Tooltip"; + +type StyledTooltipProps = TooltipProps; + +export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => { + const theme = useTheme(); + + return ( + + {props.children} + + ); +}; + +export interface TooltipWithInfoProps extends Omit { + iconSize?: number; + iconColor?: string; +} + +export const TooltipWithInfo = ({ + iconSize = 14, + iconColor, + ...props +}: TooltipWithInfoProps) => { + const theme = useTheme(); + + const defaultColor = theme.palette.text.secondary; + + return ( + + + { + e.currentTarget.style.opacity = "1"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = "0.7"; + }} + /> + + + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index 374dabef7..a96fea052 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -8,3 +8,13 @@ export * from "./BaseBox"; export * from "./Table"; export { DataTable as Table } from "./Table"; export * from "./StatusLabel"; +export * from "./PulseDot"; +export * from "./Dot"; +export * from "./MonitorStatus"; +export { default as Icon } from "./Icon"; +export * from "./Tooltip"; +export * from "./StatBox"; +export * from "./BaseChart"; +export * from "./Gauge"; +export * from "./Tabs"; +export * from "./SplitBox"; diff --git a/client/src/Components/v2/inputs/Dialog.tsx b/client/src/Components/v2/inputs/Dialog.tsx new file mode 100644 index 000000000..373d95023 --- /dev/null +++ b/client/src/Components/v2/inputs/Dialog.tsx @@ -0,0 +1,56 @@ +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import { Button } from "@/Components/v2/inputs"; +import { typographyLevels } from "@/Utils/Theme/v2Palette"; +import { useTranslation } from "react-i18next"; + +export const DialogInput = ({ + open, + title, + content, + onConfirm, + onCancel, + confirmColor = "primary", + cancelColor = "error", + loading = false, +}: { + open: boolean; + title?: string; + content?: string; + onConfirm?(item: any): any; + onCancel?(item: any): any; + confirmColor?: "error" | "primary"; + cancelColor?: "error" | "primary"; + loading?: boolean; +}) => { + const { t } = useTranslation(); + return ( + + {title} + + {content} + + + + + + + ); +}; diff --git a/client/src/Components/v2/inputs/Radio.tsx b/client/src/Components/v2/inputs/Radio.tsx new file mode 100644 index 000000000..ad8dc3374 --- /dev/null +++ b/client/src/Components/v2/inputs/Radio.tsx @@ -0,0 +1,88 @@ +import Radio from "@mui/material/Radio"; +import type { RadioProps } from "@mui/material/Radio"; +import { useTheme } from "@mui/material/styles"; +import { Circle, CircleDot } from "lucide-react"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Typography from "@mui/material/Typography"; + +interface RadioInputProps extends RadioProps {} + +export const RadioInput = ({ ...props }: RadioInputProps) => { + const theme = useTheme(); + return ( + + } + checkedIcon={ + + } + sx={{ + padding: 0, + mt: theme.spacing(0.5), + color: theme.palette.text.secondary, + "&.Mui-checked": { + color: theme.palette.primary.main, + "& svg circle": { + fill: theme.palette.primary.main, + }, + }, + "& .MuiSvgIcon-root": { + fontSize: 16, + }, + "& svg": { + stroke: "currentColor", + }, + "& svg path, & svg line, & svg polyline, & svg rect, & svg circle": { + stroke: "currentColor", + fill: "none", + }, + }} + /> + ); +}; + +export const RadioWithDescription = ({ + label, + description, + ...props +}: RadioInputProps & { label: string; description: string }) => { + const theme = useTheme(); + return ( + } + label={ + <> + {label} + + {description} + + + } + sx={{ + alignItems: "flex-start", + p: theme.spacing(2.5), + m: theme.spacing(-2.5), + borderRadius: theme.shape.borderRadius, + + "&:hover": { + backgroundColor: theme.palette.background.paper, + }, + "& .MuiButtonBase-root": { + p: 0, + mr: theme.spacing(6), + }, + }} + /> + ); +}; diff --git a/client/src/Components/v2/inputs/Select.tsx b/client/src/Components/v2/inputs/Select.tsx index 057b0e72c..b9675acd7 100644 --- a/client/src/Components/v2/inputs/Select.tsx +++ b/client/src/Components/v2/inputs/Select.tsx @@ -1,7 +1,7 @@ import Select from "@mui/material/Select"; -import React from "react"; -import { useTheme } from "@mui/material/styles"; import type { SelectProps } from "@mui/material/Select"; +import React, { forwardRef } from "react"; +import { useTheme } from "@mui/material/styles"; import Stack from "@mui/material/Stack"; import { FieldLabel } from "./FieldLabel"; import { ChevronDown } from "lucide-react"; @@ -14,18 +14,15 @@ interface SelectInputProps extends Omit, "label"> { placeholderColor?: string; } -export const SelectInput = ({ - fieldLabel, - required, - placeholder, - placeholderColor, - ...props -}: SelectInputProps) => { +const SelectInputInner = ( + { fieldLabel, required, placeholder, placeholderColor, ...props }: SelectInputProps, + ref: React.ForwardedRef +) => { const theme = useTheme(); const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled; - const renderValue = (selected: any) => { - const isMultiple = Boolean((props as any).multiple); + const renderValue = (selected: unknown) => { + const isMultiple = Boolean((props as { multiple?: boolean }).multiple); const isEmpty = isMultiple ? !Array.isArray(selected) || selected.length === 0 : selected === undefined || selected === null || selected === ""; @@ -39,7 +36,7 @@ export const SelectInput = ({ const capitalized = items.map( (item) => item.charAt(0).toUpperCase() + item.slice(1) ); - return ({capitalized.join(" | ")}) as any; + return {capitalized.join(" | ")}; } const nodes = React.Children.toArray(props.children as React.ReactNode); @@ -59,6 +56,7 @@ export const SelectInput = ({ const select = ( {...props} + ref={ref} displayEmpty renderValue={renderValue} inputProps={{ @@ -100,3 +98,7 @@ export const SelectInput = ({ return select; }; + +export const SelectInput = forwardRef(SelectInputInner) as ( + props: SelectInputProps & { ref?: React.ForwardedRef } +) => React.ReactElement; diff --git a/client/src/Components/v2/inputs/Slider.tsx b/client/src/Components/v2/inputs/Slider.tsx new file mode 100644 index 000000000..97a16ac2c --- /dev/null +++ b/client/src/Components/v2/inputs/Slider.tsx @@ -0,0 +1,89 @@ +import Typography from "@mui/material/Typography"; +import { forwardRef } from "react"; +import Slider from "@mui/material/Slider"; +import type { SliderProps } from "@mui/material/Slider"; +import { useTheme } from "@mui/material/styles"; +import Stack from "@mui/material/Stack"; +import { FieldLabel } from "./FieldLabel"; +import Box from "@mui/material/Box"; +import type { ResponsiveStyleValue } from "@mui/system"; + +interface SliderInputProps extends SliderProps { + sx?: SliderProps["sx"]; + showValue?: boolean; +} + +export const SliderInput = forwardRef( + ({ sx, showValue = false, ...props }, ref) => { + const theme = useTheme(); + const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; + + return ( + + {showValue && {props.value}} + + + ); + } +); + +interface SliderWithLabelProps extends SliderProps { + fieldLabel?: string; + required?: boolean; + showValue?: boolean; + sliderMaxWidth?: ResponsiveStyleValue; +} + +export const SliderWithLabel = forwardRef( + ( + { fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props }, + ref + ) => { + const theme = useTheme(); + + const labelText = fieldLabel; + + return ( + + {fieldLabel && {labelText}} + + + + + ); + } +); diff --git a/client/src/Components/v2/inputs/Switch.tsx b/client/src/Components/v2/inputs/Switch.tsx new file mode 100644 index 000000000..b8ae8c1ec --- /dev/null +++ b/client/src/Components/v2/inputs/Switch.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; +import Switch from "@mui/material/Switch"; +import type { SwitchProps } from "@mui/material/Switch"; +import { useTheme } from "@mui/material/styles"; + +export const SwitchComponent = forwardRef( + function SwitchComponent({ sx, ...props }, ref) { + const theme = useTheme(); + const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; + + return ( + + ); + } +); diff --git a/client/src/Components/v2/inputs/ToggleButton.tsx b/client/src/Components/v2/inputs/ToggleButton.tsx new file mode 100644 index 000000000..40b4309b8 --- /dev/null +++ b/client/src/Components/v2/inputs/ToggleButton.tsx @@ -0,0 +1,52 @@ +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import type { ToggleButtonProps } from "@mui/material/ToggleButton"; +import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup"; +import { useTheme } from "@mui/material/styles"; + +export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => { + return ( + + ); +}; + +export const ToggleButtonGroupInput = ({ sx, ...props }: ToggleButtonGroupProps) => { + const theme = useTheme(); + return ( + + ); +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 9a76f6f7f..1618daf76 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -4,3 +4,11 @@ export { SelectInput as Select } from "./Select"; export { CheckboxInput as Checkbox } from "./Checkbox"; export { AutoCompleteInput as Autocomplete } from "./AutoComplete"; export { TextInput as TextField } from "./TextInput"; +export { + ToggleButtonInput as ToggleButton, + ToggleButtonGroupInput as ToggleButtonGroup, +} from "./ToggleButton"; +export { DialogInput as Dialog } from "./Dialog"; +export * from "./Radio"; +export * from "./Switch"; +export * from "./Slider"; diff --git a/client/src/Components/v2/monitors/ControlsFilter.tsx b/client/src/Components/v2/monitors/ControlsFilter.tsx index d7eecefb5..01802ee3f 100644 --- a/client/src/Components/v2/monitors/ControlsFilter.tsx +++ b/client/src/Components/v2/monitors/ControlsFilter.tsx @@ -6,11 +6,12 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { MonitorType } from "@/Types/Monitor"; import { Typography, useTheme } from "@mui/material"; -const types = ["http", "ping", "port", "docker"]; +const types = ["http", "ping", "port", "docker", "game"]; const statuses = ["up", "down"]; const states = ["active", "paused"]; export const ControlsFilter = ({ + showTypes = true, selectedTypes, setSelectedTypes, selectedStatus, @@ -19,8 +20,9 @@ export const ControlsFilter = ({ setSelectedState, onClearFilters, }: { - selectedTypes: MonitorType[]; - setSelectedTypes: React.Dispatch>; + showTypes?: boolean; + selectedTypes?: MonitorType[]; + setSelectedTypes?: React.Dispatch>; selectedStatus: string; setSelectedStatus: React.Dispatch>; selectedState: string; @@ -30,27 +32,29 @@ export const ControlsFilter = ({ const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isFilterActive = - selectedTypes.length > 0 || selectedStatus !== "" || selectedState !== ""; + (selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== ""; return ( - + {showTypes && setSelectedTypes && ( + + )}
{ + navigate(`/checks/${row.id}`); + }} + emptyViewText={t("pages.checks.table.empty")} + /> + + + ); +}; diff --git a/client/src/Pages/Checks/Components/IncidentTable/index.jsx b/client/src/Pages/Checks/Components/IncidentTable/index.jsx deleted file mode 100644 index 31475f8b1..000000000 --- a/client/src/Pages/Checks/Components/IncidentTable/index.jsx +++ /dev/null @@ -1,233 +0,0 @@ -//Components -import Stack from "@mui/material/Stack"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableRow from "@mui/material/TableRow"; -import TableCell from "@mui/material/TableCell"; -import TableSkeleton from "@/Components/v1/Table/skeleton.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx"; - -//Utils -import { formatDateWithTz } from "@/Utils/timeUtils.js"; -import { useSelector } from "react-redux"; -import { useState } from "react"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; -import { useFetchChecksTeam, useFetchChecksByMonitor } from "@/Hooks/checkHooks.js"; -import { Button, Typography, useTheme } from "@mui/material"; -import { lighten } from "@mui/material/styles"; - -const GetTooltip = (row) => { - const theme = useTheme(); - const phases = row?.timings?.phases; - - const phaseKeyFormattingMap = { - firstByte: "first byte", - }; - return ( - - {`Status code: ${row?.statusCode}`} - {`Response time: ${row?.responseTime} ms`} - {phases && ( - <> - {`Request timing: `} -
- - {Object.keys(phases)?.map((phaseKey) => ( - - - - {`${phaseKeyFormattingMap[phaseKey] || phaseKey}:`} - - - - {`${phases[phaseKey]} ms`} - - - ))} - -
- - )} -
- ); -}; - -const IncidentTable = ({ - isLoading, - monitors, - selectedMonitor, - filter, - dateRange, - updateTrigger, -}) => { - //Redux state - const uiTimezone = useSelector((state) => state.ui.timezone); - - //Local state - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - const selectedMonitorDetails = monitors?.[selectedMonitor]; - const selectedMonitorType = selectedMonitorDetails?.type; - - //Hooks - const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] = - useFetchChecksByMonitor({ - monitorId: selectedMonitor === "0" ? undefined : selectedMonitor, - type: selectedMonitorType, - status: false, - sortOrder: "desc", - limit: null, - dateRange, - filter: filter === "resolved" ? "all" : filter, - ack: filter === "resolved" ? true : false, - page: page, - rowsPerPage: rowsPerPage, - enabled: selectedMonitor !== "0", - updateTrigger, - }); - - const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] = - useFetchChecksTeam({ - status: false, - sortOrder: "desc", - limit: null, - dateRange, - filter: filter === "resolved" ? "all" : filter, - ack: filter === "resolved" ? true : false, - page: page, - rowsPerPage: rowsPerPage, - enabled: selectedMonitor === "0", - updateTrigger, - }); - - const checks = selectedMonitor === "0" ? checksTeam : checksMonitor; - const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor; - isLoading = isLoadingTeam || isLoadingMonitor; - const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor; - - const { t } = useTranslation(); - - //Handlers - const handleChangePage = (_, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(event.target.value); - }; - - const headers = [ - { - id: "monitorName", - content: t("incidentsTableMonitorName"), - render: (row) => { - return monitors?.[row.metadata?.monitorId]?.name || "N/A"; - }, - }, - { - id: "status", - content: t("incidentsTableStatus"), - render: (row) => { - const status = row.status === true ? "up" : "down"; - return ( - - ); - }, - }, - { - id: "dateTime", - content: t("incidentsTableDateTime"), - render: (row) => { - const formattedDate = formatDateWithTz( - row.createdAt, - "YYYY-MM-DD HH:mm:ss A", - uiTimezone - ); - return formattedDate; - }, - }, - { - id: "statusCode", - content: t("incidentsTableStatusCode"), - render: (row) => , - }, - { id: "message", content: t("incidentsTableMessage"), render: (row) => row.message }, - ]; - - if (isLoading) return ; - - if (networkError) { - return ( - - - - ); - } - - if (!isLoading && typeof checksCount === "undefined") { - return {t("incidentsTableNoIncidents")}; - } - - return ( - <> - - - - ); -}; - -IncidentTable.propTypes = { - isLoading: PropTypes.bool, - monitors: PropTypes.object, - selectedMonitor: PropTypes.string, - filter: PropTypes.string, - dateRange: PropTypes.string, - updateTrigger: PropTypes.bool, - setUpdateTrigger: PropTypes.func, -}; -export default IncidentTable; diff --git a/client/src/Pages/Checks/Components/OptionsHeader/index.jsx b/client/src/Pages/Checks/Components/OptionsHeader/index.jsx deleted file mode 100644 index 74bea4711..000000000 --- a/client/src/Pages/Checks/Components/OptionsHeader/index.jsx +++ /dev/null @@ -1,140 +0,0 @@ -// Components -import { Stack, Typography, Button, ButtonGroup } from "@mui/material"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import PropTypes from "prop-types"; - -//Utils -import { useTheme } from "@emotion/react"; -import SkeletonLayout from "./skeleton.jsx"; -import { useTranslation } from "react-i18next"; - -const OptionsHeader = ({ - shouldRender, - selectedMonitor = 0, - setSelectedMonitor, - monitors, - filter = "all", - setFilter, - dateRange = "hour", - setDateRange, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - console.log(monitors); - const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : []; - const filterOptions = [ - { id: "all", name: t("incidentsOptionsHeaderFilterAll") }, - { id: "down", name: t("incidentsOptionsHeaderFilterDown") }, - { id: "resolve", name: t("incidentsOptionsHeaderFilterCannotResolve") }, - { id: "resolved", name: t("incidentsOptionsHeaderFilterResolved") }, - ]; - - // The stacks below which are three in number have the same style so - const stackStyles = { - direction: "row", - alignItems: "center", - gap: theme.spacing(6), - }; - - if (!shouldRender) return ; - - return ( - - - - {t("incidentsOptionsHeader")} - - setFilter(e.target.value)} - items={filterOptions} - sx={{ - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastTextSecondary, - }} - /> - - - - {t("incidentsOptionsHeaderShow")} - - - - - - - - - - ); -}; - -OptionsHeader.propTypes = { - shouldRender: PropTypes.bool, - selectedMonitor: PropTypes.string, - setSelectedMonitor: PropTypes.func, - monitors: PropTypes.object, - filter: PropTypes.string, - setFilter: PropTypes.func, - dateRange: PropTypes.string, - setDateRange: PropTypes.func, -}; - -export default OptionsHeader; diff --git a/client/src/Pages/Checks/Components/OptionsHeader/skeleton.jsx b/client/src/Pages/Checks/Components/OptionsHeader/skeleton.jsx deleted file mode 100644 index 8a8990dc4..000000000 --- a/client/src/Pages/Checks/Components/OptionsHeader/skeleton.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Checks/Components/StatusBoxes/StatusBox.jsx b/client/src/Pages/Checks/Components/StatusBoxes/StatusBox.jsx deleted file mode 100644 index d7f02ada3..000000000 --- a/client/src/Pages/Checks/Components/StatusBoxes/StatusBox.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Stack, Typography } from "@mui/material"; -import Background from "@/assets/Images/background-grid.svg?react"; -import Icon from "@/Components/v1/Icon"; - -const StatusBox = ({ title, value, status }) => { - const theme = useTheme(); - let sharedStyles = { - position: "absolute", - right: 8, - "& svg": { - width: 20, - height: 20, - opacity: 0.9, - "& path": { stroke: theme.palette.primary.contrastTextTertiary, strokeWidth: 1.7 }, - }, - }; - - let color; - let icon; - if (status === "up") { - color = theme.palette.success.lowContrast; - icon = ( - - - - ); - } else if (status === "down") { - color = theme.palette.error.lowContrast; - icon = ( - - - - ); - } else if (status === "paused") { - color = theme.palette.warning.lowContrast; - icon = ( - - - - ); - } else { - color = theme.palette.accent.main; - icon = ( - - - - ); - } - - return ( - - - - - - - - {title} - - {icon} - - - {value} - - - # - - - - - ); -}; - -StatusBox.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - status: PropTypes.string, -}; - -export default StatusBox; diff --git a/client/src/Pages/Checks/Components/StatusBoxes/index.jsx b/client/src/Pages/Checks/Components/StatusBoxes/index.jsx deleted file mode 100644 index bb993111a..000000000 --- a/client/src/Pages/Checks/Components/StatusBoxes/index.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from "prop-types"; -import { Stack } from "@mui/material"; -import StatusBox from "./StatusBox.jsx"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import SkeletonLayout from "./skeleton.jsx"; - -const StatusBoxes = ({ isLoading, summary }) => { - const theme = useTheme(); - const { t } = useTranslation(); - if (isLoading) return ; - return ( - - - - - - - ); -}; - -StatusBoxes.propTypes = { - isLoading: PropTypes.bool, - summary: PropTypes.object, -}; - -export default StatusBoxes; diff --git a/client/src/Pages/Checks/Components/StatusBoxes/skeleton.jsx b/client/src/Pages/Checks/Components/StatusBoxes/skeleton.jsx deleted file mode 100644 index b9aa016b7..000000000 --- a/client/src/Pages/Checks/Components/StatusBoxes/skeleton.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -const SkeletonLayout = () => { - const theme = useTheme(); - return ( - - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Checks/index.jsx b/client/src/Pages/Checks/index.jsx deleted file mode 100644 index aa8717c1a..000000000 --- a/client/src/Pages/Checks/index.jsx +++ /dev/null @@ -1,100 +0,0 @@ -// Components -import { Stack } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import IncidentTable from "./Components/IncidentTable/index.jsx"; -import OptionsHeader from "./Components/OptionsHeader/index.jsx"; -import StatusBoxes from "./Components/StatusBoxes/index.jsx"; -import { Box, Button } from "@mui/material"; - -//Utils -import { useTheme } from "@emotion/react"; -import { useFetchMonitorsByTeamId } from "@/Hooks/monitorHooks.js"; -import { useFetchChecksSummaryByTeamId } from "@/Hooks/checkHooks.js"; -import { useState, useEffect } from "react"; -import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; - -//Constants -const Checks = () => { - // Redux state - const { t } = useTranslation(); - - const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }]; - - // Local state - const [selectedMonitor, setSelectedMonitor] = useState("0"); - const [filter, setFilter] = useState(undefined); - const [dateRange, setDateRange] = useState(undefined); - const [monitorLookup, setMonitorLookup] = useState(undefined); - const [updateTrigger, setUpdateTrigger] = useState(false); - - //Hooks - - //Utils - const theme = useTheme(); - const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({}); - const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({ - updateTrigger, - }); - const { monitorId } = useParams(); - - useEffect(() => { - if (monitorId) { - setSelectedMonitor(monitorId); - } - }, [monitorId]); - - useEffect(() => { - const monitorLookup = monitors?.reduce((acc, monitor) => { - acc[monitor.id] = { - id: monitor.id, - name: monitor.name, - type: monitor.type, - }; - return acc; - }, {}); - setMonitorLookup(monitorLookup); - }, [monitors]); - - if (networkError || networkErrorSummary) { - return ( - - - - ); - } - - return ( - - - - - - - - ); -}; - -export default Checks; diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx new file mode 100644 index 000000000..fa2adfae9 --- /dev/null +++ b/client/src/Pages/Checks/index.tsx @@ -0,0 +1,176 @@ +import Stack from "@mui/material/Stack"; +import { + BasePage, + TotalChecksBox, + UpChecksBox, + DownChecksBox, +} from "@/Components/v2/design-elements"; +import { HeaderTimeRange } from "@/Components/v2/common"; +import { Select } from "@/Components/v2/inputs"; +import { ChecksTable } from "./Components/ChecksTable"; + +import { MenuItem } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useState, useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useGet } from "@/Hooks/UseApi"; +import type { Monitor } from "@/Types/Monitor"; +import type { ChecksSummary, ChecksResponse } from "@/Types/Check"; + +const Checks = () => { + const { t } = useTranslation(); + const { monitorId } = useParams<{ monitorId?: string }>(); + + const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); + const [dateRange, setDateRange] = useState("recent"); + const [statusFilter, setStatusFilter] = useState("down"); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + + const monitorsUrl = "/monitors/team"; + const summaryUrl = `/checks/team/summary?dateRange=${dateRange}`; + + const { data: monitorsResponse, isLoading: isLoadingMonitors } = + useGet(monitorsUrl); + + const { data: summaryResponse, isLoading: isLoadingSummary } = + useGet(summaryUrl); + + const selectedMonitorType = monitorsResponse?.find( + (m) => m.id === selectedMonitor + )?.type; + + const teamChecksUrl = useMemo(() => { + if (selectedMonitor !== "0") return null; + const params = new URLSearchParams(); + params.append("sortOrder", "desc"); + if (dateRange) params.append("dateRange", dateRange); + if (statusFilter) params.append("filter", statusFilter); + params.append("page", String(page)); + params.append("rowsPerPage", String(rowsPerPage)); + return `/checks/team?${params.toString()}`; + }, [selectedMonitor, dateRange, statusFilter, page, rowsPerPage]); + + const monitorChecksUrl = useMemo(() => { + if (selectedMonitor === "0" || !selectedMonitorType) return null; + const params = new URLSearchParams(); + params.append("type", selectedMonitorType); + params.append("sortOrder", "desc"); + if (statusFilter) params.append("filter", statusFilter); + if (dateRange) params.append("dateRange", dateRange); + params.append("page", String(page)); + params.append("rowsPerPage", String(rowsPerPage)); + return `/checks/${selectedMonitor}?${params.toString()}`; + }, [selectedMonitor, selectedMonitorType, dateRange, statusFilter, page, rowsPerPage]); + + const { + data: teamChecksData, + isLoading: isLoadingTeamChecks, + isValidating: isValidatingTeamChecks, + } = useGet( + teamChecksUrl, + {}, + { keepPreviousData: true, refreshInterval: 30000 } + ); + const { + data: monitorChecksData, + isLoading: isLoadingMonitorChecks, + isValidating: isValidatingMonitorChecks, + } = useGet( + monitorChecksUrl, + {}, + { keepPreviousData: true, refreshInterval: 30000 } + ); + + const checks = + selectedMonitor === "0" + ? (teamChecksData?.checks ?? []) + : (monitorChecksData?.checks ?? []); + const checksCount = + selectedMonitor === "0" + ? (teamChecksData?.checksCount ?? 0) + : (monitorChecksData?.checksCount ?? 0); + + const isLoadingChecks = + isLoadingTeamChecks || + isLoadingMonitorChecks || + isValidatingTeamChecks || + isValidatingMonitorChecks; + + const isLoading = isLoadingMonitors || isLoadingSummary || isLoadingChecks; + const totalChecks = summaryResponse?.totalChecks || 0; + const downChecks = summaryResponse?.downChecks || 0; + const upChecks = totalChecks - (summaryResponse?.downChecks || 0); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default Checks; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx new file mode 100644 index 000000000..3972b8ebb --- /dev/null +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -0,0 +1,710 @@ +import { useMemo, useState } from "react"; +import { useEffect } from "react"; +import { useParams, useLocation, useNavigate } from "react-router"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTheme } from "@mui/material"; +import Stack from "@mui/material/Stack"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControl from "@mui/material/FormControl"; +import { Trans, useTranslation } from "react-i18next"; +import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; +import Link from "@mui/material/Link"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import { Trash2 } from "lucide-react"; +import { HeaderDeleteControls } from "@/Components/v2/monitors"; + +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { + RadioWithDescription, + Button, + TextField, + Select, + Autocomplete, + SwitchComponent as Switch, + SliderWithLabel, + Dialog, +} from "@/Components/v2/inputs"; +import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; +import { useMonitorForm } from "@/Hooks/useMonitorForm"; +import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor"; +import type { Notification } from "@/Types/Notification"; +import type { MonitorFormData } from "@/Validation/monitor"; + +interface GeneralSettingsConfig { + urlLabel: string; + urlPlaceholder: string; + namePlaceholder: string; + showUrl: boolean; + showPort: boolean; + showGameSelect: boolean; + showSecret: boolean; +} + +const getGeneralSettingsConfig = ( + type: MonitorType, + t: (key: string) => string +): GeneralSettingsConfig => { + const configs: Record = { + http: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: false, + }, + ping: { + urlLabel: t("pages.createMonitor.form.general.option.host.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: false, + }, + docker: { + urlLabel: t("pages.createMonitor.form.general.option.container.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: false, + }, + port: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: true, + showGameSelect: false, + showSecret: false, + }, + game: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: true, + showGameSelect: true, + showSecret: false, + }, + pagespeed: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: false, + }, + hardware: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: true, + }, + }; + return configs[type] || configs.http; +}; + +const CreateMonitorPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { monitorId } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const isEditMode = Boolean(monitorId); + + // Extract page type from URL path (e.g., /pagespeed/create -> pagespeed) + const pageType = useMemo(() => { + const pathSegments = location.pathname.split("/").filter(Boolean); + const firstSegment = pathSegments[0]; + if (firstSegment === "pagespeed") return "pagespeed"; + if (firstSegment === "infrastructure") return "hardware"; + return "uptime"; + }, [location.pathname]); + + const showTypeSelector = pageType === "uptime" && !isEditMode; + const defaultType: MonitorType = + pageType === "pagespeed" + ? "pagespeed" + : pageType === "hardware" + ? "hardware" + : "http"; + + const { data: existingMonitor, refetch: refetchMonitor } = useGet( + isEditMode ? `/monitors/${monitorId}` : null + ); + + const { data: notifications } = useGet("/notifications/team"); + const { data: games } = useGet("/monitors/games"); + + const { schema, defaults } = useMonitorForm({ + data: existingMonitor ?? null, + defaultType, + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + const { control, watch, handleSubmit, clearErrors } = form; + + useEffect(() => { + form.reset(defaults); + }, [defaults, form]); + + const watchedType = watch("type") as MonitorType; + + const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; + + useEffect(() => { + clearErrors(); + }, [watchedType, clearErrors]); + + const generalSettingsConfig = useMemo( + () => getGeneralSettingsConfig(watchedType, t), + [watchedType, t] + ); + + const { post, loading: isCreating } = usePost(); + const { patch, loading: isUpdating } = usePatch(); + const isSubmitting = isCreating || isUpdating; + // Delete functionality + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleDeleteClick = () => { + setIsDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!monitorId) return; + await deleteFn(`/monitors/${monitorId}`); + setIsDeleteDialogOpen(false); + // Navigate based on page type + if (pageType === "pagespeed") { + navigate("/pagespeed"); + } else if (pageType === "hardware") { + navigate("/infrastructure"); + } else { + navigate("/uptime"); + } + }; + + const handleDeleteCancel = () => { + setIsDeleteDialogOpen(false); + }; + + const onSubmit = async (data: MonitorFormData) => { + let result; + if (isEditMode && monitorId) { + result = await patch(`/monitors/${monitorId}`, data); + } else { + result = await post("/monitors", data); + } + + if (result?.success) { + if (pageType === "pagespeed") { + navigate("/pagespeed"); + } else if (pageType === "hardware") { + navigate("/infrastructure"); + } else { + navigate("/uptime"); + } + } + }; + + const onError = (errors: unknown) => { + console.log(errors); + }; + + return ( + + + {/* Monitor Type Selection - only shown for uptime monitors */} + {showTypeSelector && ( + ( + + + + + + + + + + )} + /> + } + /> + )} + + + {/* URL/Host/Container field - not shown for hardware */} + {generalSettingsConfig.showUrl && ( + ( + + )} + /> + )} + + {/* Port field - only for port and game types */} + {generalSettingsConfig.showPort && ( + ( + field.onChange(Number(e.target.value) || 0)} + type="number" + fieldLabel={t("portToMonitor")} + placeholder="5173" + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + )} + + {/* Game select - only for game type */} + {generalSettingsConfig.showGameSelect && ( + ( + + )} + /> + )} + + {/* Secret field - only for hardware type */} + {generalSettingsConfig.showSecret && ( + ( + + )} + /> + )} + + ( + + )} + /> + + } + /> + + ( + + )} + /> + } + /> + + + ( + + )} + /> + ( + + )} + /> + + } + /> + + { + // Map notifications to have 'name' property for v2 Autocomplete + const notificationOptions = (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + const selectedNotifications = notificationOptions.filter((n) => + (field.value ?? []).includes(n.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof notificationOptions) => { + field.onChange(newValue.map((n) => n.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + /> + {selectedNotifications.length > 0 && ( + + {selectedNotifications.map((notification, index) => ( + + + {notification.notificationName} + + { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== notification.id + ) + ); + }} + aria-label="Remove notification" + > + + + {index < selectedNotifications.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + + {watchedType === "http" && ( + ( + + field.onChange(e.target.checked)} + /> + + {t("pages.createMonitor.form.ignoreTls.option.tls.label")} + + + )} + /> + } + /> + )} + + {watchedType === "http" && ( + + ( + + field.onChange(e.target.checked)} + /> + + {t( + "pages.createMonitor.form.advanced.option.advancedMatching.label" + )} + + + )} + /> + {watchedUseAdvancedMatching && ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + ), + }} + /> + + + )} + + } + /> + )} + + + + + + + ); +}; + +export default CreateMonitorPage; diff --git a/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx b/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx index d68b0b85e..34efd632e 100644 --- a/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx +++ b/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx @@ -9,7 +9,7 @@ import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx"; import { useEffect, useState } from "react"; import { useTheme } from "@emotion/react"; import { useTranslation } from "react-i18next"; -import { formatDateWithTz } from "@/Utils/timeUtils"; +import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js"; import { useSelector } from "react-redux"; import PropTypes from "prop-types"; @@ -112,8 +112,6 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { }; const renderContent = () => { - console.log(incidentData); - if (isLoading) { return ( { const { t } = useTranslation(); @@ -28,20 +29,24 @@ const IncidentItem = ({ incident }) => { }; return ( - - { > {incident.monitorName || t("incidentsPage.unknownMonitor")} - + - + { textTransform: "capitalize", }} /> - + - - {duration} - - + {duration} + + ); }; diff --git a/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js b/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js index e9b1da6d1..a4f434a80 100644 --- a/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js +++ b/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js @@ -1,4 +1,4 @@ -import { getHumanReadableDuration } from "@/Utils/timeUtils.js"; +import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js"; const useGetIncidentsDuration = (incident, isActive) => { if (!incident?.startTime) { return "-"; diff --git a/client/src/Pages/Infrastructure/Create/Components/CustomAlertsSection.jsx b/client/src/Pages/Infrastructure/Create/Components/CustomAlertsSection.jsx deleted file mode 100644 index 64f82cec2..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/CustomAlertsSection.jsx +++ /dev/null @@ -1,87 +0,0 @@ -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import { Box, Stack, Typography } from "@mui/material"; -import { CustomThreshold } from "./CustomThreshold/index.jsx"; -import { capitalizeFirstLetter } from "../../../../Utils/stringUtils.js"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; -const CustomAlertsSection = ({ - errors, - onChange, - infrastructureMonitor, - handleCheckboxChange, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const METRICS = ["cpu", "memory", "disk", "temperature"]; - const METRIC_PREFIX = "usage_"; - const hasAlertError = (errors) => { - return Object.keys(errors).filter((k) => k.startsWith(METRIC_PREFIX)).length > 0; - }; - const getAlertError = (errors) => { - const errorKey = Object.keys(errors).find((key) => key.startsWith(METRIC_PREFIX)); - return errorKey ? errors[errorKey] : null; - }; - return ( - - - - {t("infrastructureCustomizeAlerts")} - - - {t("infrastructureAlertNotificationDescription")} - - - - {METRICS.map((metric) => { - return ( - - ); - })} - - {hasAlertError(errors) && ( - - {getAlertError(errors)} - - )} - - - ); -}; - -CustomAlertsSection.propTypes = { - errors: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - infrastructureMonitor: PropTypes.object.isRequired, - handleCheckboxChange: PropTypes.func.isRequired, -}; - -export default CustomAlertsSection; diff --git a/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx b/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx deleted file mode 100644 index d783f7985..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Box, Stack, Typography } from "@mui/material"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; - -/** - * CustomThreshold Component - * - * A reusable component that renders a checkbox with an associated numeric input field - * and an optional unit label. The input field can be enabled/disabled based on checkbox state. - * - * @component - * @param {Object} props - Component properties - * @param {string} [props.checkboxId] - Optional unique identifier for the checkbox - * @param {string} [props.checkboxName] - Optional name attribute for the checkbox - * @param {string} props.checkboxLabel - Label text for the checkbox - * @param {boolean} props.isChecked - Current checked state of the checkbox - * @param {Function} props.onCheckboxChange - Callback function when checkbox is toggled - * @param {string} props.fieldId - Unique identifier for the input field - * @param {string} [props.fieldName] - Optional name attribute for the input field - * @param {string} props.fieldValue - Current value of the input field - * @param {Function} props.onFieldChange - Callback function when input field value changes - * @param {Function} props.onFieldBlur - Callback function when input field loses focus - * @param {string} props.alertUnit - Unit label displayed next to the input field - * @param {Object} props.errors - Object containing validation errors for the field - * @param {Object} props.infrastructureMonitor - Infrastructure monitor configuration object - * - * @returns {React.ReactElement} Rendered CustomThreshold component - * - * @example - * - */ -export const CustomThreshold = ({ - checkboxId, - checkboxName, - checkboxLabel, - onCheckboxChange, - isChecked, - fieldId, - fieldName, - fieldValue, - onFieldChange, - onFieldBlur, - alertUnit, - errors, -}) => { - const theme = useTheme(); - return ( - - - - - - - - - {alertUnit} - - - - ); -}; - -CustomThreshold.propTypes = { - checkboxId: PropTypes.string, - checkboxName: PropTypes.string, - checkboxLabel: PropTypes.string.isRequired, - isChecked: PropTypes.bool.isRequired, - onCheckboxChange: PropTypes.func.isRequired, - fieldId: PropTypes.string.isRequired, - fieldName: PropTypes.string, - fieldValue: PropTypes.string.isRequired, - onFieldChange: PropTypes.func.isRequired, - onFieldBlur: PropTypes.func, - alertUnit: PropTypes.string.isRequired, - infrastructureMonitor: PropTypes.object.isRequired, - errors: PropTypes.object.isRequired, -}; diff --git a/client/src/Pages/Infrastructure/Create/Components/DiskSelection.jsx b/client/src/Pages/Infrastructure/Create/Components/DiskSelection.jsx deleted file mode 100644 index 50c4ed980..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/DiskSelection.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from "react"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import { Box, Stack, Typography } from "@mui/material"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; - -const DiskSelection = ({ availableDisks, selectedDisks, onChange }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const handleDiskChange = (event, mountpoint) => { - const isChecked = event.target.checked; - let newSelectedDisks = []; - - if (isChecked) { - newSelectedDisks = [...selectedDisks, mountpoint]; - } else { - newSelectedDisks = selectedDisks.filter((disk) => disk !== mountpoint); - } - - onChange(newSelectedDisks); - }; - - return ( - - - - {t("v1.infrastructure.disk_selection_title")} - - - {t("v1.infrastructure.disk_selection_description")} - - - - - {!availableDisks || availableDisks.length === 0 ? ( - - {t("v1.infrastructure.disk_selection_info")} - - ) : ( - availableDisks.map((disk) => { - const identifier = disk.mountpoint || disk.device; - return ( - - - handleDiskChange(e, identifier)} - /> - - - ); - }) - )} - - - ); -}; - -DiskSelection.propTypes = { - availableDisks: PropTypes.arrayOf( - PropTypes.shape({ - mountpoint: PropTypes.string.isRequired, - }) - ), - selectedDisks: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired, -}; - -export default DiskSelection; diff --git a/client/src/Pages/Infrastructure/Create/Components/MonitorActionButtons.jsx b/client/src/Pages/Infrastructure/Create/Components/MonitorActionButtons.jsx deleted file mode 100644 index 2bde5f321..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/MonitorActionButtons.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState } from "react"; -import { Box, Button } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import Icon from "@/Components/v1/Icon"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import PropTypes from "prop-types"; - -const MonitorActionButtons = ({ monitor, isBusy, handlePause, handleRemove }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - - return ( - - - - setIsOpen(false)} - confirmationButtonLabel={t("delete")} - onConfirm={handleRemove} - /> - - ); -}; - -MonitorActionButtons.propTypes = { - monitor: PropTypes.object.isRequired, - isBusy: PropTypes.bool.isRequired, - handlePause: PropTypes.func.isRequired, - handleRemove: PropTypes.func.isRequired, -}; - -export default MonitorActionButtons; diff --git a/client/src/Pages/Infrastructure/Create/Components/MonitorStatusHeader.jsx b/client/src/Pages/Infrastructure/Create/Components/MonitorStatusHeader.jsx deleted file mode 100644 index 8fa6f4ff1..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/MonitorStatusHeader.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Box, Stack, Tooltip, Typography } from "@mui/material"; -import { useMonitorUtils } from "../../../../Hooks/useMonitorUtils.js"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PulseDot from "@/Components/v1/Animated/PulseDot.jsx"; -import PropTypes from "prop-types"; -const MonitorStatusHeader = ({ monitor, infrastructureMonitor }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const { statusColor, pagespeedStatusMsg, determineState } = useMonitorUtils(); - return ( - - - - - - - - {infrastructureMonitor.url?.replace(/^https?:\/\//, "") || "..."} - - - {t("editing")} - - - ); -}; - -MonitorStatusHeader.propTypes = { - monitor: PropTypes.object.isRequired, - infrastructureMonitor: PropTypes.object.isRequired, -}; - -export default MonitorStatusHeader; diff --git a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx deleted file mode 100644 index a38ac9fc6..000000000 --- a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState, useCallback } from "react"; -const useInfrastructureMonitorForm = () => { - const [infrastructureMonitor, setInfrastructureMonitor] = useState({ - url: "", - name: "", - notifications: [], - notify_email: false, - interval: 0.25, - statusWindowSize: 5, - statusWindowThreshold: 60, - cpu: false, - usage_cpu: "", - memory: false, - usage_memory: "", - disk: false, - usage_disk: "", - temperature: false, - usage_temperature: "", - secret: "", - selectedDisks: [], - }); - - const onChangeForm = (name, value) => { - setInfrastructureMonitor({ - ...infrastructureMonitor, - [name]: value, - }); - }; - const handleCheckboxChange = (event) => { - setInfrastructureMonitor({ - ...infrastructureMonitor, - [event.target.name]: event.target.checked, - }); - }; - const initializeInfrastructureMonitorForCreate = useCallback((globalSettings) => { - const gt = globalSettings?.data?.settings?.globalThresholds || {}; - setInfrastructureMonitor((prev) => ({ - ...prev, - url: "", - name: "", - notifications: [], - interval: 0.25, - cpu: gt.cpu !== undefined, - usage_cpu: gt.cpu !== undefined ? gt.cpu.toString() : "", - memory: gt.memory !== undefined, - usage_memory: gt.memory !== undefined ? gt.memory.toString() : "", - disk: gt.disk !== undefined, - usage_disk: gt.disk !== undefined ? gt.disk.toString() : "", - temperature: gt.temperature !== undefined, - usage_temperature: gt.temperature !== undefined ? gt.temperature.toString() : "", - secret: "", - selectedDisks: [], - })); - }, []); - - const initializeInfrastructureMonitorForUpdate = useCallback((monitor) => { - const MS_PER_MINUTE = 60000; - const { thresholds = {} } = monitor; - setInfrastructureMonitor((prev) => ({ - ...prev, - url: monitor.url.replace(/^https?:\/\//, ""), - name: monitor.name || "", - notifications: monitor.notifications || [], - interval: monitor.interval / MS_PER_MINUTE, - statusWindowSize: monitor.statusWindowSize, - statusWindowThreshold: monitor.statusWindowThreshold, - cpu: thresholds.usage_cpu !== undefined, - usage_cpu: - thresholds.usage_cpu !== undefined ? (thresholds.usage_cpu * 100).toString() : "", - memory: thresholds.usage_memory !== undefined, - usage_memory: - thresholds.usage_memory !== undefined - ? (thresholds.usage_memory * 100).toString() - : "", - disk: thresholds.usage_disk !== undefined, - usage_disk: - thresholds.usage_disk !== undefined - ? (thresholds.usage_disk * 100).toString() - : "", - temperature: thresholds.usage_temperature !== undefined, - usage_temperature: - thresholds.usage_temperature !== undefined - ? (thresholds.usage_temperature * 100).toString() - : "", - secret: monitor.secret || "", - selectedDisks: monitor.selectedDisks || [], - })); - }, []); - return { - infrastructureMonitor, - setInfrastructureMonitor, - onChangeForm, - handleCheckboxChange, - initializeInfrastructureMonitorForCreate, - initializeInfrastructureMonitorForUpdate, - }; -}; - -export default useInfrastructureMonitorForm; diff --git a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx deleted file mode 100644 index 04279cac4..000000000 --- a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useCreateMonitor, useUpdateMonitor } from "../../../../Hooks/monitorHooks.js"; -const useInfrastructureSubmit = () => { - const [createMonitor, isCreating] = useCreateMonitor(); - const [updateMonitor, isUpdating] = useUpdateMonitor(); - const buildForm = (infrastructureMonitor, https) => { - const MS_PER_MINUTE = 60000; - - let form = { - url: `http${https ? "s" : ""}://` + infrastructureMonitor.url, - name: - infrastructureMonitor.name === "" - ? infrastructureMonitor.url - : infrastructureMonitor.name, - interval: infrastructureMonitor.interval * MS_PER_MINUTE, - statusWindowSize: infrastructureMonitor.statusWindowSize, - statusWindowThreshold: infrastructureMonitor.statusWindowThreshold, - cpu: infrastructureMonitor.cpu, - ...(infrastructureMonitor.cpu - ? { usage_cpu: infrastructureMonitor.usage_cpu } - : {}), - memory: infrastructureMonitor.memory, - ...(infrastructureMonitor.memory - ? { usage_memory: infrastructureMonitor.usage_memory } - : {}), - disk: infrastructureMonitor.disk, - ...(infrastructureMonitor.disk - ? { usage_disk: infrastructureMonitor.usage_disk } - : {}), - ...(infrastructureMonitor.temperature - ? { usage_temperature: infrastructureMonitor.usage_temperature } - : {}), - secret: infrastructureMonitor.secret, - selectedDisks: infrastructureMonitor.selectedDisks, - }; - return form; - }; - const submitInfrastructureForm = async ( - infrastructureMonitor, - form, - isCreate, - monitorId - ) => { - const { - cpu, - usage_cpu, - memory, - usage_memory, - disk, - usage_disk, - temperature, - usage_temperature, - selectedDisks, - ...rest - } = form; - - const thresholds = { - ...(cpu ? { usage_cpu: usage_cpu / 100 } : {}), - ...(memory ? { usage_memory: usage_memory / 100 } : {}), - ...(disk ? { usage_disk: usage_disk / 100 } : {}), - ...(temperature ? { usage_temperature: usage_temperature / 100 } : {}), - }; - - const finalForm = { - ...(isCreate ? {} : { id: monitorId }), - ...rest, - description: form.name, - type: "hardware", - notifications: infrastructureMonitor.notifications, - selectedDisks, - thresholds, - }; - // Handle create or update - isCreate - ? await createMonitor({ monitor: finalForm, redirect: "/infrastructure" }) - : await updateMonitor({ monitor: finalForm, redirect: "/infrastructure" }); - }; - return { - buildForm, - submitInfrastructureForm, - isCreating, - isUpdating, - }; -}; -export default useInfrastructureSubmit; diff --git a/client/src/Pages/Infrastructure/Create/hooks/useValidateInfrastructureForm.jsx b/client/src/Pages/Infrastructure/Create/hooks/useValidateInfrastructureForm.jsx deleted file mode 100644 index f7665e65f..000000000 --- a/client/src/Pages/Infrastructure/Create/hooks/useValidateInfrastructureForm.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState } from "react"; -import { infrastructureMonitorValidation } from "../../../../Validation/validation.js"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -const useValidateInfrastructureForm = () => { - const [errors, setErrors] = useState({}); - - const validateField = (name, value) => { - const { error } = infrastructureMonitorValidation.validate( - { [name]: value }, - { abortEarly: false } - ); - setErrors((prev) => ({ - ...prev, - ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), - })); - }; - - const validateForm = (form) => { - const { error } = infrastructureMonitorValidation.validate(form, { - abortEarly: false, - }); - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - console.log(newErrors); - setErrors(newErrors); - createToast({ body: "Please check the form for errors." }); - return error; - } - return null; - }; - return { errors, validateField, validateForm }; -}; -export default useValidateInfrastructureForm; diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx deleted file mode 100644 index 0a09105b8..000000000 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ /dev/null @@ -1,411 +0,0 @@ -//Components -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import FieldWrapper from "@/Components/v1/Inputs/FieldWrapper/index.jsx"; -import Link from "@/Components/v1/Link/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material"; -import { HttpAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import MonitorStatusHeader from "./Components/MonitorStatusHeader.jsx"; -import MonitorActionButtons from "./Components/MonitorActionButtons.jsx"; -import CustomAlertsSection from "./Components/CustomAlertsSection.jsx"; -import DiskSelection from "./Components/DiskSelection.jsx"; -// Utils -import NotificationsConfig from "@/Components/v1/NotificationConfig/index.jsx"; -import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications.js"; -import { networkService } from "../../../Utils/NetworkService.js"; -import { useParams } from "react-router-dom"; -import { useState, useEffect } from "react"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { - useDeleteMonitor, - useFetchGlobalSettings, - useFetchMonitorById, - usePauseMonitor, -} from "@/Hooks/monitorHooks.js"; -import useInfrastructureMonitorForm from "./hooks/useInfrastructureMonitorForm.jsx"; -import useValidateInfrastructureForm from "./hooks/useValidateInfrastructureForm.jsx"; -import useInfrastructureSubmit from "./hooks/useInfrastructureSubmit.jsx"; - -const CreateInfrastructureMonitor = () => { - const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined"; - - const theme = useTheme(); - const { t } = useTranslation(); - - // State - const [monitor, setMonitor] = useState(null); - const [https, setHttps] = useState(false); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [availableDisks, setAvailableDisks] = useState([]); - - // Fetch monitor details if editing - const [isLoading] = useFetchMonitorById({ - monitorId, - setMonitor, - updateTrigger: true, - }); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - const [globalSettings, globalSettingsLoading] = useFetchGlobalSettings(); - const [notifications, notificationsAreLoading] = useGetNotificationsByTeamId(); - const [pauseMonitor, isPausing] = usePauseMonitor(); - const { - infrastructureMonitor, - setInfrastructureMonitor, - onChangeForm, - handleCheckboxChange, - initializeInfrastructureMonitorForCreate, - initializeInfrastructureMonitorForUpdate, - } = useInfrastructureMonitorForm(); - const { errors, validateField, validateForm } = useValidateInfrastructureForm(); - const { buildForm, submitInfrastructureForm, isCreating, isUpdating } = - useInfrastructureSubmit(); - - const FREQUENCIES = [ - { _id: 0.25, name: t("time.fifteenSeconds") }, - { _id: 0.5, name: t("time.thirtySeconds") }, - { _id: 1, name: t("time.oneMinute") }, - { _id: 2, name: t("time.twoMinutes") }, - { _id: 5, name: t("time.fiveMinutes") }, - { _id: 10, name: t("time.tenMinutes") }, - ]; - const CRUMBS = [ - { name: "Infrastructure monitors", path: "/infrastructure" }, - ...(isCreate - ? [{ name: "Create", path: "/infrastructure/create" }] - : [ - { name: "Details", path: `/infrastructure/${monitorId}` }, - { name: "Configure", path: `/infrastructure/configure/${monitorId}` }, - ]), - ]; - // Populate form fields if editing - useEffect(() => { - if (isCreate) { - if (globalSettingsLoading) return; - setHttps(false); - initializeInfrastructureMonitorForCreate(globalSettings); - } else if (monitor) { - setHttps(monitor.url.startsWith("https")); - initializeInfrastructureMonitorForUpdate(monitor); - const fetchLastCheck = async () => { - try { - const { stats } = monitor ?? {}; - let latestCheck = stats?.aggregateData?.latestCheck; - let disks = latestCheck?.disk || []; - - if (disks.length > 0) { - setAvailableDisks(disks); - return; - } - - const response = await networkService.getChecksByMonitor({ - monitorId, - dateRange: "all", - rowsPerPage: 1, - sortOrder: "desc", - }); - disks = response?.data?.data?.checks?.[0]?.disk || []; - setAvailableDisks(disks); - } catch (error) { - setAvailableDisks([]); - } - }; - - fetchLastCheck(); - } - }, [ - isCreate, - monitor, - globalSettings, - globalSettingsLoading, - initializeInfrastructureMonitorForCreate, - initializeInfrastructureMonitorForUpdate, - ]); - - // Handlers - const onSubmit = async (event) => { - event.preventDefault(); - const form = buildForm(infrastructureMonitor, https); - // When editing, exclude URL from validation since it's disabled and can't be changed - const formToValidate = isCreate ? form : { ...form, url: monitor.url }; - const error = validateForm(formToValidate); - if (error) { - return; - } - submitInfrastructureForm(infrastructureMonitor, form, isCreate, monitorId); - }; - - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - const onChange = (event) => { - const { value, name } = event.target; - onChangeForm(name, value); - validateField(name, value); - }; - - const handlePause = async () => { - await pauseMonitor({ monitorId, triggerUpdate }); - }; - - const handleRemove = async (event) => { - event.preventDefault(); - await deleteMonitor({ monitor, redirect: "/infrastructure" }); - }; - - const isBusy = - isLoading || - isUpdating || - isCreating || - isDeleting || - isPausing || - notificationsAreLoading; - - return ( - - - - - - - - {!isCreate ? infrastructureMonitor.name : t("createYour") + " "} - - {isCreate ? ( - - {t("monitor")} - - ) : ( - <> - )} - - {!isCreate && monitor && ( - - )} - - {!isCreate && monitor && ( - - )} - - - - - {t("settingsGeneralSettings")} - - - {t("infrastructureCreateGeneralSettingsDescription")} - - - {t("infrastructureServerRequirement")}{" "} - - - - - } - placeholder={"localhost:59232/api/v1/metrics"} - label={t("infrastructureServerUrlLabel")} - https={https} - value={infrastructureMonitor.url} - onChange={onChange} - error={errors["url"] ? true : false} - helperText={errors["url"]} - disabled={!isCreate} - /> - {isCreate && ( - - - - - - - )} - - - - - - - - {t("notificationConfig.title")} - - {t("notificationConfig.description")} - - - - - - - {t("createMonitorPage.incidentConfigTitle")} - - - {t("createMonitorPage.incidentConfigDescription")} - - - - - - - - - - {monitorId && ( - - onChangeForm("selectedDisks", newSelectedDisks) - } - /> - )} - - - - - {t("distributedUptimeCreateAdvancedSettings")} - - - - setSelectedInterface(e.target.value)} - items={availableInterfaces.map((interfaceName) => ({ - _id: interfaceName, - name: interfaceName, - }))} - sx={{ minWidth: 200 }} - /> - )} - - - - - ); -}; - -Network.propTypes = { - net: PropTypes.array, - checks: PropTypes.array, - isLoading: PropTypes.bool.isRequired, - dateRange: PropTypes.string.isRequired, - setDateRange: PropTypes.func.isRequired, -}; - -export default Network; diff --git a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/skeleton.jsx b/client/src/Pages/Infrastructure/Details/Components/NetworkStats/skeleton.jsx deleted file mode 100644 index 52fcfad87..000000000 --- a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/skeleton.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import { - Card, - CardContent, - Skeleton, - Table, - TableHead, - TableRow, - TableCell, - TableBody, -} from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - - - - Name - Bytes Sent - Bytes Received - Packets Sent - Packets Received - Errors In - Errors Out - Drops In - Drops Out - - - - {Array.from({ length: 5 }).map((_, idx) => ( - - {Array.from({ length: 9 }).map((__, colIdx) => ( - - - - ))} - - ))} - -
-
-
- ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx new file mode 100644 index 000000000..7b1d80c40 --- /dev/null +++ b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx @@ -0,0 +1,73 @@ +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/v2/design-elements"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; +import { + getAvgTemp, + getCores, + getFrequency, + getGbs, + getDiskTotalGbs, + getOsAndPlatform, +} from "@/Utils/InfraUtils"; + +export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const latestCheck = monitor?.recentChecks?.[0]; + // Get data from latest check + const physicalCores = getCores(latestCheck?.cpu?.physical_core); + const logicalCores = getCores(latestCheck?.cpu?.logical_core); + const cpuFrequency = getFrequency(latestCheck?.cpu?.frequency); + const cpuTemps = latestCheck?.cpu?.temperature ?? []; + const cpuTemperature = getAvgTemp(cpuTemps); + const memoryTotalBytes = getGbs(latestCheck?.memory?.total_bytes); + const diskTotalBytes = getDiskTotalGbs(latestCheck?.disk); + const os = getOsAndPlatform(latestCheck?.host); + + const platform = latestCheck?.host?.platform ?? undefined; + const osPlatform = + typeof os === "undefined" && typeof platform === "undefined" + ? undefined + : `${os} ${platform}`; + + return ( + + + + + + + + + + ); +}; diff --git a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx deleted file mode 100644 index ef91709cf..000000000 --- a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx +++ /dev/null @@ -1,116 +0,0 @@ -// Components -import { Stack, Typography } from "@mui/material"; -import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx"; -import StatBox from "@/Components/v1/StatBox/index.jsx"; - -//Utils -import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js"; -import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx"; -import { useTranslation } from "react-i18next"; - -const InfraStatBoxes = ({ shouldRender, monitor }) => { - // Utils - const { formatBytes } = useHardwareUtils(); - const { determineState } = useMonitorUtils(); - const { t } = useTranslation(); - - const { stats } = monitor ?? {}; - const latestCheck = stats?.aggregateData?.latestCheck; - - // Get data from latest check - const physicalCores = latestCheck?.cpu?.physical_core ?? 0; - const logicalCores = latestCheck?.cpu?.logical_core ?? 0; - const cpuFrequency = latestCheck?.cpu?.frequency ?? 0; - const cpuTemperature = - latestCheck?.cpu?.temperature?.length > 0 - ? latestCheck.cpu.temperature.reduce((acc, curr) => acc + curr, 0) / - latestCheck.cpu.temperature.length - : 0; - const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0; - const diskTotalBytes = latestCheck?.disk[0]?.total_bytes ?? 0; - const os = latestCheck?.host?.os ?? undefined; - const platform = latestCheck?.host?.platform ?? undefined; - const osPlatform = - typeof os === "undefined" && typeof platform === "undefined" - ? undefined - : `${os} ${platform}`; - - return ( - - - - {physicalCores} - - {physicalCores === 1 ? "core" : "cores"} - - - } - /> - - {logicalCores} - - {logicalCores === 1 ? "core" : "cores"} - - - } - /> - - {(cpuFrequency / 1000).toFixed(2)} - Ghz - - } - /> - - {cpuTemperature.toFixed(2)} - °C - - } - /> - - - {/* - {(uptimePercentage * 100).toFixed(2)} - % - - } - /> */} - - - ); -}; - -export default InfraStatBoxes; diff --git a/client/src/Pages/Infrastructure/Details/Components/TabNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/TabNetwork.tsx new file mode 100644 index 000000000..612040cb1 --- /dev/null +++ b/client/src/Pages/Infrastructure/Details/Components/TabNetwork.tsx @@ -0,0 +1,29 @@ +import Stack from "@mui/material/Stack"; +import { InfraNetworkCharts } from "./ChartsNetwork"; + +import { useTheme } from "@mui/material"; +import type { HardwareStats } from "@/Types/Monitor"; + +export const TabNetwork = ({ + stats, + dateRange, +}: { + stats: HardwareStats | undefined; + dateRange: string; +}) => { + const theme = useTheme(); + + if (!stats) { + return null; + } + + const checks = stats?.checks || []; + return ( + + + + ); +}; diff --git a/client/src/Pages/Infrastructure/Details/Components/TabOverview.tsx b/client/src/Pages/Infrastructure/Details/Components/TabOverview.tsx new file mode 100644 index 000000000..447a2cce6 --- /dev/null +++ b/client/src/Pages/Infrastructure/Details/Components/TabOverview.tsx @@ -0,0 +1,34 @@ +import Stack from "@mui/material/Stack"; +import { InfraDetailsGauges } from "@/Pages/Infrastructure/Details/Components/Gauges"; +import { StatusBoxes } from "@/Pages/Infrastructure/Details/Components/StatusBoxes"; +import { InfraDetailsCharts } from "@/Pages/Infrastructure/Details/Components/Charts"; + +import { useTheme } from "@mui/material"; +import type { HardwareStats, Monitor } from "@/Types/Monitor"; + +export const TabOverview = ({ + monitor, + stats, + dateRange, +}: { + monitor: Monitor | undefined; + stats: HardwareStats | undefined; + dateRange: string; +}) => { + const theme = useTheme(); + if (!monitor) { + return null; + } + + const checks = stats?.checks || []; + return ( + + + + + + ); +}; diff --git a/client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx b/client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx deleted file mode 100644 index 8938847a4..000000000 --- a/client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx +++ /dev/null @@ -1,270 +0,0 @@ -import { Typography, Tooltip } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; - -// Constants -const BASE_BOX_PADDING_VERTICAL = 4; -const BASE_BOX_PADDING_HORIZONTAL = 8; -const TYPOGRAPHY_PADDING = 8; -const CHART_CONTAINER_HEIGHT = 300; - -const useHardwareUtils = () => { - const theme = useTheme(); - const { t } = useTranslation(); - - const getDimensions = () => { - const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2; - const totalChartContainerPadding = - parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2; - return { - baseBoxPaddingVertical: BASE_BOX_PADDING_VERTICAL, - baseBoxPaddingHorizontal: BASE_BOX_PADDING_HORIZONTAL, - totalContainerPadding: parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2, - areaChartHeight: - CHART_CONTAINER_HEIGHT - totalChartContainerPadding - totalTypographyPadding, - }; - }; - - const formatBytes = (bytes, space = false) => { - if (bytes === undefined || bytes === null) - return ( - <> - {0} - {space ? " " : ""} - {t("gb")} - - ); - if (typeof bytes !== "number") - return ( - <> - {0} - {space ? " " : ""} - {t("gb")} - - ); - if (bytes === 0) - return ( - <> - {0} - {space ? " " : ""} - {t("gb")} - - ); - - const GB = bytes / (1024 * 1024 * 1024); - const MB = bytes / (1024 * 1024); - - if (GB >= 1) { - return ( - <> - {Number(GB.toFixed(2))} - {space ? " " : ""} - {t("gb")} - - ); - } else { - return ( - <> - {Number(MB.toFixed(2))} - {space ? " " : ""} - {t("mb")} - - ); - } - }; - - const formatBytesPerSecondString = (bytesPerSec, space = false) => { - if ( - bytesPerSec === undefined || - bytesPerSec === null || - typeof bytesPerSec !== "number" || - bytesPerSec === 0 - ) { - return `0${space ? " " : ""}B/s`; - } - - const GB = bytesPerSec / (1024 * 1024 * 1024); - const MB = bytesPerSec / (1024 * 1024); - const KB = bytesPerSec / 1024; - - if (GB >= 1) { - return `${Number(GB.toFixed(1))}${space ? " " : ""}GB/s`; - } else if (MB >= 1) { - return `${Number(MB.toFixed(1))}${space ? " " : ""}MB/s`; - } else if (KB >= 1) { - return `${Number(KB.toFixed(1))}${space ? " " : ""}KB/s`; - } else { - return `${Number(bytesPerSec.toFixed(1))}${space ? " " : ""}B/s`; - } - }; - - const formatPacketsPerSecondString = (packetsPerSec, space = false) => { - if ( - packetsPerSec === undefined || - packetsPerSec === null || - typeof packetsPerSec !== "number" || - packetsPerSec === 0 - ) { - return `0${space ? " " : ""}pps`; - } - - const M = packetsPerSec / (1000 * 1000); - const K = packetsPerSec / 1000; - - if (M >= 1) { - return `${Number(M.toFixed(1))}${space ? " " : ""}Mpps`; - } else if (K >= 1) { - return `${Number(K.toFixed(1))}${space ? " " : ""}Kpps`; - } else { - return `${Math.round(packetsPerSec)}${space ? " " : ""}pps`; - } - }; - - const formatDeviceName = (device) => { - const deviceStr = String(device || ""); - - // Show full device path - return ( - - - {deviceStr} - - - ); - }; - - const formatMountpoint = (mountpoint) => { - const mountpointStr = String(mountpoint || ""); - - if (!mountpointStr) { - return ( - - - N/A - - - ); - } - - // Show full mountpoint path - return ( - - - {mountpointStr} - - - ); - }; - - /** - * Converts a decimal value to a percentage - * - * @function decimalToPercentage - * @param {number} value - Decimal value to convert - * @returns {number} Percentage representation - * - * @example - * decimalToPercentage(0.75) // Returns 75 - * decimalToPercentage(null) // Returns 0 - */ - const decimalToPercentage = (value) => { - if (value === null || value === undefined) return 0; - return value * 100; - }; - - const buildTemps = (checks) => { - let numCores = 1; - if (checks === null) return { temps: [], tempKeys: [] }; - - for (const check of checks) { - if (check?.avgTemperature?.length > numCores) { - numCores = check.avgTemperature.length; - break; - } - } - const temps = checks.map((check) => { - // If there's no data, set the temperature to 0 - if ( - check?.avgTemperature?.length === 0 || - check?.avgTemperature === undefined || - check?.avgTemperature === null - ) { - check.avgTemperature = Array(numCores).fill(0); - } - const res = check?.avgTemperature?.reduce( - (acc, cur, idx) => { - acc[`core${idx + 1}`] = cur; - return acc; - }, - { - _id: check._id, - } - ); - return res; - }); - if (temps.length === 0 || !temps[0]) { - return { temps: [], tempKeys: [] }; - } - - return { - tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "_id"), - temps, - }; - }; - - return { - formatBytes, - formatDeviceName, - formatMountpoint, - decimalToPercentage, - buildTemps, - getDimensions, - formatBytesPerSecondString, - formatPacketsPerSecondString, - }; -}; - -export { useHardwareUtils }; diff --git a/client/src/Pages/Infrastructure/Details/index.jsx b/client/src/Pages/Infrastructure/Details/index.jsx deleted file mode 100644 index 62a5c2642..000000000 --- a/client/src/Pages/Infrastructure/Details/index.jsx +++ /dev/null @@ -1,142 +0,0 @@ -// Components -import { Stack, Typography, Tab } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx"; -import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx"; -import StatusBoxes from "./Components/StatusBoxes/index.jsx"; -import GaugeBoxes from "./Components/GaugeBoxes/index.jsx"; -import AreaChartBoxes from "./Components/AreaChartBoxes/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import NetworkStats from "./Components/NetworkStats/index.jsx"; -import CustomTabList from "@/Components/v1/Tab/index.jsx"; -import TabContext from "@mui/lab/TabContext"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useFetchHardwareMonitorById } from "../../../Hooks/monitorHooks.js"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; - -// Constants -const BREADCRUMBS = [ - { name: "infrastructure monitors", path: "/infrastructure" }, - { name: "details", path: "" }, -]; -const InfrastructureDetails = () => { - // Local state - const [dateRange, setDateRange] = useState("recent"); - const [trigger, setTrigger] = useState(false); - const [tab, setTab] = useState("details"); - - // Utils - const theme = useTheme(); - const { monitorId } = useParams(); - const { t } = useTranslation(); - const isAdmin = useIsAdmin(); - - const [monitor, isLoading, networkError] = useFetchHardwareMonitorById({ - monitorId, - dateRange, - updateTrigger: trigger, - }); - - const triggerUpdate = () => { - setTrigger(!trigger); - }; - - if (networkError === true) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - - if (!isLoading && monitor?.stats?.checks?.length === 0) { - return ( - - - - - {t("distributedUptimeDetailsNoMonitorHistory")} - - - ); - } - - return ( - - - - - setTab(v)} - > - - - - {tab === "details" && ( - <> - - - - - - )} - {tab === "network" && ( - - )} - - - ); -}; - -export default InfrastructureDetails; diff --git a/client/src/Pages/Infrastructure/Details/index.tsx b/client/src/Pages/Infrastructure/Details/index.tsx new file mode 100644 index 000000000..f27979a4f --- /dev/null +++ b/client/src/Pages/Infrastructure/Details/index.tsx @@ -0,0 +1,90 @@ +import { BasePage, Tab, Tabs } from "@/Components/v2/design-elements"; +import { HeaderTimeRange } from "@/Components/v2/common"; +import { MonitorStatBoxes, HeaderMonitorControls } from "@/Components/v2/monitors"; +import { TabNetwork } from "@/Pages/Infrastructure/Details/Components/TabNetwork"; +import { TabOverview } from "@/Pages/Infrastructure/Details/Components/TabOverview"; + +import { useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useGet } from "@/Hooks/UseApi"; +import type { HardwareDetailsResponse } from "@/Types/Monitor"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import { useTranslation } from "react-i18next"; + +const InfrastructureDetails = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + + const { monitorId } = useParams<{ monitorId: string }>(); + + const [dateRange, setDateRange] = useState("recent"); + const [selectedTab, setSelectedTab] = useState(0); + + const monitorDetailsUrl = useMemo(() => { + if (!monitorId) { + return null; + } + const params = new URLSearchParams(); + params.append("dateRange", dateRange); + return `/monitors/hardware/details/${monitorId}?${params.toString()}`; + }, [monitorId, dateRange]); + + const { + data: monitorDetailsData, + isLoading: monitorIsLoading, + refetch: refetchMonitor, + } = useGet( + monitorDetailsUrl, + {}, + { refreshInterval: 10000, keepPreviousData: true } + ); + + const monitor = monitorDetailsData?.monitor; + const monitorStats = monitorDetailsData?.monitorStats ?? null; + const stats = monitorDetailsData?.stats; + + return ( + + + + + { + setSelectedTab(value); + }} + > + + + + {selectedTab === 0 && ( + + )} + {selectedTab === 1 && ( + + )} + + ); +}; + +export default InfrastructureDetails; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx b/client/src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx deleted file mode 100644 index 01e56c68a..000000000 --- a/client/src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -import FilterHeader from "@/Components/v1/FilterHeader/index.jsx"; -import { useMemo } from "react"; -import { Box, Button } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; -import { useTranslation } from "react-i18next"; - -/** - * Filter Component - * - * A high-level component that provides filtering options for status in Infrastructure Page. - * It allows users to select multiple options for each filter and reset the filters. - * - * @component - * @param {Object} props - The component props. - * @param {string[]} props.selectedStatus - An array of selected status values. - * @param {function} props.setSelectedStatus - A function to set the selected status values. - * @param {function} props.setToFilterStatus - A function to set the filter status based on selected status values. - * @param {function} props.handleReset - A function to reset all filters. - * - * @returns {JSX.Element} The rendered Filter component. - */ - -const statusOptions = [ - { value: "Up", label: "Up" }, - { value: "Down", label: "Down" }, -]; - -const Filter = ({ - selectedStatus, - setSelectedStatus, - setToFilterStatus, - handleReset, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const handleStatusChange = (event) => { - const selectedValues = event.target.value; - setSelectedStatus(selectedValues.length > 0 ? selectedValues : undefined); - - if (selectedValues.length === 0 || selectedValues.length === 2) { - setToFilterStatus(undefined); - } else { - setToFilterStatus(selectedValues[0] === "Up" ? "true" : "false"); - } - }; - - const isFilterActive = useMemo(() => { - return (selectedStatus?.length ?? 0) > 0; - }, [selectedStatus]); - - return ( - - - - - ); -}; - -Filter.propTypes = { - selectedStatus: PropTypes.arrayOf(PropTypes.string), - setSelectedStatus: PropTypes.func.isRequired, - setToFilterStatus: PropTypes.func.isRequired, - handleReset: PropTypes.func.isRequired, -}; - -export default Filter; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx new file mode 100644 index 000000000..2644f969e --- /dev/null +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -0,0 +1,275 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Table, Pagination, StatusLabel, Gauge } from "@/Components/v2/design-elements"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; +import { ArrowUp, ArrowDown } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; +import { usePost } from "@/Hooks/UseApi"; + +import type { Monitor } from "@/Types/Monitor"; + +export const InfraMonitorsTable = ({ + monitors, + refetch, + setSelectedMonitor, + sortField, + setSortField, + sortOrder, + setSortOrder, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + monitors: Monitor[]; + refetch: Function; + setSelectedMonitor: Function; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const navigate = useNavigate(); + const { + post, + // loading: isPatching, + // error: postError, + } = usePost(); + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + const value = Number(e.target.value); + setPage(0); + setRowsPerPage(value); + }; + + const handleSort = (e: any, field: string) => { + e.preventDefault(); + e.stopPropagation(); + if (sortField === field) { + const newOrder = sortOrder === "asc" ? "desc" : "asc"; + setSortOrder(newOrder); + } else { + setSortField(field); + setSortOrder("asc"); + } + refetch(); + }; + + const getActions = (monitor: Monitor): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.openSite"), + action: () => { + window.open(monitor.url, "_blank", "noreferrer"); + }, + closeMenu: true, + }, + { + id: 2, + label: t("pages.common.monitors.actions.details"), + action: () => { + navigate(`${monitor.id}`); + }, + }, + { + id: 3, + label: t("pages.common.monitors.actions.incidents"), + action: () => { + navigate(`/incidents?monitorId=${monitor.id}`); + }, + }, + { + id: 4, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/infrastructure/configure/${monitor.id}`); + }, + }, + // { + // id: 5, + // label: "Clone", + // action: () => { + + // }, + // }, + { + id: 6, + label: + monitor.isActive === false + ? t("common.buttons.resume") + : t("common.buttons.pause"), + action: async () => { + await post(`/monitors/pause/${monitor.id}`, {}); + refetch(); + }, + closeMenu: true, + }, + { + id: 7, + label: ( + + {t("common.buttons.delete")} + + ), + action: () => { + setSelectedMonitor(monitor); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const renderSortIcon = (isActive: boolean) => ( + + {isActive ? ( + sortOrder === "asc" ? ( + + ) : ( + + ) + ) : null} + + ); + const headers: Header[] = [ + { + id: "name", + content: ( + handleSort(e, "name")} + sx={{ cursor: "pointer" }} + > + {t("common.table.headers.name")} + {renderSortIcon(sortField === "name")} + + ), + render: (row) => { + return row.name; + }, + }, + { + id: "status", + content: ( + handleSort(e, "status")} + sx={{ cursor: "pointer" }} + > + {t("common.table.headers.status")} + {renderSortIcon(sortField === "status")} + + ), + render: (row) => { + return ( + + ); + }, + }, + { + id: "cpu", + content: t("pages.infrastructure.table.headers.cpu"), + render: (row) => { + const check = row.recentChecks?.[0]; + const cpuUsage = (check?.cpu?.usage_percent || 0) * 100; + return ; + }, + }, + { + id: "memory", + content: t("pages.infrastructure.table.headers.memory"), + render: (row) => { + const check = row.recentChecks?.[0]; + const memoryUsage = (check?.memory?.usage_percent || 0) * 100; + return ; + }, + }, + { + id: "disk", + content: t("pages.infrastructure.table.headers.disk"), + render: (row) => { + const check = row.recentChecks?.[0]; + + const totalDiskUsage = check?.disk?.reduce( + (acc, disk) => acc + (disk?.usage_percent || 0), + 0 + ); + const diskCount = check?.disk?.length || 1; + const diskUsage = ((totalDiskUsage || 0) / diskCount) * 100; + return ; + }, + }, + + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + let headers = getHeaders(); + + if (isSmall) { + headers = headers.filter((h) => h.id !== "histogram"); + } + return ( + + { + navigate(`/infrastructure/${row.id}`); + }} + /> + + + ); +}; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx deleted file mode 100644 index 292eb9714..000000000 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx +++ /dev/null @@ -1,162 +0,0 @@ -// Components -import { Box } from "@mui/material"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Host from "@/Components/v1/Host/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import { Stack } from "@mui/material"; -import { InfrastructureMenu } from "../MonitorsTableMenu/index.jsx"; -import LoadingSpinner from "../../../../Uptime/Monitors/Components/LoadingSpinner/index.jsx"; -// Assets -import Icon from "@/Components/v1/Icon"; -import CustomGauge from "@/Components/v1/Charts/CustomGauge/index.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js"; -import { useNavigate } from "react-router-dom"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; - -const MonitorsTable = ({ - isLoading, - monitors, - isAdmin, - handleActionMenuDelete, - isSearching, -}) => { - // Utils - const theme = useTheme(); - const { t } = useTranslation(); - const { determineState } = useMonitorUtils(); - const navigate = useNavigate(); - - // Handlers - const openDetails = (id) => { - navigate(`/infrastructure/${id}`); - }; - const headers = [ - { - id: "host", - content: t("host"), - render: (row) => ( - - ), - }, - { - id: "status", - content: t("incidentsTableStatus"), - render: (row) => ( - - ), - }, - { - id: "frequency", - content: t("frequency"), - render: (row) => ( - - - {row.processor} - - ), - }, - { id: "cpu", content: t("cpu"), render: (row) => }, - { - id: "memory", - content: t("memory"), - render: (row) => , - }, - { - id: "disk", - content: t("disk"), - render: (row) => , - }, - { - id: "actions", - content: t("actions"), - render: (row) => ( - - ), - }, - ]; - - const data = monitors?.map((monitor) => { - const processor = - ((monitor.checks[0]?.cpu?.frequency ?? 0) / 1000).toFixed(2) + " GHz"; - const cpu = (monitor?.checks[0]?.cpu.usage_percent ?? 0) * 100; - const mem = (monitor?.checks[0]?.memory.usage_percent ?? 0) * 100; - const disk = (monitor?.checks[0]?.disk[0]?.usage_percent ?? 0) * 100; - const status = determineState(monitor); - const percentageColor = - monitor.uptimePercentage < 0.25 - ? theme.palette.error.main - : monitor.uptimePercentage < 0.5 - ? theme.palette.warning.main - : theme.palette.success.main; - - return { - id: monitor.id, - name: monitor.name, - url: monitor.url, - processor, - cpu, - mem, - disk, - status, - percentageColor, - }; - }); - - return ( - - - openDetails(row.id), - emptyView: "No monitors found", - }} - /> - - ); -}; - -MonitorsTable.propTypes = { - isLoading: PropTypes.bool, - monitors: PropTypes.array, - isAdmin: PropTypes.bool, - handleActionMenuDelete: PropTypes.func, - isSearching: PropTypes.bool, -}; - -export default MonitorsTable; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx deleted file mode 100644 index 50af0aaa2..000000000 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx +++ /dev/null @@ -1,196 +0,0 @@ -/* TODO I basically copied and pasted this component from the actionsMenu. Check how we can make it reusable */ - -import { useRef, useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { createToast } from "@/Utils/toastUtils.jsx"; -import { IconButton, Menu, MenuItem } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; -import PropTypes from "prop-types"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import { networkService } from "@/Utils/NetworkService.js"; -import { usePauseMonitor } from "@/Hooks/monitorHooks.js"; -import { useTranslation } from "react-i18next"; - -/** - * InfrastructureMenu Component - * Provides a dropdown menu for managing infrastructure monitors. - * - * @param {Object} props - The component props. - * @param {Object} props.monitor - The monitor object containing details about the infrastructure monitor. - * @param {string} props.monitor.id - Unique ID of the monitor. - * @param {string} [props.monitor.url] - URL associated with the monitor. - * @param {string} props.monitor.type - Type of monitor (e.g., uptime, infrastructure). - * @param {boolean} props.monitor.isActive - Indicates if the monitor is currently active (true) or paused (false). - * @param {boolean} props.isAdmin - Whether the user has admin privileges. - * @param {Function} props.updateCallback - Callback to trigger when the monitor data is updated. - * @returns {JSX.Element} The rendered component. - */ -const InfrastructureMenu = ({ monitor, isAdmin, updateCallback }) => { - const anchor = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const theme = useTheme(); - const [pauseMonitor] = usePauseMonitor(); - const { t } = useTranslation(); - - const openMenu = (e) => { - e.stopPropagation(); - setIsOpen(true); - }; - - const closeMenu = (e) => { - e.stopPropagation(); - setIsOpen(false); - }; - - const openRemove = (e) => { - closeMenu(e); - setIsDialogOpen(true); - }; - const cancelRemove = () => { - setIsDialogOpen(false); - }; - - const navigate = useNavigate(); - - function openDetails(id) { - navigate(`/infrastructure/${id}`); - } - - const openConfigure = (id) => { - navigate(`/infrastructure/configure/${id}`); - }; - - const handlePause = async () => { - // Pass updateCallback as triggerUpdate to the hook - await pauseMonitor({ monitorId: monitor.id, triggerUpdate: updateCallback }); - // Toast is already displayed in the hook, no need to display it again - }; - - const handleRemove = async () => { - try { - await networkService.deleteMonitorById({ - monitorId: monitor.id, - }); - createToast({ - body: t("monitorActions.deleteSuccess"), - }); - } catch (error) { - createToast({ body: t("monitorActions.deleteFailed") }); - } finally { - setIsDialogOpen(false); - updateCallback(); - } - }; - - return ( - <> - - - - - - { - e.stopPropagation(); - openDetails(monitor.id); - closeMenu(e); - }} - > - {t("monitorActions.details")} - - {isAdmin && ( - { - e.stopPropagation(); - openConfigure(monitor.id); - closeMenu(e); - }} - > - {t("configure")} - - )} - {isAdmin && ( - { - e.stopPropagation(); - await handlePause(); - closeMenu(e); - }} - > - {!monitor.isActive ? t("resume") : t("pause")} - - )} - {isAdmin && ( - - {t("remove")} - - )} - - - - ); -}; - -InfrastructureMenu.propTypes = { - monitor: PropTypes.shape({ - id: PropTypes.string.isRequired, - url: PropTypes.string, - // Note: type must remain optional. Making it required (type: PropTypes.string.isRequired) - // causes runtime errors as some monitors don't have a defined type property - type: PropTypes.string, - isActive: PropTypes.bool, // Determines whether the monitor is paused (false) or active (true) - status: PropTypes.string, // Represents the monitor's operational status (e.g., 'up', 'down', etc.) - }).isRequired, - isAdmin: PropTypes.bool.isRequired, - updateCallback: PropTypes.func.isRequired, -}; - -export { InfrastructureMenu }; diff --git a/client/src/Pages/Infrastructure/Monitors/index.jsx b/client/src/Pages/Infrastructure/Monitors/index.jsx deleted file mode 100644 index e4add415c..000000000 --- a/client/src/Pages/Infrastructure/Monitors/index.jsx +++ /dev/null @@ -1,139 +0,0 @@ -// Components -import { Stack } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx"; -import MonitorCreateHeader from "@/Components/v1/MonitorCreateHeader/index.jsx"; -import MonitorsTable from "./Components/MonitorsTable/index.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; -import Filter from "./Components/Filters/index.jsx"; -import SearchComponent from "../../Uptime/Monitors/Components/SearchComponent/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { useEffect, useState } from "react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useTranslation } from "react-i18next"; -import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js"; - -import { useDispatch, useSelector } from "react-redux"; -import { setRowsPerPage } from "../../../Features/UI/uiSlice.js"; -// Constants -const TYPES = ["hardware"]; -const BREADCRUMBS = [{ name: `infrastructure`, path: "/infrastructure" }]; - -const InfrastructureMonitors = () => { - // Redux state - const rowsPerPage = useSelector((state) => state.ui?.infrastructure?.rowsPerPage ?? 5); - const dispatch = useDispatch(); - - // Local state - const [page, setPage] = useState(0); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [selectedStatus, setSelectedStatus] = useState(undefined); - const [toFilterStatus, setToFilterStatus] = useState(undefined); - const [search, setSearch] = useState(undefined); - const [isSearching, setIsSearching] = useState(false); - - // Utils - const theme = useTheme(); - const isAdmin = useIsAdmin(); - const { t } = useTranslation(); - - // Handlers - const handleActionMenuDelete = () => { - setUpdateTrigger(!updateTrigger); - }; - - const handleChangePage = (event, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - dispatch( - setRowsPerPage({ - value: parseInt(event.target.value, 10), - table: "infrastructure", - }) - ); - setPage(0); - }; - - useEffect(() => { - if (isSearching) { - setPage(0); - } - }, [isSearching]); - - const handleReset = () => { - setSelectedStatus(undefined); - setToFilterStatus(undefined); - }; - - const field = toFilterStatus !== undefined ? "status" : undefined; - - const [summary, monitors, count, isLoading, networkError] = useFetchMonitorsWithChecks({ - types: TYPES, - limit: 1, - page: page, - field: field, - filter: toFilterStatus ?? search, - rowsPerPage: rowsPerPage, - monitorUpdateTrigger: updateTrigger, - }); - - return ( - <> - - - - - - - - - - - - - - - - ); -}; - -export default InfrastructureMonitors; diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx new file mode 100644 index 000000000..af7b3e899 --- /dev/null +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -0,0 +1,200 @@ +import Stack from "@mui/material/Stack"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { + MonitorBasePageWithStates, + UpStatusBox, + DownStatusBox, + PausedStatusBox, +} from "@/Components/v2/design-elements"; +import { HeaderCreate } from "@/Components/v2/common"; +import { ControlsFilter } from "@/Components/v2/monitors"; +import { TextField, Dialog } from "@/Components/v2/inputs"; + +import { useGet, useDelete } from "@/Hooks/UseApi"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor"; +import { useTheme } from "@mui/material"; +import { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector, useDispatch } from "react-redux"; +import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; +import type { RootState } from "@/Types/state"; +import { InfraMonitorsTable } from "./Components/MonitorsTable"; + +const InfrastructureMonitors = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const isAdmin = useIsAdmin(); + const dispatch = useDispatch(); + + // Redux state + const rowsPerPage = useSelector( + (state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5 + ); + + // Local state + const [selectedStatus, setSelectedStatus] = useState(""); + const [selectedState, setSelectedState] = useState(""); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [sortField, setSortField] = useState(""); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); + + const handleClearFilters = useCallback(() => { + setSelectedStatus(""); + setSelectedState(""); + setSearch(""); + }, []); + + // Convert filter selections to API filter values + const toFilterStatus = useMemo(() => { + if (selectedStatus === "up") return "true"; + if (selectedStatus === "down") return "false"; + return undefined; + }, [selectedStatus]); + + const toFilterActive = useMemo(() => { + if (selectedState === "active") return "true"; + if (selectedState === "paused") return "false"; + return undefined; + }, [selectedState]); + + // Determine field and filter for the API request + // Priority: status > isActive > search + const filterLookup = new Map([ + [toFilterStatus, "status"], + [toFilterActive, "isActive"], + ]); + const activeFilter = [...filterLookup].find(([key]) => key !== undefined); + const field = activeFilter?.[1] || (search ? "name" : sortField || undefined); + const filter = activeFilter?.[0] || search || undefined; + + // Build URL for monitors with checks + const monitorsWithChecksUrl = useMemo(() => { + const params = new URLSearchParams(); + params.append("type", "hardware"); + params.append("limit", "1"); + if (page !== undefined) params.append("page", String(page)); + if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage)); + if (filter) params.append("filter", filter); + if (field) params.append("field", field); + if (sortOrder) params.append("order", sortOrder); + return `/monitors/team/with-checks?${params.toString()}`; + }, [page, rowsPerPage, filter, field, sortOrder]); + + const { + data: monitors, + isLoading: monitorsLoading, + error, + refetch: refetchMonitors, + } = useGet("/monitors/team?type=hardware", {}, { keepPreviousData: true }); + + const { + data: monitorsWithChecksData, + isLoading: monitorsWithChecksLoading, + error: monitorsWithChecksError, + refetch, + } = useGet( + monitorsWithChecksUrl, + {}, + { refreshInterval: 5000, keepPreviousData: true } + ); + + const { summary, count } = monitorsWithChecksData ?? {}; + const isLoading = monitorsLoading || monitorsWithChecksLoading; + + // Delete hook + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleConfirm = async () => { + if (!selectedMonitor) return; + await deleteFn(`/monitors/${selectedMonitor.id}`); + setSelectedMonitor(null); + refetch(); + refetchMonitors(); + }; + + const handleCancel = () => { + setSelectedMonitor(null); + }; + + return ( + + + + + + + + + + { + setSearch(event.target.value); + }} + /> + + { + dispatch( + setRowsPerPage({ + value, + table: "infrastructure", + }) + ); + setPage(0); + }} + /> + + + ); +}; + +export default InfrastructureMonitors; diff --git a/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx b/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx index b169a6a85..13ed63618 100644 --- a/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx +++ b/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx @@ -7,7 +7,7 @@ import CircularProgress from "@mui/material/CircularProgress"; import { useTheme } from "@emotion/react"; import PropTypes from "prop-types"; -import { getHumanReadableDuration } from "../../../../../Utils/timeUtils.js"; +import { getHumanReadableDuration } from "../../../../../Utils/timeUtilsLegacy.js"; import { formatBytes } from "../../utils/utils.js"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Logs/Diagnostics/index.jsx b/client/src/Pages/Logs/Diagnostics/index.jsx index 07b504c77..194e13821 100644 --- a/client/src/Pages/Logs/Diagnostics/index.jsx +++ b/client/src/Pages/Logs/Diagnostics/index.jsx @@ -7,7 +7,7 @@ import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx"; import { useTheme } from "@emotion/react"; import { useTranslation } from "react-i18next"; import { useFetchDiagnostics } from "../../../Hooks/logHooks.js"; -import { getHumanReadableDuration } from "../../../Utils/timeUtils.js"; +import { getHumanReadableDuration } from "../../../Utils/timeUtilsLegacy.js"; import { formatBytes, getPercentage } from "./utils/utils.js"; const Diagnostics = () => { diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx b/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx index 22a61084d..a5e853160 100644 --- a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx +++ b/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx @@ -6,7 +6,7 @@ import { MS_PER_HOUR, MS_PER_DAY, MS_PER_WEEK, -} from "../../../../Utils/timeUtils.js"; +} from "../../../../Utils/timeUtilsLegacy.js"; const useMaintenanceActions = () => { const MS_LOOKUP = { diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx b/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx index 3414334a7..4ab56bef9 100644 --- a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx +++ b/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx @@ -6,7 +6,7 @@ import { MS_PER_HOUR, MS_PER_DAY, MS_PER_WEEK, -} from "../../../../Utils/timeUtils.js"; +} from "../../../../Utils/timeUtilsLegacy.js"; const useMaintenanceData = () => { const REVERSE_REPEAT_LOOKUP = { 0: "none", diff --git a/client/src/Pages/Maintenance/CreateMaintenance/index.jsx b/client/src/Pages/Maintenance/CreateMaintenance/index.jsx index a300cc3b5..af3f34512 100644 --- a/client/src/Pages/Maintenance/CreateMaintenance/index.jsx +++ b/client/src/Pages/Maintenance/CreateMaintenance/index.jsx @@ -228,6 +228,13 @@ const CreateMaintenance = () => { slotProps={{ switchViewButton: { sx: { display: "none" } }, nextIconButton: { sx: { ml: theme.spacing(2) } }, + day: { + sx: { + "&.MuiPickersDay-root.Mui-disabled": { + color: theme.palette.primary.contrastTextTertiary, + }, + }, + }, field: { sx: { width: "fit-content", diff --git a/client/src/Pages/Maintenance/MaintenanceTable/index.jsx b/client/src/Pages/Maintenance/MaintenanceTable/index.jsx index 765858050..11875bb6a 100644 --- a/client/src/Pages/Maintenance/MaintenanceTable/index.jsx +++ b/client/src/Pages/Maintenance/MaintenanceTable/index.jsx @@ -6,7 +6,7 @@ import Icon from "@/Components/v1/Icon"; import ActionsMenu from "./ActionsMenu/index.jsx"; import { memo } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { formatDurationRounded } from "../../../Utils/timeUtils.js"; +import { formatDurationRounded } from "../../../Utils/timeUtilsLegacy.js"; import { StatusLabel } from "@/Components/v1/Label/index.jsx"; import { setRowsPerPage } from "../../../Features/UI/uiSlice.js"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Notifications/components/ActionMenu.jsx b/client/src/Pages/Notifications/components/ActionMenu.jsx deleted file mode 100644 index a3ccc4bbb..000000000 --- a/client/src/Pages/Notifications/components/ActionMenu.jsx +++ /dev/null @@ -1,83 +0,0 @@ -// Components -import Menu from "@mui/material/Menu"; -import IconButton from "@mui/material/IconButton"; -import Icon from "@/Components/v1/Icon"; -import MenuItem from "@mui/material/MenuItem"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; - -const ActionMenu = ({ notification, onDelete }) => { - const theme = useTheme(); - const navigate = useNavigate(); - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - const { t } = useTranslation(); - // Handlers - const handleClick = (event) => { - event.stopPropagation(); - setAnchorEl(event.currentTarget); - }; - - const handleClose = (event) => { - if (event) { - event.stopPropagation(); - } - setAnchorEl(null); - }; - - const handleRemove = (e) => { - e.stopPropagation(); - onDelete(notification.id); - handleClose(); - }; - - const handleConfigure = (e) => { - e.stopPropagation(); - navigate(`/notifications/${notification.id}`); - handleClose(); - }; - - return ( - <> - e.stopPropagation()} - > - - - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - {t("configure")} - - {t("delete")} - - - - ); -}; - -ActionMenu.propTypes = { - notification: PropTypes.object, - onDelete: PropTypes.func, -}; - -export default ActionMenu; diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx new file mode 100644 index 000000000..2885a34bb --- /dev/null +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -0,0 +1,96 @@ +import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; +import Typography from "@mui/material/Typography"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import { Table } from "@/Components/v2/design-elements"; + +import type { Notification } from "@/Types/Notification"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +interface NotificationsTableProps { + notifications: Notification[]; + setSelectedChannel: Function; +} + +export const NotificationsTable = ({ + notifications, + setSelectedChannel, +}: NotificationsTableProps) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + + const getActions = (channel: Notification): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/notifications/configure/${channel.id}`); + }, + closeMenu: true, + }, + + { + id: 7, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedChannel(channel); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.notificationName}; + }, + }, + + { + id: "type", + content: t("common.table.headers.type"), + render: (row) => { + return {row?.type}; + }, + }, + { + id: "destination", + content: t("pages.notifications.table.headers.destination"), + render: (row) => { + return {row?.address}; + }, + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + const headers = getHeaders(); + + return ( +
{ + navigate(`/notifications/configure/${row.id}`); + }} + /> + ); +}; diff --git a/client/src/Pages/Notifications/create/index.jsx b/client/src/Pages/Notifications/create/index.jsx deleted file mode 100644 index 14b72ab61..000000000 --- a/client/src/Pages/Notifications/create/index.jsx +++ /dev/null @@ -1,320 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import Button from "@mui/material/Button"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import Box from "@mui/material/Box"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { - useCreateNotification, - useGetNotificationById, - useEditNotification, - useTestNotification, - useDeleteNotification, -} from "../../../Hooks/useNotifications.js"; -import { notificationValidation } from "../../../Validation/validation.js"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; -import { useParams, useNavigate } from "react-router-dom"; -import { - NOTIFICATION_TYPES, - TITLE_MAP, - DESCRIPTION_MAP, - LABEL_MAP, - PLACEHOLDER_MAP, -} from "../utils.js"; - -// Setup - -const CreateNotifications = () => { - const { notificationId } = useParams(); - const navigate = useNavigate(); - const theme = useTheme(); - const [createNotification, isCreating] = useCreateNotification(); - const [editNotification, isEditing] = useEditNotification(); - const [testNotification, isTesting] = useTestNotification(); - const [deleteNotification, isDeleting] = useDeleteNotification(); - - const BREADCRUMBS = [ - { name: "notifications", path: "/notifications" }, - { - name: notificationId ? "edit" : "create", - path: notificationId ? `/notifications/${notificationId}` : "/notifications/create", - }, - ]; - - // Redux state - - // local state - const [notification, setNotification] = useState({ - notificationName: "", - address: "", - type: NOTIFICATION_TYPES[0].id, - }); - const [errors, setErrors] = useState({}); - const { t } = useTranslation(); - - const [notificationIsLoading] = useGetNotificationById(notificationId, setNotification); - - const [isOpen, setIsOpen] = useState(false); - - const getNotificationTypeValue = (typeId) => { - return NOTIFICATION_TYPES.find((type) => type.id === typeId)?.value || "email"; - }; - - const extractError = (error, field) => - error?.details.find((d) => d.path.includes(field))?.message; - - // handlers - const onSubmit = (e) => { - e.preventDefault(); - const form = { - ...notification, - type: getNotificationTypeValue(notification.type), - }; - - let error = null; - - error = notificationValidation.validate(form, { abortEarly: false }).error; - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - createToast({ body: Object.values(newErrors)[0] }); - setErrors(newErrors); - return; - } - - if (notificationId) { - editNotification(notificationId, form); - } else { - createNotification(form); - } - }; - - const onChange = (e) => { - const { name, value } = e.target; - let rawNotification = { ...notification, [name]: value }; - let newNotification = { - ...rawNotification, - type: getNotificationTypeValue(rawNotification.type), - }; - - const { error } = notificationValidation.validate(newNotification, { - abortEarly: false, - }); - let validationError = { ...errors }; - - if (name === "type") { - validationError["type"] = extractError(error, "type"); - validationError["address"] = extractError(error, "address"); - } else { - validationError[name] = extractError(error, name); - } - - setNotification(rawNotification); - setErrors(validationError); - }; - - const onTestNotification = () => { - const form = { - ...notification, - type: getNotificationTypeValue(notification.type), - }; - - let error = null; - - error = notificationValidation.validate(form, { abortEarly: false }).error; - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - createToast({ body: Object.values(newErrors)[0] }); - setErrors(newErrors); - return; - } - - testNotification(form); - }; - - const onDelete = () => { - if (notificationId) { - deleteNotification(notificationId, () => navigate("/notifications")); - } - }; - - const type = getNotificationTypeValue(notification.type); - return ( - - - {t("createNotifications.title")} - - - - - {t("createNotifications.nameSettings.title")} - - - {t("createNotifications.nameSettings.description")} - - - - - - - - - - {t("createNotifications.typeSettings.title")} - - - {t("createNotifications.typeSettings.description")} - - - - + {NotificationChannels.map((type: string) => ( + + {type} + + ))} + + )} + /> + } + /> + {watchedType !== "matrix" && ( + ( + + )} + /> + } + /> + )} + {watchedType === "matrix" && ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + } + /> + )} + + + + + + ); +}; + +export default NotificationsCreatePage; diff --git a/client/src/Pages/Notifications/index.jsx b/client/src/Pages/Notifications/index.jsx deleted file mode 100644 index 70ed3ef96..000000000 --- a/client/src/Pages/Notifications/index.jsx +++ /dev/null @@ -1,121 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import Button from "@mui/material/Button"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import ActionMenu from "./components/ActionMenu.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { - useGetNotificationsByTeamId, - useDeleteNotification, -} from "../../Hooks/useNotifications.js"; -import { useTranslation } from "react-i18next"; - -const Notifications = () => { - const navigate = useNavigate(); - const theme = useTheme(); - const BREADCRUMBS = [{ name: "notifications", path: "/notifications" }]; - const [updateTrigger, setUpdateTrigger] = useState(false); - const [notifications, isLoading, error] = useGetNotificationsByTeamId(updateTrigger); - const [deleteNotification, isDeleting, deleteError] = useDeleteNotification(); - const { t } = useTranslation(); - // Handlers - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - const onDelete = (id) => { - deleteNotification(id, triggerUpdate); - }; - - const headers = [ - { - id: "name", - content: "Name", - render: (row) => { - return row.notificationName; - }, - }, - { - id: "target", - content: "Target", - render: (row) => { - return row.address; - }, - }, - { - id: "platform", - content: "Platform", - render: (row) => { - return row?.config?.platform || row.type; - }, - }, - - { - id: "actions", - content: "Actions", - onClick: (e) => { - e.stopPropagation(); - }, - render: (row) => { - return ( - - ); - }, - }, - ]; - - return ( - <> - - - - - - - {t("notifications.createTitle")} - navigate(`/notifications/${row.id}`), - rowSX: { - cursor: "pointer", - "&:hover td": { - backgroundColor: theme.palette.tertiary.main, - transition: "background-color .3s ease", - }, - }, - }} - headers={headers} - data={notifications} - /> - - - - ); -}; - -export default Notifications; diff --git a/client/src/Pages/Notifications/index.tsx b/client/src/Pages/Notifications/index.tsx new file mode 100644 index 000000000..daf59e74a --- /dev/null +++ b/client/src/Pages/Notifications/index.tsx @@ -0,0 +1,73 @@ +import { BasePageWithStates } from "@/Components/v2/design-elements"; +import { NotificationsTable } from "@/Pages/Notifications/components/NotificationsTable"; +import { Dialog } from "@/Components/v2/inputs"; +import { HeaderCreate } from "@/Components/v2/common"; + +import { useState } from "react"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import { useTranslation } from "react-i18next"; +import type { Notification } from "@/Types/Notification"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; + +const NotificationsPage = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + + const [selectedChannel, setSelectedChannel] = useState(null); + const isDialogOpen = Boolean(selectedChannel); + + const { + data: notifications, + isLoading, + isValidating, + error, + refetch, + } = useGet("/notifications/team", {}, { keepPreviousData: true }); + + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleConfirm = async () => { + if (!selectedChannel) return; + await deleteFn(`/notifications/${selectedChannel.id}`); + setSelectedChannel(null); + refetch(); + }; + + const handleCancel = () => { + setSelectedChannel(null); + }; + + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/client/src/Pages/Notifications/utils.js b/client/src/Pages/Notifications/utils.js deleted file mode 100644 index 9ad422101..000000000 --- a/client/src/Pages/Notifications/utils.js +++ /dev/null @@ -1,44 +0,0 @@ -export const NOTIFICATION_TYPES = [ - { id: 1, name: "E-mail", value: "email" }, - { id: 2, name: "Slack", value: "slack" }, - { id: 3, name: "PagerDuty", value: "pager_duty" }, - { id: 4, name: "Webhook", value: "webhook" }, - { id: 5, name: "Discord", value: "discord" }, - { id: 6, name: "Matrix", value: "matrix" }, -]; - -export const TITLE_MAP = { - email: "createNotifications.emailSettings.title", - slack: "createNotifications.slackSettings.title", - pager_duty: "createNotifications.pagerdutySettings.title", - webhook: "createNotifications.webhookSettings.title", - discord: "createNotifications.discordSettings.title", - matrix: "createNotifications.matrixSettings.title", -}; - -export const DESCRIPTION_MAP = { - email: "createNotifications.emailSettings.description", - slack: "createNotifications.slackSettings.description", - pager_duty: "createNotifications.pagerdutySettings.description", - webhook: "createNotifications.webhookSettings.description", - discord: "createNotifications.discordSettings.description", - matrix: "createNotifications.matrixSettings.description", -}; - -export const LABEL_MAP = { - email: "createNotifications.emailSettings.emailLabel", - slack: "createNotifications.slackSettings.webhookLabel", - pager_duty: "createNotifications.pagerdutySettings.integrationKeyLabel", - webhook: "createNotifications.webhookSettings.webhookLabel", - discord: "createNotifications.discordSettings.webhookLabel", - matrix: "createNotifications.matrixSettings.homeserverLabel", -}; - -export const PLACEHOLDER_MAP = { - email: "createNotifications.emailSettings.emailPlaceholder", - slack: "createNotifications.slackSettings.webhookPlaceholder", - pager_duty: "createNotifications.pagerdutySettings.integrationKeyPlaceholder", - webhook: "createNotifications.webhookSettings.webhookPlaceholder", - discord: "createNotifications.discordSettings.webhookPlaceholder", - matrix: "createNotifications.matrixSettings.homeserverPlaceholder", -}; diff --git a/client/src/Pages/PageSpeed/Create/index.css b/client/src/Pages/PageSpeed/Create/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Pages/PageSpeed/Create/index.jsx b/client/src/Pages/PageSpeed/Create/index.jsx deleted file mode 100644 index 6a786eb00..000000000 --- a/client/src/Pages/PageSpeed/Create/index.jsx +++ /dev/null @@ -1,514 +0,0 @@ -// Components -import { Box, Stack, Tooltip, Typography, Button, ButtonGroup } from "@mui/material"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import PulseDot from "@/Components/v1/Animated/PulseDot.jsx"; -import SkeletonLayout from "./skeleton.jsx"; -import NotificationsConfig from "@/Components/v1/NotificationConfig/index.jsx"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import { HttpAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import Radio from "@/Components/v1/Inputs/Radio/index.jsx"; - -// Utils -import { useState, useEffect } from "react"; -import { useSelector } from "react-redux"; -import { monitorValidation } from "../../../Validation/validation.js"; -import { parseDomainName } from "../../../Utils/monitorUtilsLegacy.js"; -import { useTranslation } from "react-i18next"; -import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications.js"; -import { useTheme } from "@emotion/react"; -import { createToast } from "../../../Utils/toastUtils.jsx"; - -import { useParams } from "react-router"; -import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js"; -import { - useCreateMonitor, - useFetchMonitorById, - useDeleteMonitor, - useUpdateMonitor, - usePauseMonitor, -} from "../../../Hooks/monitorHooks.js"; - -const PageSpeedSetup = () => { - const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined"; - const CRUMBS = [ - { name: "pagespeed", path: "/pagespeed" }, - ...(isCreate - ? [{ name: "create", path: `/pagespeed/create` }] - : [ - { name: "details", path: `/pagespeed/${monitorId}` }, - { name: "configure", path: `/pagespeed/configure/${monitorId}` }, - ]), - ]; - - // States - const [monitor, setMonitor] = useState( - isCreate - ? { - url: "", - name: "", - type: "pagespeed", - notifications: [], - interval: 180000, - } - : {} - ); - const [https, setHttps] = useState(true); - const [errors, setErrors] = useState({}); - const [isOpen, setIsOpen] = useState(false); - const [updateTrigger, setUpdateTrigger] = useState(false); - - // Setup - const { t } = useTranslation(); - const theme = useTheme(); - // Constants - const MS_PER_MINUTE = 60000; - const FREQUENCIES = [ - { _id: 3, name: t("time.threeMinutes") }, - { _id: 5, name: t("time.fiveMinutes") }, - { _id: 10, name: t("time.tenMinutes") }, - { _id: 20, name: t("time.twentyMinutes") }, - { _id: 60, name: t("time.oneHour") }, - { _id: 1440, name: t("time.oneDay") }, - { _id: 10080, name: t("time.oneWeek") }, - ]; - - const { user } = useSelector((state) => state.auth); - const { statusColor, pagespeedStatusMsg, determineState } = useMonitorUtils(); - const [notifications, notificationsAreLoading, notificationsError] = - useGetNotificationsByTeamId(); - - // Hooks for API actions - const [isLoading] = useFetchMonitorById({ monitorId, setMonitor, updateTrigger }); - const [createMonitor, isCreating] = useCreateMonitor(); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - const [updateMonitor, isUpdating] = useUpdateMonitor(); - const [pauseMonitor, isPausing] = usePauseMonitor(); - - // Handlers - const onSubmit = async (event) => { - event.preventDefault(); - if (isCreate) { - let form = { - url: `http${https ? "s" : ""}://` + monitor.url, - name: monitor.name === "" ? monitor.url : monitor.name, - type: monitor.type, - interval: monitor.interval, - }; - - const { error } = monitorValidation.validate(form, { abortEarly: false }); - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: t("checkFormError") }); - return; - } - - form = { - ...form, - description: form.name, - notifications: monitor.notifications, - }; - await createMonitor({ monitor: form, redirect: "/pagespeed" }); - } else { - const monitorParams = { - url: monitor.url, - name: monitor.name === "" ? monitor.url : monitor.name, - type: monitor.type, - interval: monitor.interval, - }; - const { error } = monitorValidation.validate(monitorParams, { abortEarly: false }); - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: t("checkFormError") }); - return; - } - await updateMonitor({ monitor, redirect: "/pagespeed" }); - } - }; - - const handleChange = (event) => { - let { value, name } = event.target; - - if (name === "interval") { - value = value * MS_PER_MINUTE; - } - - setMonitor({ - ...monitor, - [name]: value, - }); - - const { error } = monitorValidation.validate( - { [name]: value, type: monitor.type }, - { abortEarly: false } - ); - setErrors((prev) => ({ - ...prev, - ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), - })); - }; - - const handleBlur = (event) => { - const { name, value } = event.target; - if (name === "url" && monitor.name === "") { - setMonitor((prev) => ({ ...prev, name: parseDomainName(value) })); - } - }; - - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - const handlePause = async () => { - await pauseMonitor({ monitorId, triggerUpdate }); - }; - - const handleRemove = async (event) => { - event.preventDefault(); - await deleteMonitor({ monitor: { id: monitorId }, redirect: "/pagespeed" }); - }; - - const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; - - if (Object.keys(monitor).length === 0) { - return ; - } - - return ( - - - - - - - - {!isCreate ? monitor.name : t("createYour") + " "} - - {isCreate ? ( - - {t("pageSpeedMonitor")} - - ) : ( - <> - )} - - {!isCreate && ( - - - - - - - - {monitor.url?.replace(/^https?:\/\//, "") || "..."} - - - {t("editing")} - - - )} - - {!isCreate && ( - - - - - )} - - - - - {t("settingsGeneralSettings")} - - - {t("pageSpeedConfigureSettingsDescription")} - - - .Mui-disabled)": { - backgroundColor: theme.palette.tertiary.main, - }, - }} - > - : undefined} - placeholder="random.website.com" - value={monitor.url || ""} - onChange={handleChange} - onBlur={isCreate ? handleBlur : undefined} - error={!!errors["url"]} - helperText={errors["url"]} - disabled={!isCreate} - /> - - - - {isCreate && ( - - - - {t("distributedUptimeCreateChecks")} - - - {t("distributedUptimeCreateChecksDescription")} - - - - - - - - - - - {errors["type"] ? ( - - - {errors["type"]} - - - ) : ( - "" - )} - - - )} - - - - {t("notificationConfig.title")} - - {t("notificationConfig.description")} - - - - - - - {t("distributedUptimeCreateAdvancedSettings")} - - - -
{ + navigate(`/pagespeed/${row.id}`); + }} + /> + + + ); +}; diff --git a/client/src/Pages/PageSpeed/Monitors/index.jsx b/client/src/Pages/PageSpeed/Monitors/index.jsx deleted file mode 100644 index f1bfcd0dd..000000000 --- a/client/src/Pages/PageSpeed/Monitors/index.jsx +++ /dev/null @@ -1,86 +0,0 @@ -// Components -import { useState } from "react"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import { Stack } from "@mui/material"; -import CreateMonitorHeader from "@/Components/v1/MonitorCreateHeader/index.jsx"; -import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx"; -import MonitorGrid from "./Components/MonitorGrid/index.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; -import FallbackPageSpeedWarning from "@/Components/v1/Fallback/FallbackPageSpeedWarning.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js"; -import { useFetchSettings } from "@/Hooks/settingsHooks.js"; -// Constants -const BREADCRUMBS = [{ name: `pagespeed`, path: "/pagespeed" }]; -const TYPES = ["pagespeed"]; -const PageSpeed = () => { - const theme = useTheme(); - const isAdmin = useIsAdmin(); - - const [ - summary, - monitorsWithChecks, - monitorsWithChecksCount, - monitorsWithChecksIsLoading, - monitorsWithChecksNetworkError, - ] = useFetchMonitorsWithChecks({ - types: TYPES, - limit: 10, - page: null, - rowsPerPage: null, - filter: null, - field: null, - order: null, - monitorUpdateTrigger: null, - }); - - const [settingsData, setSettingsData] = useState(undefined); - const [isSettingsLoading, settingsError] = useFetchSettings({ - setSettingsData, - setIsApiKeySet: () => {}, - setIsEmailPasswordSet: () => {}, - }); - - return ( - <> - - ) - } - > - - - - - - - - - ); -}; - -export default PageSpeed; diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx new file mode 100644 index 000000000..887ec02c0 --- /dev/null +++ b/client/src/Pages/PageSpeed/Monitors/index.tsx @@ -0,0 +1,117 @@ +import { + MonitorBasePageWithStates, + UpStatusBox, + DownStatusBox, + PausedStatusBox, + PageSpeedKeyPriorityFallback, +} from "@/Components/v2/design-elements"; +import { Dialog } from "@/Components/v2/inputs"; +import { HeaderCreate } from "@/Components/v2/common"; +import Stack from "@mui/material/Stack"; +import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable"; +import type { Monitor } from "@/Types/Monitor"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import type { MonitorsWithChecksResponse } from "@/Types/Monitor"; +import type { AppSettingsResponse } from "@/Types/Settings"; + +const PageSpeedMonitorsPage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const isAdmin = useIsAdmin(); + const { deleteFn, loading: isDeleting } = useDelete(); + + const monitorsUrl = "/monitors/team/with-checks?type=pagespeed&limit=10"; + const { + data: monitorsData, + isLoading: monitorsIsLoading, + error: monitorsError, + refetch, + } = useGet(monitorsUrl, {}, { refreshInterval: 30000 }); + + const settingsUrl = "/settings"; + const { + data: settingsData, + isLoading: settingsIsLoading, + error: settingsError, + } = useGet(settingsUrl); + + const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); + const [sortField, setSortField] = useState("name"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + + const monitors = monitorsData?.monitors; + const monitorsCount = monitorsData?.count; + const summary = monitorsData?.summary; + + const isLoading = monitorsIsLoading || settingsIsLoading; + + const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet; + + const handleConfirm = async () => { + if (!selectedMonitor) return; + await deleteFn(`/monitors/${selectedMonitor.id}`); + setSelectedMonitor(null); + refetch(); + }; + + const handleCancel = () => { + setSelectedMonitor(null); + }; + + return ( + : undefined} + > + + + + + + + + + + ); +}; + +export default PageSpeedMonitorsPage; diff --git a/client/src/Pages/Settings/DummyChart.tsx b/client/src/Pages/Settings/DummyChart.tsx index 6996af9c5..39323522e 100644 --- a/client/src/Pages/Settings/DummyChart.tsx +++ b/client/src/Pages/Settings/DummyChart.tsx @@ -2,31 +2,23 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common"; -import type { Check } from "@/Types/Check"; +import type { CheckSnapshot } from "@/Types/Check"; interface DummyChartProps { chartType: string; } -const generateDummyChecks = (): Check[] => { - const checks: Check[] = []; +const generateDummyChecks = (): CheckSnapshot[] => { + const checks: CheckSnapshot[] = []; for (let i = 0; i < 25; i++) { const isUp = Math.random() > 0.1; checks.push({ id: `dummy-${i}`, - metadata: { - monitorId: "dummy-monitor", - teamId: "dummy-team", - type: "http", - }, status: isUp, statusCode: isUp ? 200 : 500, responseTime: Math.floor(Math.random() * 80) + 20, message: "", - ack: false, - expiry: new Date(Date.now() + 86400000).toISOString(), createdAt: new Date(Date.now() - i * 60000).toISOString(), - updatedAt: new Date(Date.now() - i * 60000).toISOString(), }); } return checks; diff --git a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx b/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx index 256fa4be9..c0bcf5ab4 100644 --- a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx +++ b/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx @@ -6,7 +6,6 @@ const useCreateStatusPage = (isCreate) => { const [isLoading, setIsLoading] = useState(false); const [networkError, setNetworkError] = useState(false); const createStatusPage = async ({ form, id }) => { - console.log(id); setIsLoading(true); try { await networkService.createStatusPage({ form, isCreate, id }); diff --git a/client/src/Pages/Uptime/Create/index.css b/client/src/Pages/Uptime/Create/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx deleted file mode 100644 index 01fc9eb7a..000000000 --- a/client/src/Pages/Uptime/Create/index.jsx +++ /dev/null @@ -1,826 +0,0 @@ -//Components -import { - Box, - Button, - ButtonGroup, - FormControlLabel, - Stack, - Switch, - Tooltip, - Typography, -} from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { HttpAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import Radio from "@/Components/v1/Inputs/Radio/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import NotificationsConfig from "@/Components/v1/NotificationConfig/index.jsx"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import PulseDot from "@/Components/v1/Animated/PulseDot.jsx"; -import SkeletonLayout from "./skeleton.jsx"; - -// Utils -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { monitorValidation } from "../../../Validation/validation.js"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import Icon from "@/Components/v1/Icon"; -import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js"; -import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications.js"; -import { useParams } from "react-router-dom"; -import { - useCreateMonitor, - useDeleteMonitor, - useUpdateMonitor, - usePauseMonitor, - useFetchMonitorById, - useFetchMonitorGames, -} from "../../../Hooks/monitorHooks.js"; - -/** - * Create page renders monitor creation or configuration views. - * @component - */ -const UptimeCreate = ({ isClone = false }) => { - const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined" || isClone; - - // States - const [monitor, setMonitor] = useState({ - type: "http", - statusWindowSize: 5, - statusWindowThreshold: 60, - matchMethod: "equal", - expectedValue: "", - jsonPath: "", - notifications: [], - interval: 60000, - ignoreTlsErrors: false, - ...(isCreate ? { url: "", name: "" } : { port: undefined }), - }); - const [errors, setErrors] = useState({}); - const [https, setHttps] = useState(true); - const [isOpen, setIsOpen] = useState(false); - const [useAdvancedMatching, setUseAdvancedMatching] = useState(false); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [games, setGames] = useState({}); - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - // Hooks - const [notifications, notificationsAreLoading, notificationsError] = - useGetNotificationsByTeamId(); - const { determineState, statusColor } = useMonitorUtils(); - // Fetch monitor details - const [isFetchingMonitor] = useFetchMonitorById({ - monitorId, - setMonitor, - updateTrigger, - }); - - // Fetch games - const [isFetchingGames] = useFetchMonitorGames({ - setGames, - updateTrigger, - }); - - // Combine the loading states - const isLoading = isFetchingMonitor || isFetchingGames; - - const [createMonitor, isCreating] = useCreateMonitor(); - const [pauseMonitor, isPausing] = usePauseMonitor({}); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - const [updateMonitor, isUpdating] = useUpdateMonitor(); - - // Setup - const theme = useTheme(); - const { t } = useTranslation(); - - // Constants - const MS_PER_MINUTE = 60000; - const FREQUENCIES = [ - { _id: 0.25, name: t("time.fifteenSeconds") }, - { _id: 0.5, name: t("time.thirtySeconds") }, - { _id: 1, name: t("time.oneMinute") }, - { _id: 2, name: t("time.twoMinutes") }, - { _id: 3, name: t("time.threeMinutes") }, - { _id: 4, name: t("time.fourMinutes") }, - { _id: 5, name: t("time.fiveMinutes") }, - { _id: 10, name: t("time.tenMinutes") }, - { _id: 15, name: t("time.fifteenMinutes") }, - { _id: 30, name: t("time.thirtyMinutes") }, - ]; - - const GAMELIST = Object.entries(games).map(([key, value]) => ({ - _id: key, - name: value.name, - })); - - const CRUMBS = [ - { name: "uptime", path: "/uptime" }, - ...(isCreate - ? [{ name: "create", path: `/uptime/create` }] - : [ - { name: "details", path: `/uptime/${monitorId}` }, - { name: "configure", path: `/uptime/configure/${monitorId}` }, - ]), - ]; - const matchMethodOptions = [ - { _id: "equal", name: t("matchMethodOptions.equal") }, - { _id: "include", name: t("matchMethodOptions.include") }, - { _id: "regex", name: t("matchMethodOptions.regex") }, - ]; - const expectedValuePlaceholders = { - regex: t("matchMethodOptions.regexPlaceholder"), - equal: t("matchMethodOptions.equalPlaceholder"), - include: t("matchMethodOptions.includePlaceholder"), - }; - const monitorTypeMaps = { - http: { - label: t("monitorType.http.label"), - placeholder: t("monitorType.http.placeholder"), - namePlaceholder: t("monitorType.http.namePlaceholder"), - }, - ping: { - label: t("monitorType.ping.label"), - placeholder: t("monitorType.ping.placeholder"), - namePlaceholder: t("monitorType.ping.namePlaceholder"), - }, - docker: { - label: t("monitorType.docker.label"), - placeholder: t("monitorType.docker.placeholder"), - namePlaceholder: t("monitorType.docker.namePlaceholder"), - }, - port: { - label: t("monitorType.port.label"), - placeholder: t("monitorType.port.placeholder"), - namePlaceholder: t("monitorType.port.namePlaceholder"), - }, - game: { - label: t("monitorType.game.label"), - placeholder: t("monitorType.game.placeholder"), - namePlaceholder: t("monitorType.game.namePlaceholder"), - }, - }; - - // Handlers - const onSubmit = async (event) => { - event.preventDefault(); - const { notifications, ...rest } = monitor; - - let form = {}; - if (isCreate) { - form = { - url: - monitor.type === "http" && !isClone - ? `http${https ? "s" : ""}://` + monitor.url - : monitor.url, - name: monitor.name || monitor.url.substring(0, 50), - statusWindowSize: monitor.statusWindowSize, - statusWindowThreshold: monitor.statusWindowThreshold, - type: monitor.type, - - port: - monitor.type === "port" || monitor.type === "game" ? monitor.port : undefined, - interval: monitor.interval, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, - jsonPath: monitor.jsonPath, - ignoreTlsErrors: monitor.ignoreTlsErrors, - gameId: monitor.gameId || undefined, - }; - } else { - form = { - id: monitor.id, - url: monitor.url, - name: monitor.name || monitor.url.substring(0, 50), - statusWindowSize: monitor.statusWindowSize, - statusWindowThreshold: monitor.statusWindowThreshold, - type: monitor.type, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, - jsonPath: monitor.jsonPath, - interval: monitor.interval, - teamId: monitor.teamId, - userId: monitor.userId, - port: - monitor.type === "port" || monitor.type === "game" ? monitor.port : undefined, - ignoreTlsErrors: monitor.ignoreTlsErrors, - gameId: monitor.gameId || undefined, - }; - } - if (!useAdvancedMatching) { - form.matchMethod = isCreate ? undefined : ""; - form.expectedValue = isCreate ? undefined : ""; - form.jsonPath = isCreate ? undefined : ""; - } - - const { error } = monitorValidation.validate(form, { - abortEarly: false, - }); - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: t("checkFormError") }); - return; - } - - form = { - ...form, - description: monitor.name || monitor.url, - notifications: monitor.notifications, - }; - - if (isCreate) { - await createMonitor({ monitor: form, redirect: "/uptime" }); - } else { - await updateMonitor({ monitor: form, redirect: "/uptime" }); - } - }; - - const onChange = (event) => { - let { name, value, checked } = event.target; - - if (name === "ignoreTlsErrors") { - value = checked; - } - - if (name === "useAdvancedMatching") { - setUseAdvancedMatching(checked); - return; - } - - if (name === "interval") { - value = value * MS_PER_MINUTE; - } - - setMonitor((prev) => ({ ...prev, [name]: value })); - - if (name === "type") { - setErrors({}); - return; - } - - const { error } = monitorValidation.validate( - { type: monitor.type, [name]: value }, - { abortEarly: false } - ); - - setErrors((prev) => ({ - ...prev, - ...(error && error.details[0].path[0] === name - ? { [name]: error.details[0].message } - : { [name]: undefined }), - })); - }; - - const handleRemove = async (event) => { - event.preventDefault(); - const TEMP_MONITOR = { id: monitor.id }; - await deleteMonitor({ monitor: TEMP_MONITOR, redirect: "/uptime" }); - }; - - const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; - const displayInterval = monitor?.interval / MS_PER_MINUTE || 1; - const parsedUrl = monitor?.url; - const protocol = parsedUrl?.protocol?.replace(":", "") || ""; - - useEffect(() => { - if (!isCreate || isClone) { - if (monitor.matchMethod) { - setUseAdvancedMatching(true); - } else { - setUseAdvancedMatching(false); - } - } - }, [monitor, isCreate]); - - if (Object.keys(monitor).length === 0) { - return ; - } - - return ( - - - - - - - - - {!isCreate ? monitor.name : t("createYour") + " "} - - {isCreate && ( - - {t("monitor")} - - )} - - {!isCreate && ( - - - - - - - - {monitor.url?.replace(/^https?:\/\//, "") || "..."} - - - {t("editing")} - - - )} - - {!isCreate && ( - - - - - )} - - {isCreate && ( - - - - {t("distributedUptimeCreateChecks")} - - - {t("distributedUptimeCreateChecksDescription")} - - - - - - {monitor.type === "http" ? ( - - - - - ) : ( - "" - )} - - - - - - {errors["type"] ? ( - - - {errors["type"]} - - - ) : ( - "" - )} - - - )} - - - - {t("settingsGeneralSettings")} - - - {isCreate - ? t(`uptimeGeneralInstructions.${monitor.type}`) - : t("distributedUptimeCreateSelectURL")} - - - - - ) - } - helperText={errors["url"]} - onChange={onChange} - disabled={!isCreate} - /> -
{ + navigate(`/checks/${row.id}`); + }} + /> + + + ); +}; diff --git a/client/src/Pages/Uptime/Details/Components/ResponseTable/index.jsx b/client/src/Pages/Uptime/Details/Components/ResponseTable/index.jsx deleted file mode 100644 index 2d7991324..000000000 --- a/client/src/Pages/Uptime/Details/Components/ResponseTable/index.jsx +++ /dev/null @@ -1,222 +0,0 @@ -import ChartBox from "@/Components/v1/Charts/ChartBox/index.jsx"; -import PropTypes from "prop-types"; -import Icon from "@/Components/v1/Icon"; -import Table from "@/Components/v1/Table/index.jsx"; -import TablePagination from "@/Components/v1/Table/TablePagination/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import { useTranslation } from "react-i18next"; -import { formatDateWithTz } from "../../../../../Utils/timeUtils.js"; -import SkeletonLayout from "./skeleton.jsx"; -import Tooltip from "@mui/material/Tooltip"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Stack from "@mui/material/Stack"; -import { lighten, useTheme } from "@mui/material"; - -/** - * Creates tooltip content with detailed timing breakdown - * Following the pattern from IncidentTable's GetTooltip function - * @param {Object} timings - Timing object (guaranteed to have phases by caller) - * @param {Object} theme - MUI theme object - * @param {Function} t - Translation function - * @returns {JSX.Element} Tooltip content - */ -const GetTooltip = (timings, theme, t) => { - const phases = timings.phases; - const timingDetails = [ - { label: t("dnsLookup"), value: phases.dns }, - { label: t("tcpConnection"), value: phases.tcp }, - { label: t("tlsHandshake"), value: phases.tls }, - { label: t("waitTime"), value: phases.wait }, - { label: t("timeToFirstByte"), value: phases.firstByte }, - { label: t("download"), value: phases.download }, - { label: t("total"), value: phases.total }, - ].filter((item) => item.value > 0); - - return ( - - - {t("responseTimeBreakdown")} - - {timingDetails.map((detail, index, array) => ( - - - {detail.label}: - - - {Math.round(detail.value)} ms - - - ))} - - ); -}; - -const ResponseTable = ({ - isLoading = false, - checks = [], - checksCount, - uiTimezone, - page, - setPage, - rowsPerPage, - setRowsPerPage, -}) => { - const { t } = useTranslation(); - const theme = useTheme(); - - if (isLoading) { - return ; - } - - const headers = [ - { - id: "status", - content: t("status"), - render: (row) => { - const status = row.status === true ? "up" : "down"; - - return ( - - ); - }, - }, - { - id: "date", - content: t("date&Time"), - render: (row) => - formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone), - }, - { - id: "statusCode", - content: t("statusCode"), - render: (row) => (row.statusCode ? row.statusCode : "N/A"), - }, - { - id: "message", - content: t("message"), - render: (row) => row.message, - }, - { - id: "responseTime", - content: t("responseTime"), - render: (row) => { - const hasTimings = row.timings && row.timings.phases; - const responseTime = row.responseTime; - const responseTimeDisplay = - responseTime !== null && responseTime !== undefined - ? `${Math.round(responseTime)} ms` - : "N/A"; - - if (!hasTimings) { - return responseTimeDisplay; - } - - return ( - - - {responseTimeDisplay} - - - ); - }, - }, - ]; - - return ( - - } - header="Response Times" - height="100%" - > -
- - - ); -}; - -ResponseTable.propTypes = { - isLoading: PropTypes.bool, - checks: PropTypes.array, - checksCount: PropTypes.number, - uiTimezone: PropTypes.string.isRequired, - page: PropTypes.number.isRequired, - setPage: PropTypes.func.isRequired, - rowsPerPage: PropTypes.number.isRequired, - setRowsPerPage: PropTypes.func.isRequired, -}; - -export default ResponseTable; diff --git a/client/src/Pages/Uptime/Details/Components/ResponseTable/skeleton.jsx b/client/src/Pages/Uptime/Details/Components/ResponseTable/skeleton.jsx deleted file mode 100644 index 2665ffaf1..000000000 --- a/client/src/Pages/Uptime/Details/Components/ResponseTable/skeleton.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Uptime/Details/Components/UptimeStatusBoxes/index.jsx b/client/src/Pages/Uptime/Details/Components/UptimeStatusBoxes/index.jsx deleted file mode 100644 index 252873cf9..000000000 --- a/client/src/Pages/Uptime/Details/Components/UptimeStatusBoxes/index.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx"; -import StatBox from "@/Components/v1/StatBox/index.jsx"; - -import PropTypes from "prop-types"; -import { getHumanReadableDuration } from "../../../../../Utils/timeUtils.js"; -import { useTheme } from "@mui/material/styles"; -import { Typography } from "@mui/material"; -import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js"; - -const UptimeStatusBoxes = ({ - isLoading = false, - monitor, - monitorStats, - certificateExpiry, -}) => { - const theme = useTheme(); - const { determineState } = useMonitorUtils(); - - // Determine time since last failure - const timeOfLastFailure = monitorStats?.timeOfLastFailure; - const timeSinceLastFailure = - timeOfLastFailure > 0 - ? Date.now() - timeOfLastFailure - : Date.now() - new Date(monitorStats?.createdAt); - - // Determine time since last check - const timeOfLastCheck = monitorStats?.lastCheckTimestamp; - const timeSinceLastCheck = Date.now() - timeOfLastCheck; - - const streakTime = getHumanReadableDuration(timeSinceLastFailure); - - const lastCheckTime = getHumanReadableDuration(timeSinceLastCheck); - return ( - - - - {lastCheckTime} - {"ago"} - - } - /> - - {monitorStats?.lastResponseTime} - {"ms"} - - } - /> - {monitor?.type === "http" && ( - - {certificateExpiry} - - } - /> - )} - - ); -}; - -UptimeStatusBoxes.propTypes = { - isLoading: PropTypes.bool, - monitor: PropTypes.object, - monitorStats: PropTypes.object, - certificateExpiry: PropTypes.string, -}; - -export default UptimeStatusBoxes; diff --git a/client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx b/client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx deleted file mode 100644 index e74a99d82..000000000 --- a/client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { logger } from "../../../../Utils/Logger.js"; -import { useEffect, useState } from "react"; -import { networkService } from "../../../../main.jsx"; -import { formatDateWithTz } from "../../../../Utils/timeUtils.js"; - -const useCertificateFetch = ({ - monitor, - monitorId, - certificateDateFormat, - uiTimezone, -}) => { - const [certificateExpiry, setCertificateExpiry] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const fetchCertificate = async () => { - if (monitor?.type !== "http") { - return; - } - - try { - setIsLoading(true); - const res = await networkService.getCertificateExpiry({ - monitorId: monitorId, - }); - if (res?.data?.data?.certificateDate) { - const date = res.data.data.certificateDate; - setCertificateExpiry( - formatDateWithTz(date, certificateDateFormat, uiTimezone) ?? "N/A" - ); - } - } catch (error) { - setCertificateExpiry("N/A"); - logger.error(error); - } finally { - setIsLoading(false); - } - }; - fetchCertificate(); - }, [monitorId, certificateDateFormat, uiTimezone, monitor]); - return [certificateExpiry, isLoading]; -}; - -export default useCertificateFetch; diff --git a/client/src/Pages/Uptime/Details/index.jsx b/client/src/Pages/Uptime/Details/index.jsx deleted file mode 100644 index 144ceb4dc..000000000 --- a/client/src/Pages/Uptime/Details/index.jsx +++ /dev/null @@ -1,190 +0,0 @@ -// Components -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx"; -import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx"; -import ChartBoxes from "./Components/ChartBoxes/index.jsx"; -import ResponseTimeChart from "./Components/Charts/ResponseTimeChart.jsx"; -import ResponseTable from "./Components/ResponseTable/index.jsx"; -import UptimeStatusBoxes from "./Components/UptimeStatusBoxes/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; - -// Utils -import { useState } from "react"; -import { useParams } from "react-router-dom"; -import { useSelector } from "react-redux"; -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useFetchUptimeMonitorById } from "../../../Hooks/monitorHooks.js"; -import useCertificateFetch from "./Hooks/useCertificateFetch.jsx"; -import { useFetchChecksByMonitor } from "../../../Hooks/checkHooks.js"; -import { useTranslation } from "react-i18next"; - -// Constants -const BREADCRUMBS = [ - { name: "uptime", path: "/uptime" }, - { name: "details", path: "" }, - // { name: "details", path: `/uptime/${monitorId}` }, Is this needed? We can't click on this anywy -]; - -const certificateDateFormat = "MMM D, YYYY h A"; - -const UptimeDetails = () => { - // Redux state - const uiTimezone = useSelector((state) => state.ui.timezone); - - // Local state - const [dateRange, setDateRange] = useState("recent"); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(5); - const [trigger, setTrigger] = useState(false); - - // Utils - const dateFormat = - dateRange === "day" || dateRange === "recent" ? "MMM D, h A" : "MMM D"; - const { monitorId } = useParams(); - const theme = useTheme(); - const isAdmin = useIsAdmin(); - const { t } = useTranslation(); - - const [monitorData, monitorStats, monitorIsLoading, monitorNetworkError] = - useFetchUptimeMonitorById({ - monitorId, - dateRange, - trigger, - }); - - const monitor = monitorData?.monitor; - - const [certificateExpiry, certificateIsLoading] = useCertificateFetch({ - monitor, - monitorId, - certificateDateFormat, - uiTimezone, - }); - - const monitorType = monitor?.type; - - const [checks, checksCount, checksAreLoading, checksNetworkError] = - useFetchChecksByMonitor({ - monitorId, - type: monitorType, - sortOrder: "desc", - limit: null, - dateRange, - filter: null, - page, - rowsPerPage, - }); - - // Handlers - const triggerUpdate = () => { - setTrigger(!trigger); - }; - - const handlePageChange = (_, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(event.target.value); - }; - - if (monitorNetworkError || checksNetworkError) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - - // Empty view, displayed when loading is complete and there are no checks - if (!monitorIsLoading && !checksAreLoading && checksCount === 0) { - return ( - - - - - - - - - {t("distributedUptimeDetailsNoMonitorHistory")} - - - ); - } - - return ( - - - - - - - - - - ); -}; - -export default UptimeDetails; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx new file mode 100644 index 000000000..29c9f74db --- /dev/null +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -0,0 +1,167 @@ +import { BasePage } from "@/Components/v2/design-elements"; +import { HeaderTimeRange } from "@/Components/v2/common"; +import Stack from "@mui/material/Stack"; +import { + HistogramStatus, + RadialAvgResponse, + HistogramDetails, + HeaderMonitorControls, +} from "@/Components/v2/monitors"; +import { TrendingUp, AlertTriangle } from "lucide-react"; +import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; +import { MonitorStatBoxes } from "@/Components/v2/monitors"; + +import { useTheme } from "@mui/material/styles"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import { useState, useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { useGet } from "@/Hooks/UseApi"; +import type { MonitorDetailsResponse } from "@/Types/Monitor"; +import type { ChecksResponse } from "@/Types/Check"; +import type { RootState } from "@/Types/state"; +import { formatDateWithTz } from "@/Utils/timeUtilsLegacy"; +import { t } from "i18next"; + +const certificateDateFormat = "MMM D, YYYY h A"; + +interface CertificateResponse { + certificateDate: string; +} + +const UptimeDetailsPage = () => { + const theme = useTheme(); + const isAdmin = useIsAdmin(); + const { monitorId } = useParams<{ monitorId: string }>(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(5); + const [dateRange, setDateRange] = useState("recent"); + + const monitorDetailsUrl = useMemo(() => { + if (!monitorId) { + return null; + } + const params = new URLSearchParams(); + params.append("dateRange", dateRange); + params.append("normalize", "true"); + return `/monitors/uptime/details/${monitorId}?${params.toString()}`; + }, [monitorId, dateRange]); + + const { + data: monitorDetailsData, + isLoading: monitorIsLoading, + refetch: refetchMonitor, + } = useGet( + monitorDetailsUrl, + {}, + { refreshInterval: 10000, keepPreviousData: true } + ); + + const monitorData = monitorDetailsData?.monitorData; + const monitor = monitorData?.monitor; + const monitorStats = monitorDetailsData?.monitorStats ?? null; + + // Certificate fetch - only for HTTP monitors + const certificateUrl = useMemo(() => { + if (!monitorId || monitor?.type !== "http") { + return null; + } + return `/monitors/certificate/${monitorId}`; + }, [monitorId, monitor?.type]); + + const { data: certificateData } = useGet(certificateUrl); + + const certificateExpiry = useMemo(() => { + if (!certificateData?.certificateDate) { + return undefined; + } + return ( + formatDateWithTz( + certificateData.certificateDate, + certificateDateFormat, + uiTimezone + ) ?? "N/A" + ); + }, [certificateData, uiTimezone]); + + const checksUrl = useMemo(() => { + if (!monitorId || !monitor?.type) { + return null; + } + const params = new URLSearchParams(); + params.append("type", monitor.type); + params.append("sortOrder", "desc"); + params.append("dateRange", dateRange); + params.append("page", String(page)); + params.append("rowsPerPage", String(rowsPerPage)); + return `/checks/${monitorId}?${params.toString()}`; + }, [monitorId, monitor?.type, dateRange, page, rowsPerPage]); + + const { data: checksData, isLoading: checksIsLoading } = useGet( + checksUrl, + {}, + { keepPreviousData: true } + ); + + const checks = checksData?.checks ?? []; + const checksCount = checksData?.checksCount ?? 0; + + return ( + + + + + + } + checks={monitorData?.groupedUpChecks ?? []} + range={dateRange} + /> + } + checks={monitorData?.groupedDownChecks ?? []} + range={dateRange} + /> + + + + + + ); +}; + +export default UptimeDetailsPage; diff --git a/client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx b/client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx deleted file mode 100644 index 62ba0be13..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -import FilterHeader from "@/Components/v1/FilterHeader/index.jsx"; -import { useMemo } from "react"; -import { Box, Button } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; -import { useTranslation } from "react-i18next"; - -/** - * Filter Component - * - * A high-level component that provides filtering options for type, status, and state. - * It allows users to select multiple options for each filter and reset the filters. - * - * @component - * @param {Object} props - The component props. - * @param {string[]} props.selectedTypes - An array of selected type values. - * @param {function} props.setSelectedTypes - A function to set the selected type values. - * @param {string[]} props.selectedStatus - An array of selected status values. - * @param {function} props.setSelectedStatus - A function to set the selected status values. - * @param {string[]} props.selectedState - An array of selected state values. - * @param {function} props.setSelectedState - A function to set the selected state values. - * @param {function} props.setToFilterStatus - A function to set the filter status based on selected status values. - * @param {function} props.setToFilterActive - A function to set the filter active state based on selected state values. - * @param {function} props.handleReset - A function to reset all filters. - * - * @returns {JSX.Element} The rendered Filter component. - */ - -const getTypeOptions = () => [ - { value: "http", label: "HTTP(S)" }, - { value: "ping", label: "Ping" }, - { value: "docker", label: "Docker" }, - { value: "port", label: "Port" }, - { value: "game", label: "Game" }, -]; - -// These functions were moved inline to ensure translations are applied correctly - -const Filter = ({ - selectedTypes, - setSelectedTypes, - selectedStatus, - setSelectedStatus, - selectedState, - setSelectedState, - setToFilterStatus, - setToFilterActive, - handleReset, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const typeOptions = getTypeOptions(); - // Create status options with translations - const statusOptions = [ - { value: "Up", label: t("monitorStatus.up") }, - { value: "Down", label: t("monitorStatus.down") }, - ]; - // Create state options with translations - const stateOptions = [ - { value: "Active", label: t("monitorState.active") }, - { value: "Paused", label: t("monitorState.paused") }, - ]; - - const handleTypeChange = (event) => { - const selectedValues = event.target.value; - setSelectedTypes(selectedValues.length > 0 ? selectedValues : undefined); - }; - - const handleStatusChange = (event) => { - const selectedValues = event.target.value; - setSelectedStatus(selectedValues.length > 0 ? selectedValues : undefined); - - if (selectedValues.length === 0 || selectedValues.length === 2) { - setToFilterStatus(undefined); - } else { - setToFilterStatus(selectedValues[0] === "Up" ? "true" : "false"); - } - }; - - const handleStateChange = (event) => { - const selectedValues = event.target.value; - setSelectedState(selectedValues); - - if (selectedValues.length === 0 || selectedValues.length === 2) { - setToFilterActive(undefined); - } else { - setToFilterActive(selectedValues[0] === "Active" ? "true" : "false"); - } - }; - - const isFilterActive = useMemo(() => { - return ( - (selectedTypes?.length ?? 0) > 0 || - (selectedState?.length ?? 0) > 0 || - (selectedStatus?.length ?? 0) > 0 - ); - }, [selectedState, selectedTypes, selectedStatus]); - - return ( - - - - - - - ); -}; - -Filter.propTypes = { - selectedTypes: PropTypes.arrayOf(PropTypes.string), - setSelectedTypes: PropTypes.func.isRequired, - selectedStatus: PropTypes.arrayOf(PropTypes.string), - setSelectedStatus: PropTypes.func.isRequired, - selectedState: PropTypes.arrayOf(PropTypes.string), - setSelectedState: PropTypes.func.isRequired, - setToFilterStatus: PropTypes.func.isRequired, - setToFilterActive: PropTypes.func.isRequired, - handleReset: PropTypes.func.isRequired, -}; - -export default Filter; diff --git a/client/src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx b/client/src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx deleted file mode 100644 index a649473af..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import { CircularProgress, Box } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -const LoadingSpinner = ({ shouldRender }) => { - const theme = useTheme(); - if (shouldRender === false) { - return; - } - - return ( - <> - - - - - - ); -}; - -LoadingSpinner.propTypes = { - shouldRender: PropTypes.bool, -}; - -export default LoadingSpinner; diff --git a/client/src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx b/client/src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx deleted file mode 100644 index 64c546a8a..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useState } from "react"; -import Search from "@/Components/v1/Inputs/Search/index.jsx"; -import { Box } from "@mui/material"; -import useDebounce from "../../Hooks/useDebounce.jsx"; -import { useEffect, useRef } from "react"; -import PropTypes from "prop-types"; - -const SearchComponent = ({ monitors = [], onSearchChange, setIsSearching }) => { - const isFirstRender = useRef(true); - const [localSearch, setLocalSearch] = useState(""); - const debouncedSearch = useDebounce(localSearch, 500); - useEffect(() => { - if (isFirstRender.current === true) { - isFirstRender.current = false; - return; - } - onSearchChange(debouncedSearch); - setIsSearching(false); - }, [debouncedSearch, onSearchChange, setIsSearching]); - - const handleSearch = (value) => { - setLocalSearch(value); - setIsSearching(true); - }; - - return ( - - - - ); -}; - -SearchComponent.propTypes = { - monitors: PropTypes.array, - onSearchChange: PropTypes.func, - setIsSearching: PropTypes.func, -}; - -export default SearchComponent; diff --git a/client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx b/client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx deleted file mode 100644 index 4e55be536..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -/** - * Renders a skeleton layout. - * - * @returns {JSX.Element} - */ -const SkeletonLayout = () => { - const theme = useTheme(); - - return ( - <> - - - - - - - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx deleted file mode 100644 index 5cba9e3b1..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import PropTypes from "prop-types"; -import { Stack } from "@mui/material"; -import StatusBox from "./statusBox.jsx"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import SkeletonLayout from "./skeleton.jsx"; - -const StatusBoxes = ({ shouldRender, monitorsSummary }) => { - const theme = useTheme(); - const { t } = useTranslation(); - if (!shouldRender) return ; - return ( - - - - - - ); -}; - -StatusBoxes.propTypes = { - monitorsSummary: PropTypes.object, - shouldRender: PropTypes.bool, -}; - -export default StatusBoxes; diff --git a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx deleted file mode 100644 index df6e08ccd..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -const SkeletonLayout = () => { - const theme = useTheme(); - return ( - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx deleted file mode 100644 index 136c3575d..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Stack, Typography } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; -import Background from "@/assets/Images/background-grid.svg?react"; - -const StatusBox = ({ title, value, status }) => { - const theme = useTheme(); - let sharedStyles = { - position: "absolute", - right: 8, - opacity: 0.5, - "& svg path": { stroke: theme.palette.primary.contrastTextTertiary }, - }; - - let color; - let icon; - if (status === "up") { - color = theme.palette.success.lowContrast; - icon = ( - - - - ); - } else if (status === "down") { - color = theme.palette.error.lowContrast; - icon = ( - - - - ); - } else if (status === "paused") { - color = theme.palette.warning.lowContrast; - icon = ( - - - - ); - } - - return ( - - - - - - - - {title} - - {icon} - - - {value} - - - # - - - - - ); -}; - -StatusBox.propTypes = { - title: PropTypes.string, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - status: PropTypes.string, -}; - -export default StatusBox; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx b/client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx deleted file mode 100644 index 8c3239dd3..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx +++ /dev/null @@ -1,241 +0,0 @@ -// Components -import { Box, Stack } from "@mui/material"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import Host from "@/Components/v1/Host/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import BarChart from "@/Components/v1/Charts/BarChart/index.jsx"; -import ActionsMenu from "@/Components/v1/ActionsMenu/index.jsx"; - -import LoadingSpinner from "../LoadingSpinner/index.jsx"; -import TableSkeleton from "@/Components/v1/Table/skeleton.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js"; -import { useNavigate } from "react-router-dom"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; - -/** - * UptimeDataTable displays a table of uptime monitors with sorting, searching, and action capabilities - * @param {Object} props - Component props - * @param {boolean} props.isAdmin - Whether the current user has admin privileges - * @param {boolean} props.isLoading - Loading state of the table - * @param {Array<{ - * id: string, - * url: string, - * title: string, - * percentage: number, - * percentageColor: string, - * monitor: { - * id: string, - * type: string, - * checks: Array - * } - * }>} props.monitors - Array of monitor objects to display - * @param {number} props.monitorCount - Total count of monitors - * @param {Object} props.sort - Current sort configuration - * @param {string} props.sort.field - Field to sort by - * @param {'asc'|'desc'} props.sort.order - Sort direction - * @param {Function} props.setSort - Callback to update sort configuration - * @param {string} props.search - Current search query - * @param {Function} props.setSearch - Callback to update search query - * @param {boolean} props.isSearching - Whether a search is in progress - * @param {Function} props.setIsLoading - Callback to update loading state - * @param {Function} props.triggerUpdate - Callback to trigger a data refresh - * @returns {JSX.Element} Rendered component - */ -const UptimeDataTable = ({ - isAdmin, - isSearching, - filteredMonitors, - sort, - setSort, - triggerUpdate, - monitorsAreLoading, -}) => { - // Utils - const navigate = useNavigate(); - const { determineState } = useMonitorUtils(); - const theme = useTheme(); - const { t } = useTranslation(); - - // Local state - // Handlers - const handleSort = (field) => { - let order = ""; - if (sort?.field !== field) { - order = "desc"; - } else { - order = sort?.order === "asc" ? "desc" : "asc"; - } - setSort({ field, order }); - }; - - const headers = [ - { - id: "name", - content: ( - handleSort("name")} - > - {t("host")} - - {sort?.order === "asc" ? ( - - ) : ( - - )} - - - ), - render: (row) => ( - - ), - }, - { - id: "status", - content: ( - handleSort("status")} - > - {" "} - {t("status")} - - {sort?.order === "asc" ? ( - - ) : ( - - )} - - - ), - render: (row) => { - const status = determineState(row.monitor); - return ( - - ); - }, - }, - { - id: "responseTime", - content: t("responseTime"), - render: (row) => ( - - - - ), - }, - { - id: "type", - content: t("type"), - render: (row) => ( - - {row.monitor.type === "http" ? "HTTP(s)" : row.monitor.type} - - ), - }, - { - id: "actions", - content: t("actions"), - render: (row) => ( - - ), - }, - ]; - - if (monitorsAreLoading) { - return ; - } - - return ( - - - { - navigate(`/uptime/${row.id}`); - }, - emptyView: "No monitors found", - }} - /> - - ); -}; - -UptimeDataTable.propTypes = { - isSearching: PropTypes.bool, - setSort: PropTypes.func, - setSearch: PropTypes.func, - triggerUpdate: PropTypes.func, - debouncedSearch: PropTypes.string, - onSearchChange: PropTypes.func, - isAdmin: PropTypes.bool, - monitors: PropTypes.array, - filteredMonitors: PropTypes.array, - monitorCount: PropTypes.number, - monitorsAreLoading: PropTypes.bool, - sort: PropTypes.shape({ - field: PropTypes.string, - order: PropTypes.oneOf(["asc", "desc"]), - }), -}; - -export default UptimeDataTable; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 21e014688..cc225ad81 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -24,6 +24,7 @@ import type { RootState } from "@/Types/state"; export const MonitorTable = ({ monitors, refetch, + setSelectedMonitor, sortField, setSortField, sortOrder, @@ -36,6 +37,7 @@ export const MonitorTable = ({ }: { monitors: Monitor[]; refetch: Function; + setSelectedMonitor: (monitor: Monitor | null) => void; sortField: string; setSortField: (field: string) => void; sortOrder: "asc" | "desc"; @@ -97,7 +99,7 @@ export const MonitorTable = ({ id: 4, label: t("pages.common.monitors.actions.configure"), action: () => { - navigate(`/uptime/create/${monitor.id}`); + navigate(`/uptime/configure/${monitor.id}`); }, }, // { @@ -126,9 +128,7 @@ export const MonitorTable = ({ {t("pages.common.monitors.actions.delete")} ), - action: () => { - // setSelectedMonitor(monitor); - }, + action: () => setSelectedMonitor(monitor), closeMenu: true, }, ]; @@ -199,9 +199,9 @@ export const MonitorTable = ({ content: t("pages.uptime.table.headers.responseTime"), render: (row) => { if (chartType === "histogram") { - return ; + return ; } else { - return ; + return ; } }, }, diff --git a/client/src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx b/client/src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx deleted file mode 100644 index 11d639f14..000000000 --- a/client/src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useState, useEffect } from "react"; - -const useDebounce = (value, delay) => { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - return debouncedValue; -}; - -export default useDebounce; diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 5b05817bb..efd1a4d6e 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -5,14 +5,14 @@ import { DownStatusBox, PausedStatusBox, } from "@/Components/v2/design-elements"; -import { TextField } from "@/Components/v2/inputs"; +import { TextField, Dialog } from "@/Components/v2/inputs"; import Stack from "@mui/material/Stack"; import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable"; import { HeaderCreate } from "@/Components/v2/common"; import { useTranslation } from "react-i18next"; import useMediaQuery from "@mui/material/useMediaQuery"; -import { useGet } from "@/Hooks/UseApi"; +import { useGet, useDelete } from "@/Hooks/UseApi"; import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor"; import { useState, useMemo, useCallback } from "react"; import { useSelector, useDispatch } from "react-redux"; @@ -40,6 +40,8 @@ const UptimeMonitorsPage = () => { const [search, setSearch] = useState(""); const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); // Convert filter selections to API filter values // Status: "up" -> true, "down" -> false @@ -68,7 +70,7 @@ const UptimeMonitorsPage = () => { // Default to all types when none selected const effectiveTypes = - selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port"]; + selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port", "game"]; // Build URL for monitors with checks const monitorsWithChecksUrl = useMemo(() => { @@ -88,7 +90,12 @@ const UptimeMonitorsPage = () => { data: monitors, isLoading: monitorsLoading, error, - } = useGet("/monitors/team?type=http&type=ping&type=port&type=docker"); + refetch: refetchMonitorsAndSummary, + } = useGet( + "/monitors/team?type=http&type=ping&type=port&type=docker", + {}, + { keepPreviousData: true } + ); const { data: monitorsWithChecksData, @@ -98,11 +105,14 @@ const UptimeMonitorsPage = () => { } = useGet( monitorsWithChecksUrl, {}, - { refreshInterval: 5000 } + { refreshInterval: 5000, keepPreviousData: true } ); const { monitors: monitorsWithChecks, summary, count } = monitorsWithChecksData ?? {}; + // Delete hook + const { deleteFn, loading: isDeleting } = useDelete(); + // Handlers const handleClearFilters = useCallback(() => { setSelectedTypes([]); @@ -111,6 +121,18 @@ const UptimeMonitorsPage = () => { setSearch(""); }, []); + const handleConfirm = async () => { + if (!selectedMonitor) return; + await deleteFn(`/monitors/${selectedMonitor.id}`); + setSelectedMonitor(null); + refetch(); + refetchMonitorsAndSummary(); + }; + + const handleCancel = () => { + setSelectedMonitor(null); + }; + const isLoading = monitorsLoading || monitorsWithChecksLoading; return ( @@ -160,6 +182,7 @@ const UptimeMonitorsPage = () => { { sortOrder={sortOrder} setSortOrder={setSortOrder} setRowsPerPage={(rowsPerPage: number) => { - console.log("wtf"); dispatch( setRowsPerPage({ value: rowsPerPage, @@ -179,6 +201,14 @@ const UptimeMonitorsPage = () => { setPage(0); }} /> + ); }; diff --git a/client/src/Pages/Uptime/Monitors/old.jsx b/client/src/Pages/Uptime/Monitors/old.jsx deleted file mode 100644 index 67019362f..000000000 --- a/client/src/Pages/Uptime/Monitors/old.jsx +++ /dev/null @@ -1,225 +0,0 @@ -// Required Data -// 1. Monitor summary -// 2. List of monitors filtered by search term with 25 checks each -// 2a.List of monitors must have the total number of monitors that match. - -// Components -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import Greeting from "../../../Utils/greeting.jsx"; -import StatusBoxes from "./Components/StatusBoxes/index.jsx"; -import UptimeDataTable from "./Components/UptimeDataTable/index.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; -import CreateMonitorHeader from "@/Components/v1/MonitorCreateHeader/index.jsx"; -import SearchComponent from "./Components/SearchComponent/index.jsx"; -import Filter from "./Components/Filter/index.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; - -import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx"; - -// MUI Components -import { Stack, Box, Button } from "@mui/material"; -// Utils -import { useState, useCallback, useEffect } from "react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { useSelector, useDispatch } from "react-redux"; -import { setRowsPerPage } from "../../../Features/UI/uiSlice.js"; -import PropTypes from "prop-types"; -import { - useFetchMonitorsWithChecks, - useFetchMonitorsByTeamId, -} from "@/Hooks/monitorHooks.js"; -import { useTranslation } from "react-i18next"; - -const TYPES = ["http", "ping", "docker", "port", "game"]; -const CreateMonitorButton = ({ shouldRender }) => { - // Utils - const navigate = useNavigate(); - const { t } = useTranslation(); - if (shouldRender === false) { - return; - } - - return ( - - - - ); -}; - -CreateMonitorButton.propTypes = { - shouldRender: PropTypes.bool, -}; - -const UptimeMonitors = () => { - // Redux state - const rowsPerPage = useSelector((state) => state.ui?.monitors?.rowsPerPage ?? 10); - - // Local state - const [search, setSearch] = useState(undefined); - const [page, setPage] = useState(undefined); - const [sort, setSort] = useState(undefined); - const [isSearching, setIsSearching] = useState(false); - const [monitorUpdateTrigger, setMonitorUpdateTrigger] = useState(false); - const [selectedTypes, setSelectedTypes] = useState(undefined); - const [selectedState, setSelectedState] = useState(undefined); - const [selectedStatus, setSelectedStatus] = useState(undefined); - const [toFilterStatus, setToFilterStatus] = useState(undefined); - const [toFilterActive, setToFilterActive] = useState(undefined); - - // Utils - const theme = useTheme(); - const isAdmin = useIsAdmin(); - const dispatch = useDispatch(); - const { t } = useTranslation(); - - const BREADCRUMBS = [{ name: t("menu.uptime"), path: "/uptime" }]; - - // Handlers - const handleChangePage = (event, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - dispatch( - setRowsPerPage({ - value: parseInt(event.target.value, 10), - table: "monitors", - }) - ); - setPage(0); - }; - - const triggerUpdate = useCallback(() => { - setMonitorUpdateTrigger((prev) => !prev); - }, []); - - const handleReset = () => { - setSelectedState(undefined); - setSelectedTypes(undefined); - setSelectedStatus(undefined); - setToFilterStatus(undefined); - setToFilterActive(undefined); - }; - - const filterLookup = new Map([ - [toFilterStatus, "status"], - [toFilterActive, "isActive"], - ]); - - const activeFilter = [...filterLookup].find(([key]) => key !== undefined); - const field = activeFilter?.[1] || (search ? "name" : sort?.field); - const filter = activeFilter?.[0] || search; - - const effectiveTypes = selectedTypes?.length ? selectedTypes : TYPES; - - const [ - summary, - monitorsWithChecks, - monitorsWithChecksCount, - monitorsWithChecksIsLoading, - networkError, - ] = useFetchMonitorsWithChecks({ - types: effectiveTypes, - limit: 25, - page: page, - rowsPerPage: rowsPerPage, - filter: filter, - field: field, - order: sort?.order, - monitorUpdateTrigger, - }); - - const [monitors, listIsLoading, listNetworkError] = useFetchMonitorsByTeamId({ - type: ["http", "ping", "docker", "port", "game"], - }); - - useEffect(() => { - if (isSearching) { - setPage(undefined); - } - }, [isSearching]); - - const isLoading = monitorsWithChecksIsLoading; - - return ( - <> - - - - - - - - - - - - - - - - - - ); -}; - -export default UptimeMonitors; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index e37bae063..1efdb25fc 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -16,24 +16,21 @@ import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed.jsx"; // Uptime import Uptime from "../Pages/Uptime/Monitors"; -import UptimeDetails from "../Pages/Uptime/Details/index.jsx"; -import UptimeCreate from "../Pages/Uptime/Create/index.jsx"; +import UptimeDetails from "../Pages/Uptime/Details"; // PageSpeed -import PageSpeed from "../Pages/PageSpeed/Monitors/index.jsx"; -import PageSpeedDetails from "../Pages/PageSpeed/Details/index.jsx"; -import PageSpeedCreate from "../Pages/PageSpeed/Create/index.jsx"; +import PageSpeed from "../Pages/PageSpeed/Monitors/index"; +import PageSpeedDetails from "../Pages/PageSpeed/Details/"; // Infrastructure -import Infrastructure from "../Pages/Infrastructure/Monitors/index.jsx"; -import InfrastructureCreate from "../Pages/Infrastructure/Create/index.jsx"; -import InfrastructureDetails from "../Pages/Infrastructure/Details/index.jsx"; +import Infrastructure from "../Pages/Infrastructure/Monitors"; +import InfrastructureDetails from "../Pages/Infrastructure/Details/index"; // Server Status import ServerUnreachable from "../Pages/ServerUnreachable.jsx"; // Checks -import Checks from "../Pages/Checks/index.jsx"; +import Checks from "../Pages/Checks/index"; // Incidents import Incidents from "../Pages/Incidents/index.jsx"; @@ -43,8 +40,8 @@ import CreateStatus from "../Pages/StatusPage/Create/index.jsx"; import StatusPages from "../Pages/StatusPage/StatusPages/index.jsx"; import Status from "../Pages/StatusPage/Status/index.jsx"; -import Notifications from "../Pages/Notifications/index.jsx"; -import CreateNotifications from "../Pages/Notifications/create/index.jsx"; +import Notifications from "../Pages/Notifications"; +import CreateNotifications from "../Pages/Notifications/create"; // Settings import Account from "../Pages/Account/index.jsx"; @@ -60,9 +57,12 @@ import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; import BulkImport from "../Pages/Uptime/BulkImport/index.jsx"; import Logs from "../Pages/Logs/index.jsx"; +import CreateMonitor from "@/Pages/CreateMonitor"; + const Routes = () => { const mode = useSelector((state) => state.ui.mode); const AdminCheckedRegister = withAdminCheck(AuthRegister); + const v2theme = mode === "light" ? lightTheme : darkTheme; return ( { path="/uptime" element={ <> - + @@ -95,56 +95,124 @@ const Routes = () => { } - /> - } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> { } + element={ + <> + + + + + } /> } + element={ + <> + + + + + } /> } + path="notifications/configure/:notificationId" + element={ + <> + + + + + } /> ; + net: Array<{ + name: string; + bytesSentPerSecond: number; + deltaBytesRecv: number; + deltaPacketsSent: number; + deltaPacketsRecv: number; + deltaErrIn: number; + deltaErrOut: number; + deltaDropIn: number; + deltaDropOut: number; + deltaFifoIn: number; + deltaFifoOut: number; + }>; + }>; +} + +export interface UptimeChecksResult { + monitorType: Exclude; + groupedChecks: GroupedCheck[]; + groupedUpChecks: GroupedCheck[]; + groupedDownChecks: GroupedCheck[]; + uptimePercentage: number; + avgResponseTime: number; +} + +export interface ChecksSummary { + totalChecks: number; + downChecks: number; +} + +export type CheckSnapshot = Omit< + Check, + "metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt" +>; + +export interface HasResponseTime { + responseTime: number; +} + +export type NormalizedCheck = T & { + originalResponseTime: number; +}; + +export type NormalizedUptimeCheck = T & { + originalAvgResponseTime: number; +}; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 8a0f9c7e8..02d4ccf2d 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,4 +1,5 @@ -import type { Check } from "@/Types/Check"; +import type { GroupedCheck } from "@/Types/Check"; +import type { CheckSnapshot } from "@/Types/Check"; export type MonitorStatus = boolean | undefined; export const MonitorTypes = [ @@ -13,28 +14,54 @@ export const MonitorTypes = [ ] as const; export type MonitorType = (typeof MonitorTypes)[number]; -export interface Monitor { - checks: Check[]; - createdAt: string; - createdBy: string; - interval: number; - isActive: boolean; - latestChecks: Check[]; - n: number; - name: string; - status: MonitorStatus; - type: string; - updatedAt: string; - updatedBy: string; - url: string; - id: string; +export interface MonitorThresholds { + usage_cpu?: number; + usage_memory?: number; + usage_disk?: number; + usage_temperature?: number; } -export interface MonitorWithChecks extends Monitor { - checks: Check[]; +export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; + +export interface Monitor { + id: string; + userId: string; + teamId: string; + name: string; + description?: string; + status?: boolean; + statusWindow: boolean[]; + statusWindowSize: number; + statusWindowThreshold: number; + type: MonitorType; + ignoreTlsErrors: boolean; + useAdvancedMatching: boolean; + jsonPath?: string; + expectedValue?: string; + matchMethod?: MonitorMatchMethod; + url: string; + port?: number; + isActive: boolean; + interval: number; uptimePercentage?: number; + notifications: string[]; + secret?: string; + thresholds?: MonitorThresholds; + alertThreshold: number; + cpuAlertThreshold: number; + memoryAlertThreshold: number; + diskAlertThreshold: number; + tempAlertThreshold: number; + selectedDisks: string[]; + gameId?: string; + group: string | null; + recentChecks: CheckSnapshot[]; + createdAt: string; + updatedAt: string; } +export type MonitorWithChecks = Monitor; + export interface MonitorsSummary { totalMonitors: number; upMonitors: number; @@ -45,5 +72,96 @@ export interface MonitorsSummary { export interface MonitorsWithChecksResponse { count: number; monitors: MonitorWithChecks[]; - summary: MonitorsSummary; + summary: MonitorsSummary | null; } + +export interface MonitorStats { + id: string; + monitorId: string; + avgResponseTime: number; + totalChecks: number; + totalUpChecks: number; + totalDownChecks: number; + uptimePercentage: number; + lastCheckTimestamp: number; + lastResponseTime: number; + timeOfLastFailure?: number; + createdAt: string; + updatedAt: string; +} + +export interface MonitorData { + monitor: Monitor; + groupedChecks: GroupedCheck[]; + groupedUpChecks: GroupedCheck[]; + groupedDownChecks: GroupedCheck[]; + groupedAvgResponseTime: number; + groupedUptimePercentage: number; +} + +export interface MonitorDetailsResponse { + monitorData: MonitorData; + monitorStats: MonitorStats | null; +} + +export interface PageSpeedDetailsResponse { + monitor: MonitorWithChecks; + monitorStats: MonitorStats | null; +} + +export interface HardwareDiskStats { + name: string; + readSpeed: number; + writeSpeed: number; + totalBytes: number; + freeBytes: number; + usagePercent: number; +} + +export interface HardwareNetStats { + name: string; + bytesSentPerSecond: number; + deltaBytesRecv: number; + deltaPacketsSent: number; + deltaPacketsRecv: number; + deltaErrIn: number; + deltaErrOut: number; + deltaDropIn: number; + deltaDropOut: number; + deltaFifoIn: number; + deltaFifoOut: number; +} + +export interface HardwareCheckStats { + bucketDate: string; + avgCpuUsage: number; + avgMemoryUsage: number; + avgTemperature: number[]; + disks: HardwareDiskStats[]; + net: HardwareNetStats[]; +} + +export interface HardwareStats { + aggregateData: { + totalChecks: number; + }; + upChecks: { + totalChecks: number; + }; + checks: HardwareCheckStats[]; +} + +export interface HardwareDetailsResponse { + monitor: Monitor; + stats: HardwareStats; + monitorStats: MonitorStats | null; +} + +export interface Game { + name: string; + options?: { + port?: number; + }; +} + +export type GamesMap = Record; diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts new file mode 100644 index 000000000..5b9645c9d --- /dev/null +++ b/client/src/Types/Notification.ts @@ -0,0 +1,24 @@ +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", +] as const; +export type NotificationChannel = (typeof NotificationChannels)[number]; + +export interface Notification { + id: string; + userId: string; + teamId: string; + type: NotificationChannel; + notificationName: string; + address?: string; + phone?: string; + homeserverUrl?: string; + roomId?: string; + accessToken?: string; + createdAt: string; + updatedAt: string; +} diff --git a/client/src/Types/Settings.ts b/client/src/Types/Settings.ts new file mode 100644 index 000000000..28f7bf5ed --- /dev/null +++ b/client/src/Types/Settings.ts @@ -0,0 +1,34 @@ +export interface SettingsThresholds { + cpu?: number; + memory?: number; + disk?: number; + temperature?: number; +} + +export interface Settings { + id: string; + checkTTL: number; + language: string; + systemEmailHost?: string; + systemEmailPort?: number; + systemEmailAddress?: string; + systemEmailUser?: string; + systemEmailConnectionHost?: string; + systemEmailTLSServername?: string; + systemEmailSecure: boolean; + systemEmailPool: boolean; + systemEmailIgnoreTLS: boolean; + systemEmailRequireTLS: boolean; + systemEmailRejectUnauthorized: boolean; + showURL: boolean; + singleton: boolean; + globalThresholds?: SettingsThresholds; + createdAt: string; + updatedAt: string; +} + +export interface AppSettingsResponse { + pagespeedKeySet: boolean; + emailPasswordSet: boolean; + settings: Settings; +} diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts index e90bc9f01..42a14bf00 100644 --- a/client/src/Utils/ApiClient.ts +++ b/client/src/Utils/ApiClient.ts @@ -67,6 +67,12 @@ export const patch = ( config: AxiosRequestConfig = {} ): Promise> => api.patch(url, data, config); +export const put = ( + url: string, + data: unknown, + config: AxiosRequestConfig = {} +): Promise> => api.put(url, data, config); + export const deleteOp = ( url: string, config: AxiosRequestConfig = {} diff --git a/client/src/Utils/InfraUtils.ts b/client/src/Utils/InfraUtils.ts new file mode 100644 index 000000000..07212737e --- /dev/null +++ b/client/src/Utils/InfraUtils.ts @@ -0,0 +1,54 @@ +import type { CheckDiskInfo, CheckHostInfo } from "@/Types/Check"; + +export const getFrequency = (frequency: number | undefined): string => { + if (!frequency) return "N/A"; + const ghz = (frequency / 1000).toFixed(2); + return `${ghz} GHz`; +}; + +export const getCores = (cores: number | undefined) => { + if (!cores) return "N/A"; + if (cores === 1) return `${cores} core`; + return `${cores} cores`; +}; + +export const getAvgTemp = (temps: number[]): string => { + if (!temps || temps.length === 0) return "N/A"; + const avgTemp = temps.reduce((a, b) => a + b, 0) / temps.length; + return `${avgTemp?.toFixed(2)} °C`; +}; + +export const getGbs = (bytes: number | undefined): string => { + if (!bytes) { + return "N/A"; + } + if (bytes === 0) { + return "0 GB"; + } + + const GB = bytes / (1024 * 1024 * 1024); + const MB = bytes / (1024 * 1024); + + if (GB >= 1) { + return GB.toFixed(2) + " GB"; + } else { + return MB.toFixed(2) + " MB"; + } +}; + +export const getDiskTotalGbs = (disk?: Partial[]): string => { + if (!disk) { + return getGbs(0); + } + const totalBytes = disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0; + return getGbs(totalBytes); +}; + +export const getOsAndPlatform = (hostInfo: CheckHostInfo | undefined): string => { + if (!hostInfo) { + return "N/A"; + } + const os = hostInfo?.pretty_name || hostInfo?.os || "N/A"; + const platform = hostInfo?.platform || "N/A"; + return `${os} (${platform})`; +}; diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 4a096badd..9c6ad74a0 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -594,8 +594,8 @@ class NetworkService { * @async * @returns {Promise} The response from the axios GET request. */ - getChecksAndSummaryByTeamId = async () => { - return this.axiosInstance.get(`/checks/team/summary`); + getChecksAndSummaryByTeamId = async ({ dateRange }) => { + return this.axiosInstance.get(`/checks/team/summary?dateRange=${dateRange}`); }; /** @@ -1076,7 +1076,7 @@ class NetworkService { async editNotification(config) { const { id, notification } = config; - return this.axiosInstance.put(`/notifications/${id}`, notification); + return this.axiosInstance.patch(`/notifications/${id}`, notification); } async getQueueData() { diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 3b629baf0..a19a0bf8b 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -25,10 +25,10 @@ export const formatDateWithTz = (timestamp: string, format: string, timezone: st export const tickDateFormatLookup = (range: string) => { const tickFormatLookup: Record = { - "1h": "h:mm A", - "24h": "h:mm A", - "7d": "MM/D, h:mm A", - "30d": "ddd. M/D", + recent: "h:mm A", + day: "h:mm A", + week: "MM/D, h:mm A", + month: "ddd. M/D", }; const format = tickFormatLookup[range]; if (format === undefined) { @@ -39,10 +39,10 @@ export const tickDateFormatLookup = (range: string) => { export const tooltipDateFormatLookup = (range: string) => { const dateFormatLookup: Record = { - "1h": "ddd. MMMM D, YYYY, hh:mm A", - "24h": "ddd. MMMM D, YYYY, hh:mm A", - "7d": "ddd. MMMM D, YYYY, hh:mm A", - "30d": "ddd. MMMM D, YYYY", + recent: "ddd. MMMM D, YYYY, hh:mm A", + day: "ddd. MMMM D, YYYY, hh:mm A", + week: "ddd. MMMM D, YYYY, hh:mm A", + month: "ddd. MMMM D, YYYY", }; const format = dateFormatLookup[range]; if (format === undefined) { diff --git a/client/src/Utils/timeUtils.js b/client/src/Utils/timeUtilsLegacy.js similarity index 87% rename from client/src/Utils/timeUtils.js rename to client/src/Utils/timeUtilsLegacy.js index f6e2dcec0..365572f6b 100644 --- a/client/src/Utils/timeUtils.js +++ b/client/src/Utils/timeUtilsLegacy.js @@ -135,3 +135,33 @@ export const formatDateWithTz = (timestamp, format, timezone) => { const formattedDate = dayjs(timestamp).tz(timezone).format(format); return formattedDate; }; + +export const tickDateFormatLookup = (range) => { + switch (range) { + case "recent": + return "h:mm A"; + case "day": + return "h A"; + case "week": + return "MMM D"; + case "month": + return "MMM D"; + default: + return "MMM D, h A"; + } +}; + +export const tooltipDateFormatLookup = (range) => { + switch (range) { + case "recent": + return "MMM D, h:mm A"; + case "day": + return "MMM D, h:mm A"; + case "week": + return "ddd, MMM D"; + case "month": + return "ddd, MMM D"; + default: + return "MMM D, h:mm A"; + } +}; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts new file mode 100644 index 000000000..68a15fbec --- /dev/null +++ b/client/src/Validation/monitor.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +// URL schema with custom error message +const urlSchema = z.url({ message: "Please enter a valid URL" }); + +// Common base schema for all monitor types +const baseSchema = z.object({ + name: z + .string() + .min(1, "Monitor name is required") + .max(50, "Monitor name must be at most 50 characters"), + description: z.string().optional(), + interval: z.number().min(15000, "Interval must be at least 15 seconds"), + notifications: z.array(z.string()), + statusWindowSize: z + .number({ message: "Status window size is required" }) + .min(1, "Status window size must be at least 1") + .max(25, "Status window size must be at most 25"), + statusWindowThreshold: z + .number({ message: "Threshold percentage is required" }) + .min(1, "Incident percentage must be at least 1") + .max(100, "Incident percentage must be at most 100"), +}); + +// HTTP monitor schema +const httpSchema = baseSchema.extend({ + type: z.literal("http"), + url: urlSchema, + ignoreTlsErrors: z.boolean(), + useAdvancedMatching: z.boolean(), + matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), + expectedValue: z.string().optional(), + jsonPath: z.string().optional(), +}); + +// Ping monitor schema +const pingSchema = baseSchema.extend({ + type: z.literal("ping"), + url: z.string().min(1, "Host is required"), +}); + +// Port monitor schema +const portSchema = baseSchema.extend({ + type: z.literal("port"), + url: z.string().min(1, "Host is required"), + port: z + .number() + .min(1, "Port must be at least 1") + .max(65535, "Port must be at most 65535"), +}); + +// Docker monitor schema +const dockerSchema = baseSchema.extend({ + type: z.literal("docker"), + url: z.string().min(1, "Container ID is required"), +}); + +// Game server monitor schema +const gameSchema = baseSchema.extend({ + type: z.literal("game"), + url: z.string().min(1, "Host is required"), + port: z + .number() + .min(1, "Port must be at least 1") + .max(65535, "Port must be at most 65535"), + gameId: z.string().min(1, "Game type is required"), +}); + +// PageSpeed monitor schema +const pagespeedSchema = baseSchema.extend({ + type: z.literal("pagespeed"), + url: urlSchema, +}); + +// Hardware/Infrastructure monitor schema +const hardwareSchema = baseSchema.extend({ + type: z.literal("hardware"), + url: urlSchema, + secret: z.string({ message: "Secret is required" }).min(1, "Secret is required"), + cpuAlertThreshold: z + .number() + .min(0, "CPU threshold must be at least 0") + .max(100, "CPU threshold must be at most 100"), + memoryAlertThreshold: z + .number() + .min(0, "Memory threshold must be at least 0") + .max(100, "Memory threshold must be at most 100"), + diskAlertThreshold: z + .number() + .min(0, "Disk threshold must be at least 0") + .max(100, "Disk threshold must be at most 100"), + tempAlertThreshold: z + .number() + .min(0, "Temperature threshold must be at least 0") + .max(150, "Temperature threshold must be at most 150"), + selectedDisks: z.array(z.string()), +}); + +// Discriminated union of all monitor types +export const monitorSchema = z.discriminatedUnion("type", [ + httpSchema, + pingSchema, + portSchema, + dockerSchema, + gameSchema, + pagespeedSchema, + hardwareSchema, +]); + +export type MonitorFormData = z.infer; + +// Type-specific schemas exported for individual use +export { + httpSchema, + pingSchema, + portSchema, + dockerSchema, + gameSchema, + pagespeedSchema, + hardwareSchema, +}; diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts new file mode 100644 index 000000000..e505c695f --- /dev/null +++ b/client/src/Validation/notifications.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +const baseSchema = z.object({ + notificationName: z + .string() + .min(1, "Notification name is required") + .max(100, "Notification name must be at most 100 characters"), +}); + +const emailSchema = baseSchema.extend({ + type: z.literal("email"), + address: z + .string() + .min(1, "Email is required") + .email("Please enter a valid email address"), +}); + +const slackSchema = baseSchema.extend({ + type: z.literal("slack"), + address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), +}); + +const discordSchema = baseSchema.extend({ + type: z.literal("discord"), + address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), +}); + +const webhookSchema = baseSchema.extend({ + type: z.literal("webhook"), + address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), +}); + +const pagerDutySchema = baseSchema.extend({ + type: z.literal("pager_duty"), + address: z.string().min(1, "Integration key is required"), +}); + +const matrixSchema = baseSchema.extend({ + type: z.literal("matrix"), + homeserverUrl: z + .string() + .min(1, "Homeserver URL is required") + .url("Please enter a valid URL"), + roomId: z.string().min(1, "Room ID is required"), + accessToken: z.string().min(1, "Access token is required"), +}); + +export const notificationSchema = z.discriminatedUnion("type", [ + emailSchema, + slackSchema, + discordSchema, + webhookSchema, + pagerDutySchema, + matrixSchema, +]); + +export type NotificationFormData = z.infer; diff --git a/client/src/index.css b/client/src/index.css index 8b2a1cfce..48737d4bb 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -50,7 +50,8 @@ html { cursor: not-allowed; } -.Toastify__toast-container { +/* .Toastify__toast-container { + min-width: 300px; width: auto; } .Toastify__toast-body .alert { @@ -68,7 +69,7 @@ html { } .Toastify [class*="Toastify__toast-theme"] { background-color: transparent; -} +} */ .MuiTouchRipple-root { display: none; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 1ebb3b3e0..52ec0f3cc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,10 +1,36 @@ { "common": { "appName": "Checkmate", - "monitoringAgentName": "Capture", + "breadcrumbs": { + "home": "Home", + "details": "Details" + }, + "labels": { + "responseTime": "Response time" + }, "buttons": { "toggleTheme": "Toggles light & dark", - "create": "Create" + "testNotifications": "Test notifications", + "create": "Create", + "incidents": "Incidents", + "pause": "Pause", + "resume": "Resume", + "delete": "Delete", + "cancel": "Cancel", + "confirm": "Confirm", + "save": "Save", + "test": "Test" + }, + "alerts": { + "pageSpeedApiKey": { + "content": "Warning: You haven't added a Google PageSpeed API key yet. Visit Settings to add one. Without it, the PageSpeed monitor won't function." + } + }, + "dialogs": { + "delete": { + "description": "This action cannot be undone.", + "title": "Are you sure you want to delete this?" + } }, "toasts": { "networkError": "Network error", @@ -14,21 +40,26 @@ "table": { "headers": { "actions": "Actions", - "active": "Active", "message": "Message", "monitor": "Monitor", "name": "Name", "status": "Status", - "type": "Type" + "type": "Type", + "dateTime": "Date & time" }, - "empty": "Nothing h" + "empty": "Nothing here" + }, + "charts": { + "labels": { + "uptime": "Uptime", + "downtime": "Downtime", + "averageResponseTime": "Average response time", + "low": "Low", + "high": "High" + } } }, "submit": "Submit", - "title": "Title", - "distributedStatusHeaderText": "Real-time, real-device coverage", - "distributedStatusSubHeaderText": "Powered by millions devices worldwide, view a system performance by global region, country or city", - "settingsDisabled": "Disabled", "settingsSuccessSaved": "Settings saved successfully", "settingsFailedToSave": "Failed to save settings", "settingsStatsCleared": "Stats cleared successfully", @@ -37,119 +68,28 @@ "settingsFailedToDeleteMonitors": "Failed to delete all monitors", "starPromptTitle": "Star Checkmate", "starPromptDescription": "See the latest releases and help grow the community on GitHub", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitor", "aboutus": "About Us", - "now": "Now", "delete": "Delete", "configure": "Configure", - "responseTime": "Response time", - "responseTimeBreakdown": "Response time breakdown", - "dnsLookup": "DNS lookup", - "tcpConnection": "TCP connection", - "tlsHandshake": "TLS handshake", - "waitTime": "Wait time", - "timeToFirstByte": "Time to first byte", - "download": "Download", - "ms": "ms", - "bar": "Bar", - "area": "Area", - "country": "COUNTRY", - "city": "CITY", - "response": "RESPONSE", - "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", - "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", - "webhookSendSuccess": "Webhook notification sent successfully", - "webhookSendError": "Error sending webhook notification to {platform}", - "webhookUnsupportedPlatform": "Unsupported platform: {platform}", - "distributedRightCategoryTitle": "Monitor", - "distributedStatusServerMonitors": "Server Monitors", - "distributedStatusServerMonitorsDescription": "Monitor status of related servers", - "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", - "distributedUptimeCreateChecks": "Checks to perform", - "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", - "distributedUptimeCreateIncidentNotification": "Incident notifications", - "distributedUptimeCreateIncidentDescription": "When there is an incident, notify users.", - "distributedUptimeCreateAdvancedSettings": "Advanced settings", - "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", - "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Last updated", "notifications": { - "enableNotifications": "Enable {{platform}} notifications", - "testNotification": "Test notification", - "addOrEditNotifications": "Add or edit notifications", - "slack": { - "label": "Slack", - "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL is required" - }, - "discord": { - "label": "Discord", - "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord webhook URL is required" - }, - "telegram": { - "label": "Telegram", - "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", - "tokenLabel": "Your bot token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Your Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "Telegram token and chat ID are required" - }, - "webhook": { - "label": "Webhooks", - "description": "You can set up a custom webhook to receive notifications when incidents occur.", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL is required" - }, - "testNotificationDevelop": "Test notification 2", - "integrationButton": "Notification Integration", - "testSuccess": "Test notification sent successfully!", - "testFailed": "Failed to send test notification", - "unsupportedType": "Unsupported notification type", - "networkError": "Network error occurred", - "fallback": { - "title": "A notification channel is used to:", - "checks": [ - "Alert teams about downtime or performance issues", - "Let engineers know when incidents happen", - "Keep administrators informed of system changes" - ], - "actionButton": "Let's create your first notification channel!" - }, - "createButton": "Create notification channel", - "createTitle": "Notification channel", - "create": { - "success": "Notification created successfully", - "failed": "Failed to create notification" - }, "fetch": { - "success": "Notifications fetched successfully", "failed": "Failed to fetch notifications" }, - "delete": { - "success": "Notification deleted successfully", - "failed": "Failed to delete notification" - }, - "edit": { - "success": "Notification updated successfully", - "failed": "Failed to update notification" - }, "test": { - "success": "Test notification sent successfully", - "failed": "Failed to send test notification" + "success": "Test notification sent successfully" } }, - "testLocale": "testLocale", "add": "Add", - "monitors": "monitors", + "components": { + "headerTimeRange": { + "labels": { + "recent": "Recent", + "day": "Day", + "week": "Week", + "month": "Month" + } + } + }, "pages": { "common": { "monitors": { @@ -157,6 +97,7 @@ "up": "up", "down": "down", "paused": "paused", + "total": "total", "initializing": "initializing" }, "actions": { @@ -167,6 +108,84 @@ "pause": "Pause", "resume": "Resume", "delete": "Delete" + }, + "statBoxes": { + "activeFor": "Active for", + "lastCheck": "Last check", + "lastResponseTime": "Last response time", + "certificateExpiry": "Certificate expiry" + } + } + }, + "createMonitor": { + "form": { + "type": { + "description": "Select the type of check to perform", + "title": "Type", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionDocker": "Docker", + "optionPort": "Port", + "optionGame": "Game" + }, + "general": { + "title": "General settings", + "option": { + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "name": { + "label": "Display name", + "placeholder": "e.g. My Website" + }, + "host": { + "label": "Host", + "placeholder": "192.168.1.100 or example.com" + }, + "container": { + "label": "Container name/ID", + "placeholder": "my-app or abcd1234" + }, + "secret": { + "label": "Authorization secret" + } + } + }, + "frequency": { + "title": "Check frequency", + "description": "How often do you want to check the status of this monitor?" + }, + "incidents": { + "title": "Incidents", + "description": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", + "option": { + "checks": { + "label": "Number of checks in sliding window" + } + } + }, + "notifications": { + "title": "Notifications", + "description": "Select the notification channels you want to use" + }, + "ignoreTls": { + "title": "TLS/SSL settings", + "description": "Configure TLS/SSL certificate validation for HTTPS connections.", + "option": { + "tls": { + "label": "Ignore TLS/SSL errors" + } + } + }, + "advanced": { + "title": "Advanced settings", + "description": "Optional settings for advanced use cases", + "option": { + "jsonPath": { + "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value. See jmespath.org for query language documentation." + } + } } } }, @@ -176,103 +195,181 @@ "responseTime": "Response time" } }, - "fallback": { - "title": "An uptime monitor is used to:", - "actionButton": "Create a monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ] - }, "filters": { "search": { "placeholder": "Search monitors..." } } + }, + "checks": { + "table": { + "headers": { + "dateTime": "Date & time", + "statusCode": "Status code" + }, + "empty": "No down checks in this time range" + }, + "selects": { + "monitor": { + "all": "All monitors" + }, + "status": { + "all": "All", + "down": "Down", + "up": "Up" + } + } + }, + "pageSpeed": { + "table": { + "headers": { + "pageSpeedScore": "PageSpeed score" + } + }, + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" + }, + "pie": { + "title": "Performance report" + }, + "legend": { + "title": "PageSpeed report", + "weight": "Weight" + } + } + }, + "infrastructure": { + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disk", + "memory": "Memory" + } + }, + "tabs": { + "labels": { + "overview": "Overview", + "network": "Network" + } + }, + "statBoxes": { + "cpuPhysical": "CPU (Physical)", + "cpuLogical": "CPU (Logical)", + "cpuFrequency": "CPU Frequency", + "avgCpuTemperature": "Average CPU Temperature", + "memory": "Memory", + "disk": "Disk", + "os": "OS" + }, + "gauges": { + "cpu": { + "lowerLabel": "Max frequency", + "title": "CPU usage", + "upperLabel": "Current frequency" + }, + "disk": { + "lowerLabel": "Free", + "title": "Disk {{idx}} usage", + "upperLabel": "Used" + }, + "memory": { + "lowerLabel": "Free", + "title": "Memory usage", + "upperLabel": "Used" + } + }, + "charts": { + "labels": { + "memory": "Memory usage", + "cpu": "CPU usage", + "temp": "Temp", + "disk": "Disk usage", + "netBytesSent": "{{name}} - Bytes Sent", + "netBytesRecv": "{{name}} - Bytes Received" + } + } + }, + "notifications": { + "fallback": { + "actionButton": "Create a channel", + "checks": [ + "Alert teams about downtime or performance issues", + "Let engineers know when incidents happen", + "Keep administrators informed of system changes" + ], + "title": "Notification channles are used to:" + }, + "table": { + "headers": { + "destination": "Destination" + } + }, + "form": { + "notificationName": { + "description": "A descriptive name for the notification channel", + "optionName": "Channel name", + "placeholder": "e.g. Production Alerts", + "title": "Channel name" + }, + "type": { + "description": "Select the type of notification channel to create.", + "optionType": "Type", + "title": "Channel type" + }, + "homeServer": { + "optionHomeServer": "Home server", + "placeholder": "example.com" + }, + "roomId": { + "optionRoomId": "Room ID", + "placeholder": "!abcdefg:matrix.org" + }, + "accessToken": { + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "The address where notifications will be sent.", + "optionAddress": "Address", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Address" + }, + "pagerDuty": { + "description": "Your PagerDuty integration key for receiving alerts.", + "optionIntegrationKey": "Integration key", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Integration key" + }, + "matrix": { + "description": "Configure your Matrix homeserver connection for notifications.", + "title": "Matrix configuration" + } + } } }, - "distributedUptimeStatusCreateStatusPage": "status page", - "distributedUptimeStatusCreateStatusPageAccess": "Access", - "distributedUptimeStatusCreateStatusPageReady": "If your status page is ready, you can mark it as published.", - "distributedUptimeStatusBasicInfoHeader": "Basic Information", - "distributedUptimeStatusBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "Upload a logo for your status page", - "distributedUptimeStatusLogoUploadButton": "Upload logo", - "distributedUptimeStatusStandardMonitorsHeader": "Standard Monitors", - "distributedUptimeStatusStandardMonitorsDescription": "Attach standard monitors to your status page.", - "distributedUptimeStatusCreateYour": "Create your", - "distributedUptimeStatusEditYour": "Edit your", - "distributedUptimeStatusPublishedLabel": "Published and visible to the public", - "distributedUptimeStatusCompanyNameLabel": "Company name", - "distributedUptimeStatusPageAddressLabel": "Your status page address", - "distributedUptimeStatus30Days": "30 days", - "distributedUptimeStatus60Days": "60 days", - "distributedUptimeStatus90Days": "90 days", - "distributedUptimeStatusPageNotSetUp": "A status page is not set up.", - "distributedUptimeStatusContactAdmin": "Please contact your administrator", - "distributedUptimeStatusPageNotPublic": "This status page is not public.", - "distributedUptimeStatusPageDeleteDialog": "Do you want to delete this status page?", - "distributedUptimeStatusPageDeleteConfirm": "Yes, delete status page", - "distributedUptimeStatusPageDeleteDescription": "Once deleted, your status page cannot be retrieved.", - "distributedUptimeStatusDevices": "Devices", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT Burned", - "distributedUptimeStatusUptLogo": "Upt Logo", "incidentsTableNoIncidents": "No incidents recorded", "incidentsTablePaginationLabel": "incidents", "incidentsTableMonitorName": "Monitor Name", "incidentsTableStatus": "Status", - "incidentsTableDateTime": "Date & Time", "incidentsTableStatusCode": "Status Code", "incidentsTableMessage": "Message", "incidentsOptionsHeader": "Incidents for:", "incidentsOptionsHeaderFilterBy": "Filter by:", "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterActive": "Active", - "incidentsOptionsHeaderFilterDown": "Down", - "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", "incidentsOptionsHeaderShow": "Show:", "incidentsOptionsHeaderLastHour": "Last hour", "incidentsOptionsHeaderLastDay": "Last day", "incidentsOptionsHeaderLastWeek": "Last week", "incidentsOptionsPlaceholderAllServers": "All servers", - "infrastructureCreateYour": "Create your", - "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", - "infrastructureServerRequirement": "The server you are monitoring must be running the", - "infrastructureCustomizeAlerts": "Customize alerts", - "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", - "infrastructureCreateMonitor": "Create Infrastructure Monitor", - "infrastructureProtocol": "Protocol", - "infrastructureServerUrlLabel": "Server URL", - "infrastructureDisplayNameLabel": "Display name", - "infrastructureAuthorizationSecretLabel": "Authorization secret", - "gb": "GB", - "mb": "MB", - "mem": "Mem", - "memoryUsage": "Memory usage", - "cpu": "CPU", - "cpuUsage": "CPU usage", - "cpuTemperature": "CPU Temperature", - "diskUsage": "Disk Usage", "used": "Used", "total": "Total", - "cores": "Cores", - "frequency": "Frequency", - "device": "Device", - "mountedOn": "Mounted on", "status": "Status", - "cpuPhysical": "CPU (Physical)", - "cpuLogical": "CPU (Logical)", - "cpuFrequency": "CPU Frequency", - "avgCpuTemperature": "Average CPU Temperature", - "memory": "Memory", - "disk": "Disk", "uptime": "Uptime", - "os": "OS", - "host": "Host", "actions": "Actions", "integrations": "Integrations", "integrationsPrism": "Connect Prism to your favorite service.", @@ -283,28 +380,14 @@ "integrationsZapier": "Zapier", "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", "commonSave": "Save", - "createYour": "Create your", - "createMonitor": "Create monitor", "pause": "Pause", "resume": "Resume", - "editing": "Editing...", - "url": "URL", "access": "Access", "timezone": "Timezone", "features": "Features", "administrator": "Administrator?", "loginHere": "Login here", - "displayName": "Display name", - "urlMonitor": "URL to monitor", "portToMonitor": "Port to monitor", - "websiteMonitoring": "Website monitoring", - "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", - "pingMonitoring": "Ping monitoring", - "pingMonitoringDescription": "Check whether your server is available or not.", - "dockerContainerMonitoring": "Docker container monitoring", - "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", - "portMonitoring": "Port monitoring", - "portMonitoringDescription": "Check whether your port is open or not.", "createMaintenanceWindow": "Create maintenance window", "createMaintenance": "Create maintenance", "editMaintenance": "Edit maintenance", @@ -318,11 +401,6 @@ "window": "window", "cancel": "Cancel", "close": "Close", - "message": "Message", - "low": "low", - "high": "high", - "statusCode": "Status code", - "date&Time": "Date & Time", "type": "Type", "statusPageName": "Status page name", "publicURL": "Public URL", @@ -336,24 +414,7 @@ "monitorsToApply": "Monitors to apply maintenance window to", "nextWindow": "Next window", "notFoundButton": "Go to the main dashboard", - "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", - "monitorDisplayName": "Monitor display name", - "whenNewIncident": "When there is a new incident,", - "notifySMS": "Notify via SMS (coming soon)", - "notifyEmails": "Also notify via email to multiple addresses (coming soon)", - "seperateEmails": "You can separate multiple emails with a comma", - "checkFrequency": "Check frequency", - "matchMethod": "Match Method", - "expectedValue": "Expected value", - "deleteDialogTitle": "Do you really want to delete this monitor?", - "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", - "pageSpeedMonitor": "PageSpeed monitor", - "shown": "Shown", - "ago": "ago", "companyName": "Company name", - "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", - "pageSpeedDetailsPerformanceReportCalculator": "See calculator", - "checkingEvery": "Checking every", "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", "basicInformation": "Basic Information", "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", @@ -375,16 +436,7 @@ "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", - "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", - "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", - "uptimeCreateJsonPathQuery": "for query language documentation.", "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "infrastructureEditYour": "Edit your", - "infrastructureEditMonitor": "Save Infrastructure Monitor", - "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", - "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", - "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 yet. Without it, the PageSpeed monitor won't function.", "pageSpeedLearnMoreLink": "Click here", @@ -410,9 +462,7 @@ "selectFileTips": "Select CSV file to upload", "selectFileDescription": "You can download our or 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", "invalidFileType": "Invalid file type", @@ -421,11 +471,7 @@ "DeleteAccountTitle": "Remove account", "DeleteAccountButton": "Remove account", "publicLink": "Public link", - "maskedPageSpeedKeyPlaceholder": "*************************************", "reset": "Reset", - "ignoreTLSError": "Ignore TLS/SSL error", - "tlsErrorIgnored": "TLS/SSL errors ignored", - "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", "createNew": "Create new", "greeting": { "prepend": "Hey there", @@ -468,41 +514,7 @@ }, "register": "Register a team member", "registerToast": { - "success": "User created, share credentials with the member securely.", - "dbUserExists": "User already exists.", - "unknownError": "Unknown error occurred." - }, - "registerTeamMember": { - "title": "Register team member", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "Please enter a name", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "lastName": { - "errors": { - "empty": "Please enter a surname", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "email": { - "errors": { - "empty": "To continue, please enter an email address", - "invalid": "Please recheck validity of entered email address" - } - }, - "role": { - "errors": { - "empty": "Role is required" - } - } - } - } - } + "success": "User created, share credentials with the member securely." }, "role": "Role", "changeTeamPassword": { @@ -512,11 +524,6 @@ "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way." } }, - "monitorState": { - "paused": "Paused", - "resumed": "Resumed", - "active": "Active" - }, "menu": { "uptime": "Uptime", "pagespeed": "Pagespeed", @@ -524,7 +531,6 @@ "incidents": "Incidents", "statusPages": "Status pages", "maintenance": "Maintenance", - "integrations": "Integrations", "settings": "Settings", "support": "Support", "discussions": "Discussions", @@ -538,20 +544,16 @@ "logs": "Logs", "checks": "Checks" }, - "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", - "state": "State", "statusBreadCrumbsStatusPages": "Status Pages", "statusBreadCrumbsDetails": "Details", "commonSaving": "Saving...", "navControls": "Controls", "incidentsPageTitle": "Incidents", - "checksPageTitle": "Checks", "passwordPanel": { "forgotPasswordText": "Forgot password?", "passwordChangedSuccess": "Your password was changed successfully.", "passwordInputIncorrect": "Your password input was incorrect.", "currentPassword": "Current password", - "enterCurrentPassword": "Enter your current password", "newPassword": "New password", "enterNewPassword": "Enter your new password", "confirmNewPassword": "Confirm new password", @@ -559,41 +561,19 @@ "resetPasswordText": "Reset password", "sessionExpired": "Your session has expired, please log in again to continue." }, - "emailSent": "Email sent successfully", "failedToSendEmail": "Failed to send email", "settingsTestEmailSuccess": "Test email sent successfully", "settingsTestEmailFailed": "Failed to send test email", - "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", "settingsTestEmailUnknownError": "Unknown error", - "statusMsg": { - "paused": "Monitoring is paused.", - "up": "Your site is up.", - "down": "Your site is down.", - "pending": "Pending..." - }, - "uptimeGeneralInstructions": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", - "https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard." - }, - "auth": { "common": { "navigation": { - "continue": "Continue", - "back": "Back" + "continue": "Continue" }, "inputs": { "email": { "label": "Email", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "To continue, please enter your email address", - "invalid": "Please recheck validity of entered email address" - } + "placeholder": "jordan.ellis@domain.com" }, "password": { "label": "Password", @@ -622,66 +602,27 @@ "beginning": "Passwords", "highlighted": "must match" } - }, - "errors": { - "empty": "Please enter your password", - "length": "Password must be at least 8 characters long", - "uppercase": "Password must contain at least 1 uppercase letter", - "lowercase": "Password must contain at least 1 lowercase letter", - "number": "Password must contain at least 1 number", - "special": "Password must contain at least 1 special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", - "incorrect": "The password you provided does not match our records" } }, "passwordConfirm": { "label": "Confirm password", - "placeholder": "Re-enter password to confirm", - "errors": { - "empty": "Please enter your password again for confirmation (helps with typos)", - "different": "Entered passwords don't match, so one of them is probably mistyped" - } + "placeholder": "Re-enter password to confirm" }, "firstName": { "label": "Name", - "placeholder": "Jordan", - "errors": { - "empty": "Please enter your name", - "length": "Name must be less than 50 characters", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - } + "placeholder": "Jordan" }, "lastName": { "label": "Surname", - "placeholder": "Ellis", - "errors": { - "empty": "Please enter your surname", - "length": "Surname must be less than 50 characters", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - } + "placeholder": "Ellis" } }, "errors": { "validation": "Error validating data." - }, - "fields": { - "password": { - "errors": { - "incorrect": "The password you provided does not match our records" - } - }, - "role": { - "errors": { - "min": "At least one role is required" - } - } } }, "login": { "heading": "Log in to continue", - "subheadings": { - "stepOne": "Enter your email", - "stepTwo": "Enter your password" - }, "links": { "forgotPassword": "Forgot password?", "register": "Do not have an account?", @@ -701,26 +642,12 @@ }, "registration": { "heading": { - "superAdmin": "Create super admin", "user": "Sign Up" }, - "subheadings": { - "stepOne": "Enter your personal details", - "stepTwo": "Enter your email", - "stepThree": "Create your password" - }, "description": { "superAdmin": "Create your super admin account to get started", "user": "Sign up as a user and ask super admin for access to your monitors" }, - "gettingStartedButton": { - "superAdmin": "Create super admin account", - "user": "Sign up regular user" - }, - "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", - "links": { - "login": "Already have an account? Log In" - }, "toasts": { "success": "Welcome! Your account was created successfully." }, @@ -738,12 +665,6 @@ "openEmail": "Open email app", "resetPassword": "Reset password" }, - "imageAlts": { - "passwordKey": "Password key icon", - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon" - }, "links": { "login": "Go back to Log In", "accountPassword": "Go back to Account Password", @@ -772,79 +693,15 @@ } } }, - "createNotifications": { - "title": "Create notification channel", - "nameSettings": { - "title": "Name", - "description": "A descriptive name for your integration.", - "nameLabel": "Name", - "namePlaceholder": "e.g. Slack notifications" - }, - "typeSettings": { - "title": "Type", - "description": "Select the type of notification channel you want to create.", - "typeLabel": "Type" - }, - "emailSettings": { - "title": "Email", - "description": "Destination email addresses.", - "emailLabel": "Email address", - "emailPlaceholder": "e.g. john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "Configure your Slack webhook here", - "webhookLabel": "Slack webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "Configure your PagerDuty integration here", - "integrationKeyLabel": "Integration key", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Configure your Discord webhook here", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "Configure your webhook here", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Test notification", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "dialogDeleteConfirm": "Delete" - }, - "notificationConfig": { - "title": "Notifications", - "description": "Select the notifications channels you want to use" - }, - "monitorStatus": { - "checkingEvery": "Checking every {{interval}}", - "withCaptureAgent": "with Capture agent {{version}}", - "up": "up", - "down": "down", - "paused": "paused" - }, - "advancedMatching": "Advanced matching", - "sendTestNotifications": "Send test notifications", "selectAll": "Select all", "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", "logsPage": { "title": "Logs", - "description": "System logs - last 1000 lines", "tabs": { "queue": "Job queue", "logs": "Server logs", "diagnostics": "Diagnostics" }, - "toast": { - "fetchLogsSuccess": "Logs fetched successfully" - }, "logLevelSelect": { "title": "Log level", "values": { @@ -866,7 +723,6 @@ "noLogs": "No logs found" }, "queuePage": { - "title": "Queue", "refreshButton": "Refresh", "flushButton": "Flush queue", "jobTable": { @@ -897,23 +753,9 @@ "failReasonHeader": "Fail reason" } }, - "export": { - "title": "Export Monitors", - "success": "Monitors exported successfully!", - "failed": "Failed to export monitors" - }, - "monitorActions": { - "title": "Export/Import", - "import": "Import Monitors", - "export": "Export Monitors", - "deleteSuccess": "Monitor deleted successfully", - "deleteFailed": "Failed to delete monitor", - "details": "Details" - }, "settingsPage": { "aboutSettings": { "labelDevelopedBy": "Developed by Bluewave Labs", - "labelVersion": "Version", "title": "About" }, "demoMonitorsSettings": { @@ -928,14 +770,9 @@ "labelAddress": "Email address - Used for authentication", "labelConnectionHost": "Email connection host - Hostname to use in the HELO/EHLO greeting", "labelHost": "Email host - Hostname or IP address to connect to", - "labelIgnoreTLS": "Disable STARTTLS: Don't use TLS even if the server supports it", "labelPassword": "Email password - Password for authentication", "labelPasswordSet": "Password is set. Click Reset to change it.", - "labelPool": "Enable connection pooling: Reuse existing connections to improve performance", "labelPort": "Email port - Port to connect to", - "labelRejectUnauthorized": "Reject invalid certificates: Reject connections with self-signed or untrusted certificates", - "labelRequireTLS": "Force STARTTLS: Require TLS upgrade, fail if not supported", - "labelSecure": "Use SSL (recommended): Encrypt the connection using SSL/TLS", "labelTLSServername": "TLS Servername - Optional Hostname for TLS Validation when host is an IP", "labelUser": "Email user - Username for authentication, overrides email address if specified", "linkTransport": "See specifications here", @@ -953,25 +790,20 @@ "statsSettings": { "clearAllStatsButton": "Clear all stats", "clearAllStatsDescription": "Clear all stats. This is irreversible.", - "clearAllStatsDialogConfirm": "Yes, clear all stats", "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", "clearAllStatsDialogTitle": "Do you want to clear all stats?", "description": "Define how long you want to retain historical data. You can also clear all existing data.", - "labelTTL": "The days you want to keep monitoring history.", - "labelTTLOptional": "0 for infinite", "title": "Monitor history" }, "systemResetSettings": { "buttonRemoveAllMonitors": "Remove all monitors", "description": "Remove all monitors from your system.", "dialogConfirm": "Yes, remove all monitors", - "dialogDescription": "Once removed, the monitors cannot be retrieved.", "dialogTitle": "Do you want to remove all monitors?", "title": "System reset" }, "timezoneSettings": { "description": "Select the timezone used to display dates and times throughout the application.", - "label": "Display timezone", "title": "Display timezone" }, "title": "Settings", @@ -992,54 +824,33 @@ "title": "Monitor IP/URL on Status Page" }, "globalThresholds": { - "title": "Global Thresholds", - "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." + "title": "Global Thresholds" } }, "statusPageCreate": { "buttonSave": "Save" }, - "incidentsOptionsHeaderFilterResolved": "Resolved", - "settingsSave": "Save", "statusPageCreateAppearanceTitle": "Appearance", "confirmPassword": "Confirm password", "monitorHooks": { "failureAddDemoMonitors": "Failed to add demo monitors", "successAddDemoMonitors": "Successfully added demo monitors" }, - "settingsAppearance": "Appearance", "settingsDisplayTimezone": "Display timezone", "settingsGeneralSettings": "General settings", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "statusPage": { "deleteSuccess": "Status page deleted successfully", "deleteFailed": "Failed to delete status page", "createSuccess": "Status page created successfully", "updateSuccess": "Status page updated successfully", "generalSettings": "General settings", - "contents": "Contents", - "fallback": { - "checks": [ - "Monitor and display the health of your services in real time", - "Track multiple services and share their status", - "Keep users informed about outages and performance" - ], - "title": "A status page is used to:", - "actionButton": "Let's create your first status page!" - } + "contents": "Contents" }, - "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", - "incidentsTableResolvedAt": "Resolved at", - "incidentsTableActionResolve": "Resolve", "checkHooks": { - "failureResolveOne": "Failed to resolve incident.", - "failureResolveAll": "Failed to resolve all incidents.", - "failureResolveMonitor": "Failed to resolve monitor incidents." + "failureResolveOne": "Failed to resolve incident." }, - "checkFormError": "Please check the form for errors.", "diagnosticsPage": { "diagnosticDescription": "System diagnostics", - "statsDescription": "System statistics", "gauges": { "heapAllocationTitle": "Heap allocation", "heapAllocationSubtitle": "% of available memory", @@ -1058,7 +869,6 @@ "osMemoryLimitTitle": "OS Memory Limit" } }, - "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", "time": { "fifteenSeconds": "15 seconds", "thirtySeconds": "30 seconds", @@ -1067,10 +877,6 @@ "threeMinutes": "3 minutes", "fiveMinutes": "5 minutes", "tenMinutes": "10 minutes", - "twentyMinutes": "20 minutes", - "oneHour": "1 hour", - "oneDay": "1 day", - "oneWeek": "1 week", "fourMinutes": "4 minutes", "oneMinute": "1 minute", "twoMinutes": "2 minutes" @@ -1078,51 +884,6 @@ "general": { "noOptionsFound": "No {{unit}} found" }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:", - "actionButton": "Let's create your first infrastructure monitor!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "Mark your maintenance periods", - "Eliminate any misunderstandings", - "Stop sending alerts in maintenance windows" - ], - "title": "A maintenance window is used to:", - "actionButton": "Let's create your first maintenance window!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:", - "actionButton": "Let's create your first PageSpeed monitor!" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:", - "actionButton": "Let's create your first uptime monitor!" - } - }, "editUserPage": { "form": { "email": "Email", @@ -1141,106 +902,18 @@ "validationErrors": "Validation errors" } }, - "incidentsPageActionResolveMonitor": "Resolve monitor incidents", - "checksPageActionResolveMonitor": "Resolve monitor checks", - "incidentsPageActionResolveAll": "Resolve all incidents", - "checksPageActionResolveAllMonitor": "Resolve all checks", "matchMethodOptions": { "equal": "Equal", - "equalPlaceholder": "success", "include": "Include", - "includePlaceholder": "ok", - "regex": "Regex", - "regexPlaceholder": "^(success|ok)$", - "text": "Match Method" + "regex": "Regex" }, - "monitorType": { - "docker": { - "label": "Container Name/ID", - "namePlaceholder": "My Container", - "placeholder": "my-app or abcd1234" - }, - "http": { - "label": "URL to monitor", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "IP address to monitor", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "URL to monitor", - "namePlaceholder": "Localhost:5173", - "placeholder": "localhost" - }, - "game": { - "label": "URL to monitor", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "JSON Path" - }, - "bytesPerSecond": "Bytes per second", - "bytesReceived": "Bytes Received", - "bytesSent": "Bytes Sent", "chooseGame": "Choose game", - "createMonitorPage": { - "incidentConfigDescription": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", - "incidentConfigStatusWindowLabel": "How many checks should be in the sliding window?", - "incidentConfigStatusWindowThresholdLabel": "What percentage of checks in the sliding window fail/succeed before monitor status changes?", - "incidentConfigTitle": "Incidents", - "incidentConfigDescriptionV2": "Number of consecutive checks needed to change monitor status. Maximum 25", - "incidentConfigStatusCheckNumber": "How many checks before status change?", - "intervalTitle": "Check interval", - "intervalDescription": "How often the monitor should be checked." - }, - "dataRate": "Data Rate", - "dataReceived": "Data Received", - "dataSent": "Data Sent", - "details": "Details", - "drops": "Drops", - "errors": "Errors", - "errorsIn": "Errors In", - "errorsOut": "Errors Out", - "gameServerMonitoring": "Game Server Monitoring", - "gameServerMonitoringDescription": "Check whether your game server is running or not", - "network": "Network", - "networkDrops": "Network Drops", - "networkErrors": "Network Errors", - "networkInterface": "Network Interface", - "noNetworkStatsAvailable": "No network stats available.", - "packetsPerSecond": "Packets per second", - "packetsReceived": "Packets Received", - "packetsReceivedRate": "Packets Received Rate", - "packetsSent": "Packets Sent", - "rate": "Rate", - "selectInterface": "Select Interface", - "v1": { - "infrastructure": { - "disk_selection_title": "Disk Selection", - "disk_selection_info": "No disk detected for the moment.", - "disk_selection_description": "Select the specific disks you want to monitor." - } - }, "incidentsPage": { - "title": "Incidents", - "description": "Manage incidents for your monitors. You can resolve incidents here.", "resolved": "Resolved", "active": "Active", "startTime": "Start Time", "endTime": "End Time", "resolutionType": "Resolution Type", - "unresolved": "Unresolved", - "all": "All", - "filterByMonitor": "Filter by monitor", - "filterByStatus": "Filter by status", - "filterByType": "Filter by type", - "filterByDate": "Filter by date", - "filterByPriority": "Filter by priority", "incidentsActivePanelError": "Error loading active incidents", "allSystemsAreOperational": "All Systems Are Operational", "incidentsActivePanelTitle": "Total Active Incidents", @@ -1248,18 +921,13 @@ "incidentsLatestPanelTitle": "Latest Incidents", "incidentsLatestPanelEmpty": "No recent incidents", "incidentItemMonitor": "Monitor", - "incidentItemStatus": "Status", "unknownMonitor": "Unknown Monitor", "incidentsStatisticsPanelError": "Error loading statistics", "incidentsStatisticsPanelTitle": "General Statistics", "totalIncidents": "Total Incidents", "mostAffectedMonitor": "Most Affected Monitor", "avgResolutionTime": "Avg. Resolution Time", - "resolutions": "Resolutions", - "automatic": "Automatic", - "manual": "Manual", "incidentResolvedSuccessfully": "Incident resolved successfully", - "incidentResolvedFailed": "Error resolving incident", "errorFetchingIncidents": "Error fetching incidents", "errorFetchingIncident": "Error fetching incident", "noIncidentIdProvided": "No incident ID provided", @@ -1269,7 +937,6 @@ "incidentsOptionsHeaderFilterAll": "All", "incidentsOptionsHeaderFilterActive": "Active", "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsTableResolved": "Closed", "incidentsTableActionResolveManually": "Resolve Manually", "incidentsTableActionDetails": "Details", "incidentsTableActionGoToMonitor": "Go to monitor", @@ -1279,11 +946,8 @@ "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", "resolveIncidentDialogConfirm": "Resolve", "hours": "hours", - "loading": "Loading incident details...", "incidentNotFound": "Incident not found", "incidentDetails": "INCIDENT DETAILS", - "activeIncident": "ACTIVE INCIDENT", - "resolvedIncident": "RESOLVED INCIDENT", "overview": "OVERVIEW", "URL": "URL", "incidentAnalysis": "INCIDENT ANALYSIS", diff --git a/docker/coolify/server.Dockerfile b/docker/coolify/server.Dockerfile index 238bd404c..225b47695 100755 --- a/docker/coolify/server.Dockerfile +++ b/docker/coolify/server.Dockerfile @@ -13,6 +13,8 @@ RUN npm install COPY ../../ ./ +RUN npm run build + EXPOSE 5000 -CMD ["node", "index.js"] \ No newline at end of file +CMD ["node", "dist/index.js"] diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index 9a87aa59b..2255de508 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -15,7 +15,7 @@ services: depends_on: - mongodb mongodb: - image: mongo:4.4.30 + image: mongo:6.0 restart: always command: ["mongod", "--quiet", "--bind_ip_all"] ports: @@ -23,7 +23,7 @@ services: volumes: - ./mongo/data:/data/db healthcheck: - test: ["CMD", "mongo", "--eval", "db.runCommand({ ping: 1 })", "--quiet"] + test: ["CMD", "mongosh", "--eval", "db.runCommand({ ping: 1 })", "--quiet"] interval: 5s timeout: 30s start_period: 0s diff --git a/docker/dist-arm/server.Dockerfile b/docker/dist-arm/server.Dockerfile index 91ccd427e..c9835067f 100644 --- a/docker/dist-arm/server.Dockerfile +++ b/docker/dist-arm/server.Dockerfile @@ -1,7 +1,7 @@ # --------------------- # Frontend build stage # --------------------- -FROM node:24-slim AS frontend-build +FROM node:20-slim AS frontend-build @@ -20,7 +20,7 @@ RUN npm run build # --------------------- # Backend stage # --------------------- -FROM node:24-slim AS backend +FROM node:20-slim AS backend # Install ping RUN apt-get update \ diff --git a/docker/prod/server.Dockerfile b/docker/prod/server.Dockerfile index 9d9800e7b..7341b1e9b 100755 --- a/docker/prod/server.Dockerfile +++ b/docker/prod/server.Dockerfile @@ -19,4 +19,4 @@ RUN npm run build EXPOSE 52345 -CMD ["node", "src/index.js"] \ No newline at end of file +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/server/scripts/generate-checks.js b/server/scripts/generate-checks.js index d3a33b033..544770bb6 100644 --- a/server/scripts/generate-checks.js +++ b/server/scripts/generate-checks.js @@ -54,8 +54,8 @@ async function ensureMonitor({ monitorId, teamId, userId, type }) { async function run() { const mongoUri = process.env.MONGO_URI ?? "mongodb://localhost:27017/uptime_db"; const monitorId = parseObjectId(process.env.MONITOR_ID ?? DEFAULT_MONITOR_ID, DEFAULT_MONITOR_ID); - const teamId = parseObjectId("69648b0578209af45f9ffe30"); - const userId = parseObjectId("69648b0678209af45f9ffe32"); + const teamId = parseObjectId("6971546de1b2bc3de6498e6e"); + const userId = parseObjectId("6971546de1b2bc3de6498e70"); const monitorType = process.env.MONITOR_TYPE ?? DEFAULT_MONITOR_TYPE; const total = Number(process.env.CHECK_TOTAL ?? DEFAULT_TOTAL); const batchSize = Number(process.env.CHECK_BATCH_SIZE ?? DEFAULT_BATCH_SIZE); diff --git a/server/src/app.ts b/server/src/app.ts index 04699964d..fea81ba07 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -28,18 +28,20 @@ export const createApp = ({ }) => { const allowedOrigin = envSettings.clientHost; const app = express(); + app.use(generalApiLimiter); - // Static files - app.use(express.static(frontendPath)); app.use( cors({ origin: allowedOrigin, methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", - allowedHeaders: ["Content-Type", "Authorization"], + allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"], credentials: true, }) ); + + app.use(express.static(frontendPath)); + app.use(express.json()); app.use(cookieParser()); diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 06701491d..4437e9176 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -176,7 +176,7 @@ export const initializeServices = async ({ const bufferService = new BufferService({ logger, checkService, settingsService }); - const statusService = new StatusService({ db, logger, buffer: bufferService, monitorsRepository }); + const statusService = new StatusService({ logger, buffer: bufferService, monitorsRepository }); const webhookProvider = new WebhookProvider(logger); const slackProvider = new SlackProvider(logger); diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 23ea6e42e..165bec056 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -197,7 +197,7 @@ class AuthController { throw new AppError({ message: "Unauthorized", status: 403 }); } - const userId = req.params.userId; + const userId = req.params.userId as string; const user = { ...req.body }; await editUserByIdParamValidation.validateAsync(req.params); @@ -222,7 +222,7 @@ class AuthController { throw new AppError({ message: "Unauthorized", status: 403 }); } - const userId = req.params.userId; + const userId = req.params.userId as string; await editUserByIdParamValidation.validateAsync(req.params); await editUserPasswordByIdBodyValidation.validateAsync(req.body); const updatedPassword = req.body.password; diff --git a/server/src/controllers/checkController.ts b/server/src/controllers/checkController.ts index 8277df3e7..0c085f6d3 100755 --- a/server/src/controllers/checkController.ts +++ b/server/src/controllers/checkController.ts @@ -66,7 +66,8 @@ class CheckController { getChecksSummaryByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId }); + const dateRange = req.query?.dateRange || "hour"; + const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId, dateRange }); return res.status(200).json({ success: true, msg: "Checks summary retrieved successfully", @@ -82,7 +83,7 @@ class CheckController { await deleteChecksParamValidation.validateAsync(req.params); const deletedCount = await this.checkService.deleteChecks({ - monitorId: req.params.monitorId, + monitorId: req.params.monitorId as string, teamId: req?.user?.teamId, }); diff --git a/server/src/controllers/incidentController.ts b/server/src/controllers/incidentController.ts index 72e6498f9..5f2b6d154 100644 --- a/server/src/controllers/incidentController.ts +++ b/server/src/controllers/incidentController.ts @@ -67,7 +67,7 @@ class IncidentController { getIncidentById = async (req: Request, res: Response, next: NextFunction) => { try { const teamId = requireTeamId(req.user?.teamId); - const incidentId = req.params.incidentId; + const incidentId = req.params.incidentId as string; if (!incidentId) { throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 }); } diff --git a/server/src/controllers/maintenanceWindowController.ts b/server/src/controllers/maintenanceWindowController.ts index 0cc9610ea..187e479cc 100644 --- a/server/src/controllers/maintenanceWindowController.ts +++ b/server/src/controllers/maintenanceWindowController.ts @@ -44,7 +44,7 @@ class MaintenanceWindowController { const teamId = requireTeamId(req.user?.teamId); - const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id, teamId }); + const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id as string, teamId }); return res.status(200).json({ success: true, @@ -80,7 +80,10 @@ class MaintenanceWindowController { const teamId = requireTeamId(req?.user?.teamId); - const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({ monitorId: req.params.monitorId, teamId }); + const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({ + monitorId: req.params.monitorId as string, + teamId, + }); return res.status(200).json({ success: true, @@ -97,7 +100,7 @@ class MaintenanceWindowController { const teamId = requireTeamId(req?.user?.teamId); - await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id, teamId }); + await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id as string, teamId }); return res.status(200).json({ success: true, @@ -115,7 +118,11 @@ class MaintenanceWindowController { const teamId = requireTeamId(req.user?.teamId); - const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({ id: req.params.id, body: req.body, teamId }); + const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({ + id: req.params.id as string, + body: req.body, + teamId, + }); return res.status(200).json({ success: true, msg: "Maintenance window edited successfully", diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 30ceabbc5..df1945fcc 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -93,7 +93,7 @@ class MonitorController { const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent"; const teamId = requireTeamId(req?.user?.teamId); - const monitor = await this.monitorService.getHardwareDetailsById({ + const data = await this.monitorService.getHardwareDetailsById({ teamId, monitorId, dateRange, @@ -102,7 +102,7 @@ class MonitorController { return res.status(200).json({ success: true, msg: "Hardware details retrieved successfully", - data: monitor, + data: data, }); } catch (error) { next(error); @@ -324,7 +324,6 @@ class MonitorController { try { await getMonitorsByTeamIdParamValidation.validateAsync(req.params); await getMonitorsWithChecksQueryValidation.validateAsync(req.query); - const explain = optionalBoolean(req?.query?.explain, "explain"); const limit = optionalNumber(req?.query?.limit, "limit"); const page = optionalNumber(req?.query?.page, "page"); diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 1aa10a82c..f32f80ddc 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -97,7 +97,7 @@ class NotificationController { throw new AppError({ message: "Team ID is required", status: 400 }); } - const notificationId = req.params.id; + const notificationId = req.params.id as string; if (!notificationId) { throw new AppError({ message: "Notification ID is required", status: 400 }); } @@ -115,7 +115,7 @@ class NotificationController { getNotificationById = async (req: Request, res: Response, next: NextFunction) => { try { const teamId = requireTeamId(req.user?.teamId); - const notificationId = req.params.id; + const notificationId = req.params.id as string; if (!notificationId) { throw new AppError({ message: "Notification ID is required", status: 400 }); } @@ -139,7 +139,7 @@ class NotificationController { }); const teamId = requireTeamId(req.user?.teamId); - const notificationId = req.params.id; + const notificationId = req.params.id as string; if (!notificationId) { throw new AppError({ message: "Notification ID is required", status: 400 }); } diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index 3501a6592..18428d424 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -7,7 +7,6 @@ import { IStatusPageService } from "@/service/business/statusPageService.js"; import { IMonitorsRepository } from "@/repositories/index.js"; import { ISettingsService } from "@/service/system/settingsService.js"; import { ParseBoolean } from "@/utils/utils.js"; -import { MonitorWithChecks } from "@/types/index.js"; import { NormalizeData } from "@/utils/dataUtils.js"; const SERVICE_NAME = "statusPageController"; @@ -51,7 +50,7 @@ class StatusPageController { await createStatusPageBodyValidation.validateAsync(req.body); await imageValidation.validateAsync(req.file); const teamId = requireTeamId(req?.user?.teamId); - const statusPageId = req.params.id; + const statusPageId = req.params.id as string; if (!statusPageId) { throw new AppError({ message: "Status page ID is required", status: 400 }); } @@ -78,13 +77,13 @@ class StatusPageController { throw new AppError({ message: "Status page URL is required", status: 400 }); } - const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url); + const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); const settings = await this.settingsService.getDBSettings(); const showURL = settings.showURL; const monitors = await this.monitorsRepository.findByIdsWithChecks(statusPage.monitors); const normalizedMonitors = monitors.map((monitor) => { - const normalizedChecks = NormalizeData(monitor.checks, 10, 100); + const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); if (!showURL) { const { url, port, secret, notifications, ...rest } = monitor; return { ...rest, checks: normalizedChecks }; @@ -117,7 +116,7 @@ class StatusPageController { deleteStatusPage = async (req: Request, res: Response, next: NextFunction) => { try { - const statusPageId = req.params.id; + const statusPageId = req.params.id as string; if (!statusPageId) { throw new AppError({ message: "Status page ID is required", status: 400 }); } diff --git a/server/src/db/migration/0002_convertChecksToTimeSeries.ts b/server/src/db/migration/0002_convertChecksToTimeSeries.ts index a6c503804..01ed82def 100644 --- a/server/src/db/migration/0002_convertChecksToTimeSeries.ts +++ b/server/src/db/migration/0002_convertChecksToTimeSeries.ts @@ -2,8 +2,15 @@ import mongoose from "mongoose"; const CHECKS_COLLECTION = "checks"; const BACKUP_COLLECTION = "checks_backup"; +const FAILED_DOCS_COLLECTION = "checks_migration_failed"; const BATCH_SIZE = 1000; +interface MigrationStats { + totalSource: number; + totalMigrated: number; + totalFailed: number; +} + const getDb = () => { const db = mongoose.connection.db; if (!db) { @@ -12,12 +19,31 @@ const getDb = () => { return db; }; +const restoreFromBackup = async () => { + const db = getDb(); + const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray(); + if (backupExists.length === 0) { + throw new Error("Cannot restore: backup collection does not exist"); + } + + const checksExists = await db.listCollections({ name: CHECKS_COLLECTION }).toArray(); + if (checksExists.length > 0) { + await db.collection(CHECKS_COLLECTION).drop(); + } + + await db.collection(BACKUP_COLLECTION).rename(CHECKS_COLLECTION); +}; + const backupAndDropExistingCollection = async () => { const db = getDb(); const collections = await db.listCollections({ name: CHECKS_COLLECTION }).toArray(); if (collections.length === 0) { return false; } + const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray(); + if (backupExists.length > 0) { + throw new Error(`Backup collection "${BACKUP_COLLECTION}" already exists. ` + `Please remove it manually before running migration.`); + } await db .collection(CHECKS_COLLECTION) @@ -27,69 +53,131 @@ const backupAndDropExistingCollection = async () => { return true; }; -const createTimeSeriesCollection = async () => { +const createTimeSeriesCollection = async (backedUp: boolean) => { const db = getDb(); - const existing = await db.listCollections({ name: CHECKS_COLLECTION }).toArray(); - if (existing.length === 0) { - await db.createCollection(CHECKS_COLLECTION, { - timeseries: { - timeField: "createdAt", - metaField: "metadata", - granularity: "seconds", - }, - }); - } + try { + const existing = await db.listCollections({ name: CHECKS_COLLECTION }).toArray(); + if (existing.length === 0) { + await db.createCollection(CHECKS_COLLECTION, { + timeseries: { + timeField: "createdAt", + metaField: "metadata", + granularity: "seconds", + }, + }); + } - await db - .collection(CHECKS_COLLECTION) - .createIndexes([ - { key: { updatedAt: 1 } }, - { key: { "metadata.monitorId": 1, updatedAt: 1 } }, - { key: { "metadata.monitorId": 1, updatedAt: -1 } }, - { key: { "metadata.teamId": 1, updatedAt: -1 } }, - ]); + await db + .collection(CHECKS_COLLECTION) + .createIndexes([ + { key: { createdAt: 1 } }, + { key: { "metadata.monitorId": 1, createdAt: 1 } }, + { key: { "metadata.monitorId": 1, createdAt: -1 } }, + { key: { "metadata.teamId": 1, createdAt: -1 } }, + { key: { "metadata.monitorId": 1, "metadata.type": 1, createdAt: -1 } }, + { key: { "metadata.teamId": 1, status: 1, createdAt: -1 } }, + ]); + } catch (error) { + if (backedUp) { + await restoreFromBackup(); + } + throw error; + } }; -const migrateBackupData = async () => { +const validateDocument = (doc: any): boolean => { + if (!doc.createdAt) { + return false; + } + if (!doc.monitorId || !doc.teamId || !doc.type) { + return false; + } + return true; +}; + +const storeFailedDocument = async (doc: any, reason: string) => { + const db = getDb(); + await db.collection(FAILED_DOCS_COLLECTION).insertOne({ + originalDoc: doc, + reason, + failedAt: new Date(), + }); +}; + +const migrateBackupData = async (backedUp: boolean): Promise => { const db = getDb(); const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray(); if (backupExists.length === 0) { - return; + return { totalSource: 0, totalMigrated: 0, totalFailed: 0 }; } - const source = db.collection(BACKUP_COLLECTION); - const target = db.collection(CHECKS_COLLECTION); - const cursor = source.find(); - const operations: any[] = []; + const stats: MigrationStats = { + totalSource: 0, + totalMigrated: 0, + totalFailed: 0, + }; - while (await cursor.hasNext()) { - const doc = await cursor.next(); - if (!doc) { - continue; + try { + const source = db.collection(BACKUP_COLLECTION); + const target = db.collection(CHECKS_COLLECTION); + + stats.totalSource = await source.countDocuments(); + + const cursor = source.find().addCursorFlag("noCursorTimeout", true); + const operations: any[] = []; + const invalidDocs: any[] = []; + + while (await cursor.hasNext()) { + const doc = await cursor.next(); + if (!doc) { + continue; + } + + if (!validateDocument(doc)) { + invalidDocs.push(doc); + continue; + } + + const { _id, monitorId, teamId, type, ...rest } = doc; + const metadata = { monitorId, teamId, type }; + operations.push({ insertOne: { document: { ...rest, metadata } } }); + + if (operations.length >= BATCH_SIZE) { + const result = await target.bulkWrite(operations, { ordered: false }); + stats.totalMigrated += result.insertedCount; + operations.length = 0; + } } - const metadata = { - monitorId: doc.monitorId, - teamId: doc.teamId, - type: doc.type, - }; - operations.push({ insertOne: { document: { ...doc, metadata } } }); - - if (operations.length >= BATCH_SIZE) { - await target.bulkWrite(operations); - operations.length = 0; + if (operations.length) { + const result = await target.bulkWrite(operations, { ordered: false }); + stats.totalMigrated += result.insertedCount; } - } - if (operations.length) { - await target.bulkWrite(operations); + for (const doc of invalidDocs) { + await storeFailedDocument(doc, "Missing required fields (createdAt, monitorId, teamId, or type)"); + stats.totalFailed++; + } + + await cursor.close(); + + return stats; + } catch (error) { + if (backedUp) { + await restoreFromBackup(); + } + throw error; } }; export const convertChecksToTimeSeries = async () => { const backedUp = await backupAndDropExistingCollection(); - await createTimeSeriesCollection(); + await createTimeSeriesCollection(backedUp); if (backedUp) { - await migrateBackupData(); + const stats = await migrateBackupData(backedUp); + console.log( + `Migration completed: ${stats.totalMigrated} documents migrated, ` + + `${stats.totalFailed} documents failed (stored in ${FAILED_DOCS_COLLECTION})` + ); } }; diff --git a/server/src/db/models/Check.ts b/server/src/db/models/Check.ts index 94dfa873c..7bd1cec6f 100644 --- a/server/src/db/models/Check.ts +++ b/server/src/db/models/Check.ts @@ -71,6 +71,7 @@ const cpuSchema = new Schema( physical_core: { type: Number, default: 0 }, logical_core: { type: Number, default: 0 }, frequency: { type: Number, default: 0 }, + current_frequency: { type: Number, default: 0 }, temperature: { type: [Number], default: [] }, free_percent: { type: Number, default: 0 }, usage_percent: { type: Number, default: 0 }, @@ -92,11 +93,18 @@ const diskSchema = new Schema( { device: { type: String, default: "" }, mountpoint: { type: String, default: "" }, - read_speed_bytes: { type: Number, default: 0 }, - write_speed_bytes: { type: Number, default: 0 }, total_bytes: { type: Number, default: 0 }, free_bytes: { type: Number, default: 0 }, + used_bytes: { type: Number, default: 0 }, usage_percent: { type: Number, default: 0 }, + total_inodes: { type: Number, default: 0 }, + free_inodes: { type: Number, default: 0 }, + used_inodes: { type: Number, default: 0 }, + inodes_usage_percent: { type: Number, default: 0 }, + read_bytes: { type: Number, default: 0 }, + write_bytes: { type: Number, default: 0 }, + read_time: { type: Number, default: 0 }, + write_time: { type: Number, default: 0 }, }, { _id: false } ); @@ -106,6 +114,7 @@ const hostSchema = new Schema( os: { type: String, default: "" }, platform: { type: String, default: "" }, kernel_version: { type: String, default: "" }, + pretty_name: { type: String, default: "" }, }, { _id: false } ); @@ -284,12 +293,12 @@ const CheckSchema = new Schema( } ); -CheckSchema.index({ updatedAt: 1 }); -CheckSchema.index({ "metadata.monitorId": 1, updatedAt: 1 }); -CheckSchema.index({ "metadata.monitorId": 1, updatedAt: -1 }); CheckSchema.index({ "metadata.monitorId": 1, createdAt: -1 }); -CheckSchema.index({ "metadata.teamId": 1, updatedAt: -1 }); +CheckSchema.index({ "metadata.monitorId": 1, createdAt: 1 }); +CheckSchema.index({ createdAt: 1 }); CheckSchema.index({ "metadata.teamId": 1, createdAt: -1 }); +CheckSchema.index({ "metadata.monitorId": 1, "metadata.type": 1, createdAt: -1 }); +CheckSchema.index({ "metadata.teamId": 1, status: 1, createdAt: -1 }); const CheckModel = model("Check", CheckSchema); diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 17565a2e4..d4cc9a872 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,15 +1,18 @@ import { Schema, model, Types, type UpdateQuery } from "mongoose"; -import type { Monitor, MonitorMatchMethod, MonitorThresholds } from "@/types/monitor.js"; +import type { Monitor, MonitorMatchMethod, MonitorThresholds, CheckSnapshot } from "@/types/monitor.js"; import { MonitorTypes } from "@/types/monitor.js"; import Check from "./Check.js"; import MonitorStats from "./MonitorStats.js"; import StatusPage from "./StatusPage.js"; +type CheckSnapshotDocument = Omit & { createdAt: Date }; + type MonitorDocumentBase = Omit< Monitor, - "id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "createdAt" | "updatedAt" + "id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" > & { statusWindow: boolean[]; + recentChecks: CheckSnapshotDocument[]; notifications: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; @@ -34,6 +37,15 @@ const thresholdsSchema = new Schema( { _id: false } ); +const checkSnapshotSchema = new Schema( + { + id: { type: String, required: true }, + status: { type: Boolean, required: true }, + createdAt: { type: Date, required: true }, + }, + { _id: false, strict: false } +); + const MonitorSchema = new Schema( { userId: { @@ -80,6 +92,10 @@ const MonitorSchema = new Schema( type: Boolean, default: false, }, + useAdvancedMatching: { + type: Boolean, + default: false, + }, jsonPath: { type: String, }, @@ -165,6 +181,10 @@ const MonitorSchema = new Schema( return value && value.trim() ? value.trim() : null; }, }, + recentChecks: { + type: [checkSnapshotSchema], + default: [], + }, }, { timestamps: true, @@ -202,6 +222,6 @@ MonitorSchema.index({ teamId: 1, type: 1 }); const MonitorModel = model("Monitor", MonitorSchema); -export type { MonitorDocument }; +export type { MonitorDocument, CheckSnapshotDocument }; export { MonitorModel }; export default MonitorModel; diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/repositories/checks/IChecksRepository.ts index ab5dc6ce5..74def03c2 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/repositories/checks/IChecksRepository.ts @@ -33,7 +33,7 @@ export interface IChecksRepository { dateString: string, options?: { type?: MonitorType } ): Promise; - findSummaryByTeamId(teamId: string): Promise; + findSummaryByTeamId(teamId: string, dateRange: string): Promise; // update //delete deleteByMonitorId(monitorId: string): Promise; diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index b9cf2041f..a36788e2c 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -19,7 +19,7 @@ import mongoose from "mongoose"; const SERVICE_NAME = "StatusService"; const dateRangeLookup: Record = { - recent: new Date(new Date().setDate(new Date().getDate() - 2)), + recent: new Date(new Date().setHours(new Date().getHours() - 2)), hour: new Date(new Date().setHours(new Date().getHours() - 1)), day: new Date(new Date().setDate(new Date().getDate() - 1)), week: new Date(new Date().setDate(new Date().getDate() - 7)), @@ -29,7 +29,6 @@ const dateRangeLookup: Record = { export type LatestChecksMap = Record; type DateRange = { start: Date; end: Date }; -type HardwareAggregateData = { latestCheck: CheckDocument | null; totalChecks: number }; type HardwareUpChecks = { totalChecks: number }; class MongoChecksRepository implements IChecksRepository { @@ -118,11 +117,18 @@ class MongoChecksRepository implements IChecksRepository { (disks ?? []).map((disk) => ({ device: disk?.device ?? "", mountpoint: disk?.mountpoint ?? "", - read_speed_bytes: disk?.read_speed_bytes ?? 0, - write_speed_bytes: disk?.write_speed_bytes ?? 0, total_bytes: disk?.total_bytes ?? 0, free_bytes: disk?.free_bytes ?? 0, + used_bytes: disk?.used_bytes ?? 0, usage_percent: disk?.usage_percent ?? 0, + total_inodes: disk?.total_inodes ?? 0, + free_inodes: disk?.free_inodes ?? 0, + used_inodes: disk?.used_inodes ?? 0, + inodes_usage_percent: disk?.inodes_usage_percent ?? 0, + read_bytes: disk?.read_bytes ?? 0, + write_bytes: disk?.write_bytes ?? 0, + read_time: disk?.read_time ?? 0, + write_time: disk?.write_time ?? 0, })); const mapErrors = (errors?: CheckErrorInfo[]): CheckErrorInfo[] => @@ -194,8 +200,37 @@ class MongoChecksRepository implements IChecksRepository { }; }; + private mapDocuments = (documents: CheckDocument[]): Check[] => { + if (!documents?.length) { + return []; + } + return documents.map((doc) => this.toEntity(doc)); + }; + + private toDocument = (check: Partial): CheckDocument => { + // Map id to _id for MongoDB storage + const { id, metadata, ...rest } = check; + return { + _id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(), + metadata: metadata + ? { + monitorId: new mongoose.Types.ObjectId(metadata.monitorId), + teamId: new mongoose.Types.ObjectId(metadata.teamId), + type: metadata.type, + } + : { + monitorId: new mongoose.Types.ObjectId(), + teamId: new mongoose.Types.ObjectId(), + type: "http", + }, + ...rest, + } as unknown as CheckDocument; + }; + createChecks = async (checks: Check[]) => { - return await CheckModel.insertMany(checks); + const docs = checks.map((check) => this.toDocument(check)); + const inserted = await CheckModel.insertMany(docs); + return this.mapDocuments(inserted as unknown as CheckDocument[]); }; findByMonitorId = async ( @@ -222,9 +257,14 @@ class MongoChecksRepository implements IChecksRepository { switch (filter) { case "all": break; + case "up": + matchStage.status = true; + break; case "down": + matchStage.status = false; break; case "resolve": + matchStage.status = false; matchStage.statusCode = 5000; break; default: @@ -246,33 +286,17 @@ class MongoChecksRepository implements IChecksRepository { skip = page * rowsPerPage; } - const checks = await CheckModel.aggregate([ - { $match: matchStage }, - { $sort: { createdAt: convertedSortOrder } }, - { - $facet: { - summary: [{ $count: "checksCount" }], - checks: [{ $skip: skip }, { $limit: rowsPerPage }], - }, - }, - { - $project: { - checksCount: { - $ifNull: [{ $arrayElemAt: ["$summary.checksCount", 0] }, 0], - }, - checks: { - $ifNull: ["$checks", []], - }, - }, - }, + const [checksCount, checks] = await Promise.all([ + CheckModel.countDocuments(matchStage), + CheckModel.find(matchStage).sort({ createdAt: convertedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise, ]); - return checks[0]; + + return { checksCount, checks: this.mapDocuments(checks) }; }; findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => { const matchStage: Record = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), - status: false, ...(dateRangeLookup[dateRange] && { createdAt: { $gte: dateRangeLookup[dateRange], @@ -284,9 +308,14 @@ class MongoChecksRepository implements IChecksRepository { switch (filter) { case "all": break; + case "up": + matchStage.status = true; + break; case "down": + matchStage.status = false; break; case "resolve": + matchStage.status = false; matchStage.statusCode = 5000; break; default: @@ -307,60 +336,38 @@ class MongoChecksRepository implements IChecksRepository { skip = page * rowsPerPage; } - const aggregatePipeline: any = [ - { $match: matchStage }, + const [checksCount, checks] = await Promise.all([ + CheckModel.countDocuments(matchStage), + CheckModel.find(matchStage).sort({ createdAt: parsedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise, + ]); - { $sort: { createdAt: parsedSortOrder } }, - { - $facet: { - summary: [{ $count: "checksCount" }], - checks: [{ $skip: skip }, { $limit: rowsPerPage }], - }, - }, - { - $project: { - checksCount: { $arrayElemAt: ["$summary.checksCount", 0] }, - checks: "$checks", - }, - }, - ]; - - const checks = await CheckModel.aggregate(aggregatePipeline); - return checks[0]; + return { checksCount, checks: this.mapDocuments(checks) }; }; findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { if (monitorIds.length === 0) { return {}; } - const mongoIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); const limitPerMonitor = options?.limitPerMonitor ?? 25; - const checkGroups = await CheckModel.aggregate([ - { - $match: { - "metadata.monitorId": { $in: mongoIds }, - }, - }, - { $sort: { "metadata.monitorId": 1, createdAt: -1 } }, - { - $group: { - _id: "$metadata.monitorId", - latestChecks: { - $topN: { - n: limitPerMonitor, - sortBy: { createdAt: -1 }, - output: "$$ROOT", - }, - }, - }, - }, - ]); + const dateFilter = new Date(Date.now() - 24 * 60 * 60 * 1000); + const results = await Promise.all( + monitorIds.map(async (monitorId) => { + const docs = await CheckModel.find({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + createdAt: { $gte: dateFilter }, + }) + .sort({ createdAt: -1 }) + .limit(limitPerMonitor) + .lean(); + return { monitorId, docs }; + }) + ); - return checkGroups.reduce((acc, group) => { - const monitorId = group._id.toString(); - acc[monitorId] = (group.latestChecks ?? []).map((doc: CheckDocument) => this.toEntity(doc)); + const mapped = results.reduce((acc, { monitorId, docs }) => { + acc[monitorId] = docs.map((doc: any) => this.toEntity(doc as CheckDocument)); return acc; }, {}); + return mapped; }; findByDateRangeAndMonitorId = async (monitorId: string, startDate: Date, endDate: Date, dateString: string, options?: { type?: MonitorType }) => { @@ -374,51 +381,25 @@ class MongoChecksRepository implements IChecksRepository { return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString); }; - findSummaryByTeamId = async (teamId: string) => { - const matchStage = { + findSummaryByTeamId = async (teamId: string, dateRange: string) => { + const baseMatch = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), + ...(dateRangeLookup[dateRange] && { + createdAt: { + $gte: dateRangeLookup[dateRange], + }, + }), }; - const checks = await CheckModel.aggregate([ - { $match: matchStage }, - { - $facet: { - summary: [ - { - $group: { - _id: null, - totalChecks: { $sum: { $cond: [{ $eq: ["$status", false] }, 1, 0] } }, - resolvedChecks: { - $sum: { - $cond: [{ $and: [{ $eq: ["$ack", true] }, { $eq: ["$status", false] }] }, 1, 0], - }, - }, - downChecks: { - $sum: { - $cond: [{ $and: [{ $eq: ["$ack", false] }, { $eq: ["$status", false] }] }, 1, 0], - }, - }, - cannotResolveChecks: { - $sum: { - $cond: [{ $eq: ["$statusCode", 5000] }, 1, 0], - }, - }, - }, - }, - { - $project: { - _id: 0, - }, - }, - ], - }, - }, - { - $project: { - summary: { $arrayElemAt: ["$summary", 0] }, - }, - }, + + const [totalResult, downResult] = await Promise.all([ + CheckModel.countDocuments(baseMatch), + CheckModel.countDocuments({ ...baseMatch, status: false }), ]); - return checks[0].summary; + + return { + totalChecks: totalResult, + downChecks: downResult, + }; }; deleteByMonitorId = async (monitorId: string): Promise => { @@ -440,11 +421,11 @@ class MongoChecksRepository implements IChecksRepository { ) => { const matchStage = { "metadata.monitorId": monitorObjectId, - updatedAt: { $gte: startDate, $lte: endDate }, + createdAt: { $gte: startDate, $lte: endDate }, }; const [result] = await CheckModel.aggregate([ { $match: matchStage }, - { $sort: { updatedAt: 1 } }, + { $sort: { createdAt: 1 } }, { $facet: { uptimePercentage: [ @@ -483,6 +464,7 @@ class MongoChecksRepository implements IChecksRepository { }, }, { $sort: { _id: 1 } }, + { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, ], groupedUpChecks: [ { $match: { status: true } }, @@ -496,6 +478,7 @@ class MongoChecksRepository implements IChecksRepository { }, }, { $sort: { _id: 1 } }, + { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, ], groupedDownChecks: [ { $match: { status: false } }, @@ -509,6 +492,7 @@ class MongoChecksRepository implements IChecksRepository { }, }, { $sort: { _id: 1 } }, + { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, ], }, }, @@ -531,14 +515,13 @@ class MongoChecksRepository implements IChecksRepository { const monitorId = monitorObjectId.toHexString(); const dates = { start: startDate, end: endDate }; const [aggregateDataDoc, upChecksDoc, hardwareMetrics] = await Promise.all([ - this.getHardwareAggregateData(monitorId, dates), + this.getHardwareTotalChecks(monitorId, dates), this.getHardwareUpChecks(monitorId, dates), this.getHardwareStats(monitorId, dates, dateString), ]); const aggregateData = { - latestCheck: aggregateDataDoc?.latestCheck ? this.toEntity(aggregateDataDoc.latestCheck as CheckDocument) : null, - totalChecks: aggregateDataDoc?.totalChecks ?? 0, + totalChecks: aggregateDataDoc ?? 0, }; const upChecks = { @@ -546,7 +529,7 @@ class MongoChecksRepository implements IChecksRepository { }; const checks = (hardwareMetrics ?? []).map((metric) => ({ - _id: metric._id, + bucketDate: metric._id, avgCpuUsage: metric.avgCpuUsage ?? 0, avgMemoryUsage: metric.avgMemoryUsage ?? 0, avgTemperature: metric.avgTemperature ?? [], @@ -590,29 +573,16 @@ class MongoChecksRepository implements IChecksRepository { const checks = await CheckModel.find(matchStage).sort({ createdAt: -1 }).limit(25).lean(); return { monitorType: "pagespeed" as const, - checks: checks.map((doc) => this.toEntity(doc)), + checks: this.mapDocuments(checks), }; }; - private getHardwareAggregateData = async (monitorId: string, dates: DateRange): Promise => { - const result = await CheckModel.aggregate([ - { - $match: { - "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), - "metadata.type": "hardware", - createdAt: { $gte: dates.start, $lte: dates.end }, - }, - }, - { $sort: { createdAt: -1 } }, - { - $group: { - _id: null, - latestCheck: { $first: "$$ROOT" }, - totalChecks: { $sum: 1 }, - }, - }, - ]); - return result[0] || { totalChecks: 0, latestCheck: null }; + private getHardwareTotalChecks = async (monitorId: string, dates: DateRange): Promise => { + return await CheckModel.countDocuments({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "hardware", + createdAt: { $gte: dates.start, $lte: dates.end }, + }); }; private getHardwareUpChecks = async (monitorId: string, dates: DateRange): Promise => { @@ -643,7 +613,7 @@ class MongoChecksRepository implements IChecksRepository { avgTemperatures: { $push: { $ifNull: ["$cpu.temperature", [0]] } }, disks: { $push: "$disk" }, net: { $push: "$net" }, - updatedAts: { $push: "$updatedAt" }, + createdAts: { $push: "$createdAt" }, sampleDoc: { $first: "$$ROOT" }, }, }, @@ -665,8 +635,8 @@ class MongoChecksRepository implements IChecksRepository { as: "dIdx", in: { name: { $concat: ["disk", { $toString: "$$dIdx" }] }, - readSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.read_speed_bytes", "$$dIdx"] } } } }, - writeSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.write_speed_bytes", "$$dIdx"] } } } }, + readSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.read_bytes", "$$dIdx"] } } } }, + writeSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.write_bytes", "$$dIdx"] } } } }, totalBytes: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.total_bytes", "$$dIdx"] } } } }, freeBytes: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.free_bytes", "$$dIdx"] } } } }, usagePercent: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.usage_percent", "$$dIdx"] } } } }, @@ -682,7 +652,7 @@ class MongoChecksRepository implements IChecksRepository { bytesSentPerSecond: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.bytes_sent" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.bytes_sent" } }, "$$nIdx"] }, }, @@ -692,7 +662,7 @@ class MongoChecksRepository implements IChecksRepository { deltaBytesRecv: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.bytes_recv" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.bytes_recv" } }, "$$nIdx"] }, }, @@ -702,7 +672,7 @@ class MongoChecksRepository implements IChecksRepository { deltaPacketsSent: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.packets_sent" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.packets_sent" } }, "$$nIdx"] }, }, @@ -712,7 +682,7 @@ class MongoChecksRepository implements IChecksRepository { deltaPacketsRecv: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.packets_recv" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.packets_recv" } }, "$$nIdx"] }, }, @@ -722,7 +692,7 @@ class MongoChecksRepository implements IChecksRepository { deltaErrIn: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.err_in" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.err_in" } }, "$$nIdx"] }, }, @@ -732,7 +702,7 @@ class MongoChecksRepository implements IChecksRepository { deltaErrOut: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.err_out" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.err_out" } }, "$$nIdx"] }, }, @@ -742,7 +712,7 @@ class MongoChecksRepository implements IChecksRepository { deltaDropIn: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.drop_in" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.drop_in" } }, "$$nIdx"] }, }, @@ -752,7 +722,7 @@ class MongoChecksRepository implements IChecksRepository { deltaDropOut: { $let: { vars: { - tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] }, + tDiff: { $divide: [{ $subtract: [{ $last: "$createdAts" }, { $first: "$createdAts" }] }, 1000] }, f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.drop_out" } }, "$$nIdx"] }, l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.drop_out" } }, "$$nIdx"] }, }, diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 072772e9e..2fbce3406 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -1,4 +1,4 @@ -import { type MonitorType, type Monitor, type MonitorsSummary, type MonitorWithChecks } from "@/types/index.js"; +import { type MonitorType, type Monitor, type MonitorsSummary } from "@/types/index.js"; export interface TeamQueryConfig { limit?: number; @@ -25,7 +25,7 @@ export interface IMonitorsRepository { findAll(): Promise; findByTeamId(teamId: string, config: TeamQueryConfig): Promise; findByIds(monitorIds: string[]): Promise; - findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise; + findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 5ce029634..9b30e5ac2 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -1,6 +1,6 @@ import { MonitorModel } from "@/db/models/index.js"; -import type { MonitorDocument } from "@/db/models/index.js"; -import type { Monitor, MonitorsSummary, MonitorWithChecks, Check } from "@/types/index.js"; +import type { MonitorDocument, CheckSnapshotDocument } from "@/db/models/index.js"; +import type { Monitor, MonitorsSummary, CheckSnapshot, Check } from "@/types/index.js"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; import { AppError } from "@/utils/AppError.js"; @@ -79,7 +79,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.mapDocuments(monitors); }; - findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { + findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { if (!monitorIds.length) { return []; } @@ -290,6 +290,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindowThreshold: doc.statusWindowThreshold, type: doc.type, ignoreTlsErrors: doc.ignoreTlsErrors, + useAdvancedMatching: doc.useAdvancedMatching ?? false, jsonPath: doc.jsonPath ?? undefined, expectedValue: doc.expectedValue ?? undefined, matchMethod: doc.matchMethod ?? undefined, @@ -309,12 +310,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, + recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; }; - private toEntityWithChecks = (doc: any): MonitorWithChecks => { + private toEntityWithChecks = (doc: any): Monitor => { const toStringId = (value: unknown): string => { if (value instanceof mongoose.Types.ObjectId) { return value.toString(); @@ -373,6 +375,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindowThreshold: doc.statusWindowThreshold, type: doc.type, ignoreTlsErrors: doc.ignoreTlsErrors, + useAdvancedMatching: doc.useAdvancedMatching ?? false, jsonPath: doc.jsonPath ?? undefined, expectedValue: doc.expectedValue ?? undefined, matchMethod: doc.matchMethod ?? undefined, @@ -392,9 +395,37 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, + recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), - checks, + }; + }; + + private toCheckSnapshot = (doc: CheckSnapshotDocument): CheckSnapshot => { + const toDateString = (value: Date | string): string => { + return value instanceof Date ? value.toISOString() : value; + }; + + return { + id: doc.id, + status: doc.status, + responseTime: doc.responseTime, + timings: doc.timings, + statusCode: doc.statusCode, + message: doc.message, + cpu: doc.cpu, + memory: doc.memory, + disk: doc.disk, + host: doc.host, + errors: doc.errors, + capture: doc.capture, + net: doc.net, + accessibility: doc.accessibility, + bestPractices: doc.bestPractices, + seo: doc.seo, + performance: doc.performance, + audits: doc.audits, + createdAt: toDateString(doc.createdAt), }; }; } diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index eb5604c24..e958f3664 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -50,7 +50,7 @@ class MonitorRoutes { // Individual monitor CRUD routes this.router.get("/:monitorId", this.monitorController.getMonitorById); - this.router.put("/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.editMonitor); + this.router.patch("/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.editMonitor); this.router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.deleteMonitor); } diff --git a/server/src/routes/notificationRoute.ts b/server/src/routes/notificationRoute.ts index 7fe4bf5cb..a1802d3b9 100755 --- a/server/src/routes/notificationRoute.ts +++ b/server/src/routes/notificationRoute.ts @@ -19,7 +19,7 @@ class NotificationRoutes { this.router.get("/:id", this.notificationController.getNotificationById); this.router.delete("/:id", this.notificationController.deleteNotification); - this.router.put("/:id", this.notificationController.editNotification); + this.router.patch("/:id", this.notificationController.editNotification); } getRouter() { diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts index 42788eba8..5564d1d7f 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -1,4 +1,5 @@ import { requireTeamId } from "@/controllers/controllerUtils.js"; +import { Types } from "mongoose"; import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js"; import type { MonitorType, MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js"; import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; @@ -39,6 +40,8 @@ class CheckService { const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse; const check: Partial = { + id: new Types.ObjectId().toString(), + createdAt: new Date().toISOString(), metadata: { monitorId, teamId, @@ -142,11 +145,11 @@ class CheckService { return checkData; }; - getChecksSummaryByTeamId = async ({ teamId }: { teamId: string }) => { + getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => { if (!teamId) { throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksSummaryByTeamId", status: 400 }); } - const summary = await this.checksRepository.findSummaryByTeamId(teamId); + const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange); return summary; }; diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 513fc2a82..2360b6617 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -6,6 +6,7 @@ import type { UptimeDetailsResult, HardwareDetailsResult, PageSpeedDetailsResult, + GamesMap, } from "@/types/monitor.js"; import type { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository, IStatusPagesRepository } from "@/repositories/index.js"; import fs from "fs"; @@ -51,7 +52,7 @@ export interface IMonitorService { order?: "asc" | "desc"; explain?: boolean; }): Promise; - getAllGames(): any; + getAllGames(): GamesMap; getGroupsByTeamId(args: { teamId: string }): Promise; // update @@ -257,9 +258,12 @@ export class MonitorService implements IMonitorService { checks: checksData.checks, }; + const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id); + return { - ...monitor, + monitor, stats, + monitorStats, }; }; @@ -342,7 +346,7 @@ export class MonitorService implements IMonitorService { order?: "asc" | "desc"; explain?: boolean; }): Promise => { - const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId); + const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type }); const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter }); const monitors = await this.monitorsRepository.findByTeamId(teamId, { limit, @@ -360,26 +364,17 @@ export class MonitorService implements IMonitorService { const snapshotOnlyRequest = requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType)); - const limitPerMonitor = snapshotOnlyRequest ? 1 : 25; - const checksMap = await this.checksRepository.findLatestByMonitorIds( - monitorsList.map((monitor) => monitor.id), - { limitPerMonitor } - ); - const monitorsWithChecks = monitorsList.map((monitor: Monitor) => { - const rawChecks = checksMap[monitor.id] ?? []; + const rawChecks = monitor.recentChecks ?? []; const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type); const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100); - return { - ...monitor, - checks, - }; + monitor.recentChecks = checks; + return monitor; }); - return { summary: summary ?? null, count, monitors: monitorsWithChecks }; }; - getAllGames = (): any => { + getAllGames = (): GamesMap => { return this.games; }; @@ -402,9 +397,27 @@ export class MonitorService implements IMonitorService { deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.deleteById(monitorId, teamId); - await this.monitorStatsRepository.deleteByMonitorId(monitor.id); - await this.checksRepository.deleteByMonitorId(monitor.id); - await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id); + await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: any) => { + this.logger.warn({ + message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); + await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: any) => { + this.logger.warn({ + message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); + await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id).catch((err: any) => { + this.logger.warn({ + message: `Error removing monitor ${monitor.id} with name ${monitor.name} from status pages`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); await this.jobQueue.deleteJob(monitor); return monitor; }; diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 7db8fc6e2..92d269808 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -255,7 +255,7 @@ class NetworkService implements INetworkService { } private async requestHttp(monitor: Monitor): Promise { - const { url, secret, id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor; + const { url, secret, id, teamId, type, ignoreTlsErrors, useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor; const httpResponse = this.buildStatusResponse({ monitor, overrides: { @@ -305,7 +305,7 @@ class NetworkService implements INetworkService { timings: response.timings, }); - if (!expectedValue && !jsonPath) { + if (!useAdvancedMatching) { return httpResponse; } diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 3a83dbd28..0e1011278 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -9,6 +9,7 @@ import type { Check, HardwareStatusPayload, PageSpeedStatusPayload, + CheckSnapshot, } from "@/types/index.js"; const SERVICE_NAME = "StatusService"; @@ -24,13 +25,11 @@ export interface IStatusService { export class StatusService implements IStatusService { static SERVICE_NAME = SERVICE_NAME; - private db: any; private logger: any; private buffer: any; private monitorsRepository: IMonitorsRepository; - constructor({ db, logger, buffer, monitorsRepository }: { db: any; logger: any; buffer: any; monitorsRepository: IMonitorsRepository }) { - this.db = db; + constructor({ logger, buffer, monitorsRepository }: { logger: any; buffer: any; monitorsRepository: IMonitorsRepository }) { this.logger = logger; this.buffer = buffer; this.monitorsRepository = monitorsRepository; @@ -175,14 +174,40 @@ export class StatusService implements IStatusService { // Update running stats this.updateRunningStats({ monitor, networkResponse: statusResponse }); - // If the status window size has changed, empty - monitor.statusWindow = monitor.statusWindow || []; monitor.statusWindow.push(status); while (monitor.statusWindow.length > monitor.statusWindowSize) { monitor.statusWindow.shift(); } + const checkSnapshot: CheckSnapshot = { + id: check.id, + status: check.status, + responseTime: check.responseTime, + timings: check.timings, + statusCode: check.statusCode, + message: check.message, + cpu: check.cpu, + memory: check.memory, + disk: check.disk, + host: check.host, + errors: check.errors, + capture: check.capture, + net: check.net, + accessibility: check.accessibility, + bestPractices: check.bestPractices, + seo: check.seo, + performance: check.performance, + audits: check.audits, + createdAt: check.createdAt, + }; + monitor.recentChecks = monitor.recentChecks || []; + monitor.recentChecks.push(checkSnapshot); + const maxRecentChecks = 25; + while (monitor.recentChecks.length > maxRecentChecks) { + monitor.recentChecks.shift(); + } + if (monitor.status === undefined || monitor.status === null) { monitor.status = status; } diff --git a/server/src/types/check.ts b/server/src/types/check.ts index 3195cc06a..c02b03099 100644 --- a/server/src/types/check.ts +++ b/server/src/types/check.ts @@ -12,6 +12,7 @@ export interface CheckCpuInfo { physical_core?: number; logical_core?: number; frequency?: number; + current_frequency?: number; temperature?: number[]; free_percent?: number; usage_percent?: number; @@ -28,6 +29,7 @@ export interface CheckHostInfo { os?: string; platform?: string; kernel_version?: string; + pretty_name?: string; } export interface CheckCaptureInfo { @@ -38,11 +40,18 @@ export interface CheckCaptureInfo { export interface CheckDiskInfo { device?: string; mountpoint?: string; - read_speed_bytes?: number; - write_speed_bytes?: number; total_bytes?: number; free_bytes?: number; + used_bytes?: number; usage_percent?: number; + total_inodes?: number; + free_inodes?: number; + used_inodes?: number; + inodes_usage_percent?: number; + read_bytes?: number; + write_bytes?: number; + read_time?: number; + write_time?: number; } export interface CheckErrorInfo { @@ -121,14 +130,13 @@ export interface PageSpeedChecksResult { export interface HardwareChecksResult { monitorType: "hardware"; aggregateData: { - latestCheck: Check | null; totalChecks: number; }; upChecks: { totalChecks: number; }; checks: Array<{ - _id: string; + bucketDate: string; avgCpuUsage: number; avgMemoryUsage: number; avgTemperature: number[]; @@ -157,7 +165,7 @@ export interface HardwareChecksResult { } export interface GroupedCheck { - _id: string; + bucketDate: string; avgResponseTime: number; totalChecks: number; } @@ -173,15 +181,19 @@ export interface UptimeChecksResult { export interface ChecksSummary { totalChecks: number; - resolvedChecks: number; downChecks: number; - cannotResolveChecks: number; } -export type NormalizedCheck = T & { +export interface HasResponseTime { + responseTime: number; +} + +export type NormalizedCheck = T & { originalResponseTime: number; }; export type NormalizedUptimeCheck = T & { originalAvgResponseTime: number; }; + +export type CheckSnapshot = Omit; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index c7d99a88a..f6c0f83a7 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -1,4 +1,7 @@ import type { Check } from "@/types/check.js"; +import type { CheckSnapshot } from "@/types/check.js"; +export type { CheckSnapshot } from "@/types/check.js"; + export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; @@ -23,6 +26,7 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; matchMethod?: MonitorMatchMethod; @@ -42,6 +46,7 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; group: string | null; + recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; } @@ -53,14 +58,10 @@ export interface MonitorsSummary { pausedMonitors: number; } -export interface MonitorWithChecks extends Monitor { - checks: Check[]; -} - export interface MonitorsWithChecksByTeamIdResult { summary: MonitorsSummary | null; count: number; - monitors: MonitorWithChecks[]; + monitors: Monitor[]; } export interface UptimeDetailsResult { @@ -75,43 +76,52 @@ export interface UptimeDetailsResult { monitorStats: import("./monitorStats.js").MonitorStats | null; } -export interface HardwareDetailsResult extends Monitor { - stats: { - aggregateData: { - latestCheck: import("./check.js").Check | null; - totalChecks: number; - }; - upChecks: { - totalChecks: number; - }; - checks: Array<{ - _id: string; - avgCpuUsage: number; - avgMemoryUsage: number; - avgTemperature: number[]; - disks: Array<{ - name: string; - readSpeed: number; - writeSpeed: number; - totalBytes: number; - freeBytes: number; - usagePercent: number; - }>; - net: Array<{ - name: string; - bytesSentPerSecond: number; - deltaBytesRecv: number; - deltaPacketsSent: number; - deltaPacketsRecv: number; - deltaErrIn: number; - deltaErrOut: number; - deltaDropIn: number; - deltaDropOut: number; - deltaFifoIn: number; - deltaFifoOut: number; - }>; - }>; +export interface HardwareDiskStats { + name: string; + readSpeed: number; + writeSpeed: number; + totalBytes: number; + freeBytes: number; + usagePercent: number; +} + +export interface HardwareNetStats { + name: string; + bytesSentPerSecond: number; + deltaBytesRecv: number; + deltaPacketsSent: number; + deltaPacketsRecv: number; + deltaErrIn: number; + deltaErrOut: number; + deltaDropIn: number; + deltaDropOut: number; + deltaFifoIn: number; + deltaFifoOut: number; +} + +export interface HardwareCheckStats { + bucketDate: string; + avgCpuUsage: number; + avgMemoryUsage: number; + avgTemperature: number[]; + disks: HardwareDiskStats[]; + net: HardwareNetStats[]; +} + +export interface HardwareStats { + aggregateData: { + totalChecks: number; }; + upChecks: { + totalChecks: number; + }; + checks: HardwareCheckStats[]; +} + +export interface HardwareDetailsResult { + monitor: Monitor; + stats: HardwareStats; + monitorStats: import("./monitorStats.js").MonitorStats | null; } export interface PageSpeedDetailsResult { @@ -120,3 +130,18 @@ export interface PageSpeedDetailsResult { }; monitorStats: import("./monitorStats.js").MonitorStats | null; } + +export interface Game { + name: string; + release_year?: number; + options?: { + port?: number; + port_query?: number; + protocol?: string; + }; + extra?: { + old_id?: string; + }; +} + +export type GamesMap = Record; diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index 0bf3e6416..b981365e9 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,6 +1,6 @@ -import type { Check, GroupedCheck, NormalizedCheck, NormalizedUptimeCheck } from "@/types/index.js"; +import type { GroupedCheck, NormalizedCheck, NormalizedUptimeCheck, HasResponseTime } from "@/types/index.js"; -const calculatePercentile = (arr: Check[], percentile: number): number => { +const calculatePercentile = (arr: T[], percentile: number): number => { const sorted = arr.slice().sort((a, b) => a.responseTime - b.responseTime); const index = (percentile / 100) * (sorted.length - 1); const lower = Math.floor(index); @@ -20,7 +20,7 @@ const calculatePercentileUptimeDetails = (arr: GroupedCheck[], percentile: numbe return sorted[lower]!.avgResponseTime * (1 - weight) + sorted[upper]!.avgResponseTime * weight; }; -export const NormalizeData = (checks: T[], rangeMin: number, rangeMax: number): NormalizedCheck[] => { +export const NormalizeData = (checks: T[], rangeMin: number, rangeMax: number): NormalizedCheck[] => { if (checks.length > 1) { const min = calculatePercentile(checks, 0); const max = calculatePercentile(checks, 95); diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 8871c29e2..ea151424c 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -111,7 +111,7 @@ const getMonitorByIdQueryValidation = joi.object({ status: joi.boolean(), sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), - dateRange: joi.string().valid("hour", "day", "week", "month", "all"), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), numToDisplay: joi.number(), normalize: joi.boolean(), }); @@ -152,12 +152,13 @@ const getCertificateParamValidation = joi.object({ const createMonitorBodyValidation = joi.object({ _id: joi.string(), name: joi.string().required(), - description: joi.string().required(), + description: joi.string().allow(null, ""), type: joi.string().required(), statusWindowSize: joi.number().min(1).max(20).default(5), statusWindowThreshold: joi.number().min(1).max(100).default(60), url: joi.string().required(), ignoreTlsErrors: joi.boolean().default(false), + useAdvancedMatching: joi.boolean().default(false), port: joi.number(), isActive: joi.boolean(), interval: joi.number(), @@ -171,7 +172,7 @@ const createMonitorBodyValidation = joi.object({ secret: joi.string(), jsonPath: joi.string().allow(""), expectedValue: joi.string().allow(""), - matchMethod: joi.string(), + matchMethod: joi.string().allow(null, ""), gameId: joi.string().allow(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), @@ -184,29 +185,32 @@ const createMonitorsBodyValidation = joi.array().items( }) ); -const editMonitorBodyValidation = joi.object({ - name: joi.string(), - statusWindowSize: joi.number().min(1).max(20).default(5), - statusWindowThreshold: joi.number().min(1).max(100).default(60), - description: joi.string(), - interval: joi.number(), - notifications: joi.array().items(joi.string()), - secret: joi.string(), - ignoreTlsErrors: joi.boolean(), - jsonPath: joi.string().allow(""), - expectedValue: joi.string().allow(""), - matchMethod: joi.string().allow(null, ""), - port: joi.number().min(1).max(65535), - thresholds: joi.object().keys({ - usage_cpu: joi.number(), - usage_memory: joi.number(), - usage_disk: joi.number(), - usage_temperature: joi.number(), - }), - gameId: joi.string(), - selectedDisks: joi.array().items(joi.string()).optional(), - group: joi.string().max(50).trim().allow(null, "").optional(), -}); +const editMonitorBodyValidation = joi + .object({ + name: joi.string(), + statusWindowSize: joi.number().min(1).max(20).default(5), + statusWindowThreshold: joi.number().min(1).max(100).default(60), + description: joi.string().allow(null, ""), + interval: joi.number(), + notifications: joi.array().items(joi.string()), + secret: joi.string(), + ignoreTlsErrors: joi.boolean(), + useAdvancedMatching: joi.boolean(), + jsonPath: joi.string().allow(""), + expectedValue: joi.string().allow(""), + matchMethod: joi.string().allow(null, ""), + port: joi.number().min(1).max(65535), + thresholds: joi.object().keys({ + usage_cpu: joi.number(), + usage_memory: joi.number(), + usage_disk: joi.number(), + usage_temperature: joi.number(), + }), + gameId: joi.string().allow(""), + selectedDisks: joi.array().items(joi.string()).optional(), + group: joi.string().max(50).trim().allow(null, "").optional(), + }) + .options({ stripUnknown: true }); const pauseMonitorParamValidation = joi.object({ monitorId: joi.string().required(), @@ -307,7 +311,7 @@ const getChecksQueryValidation = joi.object({ sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "down", "resolve"), + filter: joi.string().valid("all", "up", "down", "resolve"), ack: joi.boolean(), page: joi.number(), rowsPerPage: joi.number(), @@ -317,8 +321,8 @@ const getChecksQueryValidation = joi.object({ const getTeamChecksQueryValidation = joi.object({ sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), - dateRange: joi.string().valid("hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "down", "resolve"), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), + filter: joi.string().valid("all", "up", "down", "resolve"), ack: joi.boolean(), page: joi.number(), rowsPerPage: joi.number(),