Merge branch 'develop' into change-password

This commit is contained in:
Lui Duarte authored and GitHub committed 2026-02-02 15:49:30 +00:00
commit 464b16ec9d
231 files changed
+7871 -12758

No files matched your search

+2 -3
View File
@@ -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!
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+8 -1
View File
@@ -37,7 +37,14 @@ function App() {
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer />
<ToastContainer
newestOnTop={true}
theme={mode}
style={{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
}}
/>
</ThemeProvider>
);
}
@@ -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";
@@ -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 }) => {
@@ -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,
@@ -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";
@@ -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";
/**
@@ -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 (
<FormControl
sx={{ minWidth: "10%" }}
size="small"
>
<Select
multiple={multiple}
IconComponent={(props) => (
<Icon
{...props}
name="PlusCircle"
size={18}
/>
)}
displayEmpty
value={controlledValue}
onChange={onChange}
renderValue={(selected) => {
if (!selected?.length) {
return header;
}
return (
header +
" | " +
selected
.map((value) => options.find((option) => option.value === value)?.label)
.filter(Boolean)
.join(", ")
);
}}
MenuProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
}}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
sx={{
height: theme.spacing(17),
padding: 0,
}}
>
<Checkbox
checked={controlledValue.includes(option.value)}
size="small"
/>
<ListItemText primary={option.label} />
</MenuItem>
))}
</Select>
</FormControl>
);
};
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;
@@ -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 (
<React.Fragment>
<ButtonGroup
variant="contained"
color="accent"
ref={anchorRef}
aria-label="Monitor actions"
disabled={isLoading || isExporting}
>
<Button onClick={handleClick}>{options[selectedIndex]}</Button>
<Button
size="small"
aria-controls={open ? "split-button-menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-label="select monitor action"
aria-haspopup="menu"
onClick={handleToggle}
>
<Icon
name="ChevronDown"
size={20}
/>
</Button>
</ButtonGroup>
<Popper
sx={{ zIndex: 1 }}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement === "bottom" ? "center top" : "center bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList
id="split-button-menu"
autoFocusItem
>
{options.map((option, index) => (
<MenuItem
key={option}
selected={index === selectedIndex}
onClick={(event) => handleMenuItemClick(event, index)}
>
{option}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</React.Fragment>
);
};
export default MonitorActions;
@@ -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 <SkeletonLayout />;
if (monitorCount === 1) {
heading = "monitor";
}
return (
<Stack
direction="row"
alignItems="center"
display="flex"
width="fit-content"
height={theme.spacing(18)}
gap={theme.spacing(2)}
mt={theme.spacing(2)}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.spacing(1)}
sx={{
...sx,
backgroundColor: theme.palette.secondary.main,
}}
>
{monitorCount} <Typography component="h2">{heading}</Typography>
{children}
</Stack>
);
};
MonitorCountHeader.propTypes = {
isLoading: PropTypes.bool,
monitorCount: PropTypes.number,
heading: PropTypes.string,
children: PropTypes.node,
sx: PropTypes.object,
};
export default MonitorCountHeader;
@@ -1,26 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Skeleton
variant="text"
width={100}
height={32}
/>
<Skeleton
variant="circular"
width={40}
height={40}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -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();
@@ -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 <Skeleton />;
}
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Status monitor={monitor} />
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Tooltip
key={monitor?.id}
placement="bottom"
title={tooltipTitle}
>
<span>
<Button
variant="contained"
color="secondary"
loading={isSending}
startIcon={
<Icon
name="Mail"
size={18}
/>
}
disabled={isTestNotificationsDisabled}
onClick={() => {
testAllNotifications({ monitorId: monitor?.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("sendTestNotifications")}
</Button>
</span>
</Tooltip>
<Button
variant="contained"
color="secondary"
onClick={(e) => {
navigate(`/incidents/${monitor?.id}`);
}}
>
{t("menu.incidents")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPausing}
startIcon={
monitor?.isActive ? (
<Icon
name="Pause"
size={18}
/>
) : (
<Icon
name="Play"
size={18}
/>
)
}
onClick={() => {
pauseMonitor({
monitorId: monitor?.id,
triggerUpdate,
});
}}
>
{monitor?.isActive ? t("pause") : t("resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={
<Icon
name="Settings"
size={18}
/>
}
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
>
Configure
</Button>
)}
</Stack>
</Stack>
);
};
MonitorDetailsControlHeader.propTypes = {
path: PropTypes.string,
isLoading: PropTypes.bool,
isAdmin: PropTypes.bool,
monitor: PropTypes.object,
triggerUpdate: PropTypes.func,
};
export default MonitorDetailsControlHeader;
@@ -1,23 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -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 (
<Stack>
<Typography variant="monitorName">{monitor?.name}</Typography>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<PulseDot color={statusColor[determineState(monitor)]} />
<Typography variant="monitorUrl">{formatMonitorUrl(monitor?.url)}</Typography>
<Dot />
<Typography>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Stack>
);
};
Status.propTypes = {
monitor: PropTypes.object,
};
export default Status;
@@ -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 (
<Stack gap={theme.spacing(6)}>
<Search
type="notifications"
label="Notifications"
options={notifications}
filteredBy="notificationName"
multiple={true}
value={selectedNotifications}
inputValue={notificationsSearch}
handleInputChange={setNotificationsSearch}
handleChange={(value) => {
handleSearch(value);
}}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
...sx,
}}
/>
<Stack
flex={1}
width="100%"
>
{selectedNotifications.map((notification, index) => (
<Stack
direction="row"
alignItems="center"
key={notification.id}
width="100%"
>
<Typography
flexGrow={1} // <-- This will take up all available horizontal space
>
{notification.notificationName}
</Typography>
<Icon
name="Trash2"
size={20}
onClick={() => {
handleDelete(notification.id);
}}
style={{ cursor: "pointer" }}
/>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
</Stack>
);
};
NotificationConfig.propTypes = {
notifications: PropTypes.array,
setMonitor: PropTypes.func,
setNotifications: PropTypes.array,
};
export default NotificationConfig;
@@ -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 (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
sx={{
"& .MuiDialog-paper": {
width: `calc(80% - ${theme.spacing(40)})`,
maxWidth: `${theme.breakpoints.values.md - 70}px`,
},
}}
>
<DialogContent>
<Stack
direction="row"
sx={{
height: `calc(30vh - ${theme.spacing(20)})`,
}}
>
{/* Left sidebar with tabs */}
<Box
sx={{
borderRight: 1,
borderColor: theme.palette.primary.lowContrast,
width: "30%",
maxWidth: theme.spacing(120),
pr: theme.spacing(10),
}}
>
<Typography variant="h2">
{t("notifications.addOrEditNotifications")}
</Typography>
<Tabs
orientation="vertical"
variant="scrollable"
value={tabValue}
onChange={handleChangeTab}
aria-label="Notification tabs"
>
{activeNotificationTypes.map((type) => (
<Tab
key={type.id}
label={type.label}
orientation="vertical"
disableRipple
/>
))}
</Tabs>
</Box>
{/* Right side content */}
<Box
sx={{
flex: 1,
pl: theme.spacing(7.5),
overflowY: "auto",
}}
>
{activeNotificationTypes.map((type, index) => (
<TabPanel
key={type.id}
value={tabValue}
index={index}
>
<TabComponent
type={type}
integrations={integrations}
handleIntegrationChange={handleIntegrationChange}
handleInputChange={handleInputChange}
handleTestNotification={handleTestNotification}
isLoading={loading}
/>
</TabPanel>
))}
</Box>
</Stack>
</DialogContent>
<DialogActions
sx={{
p: theme.spacing(4),
display: "flex",
justifyContent: "flex-end",
mb: theme.spacing(5),
mr: theme.spacing(5),
}}
>
<Button
variant="contained"
color="accent"
onClick={handleSave}
loading={loading}
sx={{
width: "auto",
minWidth: theme.spacing(60),
px: theme.spacing(8),
}}
>
{t("commonSave")}
</Button>
</DialogActions>
</Dialog>
);
};
NotificationIntegrationModal.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
monitor: PropTypes.object.isRequired,
setMonitor: PropTypes.func.isRequired,
notificationTypes: PropTypes.array,
};
export default NotificationIntegrationModal;
@@ -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 (
<>
<Typography
variant="subtitle1"
component="h4"
sx={{
fontWeight: "bold",
color: theme.palette.primary.contrastTextSecondary,
}}
>
{type.label}
</Typography>
<Typography
sx={{
mt: theme.spacing(0.5),
mb: theme.spacing(1.5),
color: theme.palette.primary.contrastTextTertiary,
}}
>
{type.description}
</Typography>
<Box sx={{ pl: theme.spacing(1.5) }}>
<Checkbox
id={`enable-${type.id}`}
label={t("notifications.enableNotifications", { platform: type.label })}
isChecked={integrations[type.id]}
onChange={(e) => handleIntegrationChange(type.id, e.target.checked)}
disabled={isLoading}
/>
</Box>
{type.fields.map((field) => {
const fieldKey = getFieldKey(type.id, field.id);
return (
<Box
key={field.id}
sx={{ mt: theme.spacing(1) }}
>
<Typography
sx={{
mb: theme.spacing(2),
fontWeight: "bold",
color: theme.palette.primary.contrastTextSecondary,
}}
>
{field.label}
</Typography>
<TextInput
id={`${type.id}-${field.id}`}
type={field.type}
placeholder={field.placeholder}
value={integrations[fieldKey]}
onChange={(e) => handleInputChange(fieldKey, e.target.value)}
disabled={!integrations[type.id] || isLoading}
/>
</Box>
);
})}
<Box sx={{ mt: theme.spacing(1) }}>
<Button
variant="text"
color="info"
onClick={() => handleTestNotification(type.id)}
disabled={!integrations[type.id] || !areAllFieldsFilled() || isLoading}
>
{isLoading ? (
<CircularProgress
size={theme.spacing(8)}
sx={{ mr: theme.spacing(1), color: theme.palette.accent.main }}
/>
) : null}
{t("notifications.testNotification")}
</Button>
</Box>
</>
);
};
export default TabComponent;
@@ -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 (
<div
role="tabpanel"
hidden={value !== index}
id={`notification-tabpanel-${index}`}
aria-labelledby={`notification-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ pt: theme.spacing(3) }}>{children}</Box>}
</div>
);
}
TabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.number.isRequired,
value: PropTypes.number.isRequired,
};
export default TabPanel;
@@ -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;
@@ -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<number | string>;
availabilityCellSx?: SxProps;
responseCellSx?: SxProps;
@@ -34,7 +34,7 @@ export const HeatmapResponseTime = ({
const latestChecks = checks.slice(-25).reverse();
let data: Array<Check | PlaceholderCheck>;
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) {
const placeholders = Array(25 - latestChecks.length).fill({
status: "placeholder" as const,
@@ -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 = ({
<Typography>
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
{check?.originalResponseTime && (
{check?.responseTime && (
<Typography>
Response Time: {check.originalResponseTime.toFixed()} ms
{t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms
</Typography>
)}
<Typography textTransform={"capitalize"}>
Status:{" "}
<span style={{ color: getColor(check?.status) }}>{check?.status}</span>
<span style={{ color: getColor(check?.status) }}>
{check?.status === true
? t("pages.common.monitors.status.up")
: t("pages.common.monitors.status.down")}
</span>
</Typography>
</Stack>
}
@@ -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<number | string>;
gap?: ResponsiveStyleValue<number | string>;
}
@@ -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<HTMLElement>,
newValue: string | null
) => {
if (newValue !== null) {
setDateRange(newValue);
}
};
let timeFramePicker = null;
if (hasDateRange) {
timeFramePicker = (
<ToggleButtonGroup
orientation={isSmallScreen ? "vertical" : "horizontal"}
value={dateRange}
exclusive
onChange={handleChange}
size="small"
fullWidth={isSmallScreen}
>
<ToggleButton value="recent">
{t("components.headerTimeRange.labels.recent")}
</ToggleButton>
<ToggleButton value="day">
{t("components.headerTimeRange.labels.day")}
</ToggleButton>
<ToggleButton value="week">
{t("components.headerTimeRange.labels.week")}
</ToggleButton>
<ToggleButton value="month">
{t("components.headerTimeRange.labels.month")}
</ToggleButton>
</ToggleButtonGroup>
);
}
return (
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="flex-end"
alignItems="center"
gap={theme.spacing(4)}
>
<Box
width={16}
height={16}
>
{isLoading && <CircularProgress size={16} />}
</Box>
<Typography variant="body2">
{t(`components.headerTimeRange.description.${dateRange}`)}
</Typography>
{timeFramePicker}
</Stack>
);
};
+1
View File
@@ -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";
@@ -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<number | string>;
}>;
export const BaseChart = ({
children,
icon,
title,
width = "100%",
maxWidth = "100%",
padding,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
sx={{
padding: padding ?? theme.spacing(8),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
}}
>
<Stack
gap={theme.spacing(8)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
{icon && (
<BaseBox
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
backgroundColor: theme.palette.action.hover,
"& svg": {
width: 20,
height: 20,
"& path": {
stroke: theme.palette.text.secondary,
},
},
}}
>
{icon}
</BaseBox>
)}
<Typography variant="h2">{title}</Typography>
</Stack>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
);
};
@@ -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 (
<BaseFallback>
<Alert
severity="warning"
sx={{
width: "100%",
maxWidth: 600,
}}
>
<Typography>
<Trans
i18nKey="common.alerts.pageSpeedApiKey.content"
components={{
settingsLink: (
<Link
component={RouterLink}
to="/settings"
color="inherit"
fontWeight="inherit"
/>
),
}}
/>
</Typography>
</Alert>
</BaseFallback>
);
};
interface BasePageProps extends StackProps {
loading?: boolean;
error?: boolean;
@@ -55,7 +91,7 @@ export const BasePage = ({
spacing={theme.spacing(10)}
{...props}
>
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
<Breadcrumb />
{children}
</Stack>
);
@@ -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 (
<BasePage
loading={loading}
error={error}
{...props}
>
{priorityFallback}
</BasePage>
);
}
if (!loading && isEmpty(items)) {
return (
<EmptyMonitorFallback
@@ -2,25 +2,36 @@ import MuiBreadcrumbs from "@mui/material/Breadcrumbs";
import Typography from "@mui/material/Typography";
import { Link, useLocation } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { ChevronRight, Home } from "lucide-react";
import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
export const Breadcrumb = ({ breadcrumbOverride }: { breadcrumbOverride?: string[] }) => {
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 (
<MuiBreadcrumbs
@@ -38,80 +49,45 @@ export const Breadcrumb = ({ breadcrumbOverride }: { breadcrumbOverride?: string
},
}}
>
<Link
to="/uptime"
style={{
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
textDecoration: "none",
color: theme.palette.text.secondary,
}}
>
<Home
size={16}
strokeWidth={1.5}
/>
{hasSubPage ? (
<Link
to={`/${basePage}`}
style={{ textDecoration: "none" }}
>
<Typography
sx={{
fontSize: "14px",
color: theme.palette.text.secondary,
"&:hover": {
color: theme.palette.primary.main,
},
}}
>
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
</Typography>
</Link>
) : (
<Typography
sx={{
fontSize: "14px",
color: theme.palette.text.secondary,
"&:hover": {
color: theme.palette.primary.main,
},
fontWeight: 600,
color: theme.palette.primary.main,
}}
>
Home
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
</Typography>
</Link>
{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 ? (
<Typography
key={to}
sx={{
fontSize: "14px",
fontWeight: 600,
color: theme.palette.primary.main,
}}
>
{displayName}
</Typography>
) : (
<Link
key={to}
to={to}
style={{
textDecoration: "none",
}}
>
<Typography
sx={{
fontSize: "14px",
color: theme.palette.text.secondary,
"&:hover": {
color: theme.palette.primary.main,
},
}}
>
{displayName}
</Typography>
</Link>
);
})}
)}
{hasSubPage && (
<Typography
sx={{
fontSize: "14px",
fontWeight: 600,
color: theme.palette.primary.main,
}}
>
{getSubPageLabel()}
</Typography>
)}
</MuiBreadcrumbs>
);
};
@@ -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 (
<span
style={{
content: '""',
width: size,
height: size,
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
...style,
}}
/>
);
};
@@ -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 (
<Box
display={"inline-block"}
position={"relative"}
width={radius}
height={radius}
bgcolor={theme.palette.background.paper}
borderRadius={"50%"}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
width={radius}
height={radius}
>
<circle
stroke={theme.palette.secondary.main}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
/>
<circle
stroke={fillColor}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
style={{
transform: "rotate(-90deg)",
transformOrigin: "center",
transition: "stroke-dashoffset 1.5s ease-in-out",
}}
/>
</svg>
<Typography
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{`${progressWithinRange.toFixed(precision)}${unit}`}
</Typography>
</Box>
);
};
@@ -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 (
<Icon
size={size}
strokeWidth={strokeWidth}
/>
);
};
export default Icon;
@@ -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 (
<Stack>
<Typography
fontSize={typographyLevels.xl}
fontWeight={500}
color={theme.palette.primary.contrastText}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
>
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<PulseDot color={getStatusColor(monitor.status, theme)} />
<Typography
fontSize={typographyLevels.l}
fontWeight={"bolder"}
fontFamily={"monospace"}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
>
{formatUrl(monitor?.url)}
</Typography>
{!isSmall && (
<>
<Dot />
<Typography>
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography>
</>
)}
</Stack>
</Stack>
);
};
@@ -0,0 +1,48 @@
import { Box, Stack, useTheme } from "@mui/material";
interface PulseDotProps {
color: string;
}
export const PulseDot = ({ color }: PulseDotProps) => {
const theme = useTheme();
return (
<Stack
width="26px"
height="24px"
alignItems="center"
justifyContent="center"
>
<Box
minWidth="18px"
minHeight="18px"
sx={{
position: "relative",
backgroundColor: color,
borderRadius: "50%",
"&::before": {
content: `""`,
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "inherit",
borderRadius: "50%",
animation: "ripple 1.8s ease-out infinite",
},
"&::after": {
content: `""`,
position: "absolute",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: theme.palette.background.paper,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
}}
/>
</Stack>
);
};
@@ -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 (
<Stack
direction={isSmall ? "column" : "row"}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.spacing(2)}
>
<Box
padding={theme.spacing(15)}
borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.divider}
flex={0.7}
sx={{
background:
theme.palette.mode === "dark"
? "linear-gradient(135deg, rgba(255, 255, 255, 0.01) 0%, rgba(255, 255, 255, 0.02) 100%)"
: "linear-gradient(135deg, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.02) 100%)",
}}
>
{left}
</Box>
<Box
flex={1}
padding={theme.spacing(15)}
>
{right}
</Box>
</Stack>
);
};
export const ConfigBox = ({
title,
subtitle,
leftContent,
rightContent,
}: {
title: string;
subtitle: string;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
const theme = useTheme();
return (
<SplitBox
left={
<Stack spacing={theme.spacing(4)}>
<Typography
textTransform={"capitalize"}
component="h2"
variant="h2"
>
{title}
</Typography>
<Typography component="p">{subtitle}</Typography>
{leftContent}
</Stack>
}
right={rightContent}
/>
);
};
@@ -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 (
<BaseBox
sx={{
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
...sx,
}}
>
{children}
</BaseBox>
);
};
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 (
<GradientBox
palette={palette}
sx={{
...(sx as object),
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
}}
>
<Stack onClick={onClick}>
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
<Typography color={textColor}>{title}</Typography>
{tooltip && (
<TooltipWithInfo
title={tooltip}
iconColor={textColor as string}
iconSize={14}
/>
)}
</Box>
<Typography color={textColor}>{subtitle}</Typography>
{children}
</Stack>
</GradientBox>
);
};
@@ -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 (
<BaseBox
@@ -18,6 +22,7 @@ export const BGBox = ({ children }: StatusBoxProps) => {
position: "relative",
flex: 1,
padding: theme.spacing(4),
...sx,
}}
>
<Box
@@ -36,14 +41,16 @@ const StatusBox = ({
label,
n,
color,
sx,
}: {
label: string;
n: number;
color: string | undefined;
sx?: SxProps;
}) => {
const theme = useTheme();
return (
<BGBox>
<BGBox sx={sx}>
<Stack spacing={theme.spacing(4)}>
<Typography
variant={"h2"}
@@ -98,6 +105,39 @@ export const PausedStatusBox = ({ n }: { n: number }) => {
/>
);
};
export const TotalChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.total")}
n={n}
color={theme.palette.primary.light}
/>
);
};
export const DownChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.down")}
n={n}
color={theme.palette.error.light}
/>
);
};
export const UpChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.up")}
n={n}
color={theme.palette.success.light}
/>
);
};
export const InitializingStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
@@ -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");
}
@@ -147,7 +147,7 @@ export function DataTable<
<TableContainer
component={Paper}
elevation={0}
sx={{ boxShadow: "none" }}
sx={{ boxShadow: "none", overflowX: "hidden" }}
>
<Table
sx={{
@@ -173,6 +173,7 @@ export function DataTable<
color: theme.palette.text.secondary,
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize,
overflowX: "hidden",
},
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
@@ -0,0 +1,64 @@
import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
const theme = useTheme();
return (
<MuiTabs
sx={{
minHeight: 34,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
height: 2,
bottom: 0,
},
"& .MuiTabs-flexContainer": {
gap: theme.spacing(16),
},
}}
{...props}
>
{props.children}
</MuiTabs>
);
};
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 (
<MuiTab
disableRipple
iconPosition="start"
sx={{
textTransform: "none",
fontSize: 14,
fontWeight: 500,
minHeight: 34,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
alignItems: "flex-start",
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
fontWeight: 600,
},
"&:hover": {
color: theme.palette.text.secondary,
},
"& .MuiTab-iconWrapper": {
marginRight: theme.spacing(2),
marginBottom: 0,
},
}}
{...props}
/>
);
};
@@ -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 (
<MuiTooltip
title={props.title}
placement={placement}
slotProps={{
tooltip: {
sx: {
background:
theme.palette.mode === "dark"
? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)"
: "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)",
backgroundColor: "transparent",
color: "#ffffff",
fontSize: "13px",
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
},
},
}}
>
{props.children}
</MuiTooltip>
);
};
export interface TooltipWithInfoProps extends Omit<StyledTooltipProps, "children"> {
iconSize?: number;
iconColor?: string;
}
export const TooltipWithInfo = ({
iconSize = 14,
iconColor,
...props
}: TooltipWithInfoProps) => {
const theme = useTheme();
const defaultColor = theme.palette.text.secondary;
return (
<Tooltip {...props}>
<span
style={{
display: "inline-flex",
alignItems: "center",
cursor: "help",
}}
>
<Info
size={iconSize}
strokeWidth={1.5}
style={{
opacity: 0.7,
color: iconColor || defaultColor,
transition: "opacity 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "1";
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "0.7";
}}
/>
</span>
</Tooltip>
);
};
@@ -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";
@@ -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 (
<Dialog open={open}>
<DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{content}</DialogContentText>
</DialogContent>
<DialogActions>
<Button
loading={loading}
variant="contained"
color={cancelColor}
onClick={onCancel}
>
{t("common.buttons.cancel")}
</Button>
<Button
loading={loading}
variant="contained"
color={confirmColor}
onClick={onConfirm}
>
{t("common.buttons.confirm")}
</Button>
</DialogActions>
</Dialog>
);
};
+88
View File
@@ -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 (
<Radio
{...props}
icon={
<Circle
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<CircleDot
size={14}
strokeWidth={1.5}
/>
}
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 (
<FormControlLabel
control={<RadioInput {...props} />}
label={
<>
<Typography component="p">{label}</Typography>
<Typography
component="h6"
color={theme.palette.text.secondary}
>
{description}
</Typography>
</>
}
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),
},
}}
/>
);
};
+14 -12
View File
@@ -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<T> extends Omit<SelectProps<T>, "label"> {
placeholderColor?: string;
}
export const SelectInput = <T,>({
fieldLabel,
required,
placeholder,
placeholderColor,
...props
}: SelectInputProps<T>) => {
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, placeholderColor, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
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 = <T,>({
const capitalized = items.map(
(item) => item.charAt(0).toUpperCase() + item.slice(1)
);
return (<Typography>{capitalized.join(" | ")}</Typography>) as any;
return <Typography>{capitalized.join(" | ")}</Typography>;
}
const nodes = React.Children.toArray(props.children as React.ReactNode);
@@ -59,6 +56,7 @@ export const SelectInput = <T,>({
const select = (
<Select<T>
{...props}
ref={ref}
displayEmpty
renderValue={renderValue}
inputProps={{
@@ -100,3 +98,7 @@ export const SelectInput = <T,>({
return select;
};
export const SelectInput = forwardRef(SelectInputInner) as <T>(
props: SelectInputProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => React.ReactElement;
@@ -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<HTMLSpanElement, SliderInputProps>(
({ sx, showValue = false, ...props }, ref) => {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Stack
gap={theme.spacing(8)}
direction={"row"}
alignItems={"center"}
>
{showValue && <Typography>{props.value}</Typography>}
<Slider
{...props}
ref={ref}
sx={[
{
"& .MuiSlider-track": {
backgroundColor: theme.palette.primary.main,
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: theme.palette.grey[300],
opacity: 1,
},
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
"&:hover, &.Mui-focusVisible": {
boxShadow: `0 0 0 8px ${theme.palette.primary.main}20`,
},
},
"& .MuiSlider-valueLabel": {
backgroundColor: theme.palette.primary.main,
},
},
...additionalSx,
]}
/>
</Stack>
);
}
);
interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string;
required?: boolean;
showValue?: boolean;
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
}
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
(
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
ref
) => {
const theme = useTheme();
const labelText = fieldLabel;
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{labelText}</FieldLabel>}
<Box maxWidth={sliderMaxWidth}>
<SliderInput
{...props}
showValue={showValue}
value={value}
ref={ref}
/>
</Box>
</Stack>
);
}
);
@@ -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<HTMLInputElement, SwitchProps>(
function SwitchComponent({ sx, ...props }, ref) {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Switch
{...props}
slotProps={{
input: {
ref: ref,
},
}}
sx={[
{
"& .MuiSwitch-switchBase": {
"&.Mui-checked": {
color: "#E0E0E0",
"& + .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
border: 0,
},
},
},
},
...additionalSx,
]}
/>
);
}
);
@@ -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 (
<ToggleButton
{...props}
sx={{
px: 8,
textTransform: "none",
height: 34,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
"&:hover": {
boxShadow: "none",
},
...sx,
}}
/>
);
};
export const ToggleButtonGroupInput = ({ sx, ...props }: ToggleButtonGroupProps) => {
const theme = useTheme();
return (
<ToggleButtonGroup
{...props}
sx={{
"& .MuiToggleButtonGroup-grouped": {
borderColor: theme.palette.divider,
borderRadius: 0,
"&:first-of-type": {
borderTopLeftRadius: 2,
borderBottomLeftRadius: 2,
},
"&:last-of-type": {
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
},
},
...sx,
}}
/>
);
};
@@ -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";
@@ -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<React.SetStateAction<MonitorType[]>>;
showTypes?: boolean;
selectedTypes?: MonitorType[];
setSelectedTypes?: React.Dispatch<React.SetStateAction<MonitorType[]>>;
selectedStatus: string;
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
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 (
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(2)}
>
<Select
multiple
placeholder="Type"
value={selectedTypes}
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
>
{types.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
{showTypes && setSelectedTypes && (
<Select
multiple
placeholder="Type"
value={selectedTypes ?? []}
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
>
{types.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
)}
<Select
placeholder="Status"
value={selectedStatus}
@@ -0,0 +1,176 @@
import Stack from "@mui/material/Stack";
import { Icon, MonitorStatus } from "@/Components/v2/design-elements";
import { Button } from "@/Components/v2/inputs";
import { Settings, Pause, Play, Mail, Bug, Trash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor.js";
interface BaseHeaderProps {
monitor: Monitor;
}
const BaseHeader = ({ monitor, children }: React.PropsWithChildren<BaseHeaderProps>) => {
const theme = useTheme();
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"space-between"}
>
<MonitorStatus monitor={monitor} />
{children}
</Stack>
);
};
interface HeaderMonitorControlsProps {
path: string;
monitor?: Monitor | null;
isAdmin: boolean;
refetch: Function;
}
export const HeaderMonitorControls = ({
path,
monitor,
isAdmin,
refetch,
}: HeaderMonitorControlsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const {
post,
loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
if (!monitor) {
return null;
}
return (
<BaseHeader monitor={monitor}>
<Stack
width={{ xs: "100%", md: "auto" }}
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Bug} />}
onClick={() => {
navigate(`/incidents/${monitor?.id}`);
}}
>
{t("common.buttons.incidents")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={monitor?.isActive ? <Icon icon={Pause} /> : <Icon icon={Play} />}
onClick={async () => {
await post(`/monitors/pause/${monitor.id}`, {});
await refetch();
}}
>
{monitor?.isActive ? t("pause") : t("resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Settings} />}
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
>
Configure
</Button>
)}
</Stack>
</BaseHeader>
);
};
interface HeaderDeleteControlsProps {
monitor?: Monitor | null;
isAdmin: boolean;
refetch: Function;
onDelete?: () => void;
}
export const HeaderDeleteControls = ({
monitor,
isAdmin,
refetch,
onDelete,
}: HeaderDeleteControlsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const {
post,
loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
if (!monitor) {
return null;
}
return (
<BaseHeader monitor={monitor}>
<Stack
width={{ xs: "100%", md: "auto" }}
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={monitor?.isActive ? <Icon icon={Pause} /> : <Icon icon={Play} />}
onClick={async () => {
await post(`/monitors/pause/${monitor.id}`, {});
await refetch();
}}
>
{monitor?.isActive ? t("pause") : t("resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="error"
startIcon={<Icon icon={Trash} />}
onClick={() => {
onDelete?.();
}}
>
{t("common.buttons.delete")}
</Button>
)}
</Stack>
</BaseHeader>
);
};
@@ -0,0 +1,69 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/design-elements";
import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next";
interface MonitorStatBoxesProps {
monitor?: Monitor;
monitorStats: MonitorStats | null;
certificateExpiry?: string;
}
export const MonitorStatBoxes = ({
monitor,
monitorStats,
certificateExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
if (!monitorStats || !monitor) {
return null;
}
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure =
timeOfLastFailure > 0
? Date.now() - timeOfLastFailure
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck;
const streakTime = getHumanReadableDuration(timeSinceLastFailure);
const lastCheckTime = getHumanReadableDuration(timeSinceLastCheck);
const palette = getStatusPalette(monitor?.status);
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
>
<StatBox
palette={palette}
title={t("pages.common.monitors.statBoxes.activeFor")}
subtitle={streakTime}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastCheck")}
subtitle={lastCheckTime}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={monitorStats?.lastResponseTime + " ms"}
/>
{monitor?.type === "http" && (
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
)}
</Stack>
);
};
@@ -0,0 +1,163 @@
import { BaseChart, BaseBox } from "@/Components/v2/design-elements";
import { Clock } from "lucide-react";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import Typography from "@mui/material/Typography";
import { useSelector } from "react-redux";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: any };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = {
active?: boolean | undefined;
payload?: any[];
label?: string | number;
range: string;
theme: any;
uiTimezone: string;
};
const ResponseTimeToolTip = ({
active,
payload,
label,
range,
theme,
uiTimezone,
}: ResponseTimeToolTipProps) => {
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
</BaseBox>
);
};
export const HistogramDetails = ({
checks,
range,
}: {
checks: GroupedCheck[];
range: string;
}) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<BaseChart
icon={
<Clock
size={20}
strokeWidth={1.5}
/>
}
title="Response times"
>
<ResponsiveContainer
width="100%"
height={300}
>
<AreaChart data={checks?.slice()}>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient
id="colorUv"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.primary.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.primary.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
axisLine={false}
tickLine={false}
dataKey="bucketDate"
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -0,0 +1,206 @@
import { BaseChart } from "@/Components/v2/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
export const HistogramInfrastructure = ({
dateRange,
title,
type,
idx: _idx,
checks,
xKey,
yDomain,
dataKeys,
gradient = false,
gradientDirection = "vertical",
gradientStartColor,
gradientEndColor,
strokeColor,
fillColor,
yAxisFormatter,
}: {
dateRange: string;
title: string;
type: string;
idx: number | null;
checks: HardwareCheckStats[];
xKey: string;
yDomain?: number[];
dataKeys: string[];
gradient?: boolean;
gradientDirection?: "vertical" | "horizontal";
gradientStartColor?: string;
gradientEndColor?: string;
strokeColor: string;
fillColor?: string;
yAxisFormatter?: (value: number) => string;
}) => {
const theme = useTheme();
const uniqueId = useId();
const data = checks;
let avgTemps: { bucketDate: string; avg_temp: number | null }[] = [];
let tempYDomain: number[] = [];
if (type === "temp") {
avgTemps = data.map((check) => {
const temps = check.avgTemperature || [];
if (temps.length === 0) return { bucketDate: check.bucketDate, avg_temp: null };
const totalTemp = temps.reduce((sum, temp) => sum + (temp || 0), 0);
const avgTemp = totalTemp / temps.length;
return { bucketDate: check.bucketDate, avg_temp: avgTemp };
});
const maxTemp: number = avgTemps.reduce((max, item) => {
return item.avg_temp && item.avg_temp > max ? item.avg_temp : max;
}, 0);
tempYDomain = [0, Math.ceil((maxTemp * 1.3) / 10) * 10];
}
return (
<BaseChart
icon={null}
title={title}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={type === "temp" ? avgTemps : data}>
<XAxis
dataKey={xKey}
tick={(props) => (
<XTick
{...props}
range={dateRange}
/>
)}
/>
<YAxis
domain={type === "temp" ? tempYDomain : yDomain}
tickFormatter={yAxisFormatter}
/>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
{dataKeys?.map((dataKey, index) => {
const gradientId = `gradient-${uniqueId}-${index}`;
return (
<Fragment key={`${dataKey}-${index}`}>
{gradient === true &&
createGradient({
id: gradientId,
startColor: gradientStartColor || AREA_COLORS[index],
endColor: gradientEndColor || "transparent",
direction: gradientDirection,
})}
<Area
key={dataKey}
type="monotone"
dataKey={dataKey}
stroke={strokeColor || AREA_COLORS[index]}
fill={gradient === true ? `url(#${gradientId})` : fillColor}
/>
</Fragment>
);
})}
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -0,0 +1,106 @@
import Stack from "@mui/material/Stack";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import { HistogramPageSpeedTooltip } from "@/Components/v2/monitors/charts/HistogramPageSpeedTooltip";
import { useTheme } from "@mui/material/styles";
import type { CheckSnapshot } from "@/Types/Check";
import type { MonitorStatus } from "@/Types/Monitor";
import { getStatusColor } from "@/Utils/MonitorUtils";
const processData = (checks: CheckSnapshot[]) => {
if (checks.length === 0) return [];
const formattedData: { score: number; date: string }[] = [];
const calculateScore = (entry: CheckSnapshot) => {
const accessibility = entry?.accessibility || 0;
const bestPractices = entry?.bestPractices || 0;
const performance = entry?.performance || 0;
const seo = entry?.seo || 0;
return Math.floor((accessibility + bestPractices + performance + seo) / 4);
};
checks.forEach((entry) => {
const score = calculateScore(entry);
const date = entry.createdAt;
formattedData.push({ score, date });
});
return formattedData;
};
export const HistogramPageSpeed = ({
checks,
status,
}: {
checks: CheckSnapshot[];
status: MonitorStatus;
}) => {
const theme = useTheme();
const scores = processData(checks ?? []);
const color = getStatusColor(status, theme);
return (
<Stack
direction="row"
flexWrap="nowrap"
gap={theme.spacing(1.5)}
height="100px"
maxWidth="400px"
width="100%"
onClick={(event) => event.stopPropagation()}
overflow={"hidden"}
sx={{
cursor: "default",
position: "relative",
}}
>
<ResponsiveContainer
width="100%"
height={"100%"}
>
<AreaChart
data={scores}
margin={{ top: 10, bottom: -5 }}
style={{ cursor: "pointer" }}
>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.divider }}
content={<HistogramPageSpeedTooltip />}
/>
<defs>
<linearGradient
id={`pagespeed-chart-${status}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={color}
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="score"
stroke={color}
fill={`url(#pagespeed-chart-${status})`}
/>
</AreaChart>
</ResponsiveContainer>
</Stack>
);
};
@@ -0,0 +1,144 @@
import { BaseChart } from "@/Components/v2/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/v2/monitors/";
import {
XAxis,
AreaChart,
Area,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { HistogramPageSpeedScoresTooltip } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles";
import type { CheckSnapshot } from "@/Types/Check";
import type { Palette } from "@mui/material/styles";
type PaletteColorKey = Extract<
keyof Palette,
"primary" | "error" | "success" | "warning"
>;
export interface ConfigItem {
id: string;
text: string;
palette: PaletteColorKey;
}
const config: Record<string, ConfigItem> = {
seo: {
id: "seo",
text: "SEO",
palette: "primary",
},
performance: {
id: "performance",
text: "performance",
palette: "success",
},
bestPractices: {
id: "bestPractices",
text: "best practices",
palette: "warning",
},
accessibility: {
id: "accessibility",
text: "accessibility",
palette: "error",
},
};
export const HistogramPageSpeedDetails = ({
checks,
range,
}: {
checks: CheckSnapshot[];
range: string;
}) => {
const theme = useTheme();
return (
<BaseChart
icon={
<TrendingUp
size={20}
strokeWidth={1.5}
/>
}
title="Score history"
>
<ResponsiveContainer
width="100%"
minWidth={25}
height={215}
>
<AreaChart data={checks}>
<XAxis
dataKey={"createdAt"}
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.divider }}
content={<HistogramPageSpeedScoresTooltip config={config} />}
/>
<defs>
{Object.values(config).map(({ id, palette }) => {
const startColor = theme.palette[palette].main;
const endColor = theme.palette[palette].light;
return (
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
key={id}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={0}
/>
</linearGradient>
);
})}
</defs>
{Object.keys(config).map((key) => {
const { palette } = config[key];
const strokeColor = theme.palette[palette].main;
const bgColor = theme.palette.primary.main;
return (
<Area
connectNulls
key={key}
dataKey={key}
stackId={1}
stroke={strokeColor}
fill={`url(#${config[key].id})`}
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -0,0 +1,78 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { ConfigItem } from "@/Components/v2/monitors";
import type { TooltipProps } from "recharts";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
interface HistogramPageSpeedScoresTooltipProps
extends Partial<TooltipProps<number, string>> {
config: Record<string, ConfigItem>;
}
export const HistogramPageSpeedScoresTooltip = ({
active,
payload,
label,
config,
}: HistogramPageSpeedScoresTooltipProps) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
if (active && payload && payload.length) {
return (
<Box
sx={{
backgroundColor: theme.palette.background.paper,
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.text.secondary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
</Typography>
{Object.keys(config)
.reverse()
.map((key) => {
const { palette } = config[key];
const dotColor = theme.palette[palette].main;
return (
<Stack
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
/>
<Typography
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{config[key].text}
</Typography>
<Typography>{Math.floor(payload[0].payload[key])}</Typography>
</Stack>
);
})}
</Box>
);
}
return null;
};
@@ -0,0 +1,39 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { TooltipProps } from "recharts";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
export const HistogramPageSpeedTooltip = ({
active,
payload,
}: TooltipProps<number, string>) => {
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const theme = useTheme();
if (!active || !payload || payload.length === 0) {
return null;
}
const data = payload[0]?.payload;
return (
<Stack
alignItems="flex-start"
sx={{
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
p: theme.spacing(4),
}}
>
<Typography>
{formatDateWithTz(data?.date, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
<Typography>Score: {data?.score}</Typography>
</Stack>
);
};
@@ -0,0 +1,166 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/v2/design-elements";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy";
import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
interface XLabelProps {
p1: GroupedCheck;
p2: GroupedCheck;
range: string;
}
const XLabel = ({ p1, p2, range }: XLabelProps) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const dateFormat = range === "day" ? "MMM D, h:mm A" : "MMM D";
return (
<>
<text
x={0}
y="100%"
dy={-3}
textAnchor="start"
fontSize={11}
fill={theme.palette.text.secondary}
>
{formatDateWithTz(p1.bucketDate, dateFormat, uiTimezone)}
</text>
<text
x="100%"
y="100%"
dy={-3}
textAnchor="end"
fontSize={11}
fill={theme.palette.text.secondary}
>
{formatDateWithTz(p2.bucketDate, dateFormat, uiTimezone)}
</text>
</>
);
};
interface HistogramStatusProps {
title: string;
icon: React.ReactNode;
checks?: GroupedCheck[];
range: string;
}
export const HistogramStatus = ({
title,
icon,
checks = [],
range,
}: HistogramStatusProps) => {
const { t } = useTranslation();
const theme = useTheme();
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: GroupedCheck }>;
}) => {
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
if (!active || !payload?.length) return null;
const d = payload[0]?.payload;
const avg = d?.avgResponseTime ?? 0;
const titleText = t("common.charts.labels.averageResponseTime");
const fmt = range === "30d" ? "MMM D, YYYY" : "ddd, MMM D, YYYY, h:mm A";
let dateLabel = "";
if (d?.bucketDate) {
const base = new Date(d.bucketDate);
const midBucket =
range === "30d" ? new Date(base.getTime() + 12 * 60 * 60 * 1000) : base;
dateLabel = formatDateWithTz(midBucket.toISOString(), fmt, uiTimezone);
}
return (
<Stack
sx={{
p: 1,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
{dateLabel ? (
<Typography
sx={{ color: "text.secondary" }}
variant="caption"
>
{dateLabel}
</Typography>
) : null}
<Typography variant="caption">{titleText}</Typography>
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
</Stack>
);
};
const totalChecks = checks.reduce((acc, check) => acc + (check.totalChecks || 0), 0);
return (
<BaseChart
icon={icon}
title={title}
>
<Stack gap={theme.spacing(8)}>
<Stack
position="relative"
direction="row"
justifyContent="space-between"
>
<Stack>
<Typography>Total checks: {totalChecks}</Typography>
</Stack>
</Stack>
<ResponsiveContainer
width="100%"
height={155}
>
<BarChart data={checks}>
<XAxis
stroke={theme.palette.divider}
height={15}
tick={false}
label={
<XLabel
p1={checks[0]}
p2={checks[checks.length - 1]}
range={range}
/>
}
/>
<Tooltip
cursor={false}
content={<CustomTooltip />}
/>
<Bar
dataKey="avgResponseTime"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{checks?.map((groupedCheck, index) => {
const fillColor = getResponseTimeColor(groupedCheck.avgResponseTime);
return (
<Cell
key={groupedCheck.bucketDate || `check-${index}`}
fill={theme.palette[fillColor].main}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</Stack>
</BaseChart>
);
};
@@ -0,0 +1,161 @@
import { BaseChart } from "@/Components/v2/design-elements";
import { FileText } from "lucide-react";
import type { CheckAudits, CheckSnapshot } from "@/Types/Check";
import { Pie, PieChart, ResponsiveContainer, Label } from "recharts";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
import { useState } from "react";
import Tooltip from "@mui/material/Tooltip";
import { useTranslation } from "react-i18next";
const CenterLabel = ({ viewBox, value }: any) => {
const { cx, cy } = viewBox;
return (
<foreignObject
x={cx - 25}
y={cy - 10}
width={50}
height={50}
>
<Typography
variant="h1"
align="center"
>
{value}
</Typography>
</foreignObject>
);
};
export const PiePageSpeed = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
const { t } = useTranslation();
const theme = useTheme();
const [hoverTitle, setHoverTitle] = useState<string | null>(null);
if (!latestCheck) return null;
const LABELS: Record<string, string> = {
FCP: t("pages.pageSpeed.charts.common.fcp"),
SI: t("pages.pageSpeed.charts.common.si"),
LCP: t("pages.pageSpeed.charts.common.lcp"),
TBT: t("pages.pageSpeed.charts.common.tbt"),
CLS: t("pages.pageSpeed.charts.common.cls"),
};
const metrics: { key: keyof CheckAudits; color: string; weight: number }[] = [
{ key: "fcp", color: alpha("#1DE9B6", 0.6), weight: 0.1 },
{ key: "si", color: alpha("#7C4DFF", 0.6), weight: 0.1 },
{ key: "lcp", color: alpha("#FFB200", 0.6), weight: 0.25 },
{ key: "tbt", color: alpha("#00AFFE", 0.6), weight: 0.3 },
{ key: "cls", color: alpha("#FF4181", 0.6), weight: 0.25 },
];
const scores = metrics.flatMap(({ key, color, weight }) => {
const audit = latestCheck?.audits?.[key];
const val = Math.floor((audit?.score ?? 0) * 100);
const inverse = 100 - val;
return [
{
name: `${key.toUpperCase()} Inverse`,
value: inverse * weight,
fill: "transparent",
stroke: color,
weight,
},
{
name: key.toUpperCase(),
value: val * weight,
fill: color,
stroke: color,
weight,
},
];
});
const totalScore =
(latestCheck.audits?.fcp?.score || 0) * 0.1 +
(latestCheck.audits?.si?.score || 0) * 0.1 +
(latestCheck.audits?.lcp?.score || 0) * 0.25 +
(latestCheck.audits?.tbt?.score || 0) * 0.3 +
(latestCheck.audits?.cls?.score || 0) * 0.25;
const pageSpeedPalette = getPageSpeedPalette(Math.floor(totalScore * 100));
const score = [
{
value: 100,
fill: alpha(theme.palette[pageSpeedPalette].light || "#ffffff", 0.6),
stroke: "none",
},
];
return (
<BaseChart
icon={
<FileText
size={20}
strokeWidth={1.5}
/>
}
title={t("pages.pageSpeed.charts.pie.title")}
>
<Tooltip
open={Boolean(hoverTitle)}
title={hoverTitle || ""}
arrow
followCursor
placement="top"
>
<Box
sx={{
"& .recharts-wrapper:focus, & .recharts-surface:focus": {
outline: "none",
},
"& .recharts-wrapper *:focus": { outline: "none" },
"& svg:focus, & g:focus, & path:focus": { outline: "none" },
position: "relative",
}}
>
<ResponsiveContainer
width="100%"
height={250}
>
<PieChart>
<Pie
data={score}
dataKey="value"
cx="50%"
cy="50%"
outerRadius="65%"
>
<Label
position={"center"}
content={<CenterLabel value={Math.floor(totalScore * 100)} />}
/>
</Pie>
<Pie
data={scores}
innerRadius="70%"
outerRadius="80%"
label={({ name, value }) => `${name}: ${Math.round(value)}`}
dataKey="value"
stroke="none"
onMouseEnter={(_, index: number) => {
const d = scores[index];
if (!d) return;
const isInverse = String(d.name).includes("Inverse");
const pair = isInverse && scores[index + 1] ? scores[index + 1] : d;
const base = String(pair.name).replace(" Inverse", "");
const full = LABELS[base] ?? base;
const displayVal = Math.round(Number(pair.value) || 0);
setHoverTitle(`${full}: ${displayVal}`);
}}
onMouseLeave={() => setHoverTitle(null)}
/>
</PieChart>
</ResponsiveContainer>
</Box>
</Tooltip>
</BaseChart>
);
};
@@ -0,0 +1,105 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { BarChart3 } from "lucide-react";
import { BaseChart } from "@/Components/v2/design-elements";
import Typography from "@mui/material/Typography";
import type { CheckSnapshot } from "@/Types/Check";
import { useTranslation } from "react-i18next";
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
const MetricBox = ({
label,
value,
weight,
}: {
label: string;
value: number;
weight: number;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const palette = getPageSpeedPalette(value);
return (
<Stack
direction={"row"}
sx={{
border: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
}}
>
<Stack
flex={1}
p={theme.spacing(4)}
>
<Typography textTransform={"uppercase"}>{label}</Typography>
<Stack
direction="row"
justifyContent={"space-between"}
>
<Typography>{`${value}%`}</Typography>
<Typography>{`${t("pages.pageSpeed.charts.legend.weight")}: ${weight}%`}</Typography>
</Stack>
</Stack>
<Box
width={4}
bgcolor={theme.palette[palette].light}
sx={{
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
}}
/>
</Stack>
);
};
export const PiePageSpeedLegend = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
const theme = useTheme();
const { t } = useTranslation();
if (!latestCheck) {
return null;
}
return (
<BaseChart
icon={
<BarChart3
size={20}
strokeWidth={1.5}
/>
}
title={t("pages.pageSpeed.charts.legend.title")}
>
<Stack gap={theme.spacing(4)}>
<MetricBox
label={t("pages.pageSpeed.charts.common.si")}
value={Math.floor((latestCheck.audits?.si?.score || 0) * 100)}
weight={10}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.fcp")}
value={Math.floor((latestCheck.audits?.fcp?.score || 0) * 100)}
weight={10}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.cls")}
value={Math.floor((latestCheck.audits?.cls?.score || 0) * 100)}
weight={25}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.tbt")}
value={Math.floor((latestCheck.audits?.tbt?.score || 0) * 100)}
weight={30}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.lcp")}
value={Math.floor((latestCheck.audits?.lcp?.score || 0) * 100)}
weight={25}
/>
</Stack>
</BaseChart>
);
};
@@ -0,0 +1,91 @@
import { BaseChart } from "@/Components/v2/design-elements";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Gauge } from "lucide-react";
import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const { t } = useTranslation();
const theme = useTheme();
const chartData = [
{ name: "max", value: max - avg, color: "transparent" },
{ name: "avg", value: avg, color: "red" },
];
const palette = getResponseTimeColor(avg);
const msg: Record<string, string> = {
success: "Excellent",
warning: "Average",
danger: "Poor",
};
return (
<BaseChart
icon={
<Gauge
size={20}
strokeWidth={1.5}
/>
}
title={t("common.charts.labels.averageResponseTime")}
>
<Stack
height="100%"
position={"relative"}
justifyContent={"flex-end"}
>
<ResponsiveContainer
width="100%"
height={155}
>
<RadialBarChart
cy="100%"
data={chartData}
startAngle={180}
endAngle={0}
innerRadius={"120%"}
outerRadius={"200%"}
>
<RadialBar
dataKey="value"
background={{ fill: theme.palette[palette].light }}
>
<Cell visibility={"hidden"} />
<Cell fill={theme.palette[palette].main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography variant="body2">{t("common.charts.labels.low")}</Typography>
<Typography variant="body2">{t("common.charts.labels.high")}</Typography>
</Stack>
<Stack
position="absolute"
top={"50%"}
right={"50%"}
sx={{
transform: "translate(50%, 0%)",
}}
>
<Typography
variant="h6"
textAlign={"center"}
>
{msg[palette]}
</Typography>
<Typography
variant="h6"
textAlign={"center"}
>{`${avg?.toFixed()}ms`}</Typography>
</Stack>
</Stack>
</BaseChart>
);
};
@@ -1 +1,13 @@
export * from "./ControlsFilter";
export * from "./MonitorStatBoxes";
export * from "./HeaderMonitorControls";
export * from "./charts/HistogramStatus";
export * from "./charts/RadialAvgResponse";
export * from "./charts/HistogramDetails";
export * from "./charts/HistogramPageSpeed";
export * from "./charts/HistogramPageSpeedTooltip";
export * from "./charts/PiePageSpeed";
export * from "./charts/PiePageSpeedLegend";
export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
+74 -2
View File
@@ -1,7 +1,7 @@
import useSWR from "swr";
import type { SWRConfiguration } from "swr";
import type { AxiosRequestConfig } from "axios";
import { get, patch, post } from "@/Utils/ApiClient";
import { get, patch, post, put, deleteOp } from "@/Utils/ApiClient";
import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
@@ -21,7 +21,7 @@ export const useGet = <T>(
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration
) => {
const { data, error, isLoading, mutate } = useSWR<ApiResponse<T>>(
const { data, error, isLoading, isValidating, mutate } = useSWR<ApiResponse<T>>(
url,
(url: string) => fetcher<T>(url, axiosConfig),
swrConfig
@@ -30,6 +30,7 @@ export const useGet = <T>(
return {
data: data?.data ?? null,
isLoading,
isValidating,
error,
refetch: mutate,
};
@@ -107,3 +108,74 @@ export const usePatch = <B = any, R = any>() => {
return { patch: patchFn, loading, error };
};
export const usePut = <B = any, R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError, toastSuccess } = useToast();
const putFn = async (
endpoint: string,
body?: B,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await put<ApiResponse<R>>(endpoint, body, {
...config,
headers: {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
return { put: putFn, loading, error };
};
export const useDelete = <R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError, toastSuccess } = useToast();
const deleteFn = async (
endpoint: string,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await deleteOp<ApiResponse<R>>(endpoint, {
...config,
headers: {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
return { deleteFn, loading, error };
};
+1
View File
@@ -1,4 +1,5 @@
import { toast, type ToastOptions } from "react-toastify";
export const useToast = () => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
+3 -3
View File
@@ -137,7 +137,7 @@ const useFetchChecksByMonitor = ({
return [checks, checksCount, isLoading, networkError];
};
const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
const useFetchChecksSummaryByTeamId = ({ dateRange, updateTrigger } = {}) => {
const [summary, setSummary] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [networkError, setNetworkError] = useState(false);
@@ -147,7 +147,7 @@ const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
try {
setIsLoading(true);
const res = await networkService.getChecksAndSummaryByTeamId();
const res = await networkService.getChecksAndSummaryByTeamId({ dateRange });
setSummary(res.data.data);
} catch (error) {
setNetworkError(true);
@@ -158,7 +158,7 @@ const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
};
fetchSummary();
}, [updateTrigger]);
}, [dateRange, updateTrigger]);
return [summary, isLoading, networkError];
};
+19 -403
View File
@@ -1,107 +1,8 @@
import { useEffect, useState } from "react";
import { networkService } from "../main.jsx";
import { createToast } from "../Utils/toastUtils.jsx";
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "./useMonitorUtils.js";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export const useFetchMonitorsWithSummary = ({ types, monitorUpdateTrigger }) => {
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState(undefined);
const [monitorsSummary, setMonitorsSummary] = useState(undefined);
const [networkError, setNetworkError] = useState(false);
useEffect(() => {
const fetchMonitors = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorsWithSummaryByTeamId({
types,
});
const { monitors, summary } = res?.data?.data ?? {};
setMonitors(monitors);
setMonitorsSummary(summary);
} catch (error) {
console.error(error);
setNetworkError(true);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [types, monitorUpdateTrigger]);
return [monitors, monitorsSummary, isLoading, networkError];
};
export const useFetchMonitorsWithChecks = ({
types,
limit,
page,
rowsPerPage,
filter,
field,
order,
monitorUpdateTrigger,
}) => {
const [isLoading, setIsLoading] = useState(false);
const [count, setCount] = useState(undefined);
const [monitors, setMonitors] = useState(undefined);
const [summary, setSummary] = useState(undefined);
const [networkError, setNetworkError] = useState(false);
const theme = useTheme();
const { getMonitorWithPercentage } = useMonitorUtils();
useEffect(() => {
const fetchMonitors = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorsWithChecksByTeamId({
limit,
types,
page,
rowsPerPage,
filter,
field,
order,
});
const { count, monitors, summary } = res?.data?.data ?? {};
const mappedMonitors = monitors.map((monitor) =>
getMonitorWithPercentage(monitor, theme)
);
setSummary(summary);
setMonitors(mappedMonitors);
setCount(count || 0);
} catch (error) {
console.error(error);
setNetworkError(true);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [
field,
filter,
getMonitorWithPercentage,
limit,
order,
page,
rowsPerPage,
theme,
types,
monitorUpdateTrigger,
]);
return [summary, monitors, count, isLoading, networkError];
};
export const useFetchMonitorsByTeamId = ({ types, filter, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState(undefined);
@@ -132,225 +33,17 @@ export const useFetchMonitorsByTeamId = ({ types, filter, updateTrigger }) => {
return [monitors, isLoading, networkError];
};
export const useFetchStatsByMonitorId = ({
monitorId,
sortOrder,
limit,
dateRange,
numToDisplay,
normalize,
updateTrigger,
}) => {
const [monitor, setMonitor] = useState(undefined);
const [audits, setAudits] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
useEffect(() => {
const fetchMonitor = async () => {
try {
setIsLoading(true);
const res = await networkService.getStatsByMonitorId({
monitorId: monitorId,
sortOrder,
limit,
dateRange,
numToDisplay,
normalize,
});
setMonitor(res?.data?.data ?? undefined);
setAudits(res?.data?.data?.checks?.[0]?.audits ?? undefined);
} catch (error) {
setNetworkError(true);
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, dateRange, numToDisplay, normalize, sortOrder, limit, updateTrigger]);
return [monitor, audits, isLoading, networkError];
};
export const useFetchMonitorGames = ({ setGames, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchGames = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorGames();
setGames(res.data.data);
} catch (error) {
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchGames();
}, [setGames, updateTrigger]);
return [isLoading];
};
export const useFetchMonitorById = ({ monitorId, setMonitor, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (typeof monitorId === "undefined") {
setIsLoading(false);
return;
}
const fetchMonitor = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorById({ monitorId: monitorId });
setMonitor(res.data.data);
} catch (error) {
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, setMonitor, updateTrigger]);
return [isLoading];
};
export const useFetchHardwareMonitorById = ({ monitorId, dateRange, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [monitor, setMonitor] = useState(undefined);
useEffect(() => {
const fetchMonitor = async () => {
try {
if (!monitorId) {
return { monitor: undefined, isLoading: false, networkError: undefined };
}
const response = await networkService.getHardwareDetailsByMonitorId({
monitorId: monitorId,
dateRange: dateRange,
});
setMonitor(response.data.data);
} catch (error) {
setNetworkError(true);
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, dateRange, updateTrigger]);
return [monitor, isLoading, networkError];
};
export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [monitor, setMonitor] = useState(undefined);
const [monitorStats, setMonitorStats] = useState(undefined);
useEffect(() => {
const fetchMonitor = async () => {
try {
if (!monitorId) {
return { monitor: undefined, isLoading: false, networkError: undefined };
}
const response = await networkService.getPageSpeedDetailsByMonitorId({
monitorId: monitorId,
dateRange: dateRange,
});
setMonitor(response.data.data.monitor);
setMonitorStats(response.data.data.monitorStats);
} catch (error) {
setNetworkError(true);
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, dateRange, updateTrigger]);
return [monitor, monitorStats, isLoading, networkError];
};
export const useFetchUptimeMonitorById = ({ monitorId, dateRange, trigger }) => {
const [networkError, setNetworkError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [monitor, setMonitor] = useState(undefined);
const [monitorStats, setMonitorStats] = useState(undefined);
useEffect(() => {
const fetchMonitors = async () => {
try {
const res = await networkService.getUptimeDetailsById({
monitorId: monitorId,
dateRange: dateRange,
normalize: true,
});
const { monitorData, monitorStats } = res?.data?.data ?? {};
setMonitor(monitorData);
setMonitorStats(monitorStats);
} catch (error) {
setNetworkError(true);
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [dateRange, monitorId, trigger]);
return [monitor, monitorStats, isLoading, networkError];
};
export const useCreateMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const createMonitor = async ({ monitor, redirect }) => {
try {
setIsLoading(true);
await networkService.createMonitor({ monitor });
createToast({ body: "Monitor created successfully!" });
if (redirect) {
navigate(redirect);
}
} catch (error) {
createToast({ body: "Failed to create monitor." });
} finally {
setIsLoading(false);
}
};
return [createMonitor, isLoading];
};
export const useFetchGlobalSettings = () => {
const [isLoading, setIsLoading] = useState(true);
const [globalSettings, setGlobalSettings] = useState(undefined);
useEffect(() => {
const fetchGlobalSettings = async () => {
try {
const res = await networkService.getAppSettings();
setGlobalSettings(res?.data);
} catch (error) {
console.error("Failed to fetch global settings:", error);
createToast({ body: "Failed to load global settings" });
} finally {
setIsLoading(false);
}
};
fetchGlobalSettings();
}, []);
return [globalSettings, isLoading];
};
export const useDeleteMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const deleteMonitor = async ({ monitor, redirect }) => {
const { t } = useTranslation();
const deleteMonitor = async (monitorId, successCallback = () => {}) => {
try {
setIsLoading(true);
await networkService.deleteMonitorById({ monitorId: monitor.id });
createToast({ body: "Monitor deleted successfully!" });
if (redirect) {
navigate(redirect);
}
await networkService.deleteMonitorById({ monitorId });
successCallback();
createToast({ body: t("monitorDeleted") });
} catch (error) {
createToast({ body: "Failed to delete monitor." });
createToast({ body: t("failedDeleteMonitor") });
} finally {
setIsLoading(false);
}
@@ -358,68 +51,24 @@ export const useDeleteMonitor = () => {
return [deleteMonitor, isLoading];
};
export const useUpdateMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const updateMonitor = async ({ monitor, redirect }) => {
try {
setIsLoading(true);
const updatedFields = {
name: monitor.name,
statusWindowSize: monitor.statusWindowSize,
statusWindowThreshold: monitor.statusWindowThreshold,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications,
matchMethod: monitor.matchMethod,
expectedValue: monitor.expectedValue,
ignoreTlsErrors: monitor.ignoreTlsErrors,
jsonPath: monitor.jsonPath,
...((monitor.type === "port" || monitor.type === "game") && {
port: monitor.port,
}),
...(monitor.type == "game" && {
gameId: monitor.gameId,
}),
...(monitor.type === "hardware" && {
thresholds: monitor.thresholds,
secret: monitor.secret,
selectedDisks: monitor.selectedDisks,
}),
};
await networkService.updateMonitor({
monitorId: monitor.id,
updatedFields,
});
createToast({ body: "Monitor updated successfully!" });
if (redirect) {
navigate(redirect);
}
} catch (error) {
createToast({ body: "Failed to update monitor." });
} finally {
setIsLoading(false);
}
};
return [updateMonitor, isLoading];
};
export const usePauseMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const pauseMonitor = async ({ monitorId, triggerUpdate }) => {
const [error, setError] = useState(null);
const { t } = useTranslation();
const pauseMonitor = async (monitorId, successCallback = () => {}) => {
try {
setIsLoading(true);
const res = await networkService.pauseMonitorById({ monitorId });
createToast({
body: res.data.data.isActive
? "Monitor resumed successfully"
: "Monitor paused successfully",
});
triggerUpdate();
successCallback();
if (res.data.data.isActive === false) {
createToast({ body: t("monitorPaused") });
} else {
createToast({ body: t("monitorResumed") });
}
} catch (error) {
setError(error);
setError(error.message);
createToast({ body: t("failedPauseMonitor") });
} finally {
setIsLoading(false);
}
@@ -491,7 +140,7 @@ export const useCreateBulkMonitors = () => {
try {
const response = await networkService.createBulkMonitors(formData);
return [true, response.data, null]; // [success, data, error]
return [true, response.data, null];
} catch (err) {
const errorMessage = err?.response?.data?.msg || err.message;
return [false, null, errorMessage];
@@ -503,39 +152,6 @@ export const useCreateBulkMonitors = () => {
return [createBulkMonitors, isLoading];
};
export const useExportMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const exportMonitors = async () => {
setIsLoading(true);
try {
const response = await networkService.exportMonitors();
// Create a download link
const url = window.URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "monitors.csv");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
createToast({ body: t("export.success") });
return [true, null];
} catch (err) {
const errorMessage = err?.response?.data?.msg || err.message;
createToast({ body: errorMessage || t("export.failed") });
return [false, errorMessage];
} finally {
setIsLoading(false);
}
};
return [exportMonitors, isLoading];
};
export const useFetchJson = () => {
const [isLoading, setIsLoading] = useState(false);
const fetchJson = async () => {
+108
View File
@@ -0,0 +1,108 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
data?: Monitor | null;
defaultType?: MonitorType;
}
const getBaseDefaults = (data?: Monitor | null) => ({
name: data?.name || "",
description: data?.description || "",
interval: data?.interval || 60000,
notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
});
export const useMonitorForm = ({
data = null,
defaultType = "http",
}: UseMonitorFormOptions = {}) => {
return useMemo(() => {
const type = data?.type || defaultType;
const base = getBaseDefaults(data);
let defaults: MonitorFormData;
switch (type) {
case "http":
defaults = {
...base,
type: "http",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
};
break;
case "ping":
defaults = {
...base,
type: "ping",
url: data?.url || "",
};
break;
case "port":
defaults = {
...base,
type: "port",
url: data?.url || "",
port: data?.port || 80,
};
break;
case "docker":
defaults = {
...base,
type: "docker",
url: data?.url || "",
};
break;
case "game":
defaults = {
...base,
type: "game",
url: data?.url || "",
port: data?.port || 27015,
gameId: data?.gameId || "",
};
break;
case "pagespeed":
defaults = {
...base,
type: "pagespeed",
url: data?.url || "",
};
break;
case "hardware":
defaults = {
...base,
type: "hardware",
url: data?.url || "",
secret: data?.secret || "",
cpuAlertThreshold: data?.cpuAlertThreshold ?? 80,
memoryAlertThreshold: data?.memoryAlertThreshold ?? 80,
diskAlertThreshold: data?.diskAlertThreshold ?? 80,
tempAlertThreshold: data?.tempAlertThreshold ?? 80,
selectedDisks: data?.selectedDisks || [],
};
break;
default:
defaults = {
...base,
type: "http",
url: "",
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: "",
expectedValue: "",
jsonPath: "",
};
}
return { schema: monitorSchema, defaults };
}, [data, defaultType]);
};
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions {
data?: Notification | null;
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => {
const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
return { schema: notificationSchema, defaults };
}, [data]);
};
+1 -151
View File
@@ -1,36 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { createToast } from "../Utils/toastUtils.jsx";
import { networkService } from "../main.jsx";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { NOTIFICATION_TYPES } from "../Pages/Notifications/utils.js";
const useCreateNotification = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const createNotification = async (notification) => {
try {
setIsLoading(true);
await networkService.createNotification({ notification });
createToast({
body: t("notifications.create.success"),
});
navigate("/notifications");
} catch (error) {
setError(error);
createToast({
body: t("notifications.create.failed"),
});
} finally {
setIsLoading(false);
}
};
return [createNotification, isLoading, error];
};
const useGetNotificationsByTeamId = (updateTrigger) => {
const [notifications, setNotifications] = useState([]);
@@ -60,119 +31,6 @@ const useGetNotificationsByTeamId = (updateTrigger) => {
return [notifications, isLoading, error];
};
const useDeleteNotification = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const { t } = useTranslation();
const deleteNotification = async (id, callback) => {
try {
setIsLoading(true);
await networkService.deleteNotificationById({ id });
createToast({
body: t("notifications.delete.success"),
});
if (callback) {
callback();
}
} catch (error) {
setError(error);
createToast({
body: t("notifications.delete.failed"),
});
} finally {
setIsLoading(false);
}
};
return [deleteNotification, isLoading, error];
};
const useGetNotificationById = (id, setNotification) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const getNotificationById = useCallback(async () => {
try {
setIsLoading(true);
const response = await networkService.getNotificationById({ id });
const notification = response?.data?.data ?? null;
const notificationData = {
address: notification?.address,
notificationName: notification?.notificationName,
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?.id,
};
setNotification(notificationData);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
}, [id, setNotification]);
useEffect(() => {
if (id) {
getNotificationById();
}
}, [getNotificationById, id]);
return [isLoading, error];
};
const useEditNotification = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const { t } = useTranslation();
const navigate = useNavigate();
const editNotification = async (id, notification) => {
try {
setIsLoading(true);
await networkService.editNotification({ id, notification });
createToast({
body: t("notifications.edit.success"),
});
navigate(`/notifications`);
} catch (error) {
setError(error);
createToast({
body: t("notifications.edit.failed"),
});
} finally {
setIsLoading(false);
}
};
return [editNotification, isLoading, error];
};
const useTestNotification = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const { t } = useTranslation();
const testNotification = async (notification) => {
try {
setIsLoading(true);
await networkService.testNotification({ notification });
createToast({
body: t("notifications.test.success"),
});
} catch (error) {
setError(error);
createToast({
body: error?.response?.data?.msg || t("notifications.test.failed"),
});
} finally {
setIsLoading(false);
}
};
return [testNotification, isLoading, error];
};
const useTestAllNotifications = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
@@ -197,12 +55,4 @@ const useTestAllNotifications = () => {
return [testAllNotifications, isLoading, error];
};
export {
useCreateNotification,
useGetNotificationsByTeamId,
useDeleteNotification,
useGetNotificationById,
useEditNotification,
useTestNotification,
useTestAllNotifications,
};
export { useGetNotificationsByTeamId, useTestAllNotifications };
@@ -268,7 +268,6 @@ const TeamPanel = () => {
cursor: isSuperAdmin ? "pointer" : "default",
},
onRowClick: (row) => {
console.log(row);
if (isSuperAdmin) {
navigate(`/account/team/${row.id}`);
}
@@ -0,0 +1,132 @@
import {
Table,
Pagination,
ValueLabel,
StatusLabel,
} from "@/Components/v2/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/v2/design-elements/Table";
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useNavigate } from "react-router";
import type { Check } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
export const ChecksTable = ({
monitors,
checks,
checksCount,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
monitors: Monitor[] | null;
checks: Check[];
checksCount: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const navigate = useNavigate();
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<Check>[] = [
{
id: "monitorName",
content: t("common.table.headers.monitor"),
render: (row) => {
return (
monitors?.find((monitor) => monitor.id === row.metadata.monitorId)?.name ||
"N/A"
);
},
},
{
id: "status",
content: "Status",
render: (row) => {
return <StatusLabel status={row.status as MonitorStatus} />;
},
},
{
id: "date",
content: t("common.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(
row.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
);
},
},
{
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
return (
<ValueLabel
value={value}
text={String(code)}
/>
);
},
},
{
id: "message",
content: t("common.table.headers.message"),
render: (row) => {
return row.message || "N/A";
},
},
];
return headers;
};
const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
return (
<Box>
<Table
headers={headers}
data={checks}
onRowClick={(row) => {
navigate(`/checks/${row.id}`);
}}
emptyViewText={t("pages.checks.table.empty")}
/>
<Pagination
component="div"
count={checksCount}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
@@ -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 (
<Stack
backgroundColor={lighten(theme.palette.primary.main, 0.1)}
border={`1px solid ${theme.palette.primary.lowContrast}`}
borderRadius={theme.shape.borderRadius}
py={theme.spacing(2)}
px={theme.spacing(4)}
>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Status code: ${row?.statusCode}`}</Typography>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Response time: ${row?.responseTime} ms`}</Typography>
{phases && (
<>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Request timing: `}</Typography>
<Table
size="small"
sx={{ ml: theme.spacing(2), mt: theme.spacing(2) }}
>
<TableBody>
{Object.keys(phases)?.map((phaseKey) => (
<TableRow key={phaseKey}>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
variant="body2"
color="success"
>
{`${phaseKeyFormattingMap[phaseKey] || phaseKey}:`}
</Typography>
</TableCell>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
color={theme.palette.primary.contrastText}
variant="body2"
>{`${phases[phaseKey]} ms`}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</Stack>
);
};
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 (
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
);
},
},
{
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) => <HttpStatusLabel status={row.statusCode} />,
},
{ id: "message", content: t("incidentsTableMessage"), render: (row) => row.message },
];
if (isLoading) return <TableSkeleton />;
if (networkError) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
if (!isLoading && typeof checksCount === "undefined") {
return <GenericFallback>{t("incidentsTableNoIncidents")}</GenericFallback>;
}
return (
<>
<DataTable
headers={headers}
data={checks}
config={{ tooltipContent: GetTooltip }}
/>
<Pagination
paginationLabel={t("incidentsTablePaginationLabel")}
itemCount={checksCount}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</>
);
};
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;
@@ -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 <SkeletonLayout />;
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeader")}
</Typography>
<Select
id="incidents-select-monitor"
placeholder={t("incidentsOptionsPlaceholderAllServers")}
value={selectedMonitor}
onChange={(e) => setSelectedMonitor(e.target.value)}
items={monitorNames}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
}}
maxWidth={250}
/>
</Stack>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeaderFilterBy")}
</Typography>
<Select
id="incidents-select-filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
items={filterOptions}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
}}
/>
</Stack>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeaderShow")}
</Typography>
<ButtonGroup>
<Button
variant="group"
filled={(dateRange === "hour").toString()}
onClick={() => setDateRange("hour")}
>
{t("incidentsOptionsHeaderLastHour")}
</Button>
<Button
variant="group"
filled={(dateRange === "day").toString()}
onClick={() => setDateRange("day")}
>
{t("incidentsOptionsHeaderLastDay")}
</Button>
<Button
variant="group"
filled={(dateRange === "week").toString()}
onClick={() => setDateRange("week")}
>
{t("incidentsOptionsHeaderLastWeek")}
</Button>
<Button
variant="group"
filled={(dateRange === "all").toString()}
onClick={() => setDateRange("all")}
>
{t("incidentsOptionsHeaderFilterAll")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
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;
@@ -1,11 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack>
<Skeleton height={40} />
</Stack>
);
};
export default SkeletonLayout;
@@ -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 = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="CheckCircle"
size={20}
/>
</Box>
);
} else if (status === "down") {
color = theme.palette.error.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="X"
size={20}
/>
</Box>
);
} else if (status === "paused") {
color = theme.palette.warning.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="AlertTriangle"
size={20}
/>
</Box>
);
} else {
color = theme.palette.accent.main;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<Icon
name="Bell"
size={20}
/>
</Box>
);
}
return (
<Box
position="relative"
flex={1}
border={1}
backgroundColor={theme.palette.primary.main}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
p={theme.spacing(8)}
overflow="hidden"
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
<Stack direction="column">
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(8)}
>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.primary.contrastTextTertiary}
>
{title}
</Typography>
{icon}
</Stack>
<Stack
direction="row"
alignItems="flex-start"
fontSize={theme.typography.h1.fontSize}
fontWeight={600}
color={color}
gap={theme.spacing(1)}
>
{value}
<Typography
fontSize={theme.typography.label.fontSize}
fontWeight={300}
color={theme.palette.primary.contrastTextSecondary}
sx={{
opacity: 0.3,
}}
>
#
</Typography>
</Stack>
</Stack>
</Box>
);
};
StatusBox.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
status: PropTypes.string,
};
export default StatusBox;
@@ -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 <SkeletonLayout shouldRender={isLoading} />;
return (
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<StatusBox
title={t("incidentsOptionsHeaderTotalIncidents")}
value={summary?.totalChecks + summary?.cannotResolveChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterResolved")}
status="up"
value={summary?.resolvedChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterCannotResolve")}
status="paused"
value={summary?.cannotResolveChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterDown")}
status="down"
value={summary?.downChecks || 0}
/>
</Stack>
);
};
StatusBoxes.propTypes = {
isLoading: PropTypes.bool,
summary: PropTypes.object,
};
export default StatusBoxes;
@@ -1,36 +0,0 @@
import { Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
</Stack>
);
};
export default SkeletonLayout;
-100
View File
@@ -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 (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<StatusBoxes
isLoading={isLoadingSummary}
summary={summary}
/>
<OptionsHeader
shouldRender={!isLoading}
monitors={monitorLookup}
selectedMonitor={selectedMonitor}
setSelectedMonitor={setSelectedMonitor}
filter={filter}
setFilter={setFilter}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<IncidentTable
isLoading={isLoading}
monitors={monitorLookup ? monitorLookup : {}}
selectedMonitor={selectedMonitor}
filter={filter}
dateRange={dateRange}
updateTrigger={updateTrigger}
setUpdateTrigger={setUpdateTrigger}
/>
</Stack>
);
};
export default Checks;
+176
View File
@@ -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<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<string>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
const monitorsUrl = "/monitors/team";
const summaryUrl = `/checks/team/summary?dateRange=${dateRange}`;
const { data: monitorsResponse, isLoading: isLoadingMonitors } =
useGet<Monitor[]>(monitorsUrl);
const { data: summaryResponse, isLoading: isLoadingSummary } =
useGet<ChecksSummary>(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<ChecksResponse>(
teamChecksUrl,
{},
{ keepPreviousData: true, refreshInterval: 30000 }
);
const {
data: monitorChecksData,
isLoading: isLoadingMonitorChecks,
isValidating: isValidatingMonitorChecks,
} = useGet<ChecksResponse>(
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 (
<BasePage>
<Stack
direction={{ xs: "column", md: "row" }}
gap={4}
>
<TotalChecksBox n={totalChecks} />
<UpChecksBox n={upChecks} />
<DownChecksBox n={downChecks || 0} />
</Stack>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "stretch", md: "center" }}
gap={2}
>
<Stack
gap={2}
direction={{ xs: "column", md: "row" }}
>
<Select
fullWidth
value={selectedMonitor}
onChange={(e: any) => {
setSelectedMonitor(e.target.value);
setPage(0);
}}
>
<MenuItem value="0">{t("pages.checks.selects.monitor.all")}</MenuItem>
{monitorsResponse?.map((monitor) => (
<MenuItem
key={monitor.id}
value={monitor.id}
>
{monitor.name}
</MenuItem>
))}
</Select>
<Select
value={statusFilter}
onChange={(e: any) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="all">{t("pages.checks.selects.status.all")}</MenuItem>
<MenuItem value="up">{t("pages.checks.selects.status.up")}</MenuItem>
<MenuItem value="down">{t("pages.checks.selects.status.down")}</MenuItem>
</Select>
</Stack>
<HeaderTimeRange
isLoading={isLoading || isLoadingChecks}
dateRange={dateRange}
setDateRange={setDateRange}
/>
</Stack>
<ChecksTable
monitors={monitorsResponse ?? null}
checks={checks}
checksCount={checksCount}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
</BasePage>
);
};
export default Checks;
+710
View File
@@ -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<string, GeneralSettingsConfig> = {
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<Monitor>(
isEditMode ? `/monitors/${monitorId}` : null
);
const { data: notifications } = useGet<Notification[]>("/notifications/team");
const { data: games } = useGet<GamesMap>("/monitors/games");
const { schema, defaults } = useMonitorForm({
data: existingMonitor ?? null,
defaultType,
});
const form = useForm<MonitorFormData>({
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<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
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 (
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
<HeaderDeleteControls
monitor={existingMonitor}
isAdmin={true}
refetch={refetchMonitor}
onDelete={handleDeleteClick}
/>
{/* Monitor Type Selection - only shown for uptime monitors */}
{showTypeSelector && (
<ConfigBox
title={t("pages.createMonitor.form.type.title")}
subtitle={t("pages.createMonitor.form.type.description")}
rightContent={
<Controller
name="type"
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
<RadioGroup
{...field}
sx={{ gap: theme.spacing(6) }}
>
<RadioWithDescription
value="http"
label={t("pages.createMonitor.form.type.optionHttp")}
description={t(
"pages.createMonitor.form.type.optionHttpDescription"
)}
/>
<RadioWithDescription
value="ping"
label={t("pages.createMonitor.form.type.optionPing")}
description={t(
"pages.createMonitor.form.type.optionPingDescription"
)}
/>
<RadioWithDescription
value="docker"
label={t("pages.createMonitor.form.type.optionDocker")}
description={t(
"pages.createMonitor.form.type.optionDockerDescription"
)}
/>
<RadioWithDescription
value="port"
label={t("pages.createMonitor.form.type.optionPort")}
description={t(
"pages.createMonitor.form.type.optionPortDescription"
)}
/>
<RadioWithDescription
value="game"
label={t("pages.createMonitor.form.type.optionGame")}
description={t(
"pages.createMonitor.form.type.optionGameDescription"
)}
/>
</RadioGroup>
</FormControl>
)}
/>
}
/>
)}
<ConfigBox
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
rightContent={
<Stack spacing={theme.spacing(8)}>
{/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && (
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={generalSettingsConfig.urlLabel}
placeholder={generalSettingsConfig.urlPlaceholder}
fullWidth
disabled={isEditMode}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{/* Port field - only for port and game types */}
{generalSettingsConfig.showPort && (
<Controller
name="port"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
onChange={(e) => 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 && (
<Controller
name="gameId"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? ""}
fieldLabel={t("chooseGame")}
error={!!fieldState.error}
>
<MenuItem value="">Select a game</MenuItem>
{games &&
Object.entries(games).map(([key, game]) => (
<MenuItem
key={key}
value={key}
>
{game.name}
</MenuItem>
))}
</Select>
)}
/>
)}
{/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && (
<Controller
name="secret"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
placeholder={t(
"pages.createMonitor.form.general.option.secret.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
placeholder={generalSettingsConfig.namePlaceholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
subtitle={t("pages.createMonitor.form.frequency.description")}
rightContent={
<Controller
name="interval"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? 60000}
fieldLabel={t(
"pages.createMonitor.form.frequency.option.frequency.label"
)}
error={!!fieldState.error}
>
<MenuItem value={15000}>{t("time.fifteenSeconds")}</MenuItem>
<MenuItem value={30000}>{t("time.thirtySeconds")}</MenuItem>
<MenuItem value={60000}>{t("time.oneMinute")}</MenuItem>
<MenuItem value={120000}>{t("time.twoMinutes")}</MenuItem>
<MenuItem value={180000}>{t("time.threeMinutes")}</MenuItem>
<MenuItem value={240000}>{t("time.fourMinutes")}</MenuItem>
<MenuItem value={300000}>{t("time.fiveMinutes")}</MenuItem>
<MenuItem value={600000}>{t("time.tenMinutes")}</MenuItem>
<MenuItem value={900000}>{t("time.fifteenMinutes")}</MenuItem>
<MenuItem value={1800000}>{t("time.thirtyMinutes")}</MenuItem>
</Select>
)}
/>
}
/>
<ConfigBox
title={t("pages.createMonitor.form.incidents.title")}
subtitle={t("pages.createMonitor.form.incidents.description")}
rightContent={
<Stack spacing={theme.spacing(12)}>
<Controller
name="statusWindowSize"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t("pages.createMonitor.form.incidents.option.checks.label")}
min={1}
max={25}
valueLabelDisplay="auto"
/>
)}
/>
<Controller
name="statusWindowThreshold"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t(
"pages.createMonitor.form.incidents.option.percentage.label"
)}
min={1}
max={100}
valueLabelDisplay="auto"
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.createMonitor.form.notifications.title")}
subtitle={t("pages.createMonitor.form.notifications.description")}
rightContent={
<Controller
name="notifications"
control={control}
render={({ field }) => {
// 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 (
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={notificationOptions}
value={selectedNotifications}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof notificationOptions) => {
field.onChange(newValue.map((n) => n.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{selectedNotifications.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedNotifications.map((notification, index) => (
<Stack
direction="row"
alignItems="center"
key={notification.id}
width="100%"
>
<Typography flexGrow={1}>
{notification.notificationName}
</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== notification.id
)
);
}}
aria-label="Remove notification"
>
<Trash2 size={16} />
</IconButton>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")}
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
rightContent={
<Controller
name="ignoreTlsErrors"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t("pages.createMonitor.form.ignoreTls.option.tls.label")}
</Typography>
</Stack>
)}
/>
}
/>
)}
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.advanced.title")}
subtitle={t("pages.createMonitor.form.advanced.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="useAdvancedMatching"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
)}
</Typography>
</Stack>
)}
/>
{watchedUseAdvancedMatching && (
<Stack spacing={theme.spacing(8)}>
<Controller
name="matchMethod"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value ?? "equal"}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.matchMethod.label"
)}
>
<MenuItem value="equal">{t("matchMethodOptions.equal")}</MenuItem>
<MenuItem value="include">
{t("matchMethodOptions.include")}
</MenuItem>
<MenuItem value="regex">{t("matchMethodOptions.regex")}</MenuItem>
</Select>
)}
/>
<Controller
name="expectedValue"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.expectedValue.label"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="jsonPath"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.jsonPath.label"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Typography
component="span"
color="text.secondary"
sx={{ opacity: 0.8 }}
>
<Trans
i18nKey="pages.createMonitor.form.advanced.option.jsonPath.description"
components={{
jmesLink: (
<Link
href="https://jmespath.org/"
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
</Typography>
</Stack>
)}
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
</BasePage>
);
};
export default CreateMonitorPage;
@@ -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 (
<GenericFallback
@@ -9,7 +9,7 @@ import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
import IncidentActionsMenu from "./IncidentActionsMenu.jsx";
//Utils
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
@@ -2,10 +2,11 @@ import PropTypes from "prop-types";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { getHumanReadableDuration } from "@/Utils/timeUtils.js";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
import Icon from "@/Components/v1/Icon";
import { useTranslation } from "react-i18next";
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration";
import { Grid } from "@mui/material";
const IncidentItem = ({ incident }) => {
const { t } = useTranslation();
@@ -28,20 +29,24 @@ const IncidentItem = ({ incident }) => {
};
return (
<Box
<Grid
container
alignItems="center"
spacing={2}
sx={{
display: "grid",
gridTemplateColumns: "1fr 100px 80px",
alignItems: "center",
gap: theme.spacing(2),
width: "100%",
py: theme.spacing(0.5),
}}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
<Grid
item
xs={12}
lg={5}
sx={{
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
}}
>
<Box sx={iconWrapperStyle}>
<Icon
@@ -56,9 +61,18 @@ const IncidentItem = ({ incident }) => {
>
{incident.monitorName || t("incidentsPage.unknownMonitor")}
</Typography>
</Stack>
</Grid>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Grid
item
xs={12}
md={6}
lg={3}
sx={{
display: "flex",
justifyContent: { xs: "flex-start", md: "center" },
}}
>
<StatusLabel
status={isActive ? "down" : "up"}
text={isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
@@ -66,16 +80,21 @@ const IncidentItem = ({ incident }) => {
textTransform: "capitalize",
}}
/>
</Box>
</Grid>
<Typography
variant="body1"
fontWeight={500}
textAlign="right"
<Grid
item
xs={12}
md={6}
lg={4}
sx={{
textAlign: { xs: "left", md: "right" },
fontWeight: 500,
}}
>
{duration}
</Typography>
</Box>
<Typography variant="body1">{duration}</Typography>
</Grid>
</Grid>
);
};
@@ -1,4 +1,4 @@
import { getHumanReadableDuration } from "@/Utils/timeUtils.js";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
const useGetIncidentsDuration = (incident, isActive) => {
if (!incident?.startTime) {
return "-";
@@ -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 (
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("infrastructureCustomizeAlerts")}
</Typography>
<Typography component="p">
{t("infrastructureAlertNotificationDescription")}
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
{METRICS.map((metric) => {
return (
<CustomThreshold
key={metric}
infrastructureMonitor={infrastructureMonitor}
errors={errors}
checkboxId={metric}
checkboxName={metric}
checkboxLabel={
metric !== "cpu" ? capitalizeFirstLetter(metric) : metric.toUpperCase()
}
onCheckboxChange={handleCheckboxChange}
isChecked={infrastructureMonitor[metric]}
fieldId={METRIC_PREFIX + metric}
fieldName={METRIC_PREFIX + metric}
fieldValue={String(infrastructureMonitor[METRIC_PREFIX + metric])}
onFieldChange={onChange}
alertUnit={metric == "temperature" ? "°C" : "%"}
/>
);
})}
{hasAlertError(errors) && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{getAlertError(errors)}
</Typography>
)}
</Stack>
</ConfigBox>
);
};
CustomAlertsSection.propTypes = {
errors: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
infrastructureMonitor: PropTypes.object.isRequired,
handleCheckboxChange: PropTypes.func.isRequired,
};
export default CustomAlertsSection;
@@ -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
* <CustomThreshold
* checkboxId="cpu-threshold"
* checkboxName="cpu_threshold"
* checkboxLabel="Enable CPU Threshold"
* isChecked={true}
* onCheckboxChange={handleCheckboxToggle}
* fieldId="cpu-threshold-value"
* fieldName="cpu_threshold_value"
* fieldValue="80"
* onFieldChange={handleFieldChange}
* onFieldBlur={handleFieldBlur}
* alertUnit="%"
* errors={{}}
* infrastructureMonitor={monitorConfig}
* />
*/
export const CustomThreshold = ({
checkboxId,
checkboxName,
checkboxLabel,
onCheckboxChange,
isChecked,
fieldId,
fieldName,
fieldValue,
onFieldChange,
onFieldBlur,
alertUnit,
errors,
}) => {
const theme = useTheme();
return (
<Stack
direction={{ sm: "column", md: "row" }}
spacing={theme.spacing(2)}
>
<Box
sx={{
width: { md: "45%", lg: "25%", xl: "20%" },
}}
justifyContent="flex-start"
>
<Checkbox
id={checkboxId}
name={checkboxName}
label={checkboxLabel}
isChecked={isChecked}
onChange={onCheckboxChange}
/>
</Box>
<Stack
direction={"row"}
sx={{
justifyContent: "flex-start",
}}
alignItems="center"
spacing={theme.spacing(4)}
>
<TextInput
maxWidth="var(--env-var-width-4)"
type="number"
id={fieldId}
name={fieldName}
value={fieldValue}
onBlur={onFieldBlur}
onChange={onFieldChange}
error={errors[fieldId] ? true : false}
disabled={!isChecked}
/>
<Typography
component="p"
m={theme.spacing(3)}
>
{alertUnit}
</Typography>
</Stack>
</Stack>
);
};
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,
};
@@ -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 (
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("v1.infrastructure.disk_selection_title")}
</Typography>
<Typography component="p">
{t("v1.infrastructure.disk_selection_description")}
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
{!availableDisks || availableDisks.length === 0 ? (
<Typography
variant="body2"
sx={{ fontStyle: "italic", opacity: 0.8 }}
>
{t("v1.infrastructure.disk_selection_info")}
</Typography>
) : (
availableDisks.map((disk) => {
const identifier = disk.mountpoint || disk.device;
return (
<Stack
key={identifier}
direction={{ sm: "column", md: "row" }}
spacing={theme.spacing(2)}
>
<Box
sx={{
width: "100%",
}}
justifyContent="flex-start"
>
<Checkbox
id={`disk-${identifier}`}
name={identifier}
label={identifier}
isChecked={selectedDisks.includes(identifier)}
onChange={(e) => handleDiskChange(e, identifier)}
/>
</Box>
</Stack>
);
})
)}
</Stack>
</ConfigBox>
);
};
DiskSelection.propTypes = {
availableDisks: PropTypes.arrayOf(
PropTypes.shape({
mountpoint: PropTypes.string.isRequired,
})
),
selectedDisks: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
};
export default DiskSelection;
@@ -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 (
<Box
alignSelf="flex-end"
ml="auto"
>
<Button
onClick={handlePause}
loading={isBusy}
variant="contained"
color="secondary"
sx={{
pl: theme.spacing(4),
pr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
strokeWidth: 0.1,
},
},
}}
>
{monitor?.isActive ? (
<>
<Icon
name="PauseCircle"
size={20}
/>
{t("pause")}
</>
) : (
<>
<Icon
name="PlayCircle"
size={20}
/>
{t("resume")}
</>
)}
</Button>
<Button
loading={isBusy}
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{ ml: theme.spacing(6) }}
>
{t("remove")}
</Button>
<Dialog
open={isOpen}
theme={theme}
title={t("deleteDialogTitle")}
description={t("deleteDialogDescription")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t("delete")}
onConfirm={handleRemove}
/>
</Box>
);
};
MonitorActionButtons.propTypes = {
monitor: PropTypes.object.isRequired,
isBusy: PropTypes.bool.isRequired,
handlePause: PropTypes.func.isRequired,
handleRemove: PropTypes.func.isRequired,
};
export default MonitorActionButtons;
@@ -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 (
<Stack
direction="row"
alignItems="center"
height="fit-content"
gap={theme.spacing(2)}
>
<Tooltip
title={pagespeedStatusMsg[determineState(monitor)]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: { offset: [0, -8] },
},
],
},
}}
>
<Box>
<PulseDot color={statusColor[determineState(monitor)]} />
</Box>
</Tooltip>
<Typography
component="h2"
variant="monitorUrl"
>
{infrastructureMonitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
position="relative"
variant="body2"
ml={theme.spacing(6)}
mt={theme.spacing(1)}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: theme.spacing(2),
height: theme.spacing(2),
borderRadius: "50%",
backgroundColor: theme.palette.primary.contrastTextTertiary,
opacity: 0.8,
left: theme.spacing(-5),
top: "50%",
transform: "translateY(-50%)",
},
}}
>
{t("editing")}
</Typography>
</Stack>
);
};
MonitorStatusHeader.propTypes = {
monitor: PropTypes.object.isRequired,
infrastructureMonitor: PropTypes.object.isRequired,
};
export default MonitorStatusHeader;
@@ -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;
@@ -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;
@@ -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;
@@ -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 (
<Box className="create-infrastructure-monitor">
<Breadcrumbs list={CRUMBS} />
<Stack
component="form"
onSubmit={onSubmit}
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Box>
<Typography
component="h1"
variant="h1"
>
<Typography
component="span"
fontSize="inherit"
color={
!isCreate ? theme.palette.primary.contrastTextSecondary : undefined
}
>
{!isCreate ? infrastructureMonitor.name : t("createYour") + " "}
</Typography>
{isCreate ? (
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
color={theme.palette.primary.contrastTextSecondary}
>
{t("monitor")}
</Typography>
) : (
<></>
)}
</Typography>
{!isCreate && monitor && (
<MonitorStatusHeader
monitor={monitor}
infrastructureMonitor={infrastructureMonitor}
/>
)}
</Box>
{!isCreate && monitor && (
<MonitorActionButtons
monitor={monitor}
isBusy={isBusy}
handlePause={handlePause}
handleRemove={handleRemove}
/>
)}
</Stack>
<ConfigBox>
<Stack>
<Typography
component="h2"
variant="h2"
>
{t("settingsGeneralSettings")}
</Typography>
<Typography component="p">
{t("infrastructureCreateGeneralSettingsDescription")}
</Typography>
<Typography component="p">
{t("infrastructureServerRequirement")}{" "}
<Link
level="primary"
url="https://github.com/bluewave-labs/checkmate-agent"
label={t("common.monitoringAgentName")}
/>
</Typography>
</Stack>
<Stack gap={theme.spacing(8)}>
<TextInput
type="url"
id="url"
name="url"
startAdornment={<HttpAdornment https={https} />}
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 && (
<FieldWrapper
label={t("infrastructureProtocol")}
labelVariant="p"
>
<ButtonGroup>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
{t("https")}
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
{t("http")}
</Button>
</ButtonGroup>
</FieldWrapper>
)}
<TextInput
type="text"
id="name"
name="name"
label={t("infrastructureDisplayNameLabel")}
placeholder="Google"
isOptional={true}
value={infrastructureMonitor.name}
onChange={onChange}
error={errors["name"]}
/>
<TextInput
type="text"
id="secret"
name="secret"
label={t("infrastructureAuthorizationSecretLabel")}
value={infrastructureMonitor.secret}
onChange={onChange}
error={errors["secret"] ? true : false}
helperText={errors["secret"]}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("notificationConfig.title")}
</Typography>
<Typography component="p">{t("notificationConfig.description")}</Typography>
</Box>
<NotificationsConfig
notifications={notifications}
setMonitor={setInfrastructureMonitor}
setNotifications={infrastructureMonitor.notifications}
/>
</ConfigBox>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("createMonitorPage.incidentConfigTitle")}
</Typography>
<Typography component="p">
{t("createMonitorPage.incidentConfigDescription")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<TextInput
name="statusWindowSize"
label={t("createMonitorPage.incidentConfigStatusWindowLabel")}
type="number"
value={infrastructureMonitor.statusWindowSize}
onChange={onChange}
error={errors["statusWindowSize"] ? true : false}
helperText={errors["statusWindowSize"]}
/>
<TextInput
name="statusWindowThreshold"
label={t("createMonitorPage.incidentConfigStatusWindowThresholdLabel")}
type="number"
value={infrastructureMonitor.statusWindowThreshold}
onChange={onChange}
error={errors["statusWindowThreshold"] ? true : false}
helperText={errors["statusWindowThreshold"]}
/>
</Stack>
</ConfigBox>
<CustomAlertsSection
errors={errors}
onChange={onChange}
infrastructureMonitor={infrastructureMonitor}
handleCheckboxChange={handleCheckboxChange}
/>
{monitorId && (
<DiskSelection
availableDisks={availableDisks}
selectedDisks={infrastructureMonitor.selectedDisks}
onChange={(newSelectedDisks) =>
onChangeForm("selectedDisks", newSelectedDisks)
}
/>
)}
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("distributedUptimeCreateAdvancedSettings")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="interval"
name="interval"
label="Check frequency"
value={infrastructureMonitor.interval || 15}
onChange={onChange}
items={FREQUENCIES}
/>
</Stack>
</ConfigBox>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
type="submit"
variant="contained"
color="accent"
loading={isBusy}
>
{t(isCreate ? "infrastructureCreateMonitor" : "infrastructureEditMonitor")}
</Button>
</Stack>
</Stack>
</Box>
);
};
export default CreateInfrastructureMonitor;
@@ -1,37 +0,0 @@
// Components
import { Typography } from "@mui/material";
import BaseContainer from "../BaseContainer/index.jsx";
import AreaChart from "@/Components/v1/Charts/AreaChart/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
const InfraAreaChart = ({ config }) => {
const theme = useTheme();
const { getDimensions } = useHardwareUtils();
return (
<BaseContainer>
<Typography
component="h2"
padding={theme.spacing(8)}
>
{config.heading}
</Typography>
<AreaChart
height={getDimensions().areaChartHeight}
data={config.data}
dataKeys={config.dataKeys}
xKey="_id"
yDomain={config.yDomain}
customTooltip={config.toolTip}
xTick={config.xTick}
yTick={config.yTick}
strokeColor={config.strokeColor}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
/>
</BaseContainer>
);
};
export default InfraAreaChart;
@@ -1,140 +0,0 @@
// Components
import { Stack } from "@mui/material";
import InfraAreaChart from "./InfraAreaChart.jsx";
import SkeletonLayout from "./skeleton.jsx";
// Utils
import {
PercentTick,
TzTick,
InfrastructureTooltip,
TemperatureTooltip,
} from "@/Components/v1/Charts/Utils/chartUtils.jsx";
import { useTheme } from "@emotion/react";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
import { useTranslation } from "react-i18next";
const AreaChartBoxes = ({ shouldRender, monitor, dateRange }) => {
const theme = useTheme();
const { t } = useTranslation();
const { buildTemps } = useHardwareUtils();
if (!shouldRender) {
return <SkeletonLayout />;
}
const { stats } = monitor ?? {};
const { checks } = stats;
let latestCheck = checks[0];
const { temps, tempKeys } = buildTemps(checks);
const configs = [
{
type: "memory",
data: checks,
dataKeys: ["avgMemoryUsage"],
heading: t("memoryUsage"),
strokeColor: theme.palette.accent.main, // CAIO_REVIEW
gradientStartColor: theme.palette.accent.main, // CAIO_REVIEW
yLabel: t("memoryUsage"),
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.primary.main}
yKey={"avgMemoryUsage"}
yLabel={"Memory usage"}
dateRange={dateRange}
/>
),
},
{
type: "cpu",
data: checks,
dataKeys: ["avgCpuUsage"],
heading: t("cpuUsage"),
strokeColor: theme.palette.success.main,
gradientStartColor: theme.palette.success.main,
yLabel: t("cpuUsage"),
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.success.main}
yKey={"avgCpuUsage"}
yLabel={"CPU usage"}
dateRange={dateRange}
/>
),
},
{
type: "temperature",
data: temps,
dataKeys: tempKeys,
strokeColor: theme.palette.error.main,
gradientStartColor: theme.palette.error.main,
heading: t("cpuTemperature"),
yLabel: "Temperature",
xTick: <TzTick dateRange={dateRange} />,
yDomain: [
0,
Math.max(Math.max(...temps.flatMap((t) => tempKeys.map((k) => t[k]))) * 1.1, 200),
],
toolTip: (
<TemperatureTooltip
keys={tempKeys}
dotColor={theme.palette.error.main}
dateRange={dateRange}
/>
),
},
...(latestCheck?.disks?.map((disk, idx) => ({
type: "disk",
data: checks,
diskIndex: idx,
dataKeys: [`disks[${idx}].usagePercent`],
heading: `Disk${idx} usage`,
strokeColor: theme.palette.warning.main,
gradientStartColor: theme.palette.warning.main,
yLabel: t("diskUsage"),
yDomain: [0, 1],
yTick: <PercentTick />,
xTick: <TzTick dateRange={dateRange} />,
toolTip: (
<InfrastructureTooltip
dotColor={theme.palette.warning.main}
yKey={`disks.usagePercent`}
yLabel={"Disc usage"}
yIdx={idx}
dateRange={dateRange}
/>
),
})) || []),
];
return (
<Stack
direction={"row"}
// height={chartContainerHeight} // FE team HELP! Possibly no longer needed?
gap={theme.spacing(8)} // FE team HELP!
flexWrap="wrap" // //FE team HELP! Better way to do this?
sx={{
"& > *": {
flexBasis: `calc(50% - ${theme.spacing(8)})`,
maxWidth: `calc(50% - ${theme.spacing(8)})`,
},
}}
>
{configs.map((config) => (
<InfraAreaChart
key={`${config.type}-${config.diskIndex ?? ""}`}
config={config}
/>
))}
</Stack>
);
};
export default AreaChartBoxes;
@@ -1,25 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(8)}
>
<Skeleton
height={"33vh"}
sx={{
flex: 1,
}}
/>
<Skeleton
height={"33vh"}
sx={{ flex: 1 }}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -1,44 +0,0 @@
/**
* Renders a base box with consistent styling
* @param {Object} props - Component properties
* @param {React.ReactNode} props.children - Child components to render inside the box
* @param {Object} props.sx - Additional styling for the box
* @returns {React.ReactElement} Styled box component
*/
// Components
import { Box } from "@mui/material";
// Utils
import { useTheme } from "@emotion/react";
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
import PropTypes from "prop-types";
const BaseContainer = ({ children, sx = {}, shouldExpand = false }) => {
const theme = useTheme();
const { getDimensions } = useHardwareUtils();
return (
<Box
sx={{
padding: `${theme.spacing(getDimensions().baseBoxPaddingVertical)} ${theme.spacing(getDimensions().baseBoxPaddingHorizontal)}`,
minWidth: 200,
width: shouldExpand ? "100%" : 225,
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
...sx,
}}
>
{children}
</Box>
);
};
BaseContainer.propTypes = {
children: PropTypes.node.isRequired,
sx: PropTypes.object,
shouldExpand: PropTypes.bool,
};
export default BaseContainer;
@@ -0,0 +1,104 @@
import Grid from "@mui/material/Grid2";
import { HistogramInfrastructure } from "@/Components/v2/monitors";
import { useTranslation } from "react-i18next";
import type { HardwareCheckStats } from "@/Types/Monitor";
import { useMemo } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
const formatPercent = (value: number) => `${(value * 100).toFixed(0)}%`;
const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [
{
title: t("pages.infrastructure.charts.labels.memory"),
type: "memory",
dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main,
gradientStartColor: theme.palette.primary.main,
yDomain: [0, 1],
yAxisFormatter: formatPercent,
idx: null,
},
{
title: t("pages.infrastructure.charts.labels.cpu"),
type: "cpu",
dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main,
gradientStartColor: theme.palette.success.main,
yDomain: [0, 1],
yAxisFormatter: formatPercent,
idx: null,
},
{
title: t("pages.infrastructure.charts.labels.temp"),
type: "temp",
dataKeys: ["avg_temp"],
strokeColor: theme.palette.error.main,
gradientStartColor: theme.palette.error.main,
yDomain: [0, 150],
yAxisFormatter: formatTemp,
idx: null,
},
...(checks[0]?.disks?.map((_, idx) => ({
title: t("pages.infrastructure.charts.labels.disk", { idx }),
type: "disk",
dataKeys: [`disks[${idx}].usagePercent`],
strokeColor: theme.palette.warning.main,
gradientStartColor: theme.palette.warning.main,
yDomain: [0, 1],
yAxisFormatter: formatPercent,
idx,
})) || []),
];
};
export const InfraDetailsCharts = ({
checks,
dateRange,
}: {
checks: HardwareCheckStats[];
dateRange: string;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { t } = useTranslation();
const chartConfigs = useMemo(
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
return (
<Grid
container
spacing={theme.spacing(8)}
>
{chartConfigs.map((config) => {
return (
<Grid
size={isSmall ? 12 : 6}
key={`${config.type}-${config.idx ?? ""}`}
>
<HistogramInfrastructure
dateRange={dateRange}
title={config.title}
type={config.type}
idx={config.idx}
key={`${config.type}-${config.idx ?? ""}`}
checks={checks}
xKey="bucketDate"
yDomain={config.yDomain}
dataKeys={config.dataKeys}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
strokeColor={config.strokeColor}
yAxisFormatter={config.yAxisFormatter}
/>
</Grid>
);
})}
</Grid>
);
};
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more