Compare commits
182
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
189f309631 | ||
|
|
97dd3ec9cb | ||
|
|
b4c929963f | ||
|
|
4ecc737438 | ||
|
|
bc58f6e08e | ||
|
|
35eba6897a | ||
|
|
667dc55fc8 | ||
|
|
f2d2687d2a | ||
|
|
4e3e580179 | ||
|
|
4080ac8c2c | ||
|
|
0a408f8cfd | ||
|
|
e3921beac4 | ||
|
|
4c284493ba | ||
|
|
0fcceeb7a6 | ||
|
|
f799d46025 | ||
|
|
a80df60fc1 | ||
|
|
cbec0fa065 | ||
|
|
40e5550e0f | ||
|
|
90fd9930db | ||
|
|
f10f7385b0 | ||
|
|
2d85abf030 | ||
|
|
3c0c470ca3 | ||
|
|
4c5cae8722 | ||
|
|
8650f30d12 | ||
|
|
09e37f4356 | ||
|
|
6bc8a521c9 | ||
|
|
a1e6eaa9d0 | ||
|
|
bbb61a6f88 | ||
|
|
c1d2041482 | ||
|
|
7aa05c7f85 | ||
|
|
539866e119 | ||
|
|
6f0ff1583e | ||
|
|
12b61d17f2 | ||
|
|
7426418629 | ||
|
|
3566da7515 | ||
|
|
b451489bd8 | ||
|
|
d4a0489275 | ||
|
|
080c91284d | ||
|
|
dadc74373c | ||
|
|
6fdea04416 | ||
|
|
67963823d9 | ||
|
|
ab12a9bd3a | ||
|
|
3b25c50247 | ||
|
|
63e42b7276 | ||
|
|
ae7524f507 | ||
|
|
54d71bef61 | ||
|
|
a77c74b17c | ||
|
|
f2363a7a09 | ||
|
|
1fefb038f0 | ||
|
|
85190992c7 | ||
|
|
efe87b599f | ||
|
|
ed1bcf97da | ||
|
|
56ead03416 | ||
|
|
a912a5ebb8 | ||
|
|
0fd4173816 | ||
|
|
75d3644b36 | ||
|
|
853e21623e | ||
|
|
57c3bf3240 | ||
|
|
6be1eb0a3e | ||
|
|
9d70623e0f | ||
|
|
43fe66022b | ||
|
|
294fb13005 | ||
|
|
8028cab487 | ||
|
|
8160ab4a3c | ||
|
|
6063532c79 | ||
|
|
83b628c8f9 | ||
|
|
88a91022c3 | ||
|
|
5822a363a9 | ||
|
|
d54c90d511 | ||
|
|
3a5783fe91 | ||
|
|
479f22ac69 | ||
|
|
30202b2249 | ||
|
|
888910abb2 | ||
|
|
874d1b277a | ||
|
|
f34520cdbf | ||
|
|
f420044b64 | ||
|
|
53d5ae2251 | ||
|
|
bcc57c7135 | ||
|
|
6b67f4977f | ||
|
|
87255071f4 | ||
|
|
cbf1f97591 | ||
|
|
7a881fda03 | ||
|
|
50fd66ef56 | ||
|
|
f9b677b1ad | ||
|
|
b7a6d50c73 | ||
|
|
04bd7f0e2e | ||
|
|
2e7aba788d | ||
|
|
e1665b29af | ||
|
|
17450e2537 | ||
|
|
270293a2b5 | ||
|
|
a7c5b0c7fb | ||
|
|
e804c35c3a | ||
|
|
5c4133097d | ||
|
|
85f93cd6d2 | ||
|
|
d4db32e392 | ||
|
|
791b9fc7a2 | ||
|
|
206c5577a6 | ||
|
|
cbd0530d85 | ||
|
|
45ef2438de | ||
|
|
ec818eaacb | ||
|
|
91bb60f2ff | ||
|
|
443ad46fa7 | ||
|
|
f685c9cd5d | ||
|
|
0263c17618 | ||
|
|
5a12e36c7e | ||
|
|
67da6c61cf | ||
|
|
f538f61464 | ||
|
|
f6971ede69 | ||
|
|
e69283e3ed | ||
|
|
084eed85bf | ||
|
|
9a42fb031f | ||
|
|
943e88c54d | ||
|
|
24ce64b439 | ||
|
|
841d25ce3e | ||
|
|
84e4ea7b64 | ||
|
|
4b92dbd19b | ||
|
|
5eb1d54d65 | ||
|
|
a0aee182de | ||
|
|
2dca5ccb67 | ||
|
|
348eea5475 | ||
|
|
2a3683c74a | ||
|
|
806d7ae456 | ||
|
|
cac502573f | ||
|
|
830318edc3 | ||
|
|
86a55e3cfe | ||
|
|
796960aeb2 | ||
|
|
a25ff03b71 | ||
|
|
22ae6b4be6 | ||
|
|
0b5e28c296 | ||
|
|
c5c91e28f8 | ||
|
|
3ba3e4b865 | ||
|
|
77fa394004 | ||
|
|
c0ba278e47 | ||
|
|
73f41906b0 | ||
|
|
cb5af2741d | ||
|
|
af4ef87966 | ||
|
|
a154ad11b5 | ||
|
|
1bfd1c9119 | ||
|
|
fc98192bcf | ||
|
|
b1db74d96f | ||
|
|
c1258d3cdf | ||
|
|
6ffa5ac648 | ||
|
|
6395e8a10e | ||
|
|
2852d2cdce | ||
|
|
363baef7b1 | ||
|
|
a1973a6d2b | ||
|
|
12f02d6eea | ||
|
|
a6c291c8ef | ||
|
|
227dc9e7e8 | ||
|
|
38ef9ccad3 | ||
|
|
353b97e7d3 | ||
|
|
9b871ffb52 | ||
|
|
cb392eaaba | ||
|
|
dcf5a77923 | ||
|
|
ec03addba6 | ||
|
|
eefdb8d253 | ||
|
|
e539f40612 | ||
|
|
c18159afd3 | ||
|
|
5596e45ff3 | ||
|
|
bde13f4fa7 | ||
|
|
7f1c996632 | ||
|
|
396d5b369b | ||
|
|
bb8cc8b464 | ||
|
|
66efa25e2b | ||
|
|
7bef6dc0e6 | ||
|
|
1c341a4e3d | ||
|
|
f41f2322cf | ||
|
|
b653f26c96 | ||
|
|
ece1dadb78 | ||
|
|
1f003c4d48 | ||
|
|
5a468d717a | ||
|
|
975d134dd8 | ||
|
|
7a66b5310b | ||
|
|
fcfad945a0 | ||
|
|
6b4032ecfe | ||
|
|
41d6c092d4 | ||
|
|
f9e366e181 | ||
|
|
07705e9823 | ||
|
|
c48049e0af | ||
|
|
f3ba8c9815 | ||
|
|
f88e4ee6f4 | ||
|
|
300b1b2238 |
No files matched your search
@@ -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!
|
||||
|
||||
|
||||
Generated
+557
-1722
File diff suppressed because it is too large.
Load diff
+7
-7
@@ -17,10 +17,10 @@
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@mui/lab": "6.0.0-dev.240424162023-9968b4889d",
|
||||
"@mui/material": "6.4.11",
|
||||
"@mui/x-charts": "^7.5.1",
|
||||
"@mui/x-date-pickers": "7.29.0",
|
||||
"@mui/lab": "7.0.0",
|
||||
"@mui/material": "7.3.7",
|
||||
"@mui/x-charts": "7.29.1",
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@reduxjs/toolkit": "2.7.0",
|
||||
"axios": "^1.7.4",
|
||||
"dayjs": "1.11.13",
|
||||
@@ -30,11 +30,11 @@
|
||||
"i18next": "25.4.2",
|
||||
"joi": "17.13.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mui-color-input": "^6.0.0",
|
||||
"mui-color-input": "^7.0.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",
|
||||
|
||||
@@ -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/timeUtilsLegacy.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;
|
||||
-419
@@ -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;
|
||||
@@ -7,6 +7,7 @@ import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type HeatmapCheck =
|
||||
| CheckSnapshot
|
||||
| { status: "placeholder"; responseTime: 0; createdAt: "" };
|
||||
@@ -55,12 +56,9 @@ export const HeatmapResponseTimeTooltip = ({
|
||||
<Typography>
|
||||
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
{check?.responseTime && (
|
||||
<Typography>
|
||||
{t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography>
|
||||
{t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
|
||||
</Typography>
|
||||
<Typography textTransform={"capitalize"}>
|
||||
Status:{" "}
|
||||
<span style={{ color: getColor(check?.status) }}>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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;
|
||||
@@ -17,7 +21,8 @@ export const HeaderTimeRange = ({
|
||||
setDateRange,
|
||||
}: MonitorTimeFrameHeaderProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const handleChange = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
newValue: string | null
|
||||
@@ -32,34 +37,24 @@ export const HeaderTimeRange = ({
|
||||
if (hasDateRange) {
|
||||
timeFramePicker = (
|
||||
<ToggleButtonGroup
|
||||
orientation={isSmallScreen ? "vertical" : "horizontal"}
|
||||
value={dateRange}
|
||||
exclusive
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
fullWidth={isSmallScreen}
|
||||
>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="recent"
|
||||
>
|
||||
Recent
|
||||
<ToggleButton value="recent">
|
||||
{t("components.headerTimeRange.labels.recent")}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="day"
|
||||
>
|
||||
Day
|
||||
<ToggleButton value="day">
|
||||
{t("components.headerTimeRange.labels.day")}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="week"
|
||||
>
|
||||
Week
|
||||
<ToggleButton value="week">
|
||||
{t("components.headerTimeRange.labels.week")}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="month"
|
||||
>
|
||||
Month
|
||||
<ToggleButton value="month">
|
||||
{t("components.headerTimeRange.labels.month")}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
@@ -67,21 +62,19 @@ export const HeaderTimeRange = ({
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
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">
|
||||
Showing statistics for past{" "}
|
||||
{dateRange === "recent"
|
||||
? "2 hours"
|
||||
: dateRange === "day"
|
||||
? "24 hours"
|
||||
: dateRange === "week"
|
||||
? "7 days"
|
||||
: "30 days"}
|
||||
.
|
||||
{t(`components.headerTimeRange.description.${dateRange}`)}
|
||||
</Typography>
|
||||
{timeFramePicker}
|
||||
</Stack>
|
||||
|
||||
@@ -2,5 +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/HeaderMonitorControls";
|
||||
export * from "../common/controls/HeaderTimeRange";
|
||||
@@ -91,7 +91,7 @@ export const BasePage = ({
|
||||
spacing={theme.spacing(10)}
|
||||
{...props}
|
||||
>
|
||||
<Breadcrumb />
|
||||
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -3,21 +3,17 @@ import Typography from "@mui/material/Typography";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const isId = (segment: string): boolean => {
|
||||
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
|
||||
};
|
||||
|
||||
export const Breadcrumb = () => {
|
||||
const actionSegments = ["create", "configure"];
|
||||
|
||||
const BreadcrumbWrapper = ({ children }: { children: ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const segments = location.pathname.split("/").filter((x) => x);
|
||||
|
||||
// Build simplified breadcrumb: "uptime" or "uptime / details"
|
||||
const basePage = segments[0] || "home";
|
||||
const hasDetailsPage = segments.length > 1 && isId(segments[1]);
|
||||
|
||||
return (
|
||||
<MuiBreadcrumbs
|
||||
separator={
|
||||
@@ -34,7 +30,73 @@ export const Breadcrumb = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{hasDetailsPage ? (
|
||||
{children}
|
||||
</MuiBreadcrumbs>
|
||||
);
|
||||
};
|
||||
|
||||
export const Breadcrumb = ({
|
||||
breadcrumbOverride,
|
||||
}: {
|
||||
breadcrumbOverride?: string[] | undefined;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
// If override is an empty array, hide entirely
|
||||
if (breadcrumbOverride !== undefined && breadcrumbOverride.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If override has items, render them directly
|
||||
if (breadcrumbOverride !== undefined && breadcrumbOverride.length > 0) {
|
||||
return (
|
||||
<BreadcrumbWrapper>
|
||||
{breadcrumbOverride.map((item, index) => {
|
||||
const isLast = index === breadcrumbOverride.length - 1;
|
||||
return (
|
||||
<Typography
|
||||
key={index}
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: isLast ? 600 : 400,
|
||||
color: isLast ? theme.palette.primary.main : theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Default behavior: use location pathname
|
||||
const segments = location.pathname.split("/").filter((x) => x);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create"
|
||||
const basePage = segments[0] || t("common.breadcrumbs.home");
|
||||
|
||||
const secondSegment = segments[1];
|
||||
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
||||
const isDetailsPage = secondSegment && isId(secondSegment);
|
||||
const hasSubPage = isActionPage || isDetailsPage;
|
||||
|
||||
const getSubPageLabel = (): string => {
|
||||
if (isActionPage) {
|
||||
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
||||
}
|
||||
return t("common.breadcrumbs.details");
|
||||
};
|
||||
|
||||
return (
|
||||
<BreadcrumbWrapper>
|
||||
{hasSubPage ? (
|
||||
<Link
|
||||
to={`/${basePage}`}
|
||||
style={{ textDecoration: "none" }}
|
||||
@@ -62,7 +124,7 @@ export const Breadcrumb = () => {
|
||||
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
|
||||
</Typography>
|
||||
)}
|
||||
{hasDetailsPage && (
|
||||
{hasSubPage && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
@@ -70,9 +132,9 @@ export const Breadcrumb = () => {
|
||||
color: theme.palette.primary.main,
|
||||
}}
|
||||
>
|
||||
Details
|
||||
{getSubPageLabel()}
|
||||
</Typography>
|
||||
)}
|
||||
</MuiBreadcrumbs>
|
||||
</BreadcrumbWrapper>
|
||||
);
|
||||
};
|
||||
@@ -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,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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -98,7 +98,7 @@ export const ValueLabel = ({ value, text }: { value: ValueType; text: string })
|
||||
borderRadius="50%"
|
||||
marginRight="5px"
|
||||
/>
|
||||
{transformedText}
|
||||
<Typography>{transformedText}</Typography>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Grid2 from "@mui/material/Grid2";
|
||||
import Grid2 from "@mui/material/Grid";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -15,3 +15,6 @@ 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,48 @@
|
||||
import { MuiColorInput } from "mui-color-input";
|
||||
import type { MuiColorInputProps } from "mui-color-input";
|
||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "@/Components/v2/inputs/FieldLabel";
|
||||
|
||||
interface ColorPickerProps extends MuiColorInputProps {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps) => {
|
||||
const theme = useTheme();
|
||||
const input = (
|
||||
<MuiColorInput
|
||||
{...props}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
fontSize: typographyLevels.base,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{input}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import type { DialogProps } from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
@@ -6,6 +7,7 @@ import DialogTitle from "@mui/material/DialogTitle";
|
||||
import { Button } from "@/Components/v2/inputs";
|
||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const DialogInput = ({
|
||||
open,
|
||||
@@ -16,6 +18,11 @@ export const DialogInput = ({
|
||||
confirmColor = "primary",
|
||||
cancelColor = "error",
|
||||
loading = false,
|
||||
cancelText,
|
||||
confirmText,
|
||||
children,
|
||||
maxWidth,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
@@ -25,13 +32,23 @@ export const DialogInput = ({
|
||||
confirmColor?: "error" | "primary";
|
||||
cancelColor?: "error" | "primary";
|
||||
loading?: boolean;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
children?: ReactNode;
|
||||
maxWidth?: DialogProps["maxWidth"];
|
||||
fullWidth?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth={maxWidth}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
|
||||
<DialogContent>
|
||||
<DialogContentText>{content}</DialogContentText>
|
||||
{content && <DialogContentText>{content}</DialogContentText>}
|
||||
{children}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
@@ -40,7 +57,7 @@ export const DialogInput = ({
|
||||
color={cancelColor}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
{cancelText ?? t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={loading}
|
||||
@@ -48,7 +65,7 @@ export const DialogInput = ({
|
||||
color={confirmColor}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t("common.buttons.confirm")}
|
||||
{confirmText ?? t("common.buttons.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Box, Stack, Typography, IconButton } from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Upload, X } from "lucide-react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileObject {
|
||||
src: string;
|
||||
name: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface ImageUploadProps {
|
||||
src?: string;
|
||||
onChange?: (file: FileObject | undefined) => void;
|
||||
maxSize?: number;
|
||||
accept?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const ImageUpload = ({
|
||||
src,
|
||||
onChange,
|
||||
maxSize = 3 * 1024 * 1024,
|
||||
accept = ["jpg", "jpeg", "png"],
|
||||
error,
|
||||
}: ImageUploadProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<FileObject | null>(null);
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File | undefined) => {
|
||||
if (!file) return;
|
||||
const isValidType = accept.some((type) => file.type.includes(type));
|
||||
const isValidSize = file.size <= maxSize;
|
||||
if (!isValidType) {
|
||||
setLocalError(t("common.errors.invalidFileFormat"));
|
||||
return;
|
||||
}
|
||||
if (!isValidSize) {
|
||||
setLocalError(t("common.errors.invalidFileSize"));
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
const fileObj: FileObject = {
|
||||
src: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
file,
|
||||
};
|
||||
setPreview(fileObj);
|
||||
onChange?.(fileObj);
|
||||
},
|
||||
[maxSize, accept, onChange, t]
|
||||
);
|
||||
|
||||
const handleClear = () => {
|
||||
setPreview(null);
|
||||
setLocalError(null);
|
||||
onChange?.(undefined);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
const displaySrc = src || preview?.src;
|
||||
const displayError = localError || error;
|
||||
|
||||
return (
|
||||
<Stack sx={{ width: "100%", maxWidth: 500 }}>
|
||||
{displaySrc ? (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={displaySrc}
|
||||
alt="Preview"
|
||||
sx={{ maxWidth: 250, maxHeight: 250, objectFit: "contain", borderRadius: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleClear}
|
||||
sx={{ color: theme.palette.error.main }}
|
||||
>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={1}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
minHeight: 150,
|
||||
border: "2px dashed",
|
||||
borderRadius: 1,
|
||||
borderColor: isDragging ? theme.palette.primary.main : theme.palette.divider,
|
||||
backgroundColor: isDragging ? theme.palette.action.hover : "transparent",
|
||||
transition: "0.2s",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept.map((ext) => `.${ext}`).join(",")}
|
||||
onChange={(e) => handleFile(e.target.files?.[0])}
|
||||
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<Upload size={24} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="primary"
|
||||
fontWeight={500}
|
||||
>
|
||||
{t("common.imageUpload.clickToUpload")}
|
||||
</Typography>{" "}
|
||||
{t("common.imageUpload.orDragAndDrop")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
>
|
||||
{accept.join(", ").toUpperCase()} • {t("common.imageUpload.maxSize")}{" "}
|
||||
{Math.round(maxSize / 1024 / 1024)}MB
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{displayError && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{displayError}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,47 @@
|
||||
import { forwardRef } from "react";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import type { SwitchProps } from "@mui/material/Switch";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface SwitchComponentProps extends SwitchProps {
|
||||
dualOption?: boolean;
|
||||
}
|
||||
|
||||
export const SwitchComponent = forwardRef<HTMLInputElement, SwitchComponentProps>(
|
||||
function SwitchComponent({ sx, dualOption = false, ...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,
|
||||
},
|
||||
},
|
||||
},
|
||||
...(dualOption && {
|
||||
"& .MuiSwitch-track": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
...additionalSx,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -9,3 +9,8 @@ export {
|
||||
ToggleButtonGroupInput as ToggleButtonGroup,
|
||||
} from "./ToggleButton";
|
||||
export { DialogInput as Dialog } from "./Dialog";
|
||||
export * from "./Radio";
|
||||
export * from "./Switch";
|
||||
export * from "./Slider";
|
||||
export * from "./ImageUpload";
|
||||
export * from "./ColorPicker";
|
||||
@@ -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}
|
||||
|
||||
+87
-13
@@ -1,7 +1,7 @@
|
||||
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 } from "lucide-react";
|
||||
import { Settings, Pause, Play, Mail, Bug, Trash } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -9,10 +9,28 @@ 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;
|
||||
monitor?: Monitor | null;
|
||||
isAdmin: boolean;
|
||||
refetch: Function;
|
||||
}
|
||||
@@ -24,8 +42,8 @@ export const HeaderMonitorControls = ({
|
||||
refetch,
|
||||
}: HeaderMonitorControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
post,
|
||||
loading: isPosting,
|
||||
@@ -36,13 +54,7 @@ export const HeaderMonitorControls = ({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack
|
||||
spacing={{ xs: theme.spacing(8), md: 0 }}
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
alignItems={"center"}
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<MonitorStatus monitor={monitor} />
|
||||
<BaseHeader monitor={monitor}>
|
||||
<Stack
|
||||
width={{ xs: "100%", md: "auto" }}
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
@@ -83,7 +95,7 @@ export const HeaderMonitorControls = ({
|
||||
await refetch();
|
||||
}}
|
||||
>
|
||||
{monitor?.isActive ? t("pause") : t("resume")}
|
||||
{monitor?.isActive ? t("common.buttons.pause") : t("common.buttons.resume")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@@ -93,10 +105,72 @@ export const HeaderMonitorControls = ({
|
||||
startIcon={<Icon icon={Settings} />}
|
||||
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
|
||||
>
|
||||
Configure
|
||||
{t("common.buttons.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</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("common.buttons.pause") : t("common.buttons.resume")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<Icon icon={Trash} />}
|
||||
onClick={() => {
|
||||
onDelete?.();
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</BaseHeader>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,9 @@ import type { TooltipProps } from "recharts";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
interface HistogramPageSpeedScoresTooltipProps
|
||||
extends Partial<TooltipProps<number, string>> {
|
||||
interface HistogramPageSpeedScoresTooltipProps extends Partial<
|
||||
TooltipProps<number, string>
|
||||
> {
|
||||
config: Record<string, ConfigItem>;
|
||||
}
|
||||
export const HistogramPageSpeedScoresTooltip = ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./ControlsFilter";
|
||||
export * from "./MonitorStatBoxes";
|
||||
export * from "./HeaderMonitorControls";
|
||||
export * from "./charts/HistogramStatus";
|
||||
export * from "./charts/RadialAvgResponse";
|
||||
export * from "./charts/HistogramDetails";
|
||||
@@ -9,3 +10,4 @@ export * from "./charts/PiePageSpeed";
|
||||
export * from "./charts/PiePageSpeedLegend";
|
||||
export * from "./charts/HistogramPageSpeedDetails";
|
||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
export * from "./charts/HistogramInfrastructure";
|
||||
@@ -1,7 +1,7 @@
|
||||
import useSWR from "swr";
|
||||
import type { SWRConfiguration } from "swr";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import { get, patch, post, deleteOp } 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,
|
||||
};
|
||||
@@ -108,6 +109,42 @@ 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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,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 };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
interface UseStatusPageFormOptions {
|
||||
data?: StatusPage | null;
|
||||
monitors?: Monitor[] | null;
|
||||
}
|
||||
|
||||
const generateDefaultUrl = () => Math.floor(Math.random() * 1000000).toString();
|
||||
|
||||
const transformLogo = (logo: StatusPage["logo"]): StatusPageFormData["logo"] => {
|
||||
if (!logo || !logo.data) return null;
|
||||
return {
|
||||
data: `data:${logo.contentType};base64,${logo.data}`,
|
||||
contentType: logo.contentType,
|
||||
};
|
||||
};
|
||||
|
||||
export const useStatusPageForm = ({
|
||||
data = null,
|
||||
monitors = null,
|
||||
}: UseStatusPageFormOptions = {}) => {
|
||||
return useMemo(() => {
|
||||
const defaults: StatusPageFormData = {
|
||||
companyName: data?.companyName || "",
|
||||
url: data?.url || generateDefaultUrl(),
|
||||
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
color: data?.color || "#4169E1",
|
||||
monitors: data?.monitors || [],
|
||||
isPublished: data?.isPublished ?? false,
|
||||
showCharts: data?.showCharts ?? true,
|
||||
showUptimePercentage: data?.showUptimePercentage ?? true,
|
||||
showAdminLoginLink: data?.showAdminLoginLink ?? false,
|
||||
customCSS: data?.customCSS || "",
|
||||
logo: transformLogo(data?.logo),
|
||||
};
|
||||
|
||||
return { schema: statusPageSchema, defaults };
|
||||
}, [data, monitors]);
|
||||
};
|
||||
@@ -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/timeUtilsLegacy.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 { 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,139 +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();
|
||||
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;
|
||||
@@ -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";
|
||||
|
||||
//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("hour");
|
||||
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({
|
||||
dateRange,
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,134 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import SummaryCard from "../SummaryCard/index.jsx";
|
||||
|
||||
/**
|
||||
* ActiveIncidentsPanel Component
|
||||
*
|
||||
* Displays a quick overview of currently active incidents.
|
||||
* Shows only essential information: monitor name, status badge, and duration.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.incidents - Array of active incidents
|
||||
* @param {number} props.totalCount - Total count of active incidents (for badge)
|
||||
* @param {boolean} props.isLoading - Loading state
|
||||
* @param {Object} props.error - Error object if any
|
||||
* @param {Function} props.onIncidentClick - Callback when incident is clicked (optional)
|
||||
*/
|
||||
const ActiveIncidentsPanel = ({ totalCount = 0, isLoading = false, error = null }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <PanelSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SummaryCard>
|
||||
<Typography
|
||||
color="error"
|
||||
align="center"
|
||||
>
|
||||
{t("incidentsPage.incidentsActivePanelError")}
|
||||
</Typography>
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!totalCount || totalCount === 0) {
|
||||
return (
|
||||
<SummaryCard title={t("incidentsPage.incidentsActivePanelTitle")}>
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={theme.spacing(6)}
|
||||
gap={theme.spacing(2)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
color: theme.palette.success.main,
|
||||
"& svg": {
|
||||
width: 24,
|
||||
height: 24,
|
||||
"& path": { stroke: "currentColor", strokeWidth: 2 },
|
||||
},
|
||||
mb: theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="CheckCircle"
|
||||
size={60}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
textAlign: "center",
|
||||
color: theme.palette.success.lowContrast,
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.allSystemsAreOperational")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SummaryCard title={t("incidentsPage.incidentsActivePanelTitle")}>
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={theme.spacing(2)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
color: theme.palette.error.lowContrast,
|
||||
"& svg": {
|
||||
width: 24,
|
||||
height: 24,
|
||||
"& path": { stroke: "currentColor", strokeWidth: 2 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="AlertTriangle"
|
||||
size={60}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
fontWeight: 600,
|
||||
color: theme.palette.error.lowContrast,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{totalCount}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
|
||||
ActiveIncidentsPanel.propTypes = {
|
||||
totalCount: PropTypes.number,
|
||||
isLoading: PropTypes.bool,
|
||||
error: PropTypes.object,
|
||||
onIncidentClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ActiveIncidentsPanel;
|
||||
@@ -0,0 +1,195 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseBox, ValueLabel } from "@/Components/v2/design-elements";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Incident } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { getIncidentsDuration } from "@/Pages/Incidents/utils";
|
||||
|
||||
interface CardDetailsProps {
|
||||
incident: Incident | null;
|
||||
monitor: Monitor | null;
|
||||
sx?: object;
|
||||
}
|
||||
|
||||
export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
if (!incident) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
sx={sx}
|
||||
>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.title")}
|
||||
</Typography>
|
||||
<BaseBox padding={8}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.overview")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(4)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
|
||||
<Grid size={10}>
|
||||
<ValueLabel
|
||||
value={incident.status ? "negative" : "positive"}
|
||||
text={
|
||||
incident.status
|
||||
? t("common.labels.active")
|
||||
: t("common.labels.resolved")
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
{monitor && (
|
||||
<>
|
||||
<Grid size={2}>{t("pages.incidents.dialog.details.monitor")}</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>{monitor.name ?? "N/A"}</Typography>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.url")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>{monitor.url ?? "N/A"}</Typography>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
<BaseBox padding={8}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.analysis")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
<Grid size={6}>
|
||||
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Typography>{t("pages.incidents.dialog.details.detailsLabel")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Divider></Divider>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Divider></Divider>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.startedAt")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>
|
||||
{formatDateWithTz(incident.startTime, "D MMM YYYY, h:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.statusCode")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>{incident.statusCode ?? "N/A"}</Typography>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.downtime")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>{getIncidentsDuration(incident)}</Typography>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.message")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>{incident.message ?? "N/A"}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
{!incident.status && (
|
||||
<BaseBox padding={8}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.resolutionDetails")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(2)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.resolvedAt")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>
|
||||
{incident.endTime
|
||||
? formatDateWithTz(incident.endTime, "D MMM YYYY, h:mm A", uiTimezone)
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>
|
||||
{t("pages.incidents.dialog.details.resolutionType")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>
|
||||
{incident.resolutionType
|
||||
? t(
|
||||
`pages.incidents.dialog.details.resolutionTypes.${incident.resolutionType}`
|
||||
)
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Grid>
|
||||
{incident.resolvedBy && (
|
||||
<>
|
||||
<Grid size={2}>
|
||||
<Typography>
|
||||
{t("pages.incidents.dialog.details.resolvedBy")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>
|
||||
{incident.resolvedByEmail ?? incident.resolvedBy}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{incident.comment && (
|
||||
<>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.comment")}</Typography>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>{incident.comment}</Typography>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { BaseBox, ValueLabel } from "@/Components/v2/design-elements";
|
||||
import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material";
|
||||
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
|
||||
import { getIncidentsDuration } from "@/Pages/Incidents/utils";
|
||||
|
||||
interface SummaryItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string;
|
||||
sx?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SummaryCard = ({
|
||||
title,
|
||||
sx,
|
||||
children,
|
||||
}: React.PropsWithChildren<SummaryCardProps>) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{ ...sx }}
|
||||
padding={8}
|
||||
flex={1}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
height="100%"
|
||||
>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Divider />
|
||||
{children}
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryCardActiveIncidentsProps {
|
||||
summary?: IncidentSummary | null;
|
||||
}
|
||||
|
||||
export const SummaryCardActiveIncidents = ({
|
||||
summary,
|
||||
}: SummaryCardActiveIncidentsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const activeCount = summary.totalActive;
|
||||
const hasActive = activeCount > 0;
|
||||
const color = hasActive ? theme.palette.error.main : theme.palette.success.main;
|
||||
const icon = hasActive ? (
|
||||
<TriangleAlert color={color} />
|
||||
) : (
|
||||
<CircleCheck color={color} />
|
||||
);
|
||||
const msg = t("pages.incidents.summaryCard.activeIncidents.active", {
|
||||
count: activeCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
title={t("pages.incidents.summaryCard.activeIncidents.title")}
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
{icon}
|
||||
<Typography>{msg}</Typography>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
|
||||
const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const duration = getIncidentsDuration(incident);
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
sx={{
|
||||
width: "100%",
|
||||
py: theme.spacing(0.5),
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
size={{ xs: 12, lg: 5 }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Globe />
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
noWrap
|
||||
>
|
||||
{incident.monitorName ?? t("common.labels.na")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
size={{ xs: 12, md: 6, lg: 3 }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<ValueLabel
|
||||
value={incident.status ? "negative" : "positive"}
|
||||
text={incident.status ? t("common.labels.active") : t("common.labels.resolved")}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
size={{ xs: 12, md: 6, lg: 4 }}
|
||||
sx={{
|
||||
textAlign: { xs: "left", md: "right" },
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{duration}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryCardLatestIncidentsProps {
|
||||
summary?: IncidentSummary | null;
|
||||
}
|
||||
|
||||
export const SummaryCardLatestIncidents = ({
|
||||
summary,
|
||||
}: SummaryCardLatestIncidentsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const latestIncidents = summary?.latestIncidents ?? [];
|
||||
|
||||
return (
|
||||
<SummaryCard title={t("pages.incidents.summaryCard.latestIncidents.title")}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
{latestIncidents.slice(0, 3).map((incident, index) => (
|
||||
<Box key={incident.id}>
|
||||
<SummaryIncidentItem incident={incident} />
|
||||
{index < latestIncidents.length - 1 && (
|
||||
<Divider sx={{ mt: theme.spacing(2) }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryCardStatsProps {
|
||||
summary?: IncidentSummary | null;
|
||||
}
|
||||
|
||||
export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!summary) return null;
|
||||
const mostAffected =
|
||||
!summary.total || summary.total === 0
|
||||
? t("common.labels.na")
|
||||
: summary.topMonitor?.monitorName || t("common.labels.na");
|
||||
return (
|
||||
<SummaryCard title={t("pages.incidents.summaryCard.incidentStats.title")}>
|
||||
<SummaryItem
|
||||
icon={<Bell size={18} />}
|
||||
label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")}
|
||||
value={summary?.total || 0}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<TriangleAlert size={18} />}
|
||||
label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")}
|
||||
value={mostAffected}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Wrench size={18} />}
|
||||
label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")}
|
||||
value={
|
||||
summary.total > 0
|
||||
? `${summary.avgResolutionTimeHours || 0} hours`
|
||||
: t("common.labels.na")
|
||||
}
|
||||
/>
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Select, Button } from "@/Components/v2/inputs";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
const resolutionTypes = ["manual", "automatic"];
|
||||
|
||||
interface ControlsIncidentFilterProps {
|
||||
monitors?: Monitor[];
|
||||
selectedMonitor: string;
|
||||
setSelectedMonitor: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedResolutionType: string;
|
||||
setSelectedResolutionType: React.Dispatch<React.SetStateAction<string>>;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export const ControlsIncidentFilter = ({
|
||||
monitors,
|
||||
selectedMonitor,
|
||||
setSelectedMonitor,
|
||||
selectedResolutionType,
|
||||
setSelectedResolutionType,
|
||||
onClearFilters,
|
||||
}: ControlsIncidentFilterProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isFilterActive =
|
||||
selectedMonitor !== "0" ||
|
||||
(selectedResolutionType !== "" && selectedResolutionType !== "all");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Select
|
||||
placeholder={t("pages.incidents.filters.monitor")}
|
||||
value={selectedMonitor}
|
||||
onChange={(e) => setSelectedMonitor(e.target.value)}
|
||||
>
|
||||
<MenuItem value="0">
|
||||
<Typography>{t("pages.incidents.filters.allMonitors")}</Typography>
|
||||
</MenuItem>
|
||||
{monitors?.map((monitor) => (
|
||||
<MenuItem
|
||||
key={monitor.id}
|
||||
value={monitor.id}
|
||||
>
|
||||
<Typography>{monitor.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
placeholder={t("pages.incidents.filters.resolutionType")}
|
||||
value={selectedResolutionType}
|
||||
onChange={(e) => setSelectedResolutionType(e.target.value)}
|
||||
>
|
||||
{resolutionTypes.map((type) => (
|
||||
<MenuItem
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
<Typography textTransform="capitalize">
|
||||
{t(`pages.incidents.filters.resolutionTypes.${type}`)}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{isFilterActive && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
{t("pages.incidents.filters.clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Dialog } from "@/Components/v2/inputs";
|
||||
import { CardDetails } from "@/Pages/Incidents/Components/CardDetails";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Incident } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
interface DialogIncidentDetailsProps {
|
||||
open: boolean;
|
||||
incident: Incident | null;
|
||||
monitor: Monitor | null;
|
||||
onClose: () => void;
|
||||
onResolve: () => void;
|
||||
}
|
||||
|
||||
export const DialogIncidentDetails = ({
|
||||
open,
|
||||
incident,
|
||||
monitor,
|
||||
onClose,
|
||||
onResolve,
|
||||
}: DialogIncidentDetailsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onConfirm={onResolve}
|
||||
cancelText={t("common.buttons.cancel")}
|
||||
confirmText={t("pages.incidents.dialog.details.resolve")}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<CardDetails
|
||||
incident={incident}
|
||||
monitor={monitor}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Dialog, TextField } from "@/Components/v2/inputs";
|
||||
import { usePut } from "@/Hooks/UseApi";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
interface DialogResolutionProps {
|
||||
open: boolean;
|
||||
incidentId: string | null;
|
||||
onClose: () => void;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
export const DialogResolution = ({
|
||||
open,
|
||||
incidentId,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: DialogResolutionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [comment, setComment] = useState("");
|
||||
const { put: resolveIncident, loading: isResolving } = usePut();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setComment("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!incidentId) return;
|
||||
const body = comment.trim() ? { comment: comment.trim() } : {};
|
||||
const result = await resolveIncident(`/incidents/${incidentId}/resolve`, body);
|
||||
if (result) {
|
||||
onClose();
|
||||
onResolved?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={t("incidentsPage.resolveIncidentDialogTitle")}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
confirmText={t("incidentsPage.resolveIncidentDialogConfirm")}
|
||||
confirmColor="error"
|
||||
cancelColor="primary"
|
||||
loading={isResolving}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Box sx={{ mt: theme.spacing(4) }}>
|
||||
<TextField
|
||||
fieldLabel={t("incidentsPage.resolveIncidentDialogCommentLabel")}
|
||||
placeholder={t("incidentsPage.resolveIncidentDialogCommentPlaceholder")}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,413 +0,0 @@
|
||||
// Components
|
||||
import { Stack, Typography, Grid, Box, Button, Divider } from "@mui/material";
|
||||
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import SummaryCard from "../SummaryCard/index.jsx";
|
||||
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
|
||||
|
||||
// Utils
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
// Hooks
|
||||
import useFetchIncidents from "../../hooks/useFetchIncidents.js";
|
||||
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration.js";
|
||||
|
||||
const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { fetchIncidentById, resolveIncident } = useFetchIncidents();
|
||||
const [incidentData, setIncidentData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false);
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
const isActive = incidentData?.incident?.status === true;
|
||||
const duration = useGetIncidentsDuration(incidentData?.incident || null, isActive);
|
||||
|
||||
const statusColor = isActive ? theme.palette.error.main : theme.palette.success.main;
|
||||
const toCapitalLetter = (text) =>
|
||||
typeof text === "string" && text.length > 0
|
||||
? text.charAt(0).toUpperCase() + text.slice(1)
|
||||
: text;
|
||||
|
||||
const KeyValueRow = ({ label, value, sx = {} }) => (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
alignItems="flex-start"
|
||||
sx={{ ...sx }}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={4}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
textAlign="right"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadIncident = async () => {
|
||||
if (!incidentId || !open) {
|
||||
setIncidentData(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const incidentData = await fetchIncidentById(incidentId);
|
||||
setIncidentData(incidentData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching incident:", error);
|
||||
setIncidentData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadIncident();
|
||||
}, [incidentId, open, fetchIncidentById]);
|
||||
|
||||
const openResolveDialog = () => {
|
||||
setIsResolveDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeResolveDialog = () => {
|
||||
setIsResolveDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleAfterResolve = async () => {
|
||||
if (!incidentId) return;
|
||||
const updatedIncident = await fetchIncidentById(incidentId);
|
||||
if (updatedIncident) {
|
||||
setIncidentData(updatedIncident);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<GenericFallback
|
||||
isLoading={true}
|
||||
message={t("incidentsPage.loadingIncidentDetails")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!incidentData) {
|
||||
return (
|
||||
<GenericFallback
|
||||
isLoading={false}
|
||||
message={t("incidentsPage.incidentNotFound")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={12}
|
||||
>
|
||||
<SummaryCard title={t("incidentsPage.overview")}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
sx={{ mb: theme.spacing(3) }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
bgcolor: statusColor,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: statusColor,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4,
|
||||
}}
|
||||
>
|
||||
{isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="start"
|
||||
gap={theme.spacing(3)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={2}
|
||||
sx={{ alignItems: "baseline" }}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
sx={{ minWidth: 70 }}
|
||||
>
|
||||
{t("incidentsPage.incidentItemMonitor")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
{incidentData?.monitor?.name || t("incidentsPage.unknownMonitor")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={2}
|
||||
sx={{ alignItems: "baseline" }}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
sx={{ minWidth: 70 }}
|
||||
>
|
||||
{t("incidentsPage.URL")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ flex: 1, wordBreak: "break-word", fontFamily: "monospace" }}
|
||||
>
|
||||
{incidentData?.monitor?.url || "-"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={12}
|
||||
>
|
||||
<SummaryCard title={t("incidentsPage.incidentAnalysis")}>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
alignItems="stretch"
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
>
|
||||
<Stack gap={theme.spacing(2)}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={700}
|
||||
>
|
||||
{t("incidentsPage.timeline")}
|
||||
</Typography>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.startedAt")}
|
||||
value={
|
||||
formatDateWithTz(
|
||||
incidentData?.incident?.startTime,
|
||||
"D MMM YYYY, h:mm A",
|
||||
uiTimezone
|
||||
) || "-"
|
||||
}
|
||||
sx={{ paddingBottom: theme.spacing(3) }}
|
||||
/>
|
||||
{!isActive && (
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.endedAt")}
|
||||
value={
|
||||
formatDateWithTz(
|
||||
incidentData?.incident?.endTime,
|
||||
"D MMM YYYY, h:mm A",
|
||||
uiTimezone
|
||||
) || "-"
|
||||
}
|
||||
sx={{ paddingBottom: theme.spacing(3) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.downtime")}
|
||||
value={duration || "-"}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sm="auto"
|
||||
sx={{ display: { xs: "none", sm: "flex" } }}
|
||||
>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
/>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm
|
||||
>
|
||||
<Stack gap={theme.spacing(2)}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ letterSpacing: 0.6, fontWeight: 700 }}
|
||||
>
|
||||
{isActive
|
||||
? t("incidentsPage.errorDetails")
|
||||
: t("incidentsPage.resolutionDetails")}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.statusCode")}
|
||||
value={incidentData?.incident?.statusCode ?? "-"}
|
||||
/>
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.message")}
|
||||
value={incidentData?.incident?.message ?? "-"}
|
||||
/>
|
||||
|
||||
{!isActive && (
|
||||
<>
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.resolutionMethod")}
|
||||
value={
|
||||
toCapitalLetter(incidentData?.incident?.resolutionType) || "-"
|
||||
}
|
||||
/>
|
||||
{incidentData?.incident?.resolutionType === "manual" && (
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.comment")}
|
||||
value={
|
||||
incidentData?.incident.comment?.trim()
|
||||
? incidentData.incident.comment
|
||||
: t("incidentsPage.noCommentProvided")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericDialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
theme={theme}
|
||||
title={t("incidentsPage.incidentDetails")}
|
||||
width={{ xs: "95%", md: "45%", lg: "40%", xl: "35%" }}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(6)}
|
||||
sx={{ maxHeight: "70vh", overflowY: "auto", pr: 1 }}
|
||||
>
|
||||
{renderContent()}
|
||||
{!isActive &&
|
||||
incidentData?.incident?.resolutionType === "manual" &&
|
||||
incidentData?.user?.email && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.resolvedBy")}: {incidentData.user.email}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
gap={2}
|
||||
sx={{ pt: theme.spacing(3) }}
|
||||
>
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={openResolveDialog}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableActionResolveManually")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<ResolveIncidentDialog
|
||||
open={isResolveDialogOpen}
|
||||
incidentId={incidentId}
|
||||
onClose={closeResolveDialog}
|
||||
onResolve={resolveIncident}
|
||||
onAfterResolve={handleAfterResolve}
|
||||
onResolved={onResolved}
|
||||
/>
|
||||
</Stack>
|
||||
</GenericDialog>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentDetailsModal.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
incidentId: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onResolved: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IncidentDetailsModal;
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Table } from "@/Components/v2/design-elements";
|
||||
import { Pagination } from "@/Components/v2/design-elements/Table";
|
||||
import type { Header } from "@/Components/v2/design-elements/Table";
|
||||
import { ValueLabel } from "@/Components/v2/design-elements";
|
||||
import type { ValueType } from "@/Components/v2/design-elements/StatusLabel";
|
||||
import { ActionsMenu } from "@/Components/v2/actions-menu";
|
||||
import type { Incident } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ActionMenuItem } from "@/Components/v2/actions-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
interface IncidentsTableProps {
|
||||
title?: string;
|
||||
incidents?: Incident[];
|
||||
monitors?: Monitor[];
|
||||
incidentsCount: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
onOpenDetails?: (incidentId: string) => void;
|
||||
onResolve?: (incidentId: string) => void;
|
||||
}
|
||||
|
||||
export const IncidentsTable = ({
|
||||
incidents,
|
||||
monitors,
|
||||
incidentsCount,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
onOpenDetails,
|
||||
onResolve,
|
||||
}: IncidentsTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const getActions = (incident: Incident): ActionMenuItem[] => {
|
||||
const monitor = monitors?.find((m) => m.id === incident.monitorId);
|
||||
const isActive = incident.status === true;
|
||||
|
||||
const actions: ActionMenuItem[] = [
|
||||
{
|
||||
id: "details",
|
||||
label: t("pages.incidents.table.actions.details"),
|
||||
action: () => onOpenDetails?.(incident.id),
|
||||
closeMenu: true,
|
||||
},
|
||||
{
|
||||
id: "goToMonitor",
|
||||
label: t("pages.incidents.table.actions.goToMonitor"),
|
||||
action: () => {
|
||||
if (monitor) {
|
||||
const path = TypeToPathMap[monitor.type as keyof typeof TypeToPathMap];
|
||||
if (path && monitor.id) {
|
||||
navigate(`/${path}/${monitor.id}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (isActive) {
|
||||
actions.push({
|
||||
id: "resolve",
|
||||
label: t("pages.incidents.table.actions.resolveManually"),
|
||||
action: () => onResolve?.(incident.id),
|
||||
closeMenu: true,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const getHeaders = (): Header<Incident>[] => {
|
||||
return [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: t("common.table.headers.monitor"),
|
||||
render: (row) => {
|
||||
const monitor = monitors?.find((m) => m.id === row.monitorId);
|
||||
return monitor?.name || "N/A";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: t("common.table.headers.status"),
|
||||
render: (row) => {
|
||||
const isActive = row.status === true;
|
||||
return (
|
||||
<ValueLabel
|
||||
value={isActive ? "negative" : "positive"}
|
||||
text={
|
||||
isActive
|
||||
? t("pages.incidents.table.status.active")
|
||||
: t("pages.incidents.table.status.resolved")
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "startTime",
|
||||
content: t("pages.incidents.table.headers.startTime"),
|
||||
render: (row) => {
|
||||
return formatDateWithTz(row.createdAt, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endTime",
|
||||
content: t("pages.incidents.table.headers.endTime"),
|
||||
render: (row) => {
|
||||
if (row.endTime) {
|
||||
return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resolutionType",
|
||||
content: t("pages.incidents.table.headers.resolutionType"),
|
||||
render: (row) => {
|
||||
if (row.resolutionType) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
color:
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main,
|
||||
}}
|
||||
>
|
||||
{row.resolutionType}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: t("pages.incidents.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
let value: ValueType = "neutral";
|
||||
if (code < 300) value = "positive";
|
||||
else if (code >= 400) value = "negative";
|
||||
return (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message",
|
||||
content: t("common.table.headers.message"),
|
||||
render: (row) => row.message || "-",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
content: t("common.table.headers.actions"),
|
||||
render: (row) => <ActionsMenu items={getActions(row)} />,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
if (!incidents || !monitors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Table
|
||||
headers={getHeaders()}
|
||||
data={incidents}
|
||||
onRowClick={(row) => onOpenDetails?.(row.id)}
|
||||
emptyViewText={t("common.table.empty")}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
count={incidentsCount}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
// Components
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Icon from "@/Components/v1/Icon/index.jsx";
|
||||
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
|
||||
|
||||
// Utils
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const IncidentActionsMenu = ({ incident, monitor, onResolve, onOpenDetails }) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openMenu = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const closeMenu = (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const openResolveDialog = (e) => {
|
||||
closeMenu(e);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const isActive = incident.status === true;
|
||||
const handleGoToDetails = (e) => {
|
||||
e.stopPropagation();
|
||||
closeMenu(e);
|
||||
if (onOpenDetails) {
|
||||
onOpenDetails(incident.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToMonitor = async (e) => {
|
||||
e.stopPropagation();
|
||||
closeMenu(e);
|
||||
try {
|
||||
if (monitor) {
|
||||
const path = TypeToPathMap[monitor.type];
|
||||
if (path && monitor.id) {
|
||||
navigate(`/${path}/${monitor.id}`);
|
||||
} else {
|
||||
console.error(`Monitor type not found`);
|
||||
}
|
||||
} else {
|
||||
console.error("Monitor information not found in incident data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching incident details:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="incident actions"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openMenu(event);
|
||||
}}
|
||||
sx={{
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
"& svg path": {
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="Settings"
|
||||
size={20}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
className="incident-actions-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={closeMenu}
|
||||
disableScrollLock
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
"& ul": {
|
||||
p: theme.spacing(2.5),
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
},
|
||||
"& li": { m: 0, color: theme.palette.primary.contrastTextSecondary },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleGoToDetails}>
|
||||
{t("incidentsPage.incidentsTableActionDetails", "Details")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleGoToMonitor}>
|
||||
{t("incidentsPage.incidentsTableActionGoToMonitor", "Go to monitor")}
|
||||
</MenuItem>
|
||||
{isActive && (
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(e);
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableActionResolveManually")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<ResolveIncidentDialog
|
||||
open={isDialogOpen}
|
||||
incidentId={incident.id}
|
||||
onClose={closeDialog}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentActionsMenu.propTypes = {
|
||||
incident: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
status: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
monitor: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
onResolve: PropTypes.func.isRequired,
|
||||
onOpenDetails: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IncidentActionsMenu;
|
||||
@@ -1,193 +0,0 @@
|
||||
//Components
|
||||
import DataTable from "@/Components/v1/Table/index.jsx";
|
||||
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";
|
||||
import IncidentActionsMenu from "./IncidentActionsMenu.jsx";
|
||||
|
||||
//Utils
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
|
||||
const IncidentTable = ({
|
||||
monitors = [],
|
||||
incidents = [],
|
||||
incidentsCount = 0,
|
||||
isLoading = false,
|
||||
networkError = false,
|
||||
page = 0,
|
||||
rowsPerPage = 10,
|
||||
handleChangePage,
|
||||
handleChangeRowsPerPage,
|
||||
resolveIncident,
|
||||
handleUpdateTrigger,
|
||||
onOpenDetails,
|
||||
}) => {
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleResolveIncident = async (incidentId, options = {}) => {
|
||||
try {
|
||||
await resolveIncident(incidentId, options);
|
||||
handleUpdateTrigger();
|
||||
} catch (error) {
|
||||
console.error(t("incidentsPage.errorResolvingIncident"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const headers = [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: t("incidentsTableMonitorName"),
|
||||
render: (row) => {
|
||||
const monitor = monitors.find((monitor) => monitor.id === row.monitorId);
|
||||
return monitor ? monitor.name : "N/A";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: t("incidentsTableStatus"),
|
||||
render: (row) => {
|
||||
const status = row.status === true ? "down" : "up";
|
||||
const statusText =
|
||||
row.status === true ? t("incidentsPage.active") : t("incidentsPage.resolved");
|
||||
return (
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={statusText}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "startTime",
|
||||
content: t("incidentsPage.startTime"),
|
||||
render: (row) => {
|
||||
const formattedDate = formatDateWithTz(
|
||||
row.startTime || row.createdAt,
|
||||
"YYYY-MM-DD HH:mm:ss A",
|
||||
uiTimezone
|
||||
);
|
||||
return formattedDate;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endTime",
|
||||
content: t("incidentsPage.endTime"),
|
||||
render: (row) => {
|
||||
if (row.endTime) {
|
||||
return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resolutionType",
|
||||
content: t("incidentsPage.resolutionType"),
|
||||
render: (row) => {
|
||||
if (row.resolutionType) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
color:
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.accent.main
|
||||
: theme.palette.success.main,
|
||||
}}
|
||||
>
|
||||
{row.resolutionType}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: t("incidentsTableStatusCode"),
|
||||
render: (row) => <HttpStatusLabel status={row.statusCode} />,
|
||||
},
|
||||
{
|
||||
id: "message",
|
||||
content: t("incidentsTableMessage"),
|
||||
render: (row) => row.message || "-",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
content: t("actions"),
|
||||
render: (row) => {
|
||||
return (
|
||||
<IncidentActionsMenu
|
||||
incident={row}
|
||||
monitor={monitors.find((monitor) => monitor.id === row.monitorId)}
|
||||
onResolve={handleResolveIncident}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <TableSkeleton />;
|
||||
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && !networkError && incidents?.length === 0) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
{t("incidentsTableNoIncidents", "No incidents found")}
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
const incidentsData = Array.isArray(incidents) ? incidents : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
headers={headers}
|
||||
data={incidentsData}
|
||||
/>
|
||||
<Pagination
|
||||
paginationLabel={t("incidentsTablePaginationLabel", "Incidents")}
|
||||
itemCount={incidentsCount || 0}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentTable.propTypes = {
|
||||
incidents: PropTypes.array.isRequired, // Array of incident objects
|
||||
incidentsCount: PropTypes.number.isRequired, // Total count for pagination
|
||||
isLoading: PropTypes.bool.isRequired, // Loading state
|
||||
networkError: PropTypes.bool, // Network error object
|
||||
page: PropTypes.number.isRequired, // Current page number
|
||||
rowsPerPage: PropTypes.number.isRequired, // Number of rows per page
|
||||
handleChangePage: PropTypes.func.isRequired, // Handler for page change
|
||||
handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change
|
||||
resolveIncident: PropTypes.func.isRequired,
|
||||
handleUpdateTrigger: PropTypes.func.isRequired,
|
||||
onOpenDetails: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IncidentTable;
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
// Hooks
|
||||
import useFetchIncidents from "../../hooks/useFetchIncidents.js";
|
||||
import PropTypes from "prop-types";
|
||||
// Components
|
||||
import { Grid } from "@mui/material";
|
||||
import ActiveIncidentsPanel from "../ActiveIncidentsPanel/index.jsx";
|
||||
import LatestIncidentsPanel from "../LatestIncidentsPanel/index.jsx";
|
||||
import StatisticsPanel from "../StatisticsPanel/index.jsx";
|
||||
|
||||
const IncidentsSummaryPanel = ({ updateTrigger }) => {
|
||||
const { fetchIncidentSummary } = useFetchIncidents();
|
||||
const [summary, setSummary] = useState({
|
||||
totalActive: 0,
|
||||
avgResolutionTimeHours: 0,
|
||||
total: 0,
|
||||
topMonitor: null,
|
||||
totalManualResolutions: 0,
|
||||
totalAutomaticResolutions: 0,
|
||||
latestIncidents: [],
|
||||
});
|
||||
|
||||
const [isLoadingSummary, setIsLoadingSummary] = useState(false);
|
||||
const [summaryError, setSummaryError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSummary = async () => {
|
||||
setIsLoadingSummary(true);
|
||||
setSummaryError(null);
|
||||
|
||||
try {
|
||||
const summaryData = await fetchIncidentSummary({
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
if (summaryData) {
|
||||
setSummary(summaryData);
|
||||
} else {
|
||||
setSummaryError(new Error("Failed to fetch summary"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching incident summary:", error);
|
||||
setSummaryError(error);
|
||||
} finally {
|
||||
setIsLoadingSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, [fetchIncidentSummary, updateTrigger]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
container
|
||||
spacing={"16px"}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<ActiveIncidentsPanel
|
||||
totalCount={summary.totalActive || 0}
|
||||
isLoading={isLoadingSummary}
|
||||
error={summaryError}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<LatestIncidentsPanel
|
||||
incidents={summary.latestIncidents || []}
|
||||
isLoading={isLoadingSummary}
|
||||
error={summaryError}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<StatisticsPanel
|
||||
summary={summary}
|
||||
isLoading={isLoadingSummary}
|
||||
error={summaryError}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentsSummaryPanel.propTypes = {
|
||||
updateTrigger: PropTypes.bool.isRequired,
|
||||
};
|
||||
export default IncidentsSummaryPanel;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
import SummaryCard from "../SummaryCard/index.jsx";
|
||||
|
||||
/**
|
||||
* Simple skeleton loader for summary panels
|
||||
* Reusable for ActiveIncidentsPanel, LatestIncidentsPanel, and StatisticsPanel
|
||||
*/
|
||||
const PanelSkeleton = () => {
|
||||
return (
|
||||
<SummaryCard sx={{ minHeight: "25vh" }}>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PanelSkeleton;
|
||||
@@ -1,96 +0,0 @@
|
||||
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/timeUtilsLegacy.js";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration";
|
||||
|
||||
const IncidentItem = ({ incident }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isActive = incident.status === true;
|
||||
const duration = useGetIncidentsDuration(incident, isActive);
|
||||
const iconWrapperStyle = {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mx: theme.spacing(2),
|
||||
color: theme.palette.primary.contrastTextTertiary,
|
||||
"& svg": {
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
"& svg path": {
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
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)}
|
||||
>
|
||||
<Box sx={iconWrapperStyle}>
|
||||
<Icon
|
||||
name="Globe"
|
||||
size={18}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
noWrap
|
||||
>
|
||||
{incident.monitorName || t("incidentsPage.unknownMonitor")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<StatusLabel
|
||||
status={isActive ? "down" : "up"}
|
||||
text={isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
|
||||
customStyles={{
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
textAlign="right"
|
||||
>
|
||||
{duration}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentItem.propTypes = {
|
||||
incident: PropTypes.shape({
|
||||
_id: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
monitorId: PropTypes.string,
|
||||
monitorName: PropTypes.string,
|
||||
status: PropTypes.bool,
|
||||
startTime: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]),
|
||||
endTime: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]),
|
||||
resolutionType: PropTypes.oneOf(["automatic", "manual"]),
|
||||
}),
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IncidentItem;
|
||||
@@ -1,114 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Box, Stack, Typography, Divider } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx";
|
||||
import IncidentItem from "./IncidentItem.jsx";
|
||||
import SummaryCard from "../SummaryCard/index.jsx";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
|
||||
/**
|
||||
* LatestIncidentsPanel Component
|
||||
*
|
||||
* Displays a quick overview of recent incidents (both active and resolved).
|
||||
* Shows only essential information: monitor name, status, duration, and resolution type.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.incidents - Array of recent incidents
|
||||
* @param {boolean} props.isLoading - Loading state
|
||||
* @param {Object} props.error - Error object if any
|
||||
*/
|
||||
const LatestIncidentsPanel = ({ incidents = [], isLoading = false, error = null }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <PanelSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SummaryCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
color="error"
|
||||
align="center"
|
||||
>
|
||||
{t("incidentsPage.incidentsLatestPanelError")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SummaryCard title={t("incidentsPage.incidentsLatestPanelTitle")}>
|
||||
{!incidents || incidents.length === 0 ? (
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={theme.spacing(6)}
|
||||
gap={theme.spacing(2)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
<Box sx={{ mb: theme.spacing(1) }}>
|
||||
<Icon
|
||||
name="Check"
|
||||
size={24}
|
||||
color={theme.palette.success.main}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
textAlign: "center",
|
||||
color: theme.palette.success.lowContrast,
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.incidentsLatestPanelEmpty")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
{incidents.map((incident, index) => (
|
||||
<Box key={incident.id}>
|
||||
<IncidentItem incident={incident} />
|
||||
{index < incidents.length - 1 && <Divider sx={{ mt: theme.spacing(2) }} />}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
|
||||
LatestIncidentsPanel.propTypes = {
|
||||
incidents: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
_id: PropTypes.string,
|
||||
monitorId: PropTypes.string,
|
||||
monitorName: PropTypes.string,
|
||||
status: PropTypes.bool,
|
||||
duration: PropTypes.string,
|
||||
resolutionType: PropTypes.oneOf(["automatic", "manual"]),
|
||||
})
|
||||
),
|
||||
isLoading: PropTypes.bool,
|
||||
error: PropTypes.object,
|
||||
onIncidentClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default LatestIncidentsPanel;
|
||||
@@ -1,139 +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 { useTranslation } from "react-i18next";
|
||||
|
||||
const OptionsHeader = ({
|
||||
shouldRender,
|
||||
selectedMonitor = 0,
|
||||
setSelectedMonitor,
|
||||
monitors,
|
||||
filter = "all",
|
||||
setFilter,
|
||||
dateRange = "all",
|
||||
setDateRange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
|
||||
|
||||
const filterOptions = [
|
||||
{ _id: "all", name: t("incidentsPage.incidentsOptionsHeaderFilterAll") },
|
||||
{ _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") },
|
||||
{ _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") },
|
||||
{ _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") },
|
||||
{ _id: "automatic", name: t("incidentsPage.incidentsOptionsHeaderFilterAutomatic") },
|
||||
];
|
||||
|
||||
const stackStyles = {
|
||||
direction: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(6),
|
||||
};
|
||||
|
||||
if (!shouldRender) return;
|
||||
|
||||
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,101 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Stack } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
|
||||
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
|
||||
|
||||
const ResolveIncidentDialog = ({
|
||||
open,
|
||||
incidentId,
|
||||
onClose,
|
||||
onResolve,
|
||||
onAfterResolve,
|
||||
onResolved,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [comment, setComment] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setComment("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!incidentId) return;
|
||||
const trimmed = comment.trim();
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await onResolve(incidentId, trimmed ? { comment: trimmed } : {});
|
||||
if (onAfterResolve) {
|
||||
await onAfterResolve();
|
||||
}
|
||||
onResolved?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error resolving incident:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericDialog
|
||||
open={open}
|
||||
theme={theme}
|
||||
title={t("incidentsPage.resolveIncidentDialogTitle")}
|
||||
description={t("incidentsPage.resolveIncidentDialogDescription")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<TextInput
|
||||
label={t("incidentsPage.resolveIncidentDialogCommentLabel")}
|
||||
placeholder={t("incidentsPage.resolveIncidentDialogCommentPlaceholder")}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
maxWidth="100%"
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(4)}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
{t("incidentsPage.resolveIncidentDialogConfirm")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GenericDialog>
|
||||
);
|
||||
};
|
||||
|
||||
ResolveIncidentDialog.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
incidentId: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onResolve: PropTypes.func.isRequired,
|
||||
onAfterResolve: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
onResolved: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ResolveIncidentDialog;
|
||||
@@ -1,168 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Box, Stack, Typography, Divider } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import SummaryCard from "../SummaryCard/index.jsx";
|
||||
|
||||
/**
|
||||
* StatisticsPanel Component
|
||||
*
|
||||
* Displays key metrics and statistics about incidents.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.statistics - Statistics data object
|
||||
* @param {number} props.statistics.total - Total incidents count
|
||||
* @param {number} props.statistics.totalTrend - Trend percentage for total (optional)
|
||||
* @param {string} props.statistics.mttr - Mean Time To Resolve (formatted string, e.g., "2h 15m")
|
||||
* @param {number} props.statistics.mttrTrend - Trend percentage for MTTR (optional)
|
||||
* @param {Object} props.statistics.topMonitor - Top monitor with most incidents
|
||||
* @param {string} props.statistics.topMonitor.name - Monitor name
|
||||
* @param {number} props.statistics.topMonitor.count - Incident count
|
||||
* @param {number} props.statistics.topMonitor.percentage - Percentage of total
|
||||
* @param {string} props.statistics.uptimeAffected - Total downtime (formatted string, e.g., "12h 45m")
|
||||
* @param {number} props.statistics.availability - Availability percentage
|
||||
* @param {boolean} props.isLoading - Loading state
|
||||
* @param {Object} props.error - Error object if any
|
||||
*/
|
||||
const StatisticsPanel = ({ isLoading = false, error = null, summary = {} }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const totalResolutions =
|
||||
(summary?.totalManualResolutions || 0) + (summary?.totalAutomaticResolutions || 0);
|
||||
|
||||
const iconWrapperStyle = {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mx: theme.spacing(2),
|
||||
color: theme.palette.primary.contrastTextTertiary,
|
||||
"& svg": {
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
"& svg path": {
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
if (isLoading) {
|
||||
return <PanelSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SummaryCard>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
color="error"
|
||||
align="center"
|
||||
>
|
||||
{t("incidentsPage.incidentsStatisticsPanelError")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
const getMostAffectedMonitor = () => {
|
||||
if (!summary.total || summary.total === 0) {
|
||||
return t("incidentsPage.none");
|
||||
}
|
||||
return summary.topMonitor?.monitorName || t("incidentsPage.none");
|
||||
};
|
||||
|
||||
const rowStyle = {
|
||||
py: theme.spacing(0.5),
|
||||
minHeight: 32,
|
||||
};
|
||||
|
||||
return (
|
||||
<SummaryCard title={t("incidentsPage.incidentsStatisticsPanelTitle")}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
sx={rowStyle}
|
||||
>
|
||||
<Box sx={iconWrapperStyle}>
|
||||
<Icon
|
||||
name="Bell"
|
||||
size={18}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
>
|
||||
{t("incidentsPage.totalIncidents")}: {summary.total || 0}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ mt: theme.spacing(2) }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
sx={rowStyle}
|
||||
>
|
||||
<Box sx={iconWrapperStyle}>
|
||||
<Icon
|
||||
name="AlertTriangle"
|
||||
size={18}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
>
|
||||
{t("incidentsPage.mostAffectedMonitor")}: {getMostAffectedMonitor()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ mt: theme.spacing(2) }} />
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
sx={rowStyle}
|
||||
>
|
||||
<Box sx={iconWrapperStyle}>
|
||||
<Icon
|
||||
name="Wrench"
|
||||
size={18}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
>
|
||||
{t("incidentsPage.avgResolutionTime")}:{" "}
|
||||
{summary.total > 0
|
||||
? `${summary.avgResolutionTimeHours || 0} ${t("incidentsPage.hours")}`
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
|
||||
StatisticsPanel.propTypes = {
|
||||
summary: PropTypes.object,
|
||||
isLoading: PropTypes.bool,
|
||||
error: PropTypes.object,
|
||||
};
|
||||
|
||||
export default StatisticsPanel;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import PropTypes from "prop-types";
|
||||
import { Box, Stack, Typography, Divider } from "@mui/material";
|
||||
const SummaryCard = ({ children, isHighPriority = false, sx = {}, title = null }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
padding: "16px",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
border: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
color: theme.palette.primary.contrastTextTertiary,
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
padding: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
fontSize: 13,
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
)}
|
||||
<Stack
|
||||
mt={theme.spacing(2)}
|
||||
paddingTop={theme.spacing(2)}
|
||||
gap={theme.spacing(2)}
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
SummaryCard.propTypes = {
|
||||
children: PropTypes.node,
|
||||
isHighPriority: PropTypes.bool,
|
||||
sx: PropTypes.object,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
export default SummaryCard;
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { networkService } from "../../../main.jsx";
|
||||
import { createToast } from "../../../Utils/toastUtils.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
/**
|
||||
* Hook to fetch and manage incidents
|
||||
*
|
||||
* Provides multiple functions to fetch incidents with different filters.
|
||||
*
|
||||
* @returns {Object} {
|
||||
* incidents,
|
||||
* incidentsCount,
|
||||
* isLoading,
|
||||
* networkError,
|
||||
* fetchIncidents,
|
||||
* fetchActiveIncidents,
|
||||
* fetchResolvedIncidents,
|
||||
* fetchIncidentsByTeam,
|
||||
* fetchIncidentById,
|
||||
* resolveIncident,
|
||||
* fetchIncidentSummary
|
||||
* }
|
||||
*/
|
||||
const useFetchIncidents = () => {
|
||||
const [incidents, setIncidents] = useState(null);
|
||||
const [incidentsCount, setIncidentsCount] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* Generic function to fetch incidents by team with custom config
|
||||
*
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {string} [config.monitorId] - Filter by monitor ID
|
||||
* @param {boolean} [config.status] - Filter by status (true=active, false=resolved)
|
||||
* @param {string} [config.resolutionType] - Filter by resolution type (automatic/manual)
|
||||
* @param {string} [config.sortOrder] - Sort order (asc/desc)
|
||||
* @param {string} [config.dateRange] - Date range filter
|
||||
* @param {number} [config.page] - Page number
|
||||
* @param {number} [config.rowsPerPage] - Rows per page
|
||||
*/
|
||||
const fetchIncidentsByTeam = useCallback(async (config = {}) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setNetworkError(false);
|
||||
|
||||
const res = await networkService.getIncidentsByTeam(config);
|
||||
setIncidents(res.data?.data?.incidents || []);
|
||||
setIncidentsCount(res.data?.data?.count || 0);
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
console.error(t("incidentsPage.errorFetchingIncidents"), error);
|
||||
createToast({ body: error.message || t("incidentsPage.errorFetchingIncidents") });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch active (open) incidents - Default function
|
||||
*
|
||||
* @param {Object} config - Additional configuration
|
||||
*/
|
||||
const fetchActiveIncidents = useCallback(
|
||||
async (config = {}) => {
|
||||
await fetchIncidentsByTeam({
|
||||
status: true,
|
||||
sortOrder: "desc",
|
||||
...config,
|
||||
});
|
||||
},
|
||||
[fetchIncidentsByTeam]
|
||||
);
|
||||
|
||||
const fetchIncidentsByResolutionType = useCallback(
|
||||
async (resolutionType, config = {}) => {
|
||||
await fetchIncidentsByTeam({
|
||||
resolutionType,
|
||||
sortOrder: "desc",
|
||||
...config,
|
||||
});
|
||||
},
|
||||
[fetchIncidentsByTeam]
|
||||
);
|
||||
/**
|
||||
* Fetch resolved incidents
|
||||
*
|
||||
* @param {Object} config - Additional configuration
|
||||
*/
|
||||
const fetchResolvedIncidents = useCallback(
|
||||
async (config = {}) => {
|
||||
await fetchIncidentsByTeam({
|
||||
status: false,
|
||||
sortOrder: "desc",
|
||||
...config,
|
||||
});
|
||||
},
|
||||
[fetchIncidentsByTeam]
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch incidents with custom filters (alias for fetchIncidentsByTeam)
|
||||
*
|
||||
* @param {Object} config - Configuration object
|
||||
*/
|
||||
const fetchIncidents = useCallback(
|
||||
async (config = {}) => {
|
||||
await fetchIncidentsByTeam(config);
|
||||
},
|
||||
[fetchIncidentsByTeam]
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch a single incident by ID
|
||||
*
|
||||
* @param {string} incidentId - The ID of the incident to fetch
|
||||
* @returns {Promise<Object|null>} The incident object or null if not found
|
||||
*/
|
||||
const fetchIncidentById = useCallback(async (incidentId) => {
|
||||
if (!incidentId) {
|
||||
console.error(t("incidentsPage.noIncidentIdProvided"));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setNetworkError(false);
|
||||
|
||||
const res = await networkService.getIncidentById(incidentId);
|
||||
return res.data?.data || null;
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
console.error(t("incidentsPage.errorFetchingIncident"), error);
|
||||
createToast({ body: error.message || t("incidentsPage.errorFetchingIncident") });
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Resolve an incident manually
|
||||
*
|
||||
* @param {string} incidentId - The ID of the incident to resolve
|
||||
* @param {Object} options - Resolution options
|
||||
* @param {string} [options.comment] - Optional comment about the resolution
|
||||
* @param {Function} [onSuccess] - Callback function to call on success
|
||||
* @param {Function} [onError] - Callback function to call on error
|
||||
*/
|
||||
const resolveIncident = useCallback(async (incidentId, options = {}) => {
|
||||
if (!incidentId) {
|
||||
console.error(t("incidentsPage.noIncidentIdProvided"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setNetworkError(false);
|
||||
|
||||
const res = await networkService.resolveIncidentManually(incidentId, options);
|
||||
if (res.data?.success) {
|
||||
createToast({ body: t("incidentsPage.incidentResolvedSuccessfully") });
|
||||
} else {
|
||||
createToast({ body: t("incidentsPage.errorResolvingIncident") });
|
||||
}
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
console.error(t("incidentsPage.errorResolvingIncident"), error);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
t("incidentsPage.errorResolvingIncident");
|
||||
createToast({ body: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch incident summary
|
||||
*
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {number} [config.limit=10] - Number of latest incidents to return
|
||||
* @returns {Promise<Object|null>} The summary object or null if error
|
||||
*/
|
||||
const fetchIncidentSummary = useCallback(async (config = {}) => {
|
||||
try {
|
||||
const res = await networkService.getIncidentSummary(config);
|
||||
return res.data?.data || null;
|
||||
} catch (error) {
|
||||
console.error(t("incidentsPage.errorFetchingIncidentSummary"), error);
|
||||
createToast({
|
||||
body: error.message || t("incidentsPage.errorFetchingIncidentSummary"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
incidents,
|
||||
incidentsCount,
|
||||
isLoading,
|
||||
networkError,
|
||||
fetchIncidents,
|
||||
fetchActiveIncidents,
|
||||
fetchResolvedIncidents,
|
||||
fetchIncidentsByTeam,
|
||||
fetchIncidentById,
|
||||
resolveIncident,
|
||||
fetchIncidentSummary,
|
||||
fetchIncidentsByResolutionType,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFetchIncidents;
|
||||
@@ -1,178 +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 IncidentsSummaryPanel from "./Components/IncidentsSummaryPanel/index.jsx";
|
||||
import IncidentDetailsModal from "./Components/IncidentDetailsModal/index.jsx";
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
// Hooks
|
||||
import useFetchIncidents from "./hooks/useFetchIncidents.js";
|
||||
import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks.js";
|
||||
|
||||
const Incidents2 = () => {
|
||||
const { monitorId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const BREADCRUMBS = [
|
||||
{ name: t("incidentsPageTitle", "Incidents"), path: "/incidents" },
|
||||
];
|
||||
|
||||
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [monitorLookup, setMonitorLookup] = useState(undefined);
|
||||
const [updateTrigger, setUpdateTrigger] = useState(false);
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
const [selectedIncidentId, setSelectedIncidentId] = useState(null);
|
||||
const handleUpdateTrigger = () => {
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
};
|
||||
|
||||
const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
|
||||
{}
|
||||
);
|
||||
|
||||
const {
|
||||
incidents,
|
||||
incidentsCount,
|
||||
isLoading: isLoadingIncidents,
|
||||
networkError: incidentsNetworkError,
|
||||
fetchIncidents,
|
||||
fetchActiveIncidents,
|
||||
fetchResolvedIncidents,
|
||||
resolveIncident,
|
||||
fetchIncidentsByResolutionType,
|
||||
} = useFetchIncidents();
|
||||
|
||||
const networkError = monitorsNetworkError || incidentsNetworkError;
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [selectedMonitor, filter, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = {
|
||||
monitorId: selectedMonitor !== "0" ? selectedMonitor : undefined,
|
||||
sortOrder: "desc",
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
};
|
||||
|
||||
if (filter === "active") {
|
||||
fetchActiveIncidents(config);
|
||||
} else if (filter === "resolved") {
|
||||
fetchResolvedIncidents(config);
|
||||
} else if (filter === "manual") {
|
||||
fetchIncidentsByResolutionType("manual", config);
|
||||
} else if (filter === "automatic") {
|
||||
fetchIncidentsByResolutionType("automatic", config);
|
||||
} else {
|
||||
fetchIncidents(config);
|
||||
}
|
||||
}, [
|
||||
selectedMonitor,
|
||||
filter,
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
updateTrigger,
|
||||
fetchActiveIncidents,
|
||||
fetchResolvedIncidents,
|
||||
fetchIncidents,
|
||||
fetchIncidentsByResolutionType,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const lookup = monitors?.reduce((acc, monitor) => {
|
||||
acc[monitor.id] = {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
type: monitor.type,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
setMonitorLookup(lookup);
|
||||
}, [monitors]);
|
||||
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChangePage = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
const handleOpenDetails = (id) => {
|
||||
if (!id) return;
|
||||
setSelectedIncidentId(id);
|
||||
setIsDetailsOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
setIsDetailsOpen(false);
|
||||
setSelectedIncidentId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
|
||||
<IncidentsSummaryPanel updateTrigger={updateTrigger} />
|
||||
|
||||
<OptionsHeader
|
||||
shouldRender={!isLoadingMonitors}
|
||||
monitors={monitorLookup}
|
||||
selectedMonitor={selectedMonitor}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
|
||||
<IncidentTable
|
||||
monitors={monitors || []}
|
||||
incidents={incidents || []}
|
||||
incidentsCount={incidentsCount || 0}
|
||||
isLoading={isLoadingIncidents}
|
||||
networkError={incidentsNetworkError}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
resolveIncident={resolveIncident}
|
||||
handleUpdateTrigger={handleUpdateTrigger}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
/>
|
||||
<IncidentDetailsModal
|
||||
open={isDetailsOpen}
|
||||
incidentId={selectedIncidentId}
|
||||
onClose={handleCloseDetails}
|
||||
onResolved={handleUpdateTrigger}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Incidents2;
|
||||
@@ -0,0 +1,245 @@
|
||||
import { BasePage } from "@/Components/v2/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SummaryCardActiveIncidents,
|
||||
SummaryCardLatestIncidents,
|
||||
SummaryCardStats,
|
||||
} from "@/Pages/Incidents/Components/CardSummary";
|
||||
import { IncidentsTable } from "@/Pages/Incidents/Components/IncidentTable";
|
||||
import { DialogResolution } from "@/Pages/Incidents/Components/DialogResolution";
|
||||
import { DialogIncidentDetails } from "@/Pages/Incidents/Components/DialogIncidentDetails";
|
||||
import { HeaderTimeRange } from "@/Components/v2/common";
|
||||
import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
const IncidentsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { monitorId } = useParams();
|
||||
|
||||
// Filter state
|
||||
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState("recent");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
|
||||
const [activeIncidentsRowsPerPage, setActiveIncidentsRowsPerPage] = useState(5);
|
||||
|
||||
// Resolve dialog state
|
||||
const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false);
|
||||
const [resolveIncidentId, setResolveIncidentId] = useState<string | null>(null);
|
||||
|
||||
// Details dialog state
|
||||
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
||||
const [selectedIncident, setSelectedIncident] = useState<Incident | null>(null);
|
||||
const [selectedIncidentMonitor, setSelectedIncidentMonitor] = useState<Monitor | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Build incidents URL with query params
|
||||
const incidentsUrl = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedMonitor !== "0") params.append("monitorId", selectedMonitor);
|
||||
// Always show only resolved incidents (active ones are in the separate table above)
|
||||
params.append("status", "false");
|
||||
if (filter === "manual" || filter === "automatic")
|
||||
params.append("resolutionType", filter);
|
||||
params.append("sortOrder", "desc");
|
||||
if (dateRange !== "all") params.append("dateRange", dateRange);
|
||||
params.append("page", String(page));
|
||||
params.append("rowsPerPage", String(rowsPerPage));
|
||||
return `/incidents/team?${params.toString()}`;
|
||||
}, [selectedMonitor, filter, dateRange, page, rowsPerPage]);
|
||||
|
||||
// Fetch incidents
|
||||
const {
|
||||
data: incidentsData,
|
||||
isLoading: isLoadingIncidents,
|
||||
refetch: refetchIncidents,
|
||||
} = useGet<IncidentsResponse>(
|
||||
incidentsUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, refreshInterval: 10000 }
|
||||
);
|
||||
|
||||
// Fetch monitors for lookup
|
||||
const { data: monitorsData } = useGet<Monitor[]>("/monitors/team");
|
||||
|
||||
// Build active incidents URL (always fetch all active, no date filter)
|
||||
const activeIncidentsUrl = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedMonitor !== "0") params.append("monitorId", selectedMonitor);
|
||||
params.append("status", "true");
|
||||
params.append("sortOrder", "desc");
|
||||
params.append("page", String(activeIncidentsPage));
|
||||
params.append("rowsPerPage", String(activeIncidentsRowsPerPage));
|
||||
return `/incidents/team?${params.toString()}`;
|
||||
}, [selectedMonitor, activeIncidentsPage, activeIncidentsRowsPerPage]);
|
||||
|
||||
// Fetch active incidents
|
||||
const { data: activeIncidentsData, refetch: refetchActiveIncidents } =
|
||||
useGet<IncidentsResponse>(
|
||||
activeIncidentsUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, refreshInterval: 10000 }
|
||||
);
|
||||
|
||||
// Fetch incident summary
|
||||
const { data: summaryData, refetch: refetchSummary } = useGet<IncidentSummary>(
|
||||
"/incidents/team/summary"
|
||||
);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [selectedMonitor, filter, dateRange]);
|
||||
|
||||
// Derived state
|
||||
const incidents = incidentsData?.incidents ?? [];
|
||||
const incidentsCount = incidentsData?.count ?? 0;
|
||||
const activeIncidents = activeIncidentsData?.incidents ?? [];
|
||||
const activeIncidentsCount = activeIncidentsData?.count ?? 0;
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedMonitor("0");
|
||||
setFilter("all");
|
||||
};
|
||||
|
||||
const handleOpenDetails = (incidentId: string) => {
|
||||
const incident =
|
||||
incidents.find((i) => i.id === incidentId) ??
|
||||
activeIncidents.find((i) => i.id === incidentId) ??
|
||||
null;
|
||||
const monitor = monitorsData?.find((m) => m.id === incident?.monitorId) ?? null;
|
||||
setIsDetailsDialogOpen(true);
|
||||
setSelectedIncident(incident);
|
||||
setSelectedIncidentMonitor(monitor);
|
||||
};
|
||||
|
||||
const handleDetailsClose = () => {
|
||||
setIsDetailsDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDetailsResolve = () => {
|
||||
if (selectedIncident) {
|
||||
handleDetailsClose();
|
||||
handleResolve(selectedIncident.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = (incidentId: string) => {
|
||||
setResolveIncidentId(incidentId);
|
||||
setIsResolveDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleResolveClose = () => {
|
||||
setIsResolveDialogOpen(false);
|
||||
setResolveIncidentId(null);
|
||||
};
|
||||
|
||||
const handleResolved = () => {
|
||||
refetchIncidents();
|
||||
refetchActiveIncidents();
|
||||
refetchSummary();
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<SummaryCardActiveIncidents summary={summaryData} />
|
||||
<SummaryCardLatestIncidents summary={summaryData} />
|
||||
<SummaryCardStats summary={summaryData} />
|
||||
</Stack>
|
||||
<ControlsIncidentFilter
|
||||
monitors={monitorsData ?? undefined}
|
||||
selectedMonitor={selectedMonitor}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
selectedResolutionType={filter}
|
||||
setSelectedResolutionType={setFilter}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
{activeIncidentsCount > 0 ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
|
||||
>
|
||||
{t("pages.incidents.table.activeIncidents")}
|
||||
</Typography>
|
||||
|
||||
<IncidentsTable
|
||||
incidents={activeIncidents}
|
||||
monitors={monitorsData ?? undefined}
|
||||
incidentsCount={activeIncidentsCount}
|
||||
page={activeIncidentsPage}
|
||||
setPage={setActiveIncidentsPage}
|
||||
rowsPerPage={activeIncidentsRowsPerPage}
|
||||
setRowsPerPage={setActiveIncidentsRowsPerPage}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onResolve={handleResolve}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ mb: theme.spacing(4), color: theme.palette.success.main }}
|
||||
>
|
||||
{t("pages.incidents.summaryCard.activeIncidents.active_zero")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
|
||||
>
|
||||
{t("pages.incidents.table.resolvedIncidents")}
|
||||
</Typography>
|
||||
|
||||
<HeaderTimeRange
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
isLoading={isLoadingIncidents}
|
||||
/>
|
||||
<IncidentsTable
|
||||
incidents={incidents}
|
||||
monitors={monitorsData ?? undefined}
|
||||
incidentsCount={incidentsCount}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setRowsPerPage={setRowsPerPage}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onResolve={handleResolve}
|
||||
/>
|
||||
<DialogResolution
|
||||
open={isResolveDialogOpen}
|
||||
incidentId={resolveIncidentId}
|
||||
onClose={handleResolveClose}
|
||||
onResolved={handleResolved}
|
||||
/>
|
||||
<DialogIncidentDetails
|
||||
open={isDetailsDialogOpen}
|
||||
incident={selectedIncident}
|
||||
monitor={selectedIncidentMonitor}
|
||||
onClose={handleDetailsClose}
|
||||
onResolve={handleDetailsResolve}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentsPage;
|
||||
+6
-3
@@ -1,10 +1,14 @@
|
||||
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
|
||||
const useGetIncidentsDuration = (incident, isActive) => {
|
||||
|
||||
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
|
||||
|
||||
export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryItem) => {
|
||||
if (!incident?.startTime) {
|
||||
return "-";
|
||||
}
|
||||
const startTime = new Date(incident?.startTime);
|
||||
const endTime = isActive
|
||||
const endTime = incident.status
|
||||
? new Date()
|
||||
: incident?.endTime
|
||||
? new Date(incident.endTime)
|
||||
@@ -22,4 +26,3 @@ const useGetIncidentsDuration = (incident, isActive) => {
|
||||
|
||||
return getHumanReadableDuration(durationMs);
|
||||
};
|
||||
export default useGetIncidentsDuration;
|
||||
@@ -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,409 +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 {
|
||||
let disks = monitor?.recentChecks?.[0]?.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/Grid";
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import Grid from "@mui/material/Grid";
|
||||
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 formatBytesToMB = (value: number) => `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
||||
|
||||
interface ChartConfig {
|
||||
title: string;
|
||||
type: string;
|
||||
dataKeys: string[];
|
||||
strokeColor: string;
|
||||
gradientStartColor: string;
|
||||
idx: number | null;
|
||||
interfaceName?: string;
|
||||
}
|
||||
|
||||
const getChartConfigs = (
|
||||
theme: any,
|
||||
checks: HardwareCheckStats[],
|
||||
t: any
|
||||
): ChartConfig[] => {
|
||||
const configs: ChartConfig[] = [];
|
||||
|
||||
// Find the first check that has network data to get interface names
|
||||
const checkWithNet = checks.find((c) => c.net && c.net.length > 0);
|
||||
const netInterfaces = checkWithNet?.net || [];
|
||||
|
||||
netInterfaces.forEach((iface, idx) => {
|
||||
configs.push(
|
||||
{
|
||||
title: t("pages.infrastructure.charts.labels.netBytesSent", {
|
||||
name: iface.name,
|
||||
}),
|
||||
type: "netBytesSent",
|
||||
dataKeys: [`net[${idx}].bytesSentPerSecond`],
|
||||
strokeColor: theme.palette.primary.main,
|
||||
gradientStartColor: theme.palette.primary.main,
|
||||
idx,
|
||||
interfaceName: iface.name,
|
||||
},
|
||||
{
|
||||
title: t("pages.infrastructure.charts.labels.netBytesRecv", {
|
||||
name: iface.name,
|
||||
}),
|
||||
type: "netBytesRecv",
|
||||
dataKeys: [`net[${idx}].deltaBytesRecv`],
|
||||
strokeColor: theme.palette.success.main,
|
||||
gradientStartColor: theme.palette.success.main,
|
||||
idx,
|
||||
interfaceName: iface.name,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
export const InfraNetworkCharts = ({
|
||||
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.interfaceName ?? config.idx ?? ""}`}
|
||||
>
|
||||
<HistogramInfrastructure
|
||||
dateRange={dateRange}
|
||||
title={config.title}
|
||||
type={config.type}
|
||||
idx={config.idx}
|
||||
checks={checks}
|
||||
xKey="bucketDate"
|
||||
dataKeys={config.dataKeys}
|
||||
gradient={true}
|
||||
gradientStartColor={config.gradientStartColor}
|
||||
gradientEndColor="#ffffff"
|
||||
strokeColor={config.strokeColor}
|
||||
yAxisFormatter={formatBytesToMB}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
// Components
|
||||
import CustomGauge from "@/Components/v1/Charts/CustomGauge/index.jsx";
|
||||
import BaseContainer from "../BaseContainer/index.jsx";
|
||||
import { Stack, Typography, Box } from "@mui/material";
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Gauge = ({
|
||||
value,
|
||||
heading,
|
||||
metricOne,
|
||||
valueOne,
|
||||
metricTwo,
|
||||
valueTwo,
|
||||
metricThree,
|
||||
valueThree,
|
||||
metricFour,
|
||||
valueFour,
|
||||
shouldExpand = false,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const valueStyle = {
|
||||
borderRadius: theme.spacing(2),
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
minWidth: "40%",
|
||||
maxWidth: "60%",
|
||||
mb: theme.spacing(2),
|
||||
mt: theme.spacing(2),
|
||||
pr: theme.spacing(2),
|
||||
textAlign: "right",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseContainer shouldExpand={shouldExpand}>
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(2)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
backgroundColor: theme.palette.gradient.color1,
|
||||
}}
|
||||
>
|
||||
<CustomGauge
|
||||
progress={value}
|
||||
radius={100}
|
||||
/>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
{heading}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderTop: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricOne}</Typography>
|
||||
<Typography sx={valueStyle}>{valueOne}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricTwo}</Typography>
|
||||
<Typography sx={valueStyle}>{valueTwo}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricThree}</Typography>
|
||||
<Typography sx={valueStyle}>{valueThree}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricFour}</Typography>
|
||||
<Typography sx={valueStyle}>{valueFour}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</BaseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Gauge.propTypes = {
|
||||
value: PropTypes.number,
|
||||
heading: PropTypes.string,
|
||||
metricOne: PropTypes.string,
|
||||
valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
metricTwo: PropTypes.string,
|
||||
valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
metricThree: PropTypes.string,
|
||||
valueThree: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
metricFour: PropTypes.string,
|
||||
valueFour: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
shouldExpand: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Gauge;
|
||||
@@ -1,112 +0,0 @@
|
||||
// Components
|
||||
import { Box } from "@mui/material";
|
||||
import Gauge from "./Gauge.jsx";
|
||||
import SkeletonLayout from "./skeleton.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
// Utils
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Gauges = ({ isLoading = false, monitor }) => {
|
||||
const { decimalToPercentage, formatBytes, formatDeviceName, formatMountpoint } =
|
||||
useHardwareUtils();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLayout />;
|
||||
}
|
||||
|
||||
const latestCheck = monitor?.recentChecks?.[0];
|
||||
const memoryUsagePercent = latestCheck?.memory?.usage_percent ?? 0;
|
||||
const memoryUsedBytes = latestCheck?.memory?.used_bytes ?? 0;
|
||||
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
||||
const cpuUsagePercent = latestCheck?.cpu?.usage_percent ?? 0;
|
||||
const cpuPhysicalCores = latestCheck?.cpu?.physical_core ?? 0;
|
||||
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
|
||||
|
||||
const gauges = [
|
||||
{
|
||||
type: "memory",
|
||||
value: decimalToPercentage(memoryUsagePercent),
|
||||
heading: t("memoryUsage"),
|
||||
metricOne: t("used"),
|
||||
valueOne: formatBytes(memoryUsedBytes, true),
|
||||
metricTwo: t("total"),
|
||||
valueTwo: formatBytes(memoryTotalBytes, true),
|
||||
},
|
||||
{
|
||||
type: "cpu",
|
||||
value: decimalToPercentage(cpuUsagePercent),
|
||||
heading: t("cpuUsage"),
|
||||
metricOne: t("cores"),
|
||||
valueOne: cpuPhysicalCores ?? 0,
|
||||
metricTwo: t("frequency"),
|
||||
valueTwo: `${(cpuFrequency / 1000).toFixed(2)} Ghz`,
|
||||
},
|
||||
...(latestCheck?.disk ?? [])
|
||||
.filter((disk) => {
|
||||
if (!monitor?.selectedDisks || monitor.selectedDisks.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return monitor.selectedDisks.includes(disk.mountpoint || disk.device);
|
||||
})
|
||||
.map((disk, idx) => ({
|
||||
type: "disk",
|
||||
diskIndex: idx,
|
||||
value: decimalToPercentage(disk.usage_percent),
|
||||
heading: `Disk${idx} usage`,
|
||||
metricOne: t("used"),
|
||||
valueOne: formatBytes(disk.total_bytes - disk.free_bytes, true),
|
||||
metricTwo: t("total"),
|
||||
valueTwo: formatBytes(disk.total_bytes, true),
|
||||
metricThree: t("device"),
|
||||
valueThree: formatDeviceName(disk.device),
|
||||
metricFour: t("mountedOn"),
|
||||
valueFour: formatMountpoint(disk.mountpoint),
|
||||
})),
|
||||
];
|
||||
|
||||
// Only expand gauges to fill row when there are 4 or more
|
||||
const shouldExpand = gauges.length >= 4;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: shouldExpand
|
||||
? "repeat(auto-fill, minmax(200px, 1fr))"
|
||||
: "repeat(auto-fill, 225px)",
|
||||
gap: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
{gauges.map((gauge) => {
|
||||
return (
|
||||
<Gauge
|
||||
key={`${gauge.type}-${gauge.diskIndex ?? ""}`}
|
||||
value={gauge.value}
|
||||
heading={gauge.heading}
|
||||
metricOne={gauge.metricOne}
|
||||
valueOne={gauge.valueOne}
|
||||
metricTwo={gauge.metricTwo}
|
||||
valueTwo={gauge.valueTwo}
|
||||
metricThree={gauge.metricThree}
|
||||
valueThree={gauge.valueThree}
|
||||
metricFour={gauge.metricFour}
|
||||
valueFour={gauge.valueFour}
|
||||
shouldExpand={shouldExpand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Gauges.propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
monitor: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Gauges;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Stack, Skeleton } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
|
||||
const SkeletonLayout = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
{Array.from({ length: 3 }).map((_, idx) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={`gauge-${idx}`}
|
||||
variant="rectangular"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
@@ -0,0 +1,109 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseChart, Gauge } from "@/Components/v2/design-elements";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getGbs, getFrequency } from "@/Utils/InfraUtils";
|
||||
import { useTheme } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
|
||||
const InfraDetailGauge = ({
|
||||
title,
|
||||
progress,
|
||||
upperLabel,
|
||||
upperValue,
|
||||
lowerLabel,
|
||||
lowerValue,
|
||||
}: {
|
||||
title: string;
|
||||
progress: number;
|
||||
upperLabel?: string;
|
||||
upperValue?: string | number;
|
||||
lowerLabel?: string;
|
||||
lowerValue?: string | number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseChart
|
||||
icon={null}
|
||||
title={title}
|
||||
maxWidth={225}
|
||||
>
|
||||
<Stack
|
||||
alignItems={"center"}
|
||||
mb={theme.spacing(4)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Gauge progress={progress} />
|
||||
</Stack>
|
||||
<Stack
|
||||
direction={"row"}
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<Typography>{upperLabel}</Typography>
|
||||
<Typography>{upperValue}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction={"row"}
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<Typography>{lowerLabel}</Typography>
|
||||
<Typography>{lowerValue}</Typography>
|
||||
</Stack>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
|
||||
export const InfraDetailsGauges = ({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: CheckSnapshot | undefined;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
spacing={theme.spacing(8)}
|
||||
alignItems={"center"}
|
||||
>
|
||||
<InfraDetailGauge
|
||||
title={t("pages.infrastructure.gauges.memory.title")}
|
||||
progress={(snapshot?.memory?.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
|
||||
upperValue={getGbs(snapshot?.memory?.used_bytes || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
|
||||
lowerValue={getGbs(snapshot?.memory?.total_bytes || 0)}
|
||||
/>
|
||||
<InfraDetailGauge
|
||||
title={t("pages.infrastructure.gauges.cpu.title")}
|
||||
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
|
||||
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
|
||||
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
|
||||
/>
|
||||
{snapshot?.disk?.map((disk, idx) => {
|
||||
return (
|
||||
<InfraDetailGauge
|
||||
key={disk?.device || 0 + idx}
|
||||
// title={`Disk ${idx} usage`}
|
||||
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
||||
progress={(disk.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
||||
upperValue={getGbs(disk?.used_bytes || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
|
||||
lowerValue={getGbs(disk?.total_bytes || 0)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import InfraAreaChart from "../AreaChartBoxes/InfraAreaChart.jsx";
|
||||
|
||||
import {
|
||||
TzTick,
|
||||
InfrastructureTooltip,
|
||||
NetworkTick,
|
||||
} from "@/Components/v1/Charts/Utils/chartUtils.jsx";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
|
||||
|
||||
const NetworkCharts = ({ ethernetData, dateRange }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { formatBytesPerSecondString, formatPacketsPerSecondString } = useHardwareUtils();
|
||||
|
||||
if (!ethernetData?.length) {
|
||||
return <Typography>{t("noNetworkStatsAvailable")}</Typography>;
|
||||
}
|
||||
|
||||
const configs = [
|
||||
{
|
||||
type: "network-bytes",
|
||||
data: ethernetData,
|
||||
dataKeys: ["bytesPerSec"],
|
||||
heading: t("dataReceived"),
|
||||
strokeColor: theme.palette.info.main,
|
||||
gradientStartColor: theme.palette.info.main,
|
||||
yLabel: t("rate"),
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
yTick: <NetworkTick formatter={formatBytesPerSecondString} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.info.main}
|
||||
yKey={"bytesPerSec"}
|
||||
yLabel={t("dataRate") + ": "}
|
||||
dateRange={dateRange}
|
||||
formatter={formatBytesPerSecondString}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "network-packets",
|
||||
data: ethernetData,
|
||||
dataKeys: ["packetsPerSec"],
|
||||
heading: t("packetsReceivedRate"),
|
||||
strokeColor: theme.palette.success.main,
|
||||
gradientStartColor: theme.palette.success.main,
|
||||
yLabel: t("rate"),
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
yTick: <NetworkTick formatter={formatPacketsPerSecondString} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.success.main}
|
||||
yKey={"packetsPerSec"}
|
||||
yLabel={t("packetsPerSecond") + ": "}
|
||||
dateRange={dateRange}
|
||||
formatter={formatPacketsPerSecondString}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "network-errors",
|
||||
data: ethernetData,
|
||||
dataKeys: ["errors"],
|
||||
heading: t("networkErrors"),
|
||||
strokeColor: theme.palette.error.main,
|
||||
gradientStartColor: theme.palette.error.main,
|
||||
yLabel: t("errors"),
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.error.main}
|
||||
yKey={"errors"}
|
||||
yLabel={t("errors") + ": "}
|
||||
dateRange={dateRange}
|
||||
formatter={(value) => Math.round(value).toLocaleString()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "network-drops",
|
||||
data: ethernetData,
|
||||
dataKeys: ["drops"],
|
||||
heading: t("networkDrops"),
|
||||
strokeColor: theme.palette.warning.main,
|
||||
gradientStartColor: theme.palette.warning.main,
|
||||
yLabel: t("drops"),
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.warning.main}
|
||||
yKey={"drops"}
|
||||
yLabel={t("drops") + ": "}
|
||||
dateRange={dateRange}
|
||||
formatter={(value) => Math.round(value).toLocaleString()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
gap={theme.spacing(8)}
|
||||
flexWrap="wrap"
|
||||
sx={{
|
||||
"& > *": {
|
||||
flexBasis: `calc(50% - ${theme.spacing(8)})`,
|
||||
maxWidth: `calc(50% - ${theme.spacing(8)})`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{configs.map((config) => (
|
||||
<InfraAreaChart
|
||||
key={config.type}
|
||||
config={config}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
NetworkCharts.propTypes = {
|
||||
ethernetData: PropTypes.array.isRequired,
|
||||
dateRange: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default NetworkCharts;
|
||||
@@ -1,81 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx";
|
||||
import StatBox from "@/Components/v1/StatBox/index.jsx";
|
||||
import { Typography } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx";
|
||||
|
||||
function formatNumber(num) {
|
||||
return num != null ? num.toLocaleString() : "0";
|
||||
}
|
||||
|
||||
const NetworkStatBoxes = ({ shouldRender, net, ifaceName }) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatBytes } = useHardwareUtils();
|
||||
|
||||
const filtered = net?.filter((iface) => iface.name === ifaceName) || [];
|
||||
|
||||
if (!net?.length) {
|
||||
return <Typography>{t("noNetworkStatsAvailable")}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBoxes
|
||||
shouldRender={shouldRender}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{filtered
|
||||
.map((iface) => [
|
||||
<StatBox
|
||||
key={`${iface.name}-bytes-sent`}
|
||||
heading={t("bytesSent")}
|
||||
subHeading={formatBytes(iface.bytes_sent)}
|
||||
/>,
|
||||
<StatBox
|
||||
key={`${iface.name}-bytes-recv`}
|
||||
heading={t("bytesReceived")}
|
||||
subHeading={formatBytes(iface.bytes_recv)}
|
||||
/>,
|
||||
<StatBox
|
||||
key={`${iface.name}-packets-sent`}
|
||||
heading={t("packetsSent")}
|
||||
subHeading={formatNumber(iface.packets_sent)}
|
||||
/>,
|
||||
<StatBox
|
||||
key={`${iface.name}-packets-recv`}
|
||||
heading={t("packetsReceived")}
|
||||
subHeading={formatNumber(iface.packets_recv)}
|
||||
/>,
|
||||
<StatBox
|
||||
key={`${iface.name}-err-in`}
|
||||
heading={t("errorsIn")}
|
||||
subHeading={formatNumber(iface.err_in)}
|
||||
/>,
|
||||
<StatBox
|
||||
key={`${iface.name}-err-out`}
|
||||
heading={t("errorsOut")}
|
||||
subHeading={formatNumber(iface.err_out)}
|
||||
/>,
|
||||
])
|
||||
.flat()}
|
||||
</StatusBoxes>
|
||||
);
|
||||
};
|
||||
|
||||
NetworkStatBoxes.propTypes = {
|
||||
shouldRender: PropTypes.bool.isRequired,
|
||||
net: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
bytes_sent: PropTypes.number,
|
||||
bytes_recv: PropTypes.number,
|
||||
packets_sent: PropTypes.number,
|
||||
packets_recv: PropTypes.number,
|
||||
err_in: PropTypes.number,
|
||||
err_out: PropTypes.number,
|
||||
})
|
||||
),
|
||||
ifaceName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default NetworkStatBoxes;
|
||||
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user