Compare commits

..
Author SHA1 Message Date
Alex Holliday 189f309631 format 2026-02-04 18:10:52 +00:00
Alex Holliday 97dd3ec9cb add permissin check to sendInviteEmail 2026-02-04 17:57:00 +00:00
Alex Holliday b4c929963f add permission check to invite service 2026-02-04 17:49:45 +00:00
Alex Holliday 4ecc737438 add helper method to extract user roles 2026-02-04 17:49:19 +00:00
Alex Holliday bc58f6e08e add user role hierearchy lookup and management function 2026-02-04 17:48:58 +00:00
Alexander Holliday 35eba6897a Merge pull request #3238 from bluewave-labs/feat/v2-create-config-status-page
feat: v2 create config status page
2026-02-03 15:31:39 -08:00
Alex Holliday 667dc55fc8 types 2026-02-03 23:30:23 +00:00
Alex Holliday f2d2687d2a responsive 2026-02-03 22:58:09 +00:00
Alex Holliday 4e3e580179 align logo 2026-02-03 22:52:20 +00:00
Alex Holliday 4080ac8c2c format 2026-02-03 22:40:26 +00:00
Alex Holliday 0a408f8cfd sorting 2026-02-03 22:07:59 +00:00
Alex Holliday e3921beac4 hide unused features 2026-02-03 21:53:34 +00:00
Alex Holliday 4c284493ba actin menu 2026-02-03 21:40:17 +00:00
Alex Holliday 0fcceeb7a6 strings 2026-02-03 21:34:16 +00:00
Alex Holliday f799d46025 cleanup, style picler 2026-02-03 21:29:02 +00:00
Alex Holliday a80df60fc1 handle delete 2026-02-03 21:14:16 +00:00
Alexander Holliday cbec0fa065 Merge pull request #3213 from sukritraj02/#3164
#3164
2026-02-03 13:07:14 -08:00
Alex Holliday 40e5550e0f delete header 2026-02-03 20:18:51 +00:00
Alex Holliday 90fd9930db handle remove logo 2026-02-03 20:11:47 +00:00
Alex Holliday f10f7385b0 fix hook 2026-02-03 19:59:42 +00:00
Alex Holliday 2d85abf030 id 2026-02-03 19:50:36 +00:00
Alex Holliday 3c0c470ca3 features config box 2026-02-03 19:36:43 +00:00
Alex Holliday 4c5cae8722 missing key 2026-02-03 19:31:51 +00:00
Alex Holliday 8650f30d12 type 2026-02-03 19:28:55 +00:00
Alexander Holliday 09e37f4356 Merge pull request #3233 from bluewave-labs/fix/fe/account-page-password-ui
Fix: Send Email Invite
2026-02-03 11:25:52 -08:00
Alex Holliday 6bc8a521c9 logo 2026-02-03 19:24:50 +00:00
Alex Holliday a1e6eaa9d0 submit 2026-02-03 19:03:17 +00:00
Alex Holliday bbb61a6f88 format 2026-02-03 10:40:00 -08:00
Alex Holliday c1d2041482 Merge branch 'develop' into feat/v2-create-config-status-page 2026-02-03 18:34:17 +00:00
Alex Holliday 7aa05c7f85 strings 2026-02-03 18:34:15 +00:00
Alexander Holliday 539866e119 Merge pull request #3237 from bluewave-labs/fix/strings
Fix/strings
2026-02-03 10:26:02 -08:00
Alex Holliday 6f0ff1583e rebuild strings 2026-02-03 18:23:19 +00:00
Alex Holliday 12b61d17f2 initial commit 2026-02-03 18:09:51 +00:00
Alex Holliday 7426418629 fix uptime 2026-02-03 18:03:26 +00:00
Alex Holliday 3566da7515 uptime strings 2026-02-03 18:02:24 +00:00
Alex Holliday b451489bd8 Merge branch 'develop' into feat/v2-create-config-status-page 2026-02-03 18:02:13 +00:00
Alexander Holliday d4a0489275 Merge pull request #3236 from bluewave-labs/fix/strings
sort
2026-02-03 09:54:15 -08:00
Alex Holliday 080c91284d sort 2026-02-03 17:53:45 +00:00
Alexander Holliday dadc74373c Merge pull request #3235 from bluewave-labs/fix/strings
Revert "remove unused strings"
2026-02-03 09:53:16 -08:00
Alex Holliday 6fdea04416 Revert "remove unused strings"
This reverts commit a7c5b0c7fb.
2026-02-03 17:51:32 +00:00
Alex Holliday 67963823d9 Merge branch 'develop' into feat/v2-create-config-status-page 2026-02-03 17:43:19 +00:00
Alexander Holliday ab12a9bd3a Merge pull request #3234 from bluewave-labs/fix/validation
Fix validation
2026-02-03 09:42:32 -08:00
Alex Holliday 3b25c50247 Fix validation 2026-02-03 17:41:31 +00:00
Br0wnHammer 63e42b7276 Fix: Send Email Invite 2026-02-03 22:36:19 +05:30
Alex Holliday ae7524f507 monitors details 2026-02-02 23:57:14 +00:00
Alex Holliday 54d71bef61 monitors 2026-02-02 23:46:15 +00:00
Alexander Holliday a77c74b17c Merge pull request #3231 from bluewave-labs/feat/v2-status-page
feat: v2 status page
2026-02-02 15:01:41 -08:00
Alex Holliday f2363a7a09 form initial commit 2026-02-02 23:01:35 +00:00
Alex Holliday 1fefb038f0 cleanup 2026-02-02 22:15:57 +00:00
Alex Holliday 85190992c7 format 2026-02-02 22:02:14 +00:00
Alex Holliday efe87b599f add missing / 2026-02-02 22:01:42 +00:00
Alex Holliday ed1bcf97da monitor name overflow 2026-02-02 21:57:07 +00:00
Alex Holliday 56ead03416 breadcrumbs 2026-02-02 21:51:06 +00:00
Alex Holliday a912a5ebb8 monitors lis tskeleton 2026-02-02 20:42:27 +00:00
Alex Holliday 0fd4173816 status bar 2026-02-02 20:39:16 +00:00
Alex Holliday 75d3644b36 remove unused 2026-02-02 19:25:30 +00:00
Alex Holliday 853e21623e new status pages 2026-02-02 19:20:59 +00:00
Alex Holliday 57c3bf3240 findBYIds only 2026-02-02 19:00:03 +00:00
Alexander Holliday 6be1eb0a3e Merge pull request #3230 from bluewave-labs/feat/v2-incidents
feat: v2 incidents
2026-02-02 10:51:29 -08:00
Alex Holliday 9d70623e0f bump mui color 2026-02-02 18:50:20 +00:00
Alex Holliday 43fe66022b format 2026-02-02 18:45:52 +00:00
Alex Holliday 294fb13005 remove unused 2026-02-02 18:45:29 +00:00
Alex Holliday 8028cab487 no active incidents msg 2026-02-02 18:31:21 +00:00
Alex Holliday 8160ab4a3c fix isFilterACtive logic 2026-02-02 17:56:11 +00:00
Alex Holliday 6063532c79 fitlers 2026-02-02 17:49:36 +00:00
Alex Holliday 83b628c8f9 divider 2026-02-02 17:36:42 +00:00
Alex Holliday 88a91022c3 summary, activ eincident table 2026-02-02 17:28:22 +00:00
Alex Holliday 5822a363a9 resolve strings 2026-02-02 17:16:34 +00:00
Alex Holliday d54c90d511 require team ID 2026-02-02 06:09:57 +00:00
Alex Holliday 3a5783fe91 email 2026-02-02 06:08:51 +00:00
Alex Holliday 479f22ac69 resolution details 2026-02-02 05:41:32 +00:00
Alex Holliday 30202b2249 grid 2026-02-01 01:54:18 +00:00
Alex Holliday 888910abb2 bump mui 2026-02-01 00:49:35 +00:00
Alex Holliday 874d1b277a incidnet details 2026-02-01 00:34:48 +00:00
Alex Holliday f34520cdbf dialog initial commit 2026-02-01 00:03:08 +00:00
Alex Holliday f420044b64 header 2026-01-31 01:32:33 +00:00
Alex Holliday 53d5ae2251 time header 2026-01-31 01:23:54 +00:00
Alex Holliday bcc57c7135 strings 2026-01-31 01:12:05 +00:00
Alex Holliday 6b67f4977f wire up incidents table 2026-01-31 00:41:03 +00:00
Alex Holliday 87255071f4 headers and actions 2026-01-31 00:18:33 +00:00
Alex Holliday cbf1f97591 initial table commit 2026-01-30 23:35:33 +00:00
Alex Holliday 7a881fda03 strings 2026-01-30 23:31:03 +00:00
Alex Holliday 50fd66ef56 center 2026-01-30 23:28:36 +00:00
Alex Holliday f9b677b1ad strings 2026-01-30 23:22:56 +00:00
Alex Holliday b7a6d50c73 cards 2026-01-30 23:16:35 +00:00
Alex Holliday 04bd7f0e2e initial comit 2026-01-30 21:45:14 +00:00
Alexander Holliday 2e7aba788d Merge pull request #3227 from bluewave-labs/fix/infra-link
hardware->infrastructure
2026-01-30 13:40:36 -08:00
Alex Holliday e1665b29af hardware->infrastructure 2026-01-30 21:40:10 +00:00
Alexander Holliday 17450e2537 Merge pull request #3226 from bluewave-labs/fix/strings
fix: remove unused strings
2026-01-30 13:37:22 -08:00
Alex Holliday 270293a2b5 format 2026-01-30 21:35:51 +00:00
Alex Holliday a7c5b0c7fb remove unused strings 2026-01-30 21:35:38 +00:00
Alex Holliday e804c35c3a remove commments 2026-01-30 21:30:10 +00:00
Alexander Holliday 5c4133097d Merge pull request #3225 from bluewave-labs/feat/v2-create-monitors
feat: V2 create monitors
2026-01-30 13:27:52 -08:00
Alex Holliday 85f93cd6d2 format 2026-01-30 21:26:56 +00:00
Alex Holliday d4db32e392 game 2026-01-30 21:26:27 +00:00
Alex Holliday 791b9fc7a2 game 2026-01-30 21:26:17 +00:00
Alex Holliday 206c5577a6 move header 2026-01-30 21:14:11 +00:00
Alex Holliday cbd0530d85 remove unused 2026-01-30 21:10:52 +00:00
Alex Holliday 45ef2438de format 2026-01-30 21:05:32 +00:00
Alex Holliday ec818eaacb delete 2026-01-30 21:04:14 +00:00
Alex Holliday 91bb60f2ff delte header 2026-01-30 20:55:24 +00:00
Alex Holliday 443ad46fa7 update strings 2026-01-30 20:38:05 +00:00
Alex Holliday f685c9cd5d useAdvncedMatching 2026-01-30 20:21:31 +00:00
Alex Holliday 0263c17618 advance dmatching section 2026-01-30 19:54:44 +00:00
Alex Holliday 5a12e36c7e put -> patch 2026-01-30 19:43:34 +00:00
Alex Holliday 67da6c61cf put/create 2026-01-30 19:41:41 +00:00
Alex Holliday f538f61464 slider update 2026-01-30 18:28:30 +00:00
Alex Holliday f6971ede69 slider w label 2026-01-30 00:00:12 +00:00
Alex Holliday e69283e3ed sliders 2026-01-29 23:52:21 +00:00
Alex Holliday 084eed85bf add incidents config box 2026-01-29 23:39:14 +00:00
Alex Holliday 9a42fb031f check frequency 2026-01-29 23:17:08 +00:00
Alex Holliday 943e88c54d TLS/SSL 2026-01-29 23:10:34 +00:00
Alex Holliday 24ce64b439 notifications 2026-01-29 23:04:40 +00:00
Alex Holliday 841d25ce3e general 2026-01-29 22:47:29 +00:00
Alex Holliday 84e4ea7b64 forward ref 2026-01-29 22:20:50 +00:00
Alex Holliday 4b92dbd19b initial commit 2026-01-29 22:17:56 +00:00
Alexander Holliday 5eb1d54d65 Merge pull request #3223 from bluewave-labs/feat/v2-notifications-create
Feat: v2 notifications create
2026-01-29 13:16:28 -08:00
Alex Holliday a0aee182de remove unused 2026-01-29 21:15:14 +00:00
Alex Holliday 2dca5ccb67 format 2026-01-29 21:09:00 +00:00
Alex Holliday 348eea5475 breadcrumb fix 2026-01-29 21:08:36 +00:00
Alex Holliday 2a3683c74a breadcurmbs 2026-01-29 21:08:26 +00:00
Alex Holliday 806d7ae456 test 2026-01-29 20:59:47 +00:00
Alex Holliday cac502573f put -> patch 2026-01-29 20:55:14 +00:00
Alex Holliday 830318edc3 put -> patch 2026-01-29 20:55:02 +00:00
Alex Holliday 86a55e3cfe submit 2026-01-29 20:47:47 +00:00
Alex Holliday 796960aeb2 matrix in one config 2026-01-29 20:26:28 +00:00
Alex Holliday a25ff03b71 remaining fields 2026-01-29 20:12:50 +00:00
Alex Holliday 22ae6b4be6 type 2026-01-29 19:34:49 +00:00
Alex Holliday 0b5e28c296 form 2026-01-29 19:29:36 +00:00
sukritraj02 c5c91e28f8 udated package-lock.json client 2026-01-30 00:23:02 +05:30
Alex Holliday 3ba3e4b865 initial commit 2026-01-29 18:41:46 +00:00
Alexander Holliday 77fa394004 Merge pull request #3222 from bluewave-labs/fix/missing-create-header
fix: missing create header
2026-01-29 10:37:02 -08:00
Alex Holliday c0ba278e47 loading state 2026-01-29 18:34:50 +00:00
sukritraj02 73f41906b0 initial changes , updated package-lock.json file 2026-01-30 00:00:35 +05:30
Alexander Holliday cb5af2741d Merge pull request #3216 from bluewave-labs/fix/latest-incident-overflow
fix: latest incidents panel overflow text on smaller devices
2026-01-29 10:28:32 -08:00
mannilakash af4ef87966 Merge branch 'develop' of github.com:bluewave-labs/Checkmate into fix/latest-incident-overflow 2026-01-29 13:22:20 -05:00
mannilakash a154ad11b5 fix: latest incident panel: Box -> Grid 2026-01-29 13:21:56 -05:00
Alexander Holliday 1bfd1c9119 Merge pull request #3221 from bluewave-labs/feat/v2-notifications
Feat/v2 notifications
2026-01-29 10:21:35 -08:00
Alex Holliday fc98192bcf format 2026-01-29 18:20:05 +00:00
Alex Holliday b1db74d96f remove unused 2026-01-29 18:19:55 +00:00
Alex Holliday c1258d3cdf v2 2026-01-29 18:18:52 +00:00
Alex Holliday 6ffa5ac648 v2-table 2026-01-29 18:14:23 +00:00
Alex Holliday 6395e8a10e iniital commit 2026-01-29 17:52:10 +00:00
Alexander Holliday 2852d2cdce Merge pull request #3220 from bluewave-labs/fix/infra-cleanup
fix: remove unused infra components
2026-01-29 09:45:14 -08:00
Alex Holliday 363baef7b1 remove unused 2026-01-29 17:43:33 +00:00
Alexander Holliday a1973a6d2b Merge pull request #3219 from bluewave-labs/feat/v2-infra-details
Feat/v2 infra details
2026-01-29 09:26:14 -08:00
Gorkem Cetin (BWL) 12f02d6eea Refine questions and ideas section in README
Updated communication options with preferred channels.
2026-01-28 19:47:48 -05:00
Alex Holliday a6c291c8ef build fix 2026-01-29 00:07:11 +00:00
Alex Holliday 227dc9e7e8 y axis formatting 2026-01-29 00:04:51 +00:00
Alex Holliday 38ef9ccad3 network charts 2026-01-29 00:01:22 +00:00
Alex Holliday 353b97e7d3 _id -> bucketDate 2026-01-28 23:34:18 +00:00
Alex Holliday 9b871ffb52 grid fix 2026-01-28 23:29:04 +00:00
Alex Holliday cb392eaaba gauges, charts 2026-01-28 23:26:00 +00:00
Alex Holliday dcf5a77923 stat boxes 2026-01-28 22:38:02 +00:00
Alex Holliday ec03addba6 headers 2026-01-28 22:00:33 +00:00
Alex Holliday eefdb8d253 initial commit 2026-01-28 21:39:38 +00:00
Alexander Holliday e539f40612 Merge pull request #3218 from bluewave-labs/feat/v2-infra
feat: v2 infra
2026-01-28 13:39:08 -08:00
Alex Holliday c18159afd3 v2 infra migration 2026-01-28 21:34:43 +00:00
Alex Holliday 5596e45ff3 initial commit 2026-01-28 20:48:02 +00:00
Alexander Holliday bde13f4fa7 Merge pull request #3217 from bluewave-labs/fix/check-cleanup
fix: cleanup
2026-01-28 12:23:55 -08:00
Alex Holliday 7f1c996632 format 2026-01-28 20:21:41 +00:00
Alex Holliday 396d5b369b remove unused 2026-01-28 20:20:18 +00:00
Alex Holliday bb8cc8b464 cleanup, check fix 2026-01-28 20:00:42 +00:00
mannilakash 66efa25e2b fix: latest incidents panel overflow text on smaller devices 2026-01-28 14:36:51 -05:00
Alexander Holliday 7bef6dc0e6 Merge pull request #3215 from bluewave-labs/feat/v2-checks
feat: v2 checks
2026-01-28 11:28:10 -08:00
Alexander Holliday 1c341a4e3d Merge pull request #3214 from bluewave-labs/fix/bump-mongo
bump mongo for ARM to 6.0
2026-01-28 11:26:39 -08:00
Alex Holliday f41f2322cf remove unused import 2026-01-28 19:26:15 +00:00
Alex Holliday b653f26c96 format 2026-01-28 19:24:17 +00:00
Alex Holliday ece1dadb78 correctly convert to entity 2026-01-28 19:20:16 +00:00
Alex Holliday 1f003c4d48 fix breakpoint 2026-01-28 19:06:21 +00:00
Alex Holliday 5a468d717a Responsive 2026-01-28 19:00:34 +00:00
Alex Holliday 975d134dd8 extract strings 2026-01-28 18:45:11 +00:00
Alex Holliday 7a66b5310b expose isValidating 2026-01-28 18:03:33 +00:00
Alex Holliday fcfad945a0 selects 2026-01-28 17:59:11 +00:00
Alex Holliday 6b4032ecfe bump mongo for ARM to 6.0 2026-01-28 17:08:36 +00:00
sukritraj02 41d6c092d4 eat: improve notification grouping email subject and monitor name formatting 2026-01-28 18:53:51 +05:30
sukritraj02 f9e366e181 fixed notificationsService.ts 2026-01-28 18:23:22 +05:30
Alex Holliday 07705e9823 fix recent 2026-01-27 23:21:04 +00:00
Alex Holliday c48049e0af checks table 2026-01-27 23:17:40 +00:00
Alex Holliday f3ba8c9815 monitor select 2026-01-27 23:05:00 +00:00
Alex Holliday f88e4ee6f4 refactor 2026-01-27 22:24:16 +00:00
Alex Holliday 300b1b2238 initial commit 2026-01-27 21:57:09 +00:00
230 changed files with 8875 additions and 17657 deletions

No files matched your search

+2 -3
View File
@@ -79,9 +79,8 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/)
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you!
+557 -1722
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -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;
@@ -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>
-1
View File
@@ -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;
};
+22 -5
View File
@@ -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>
);
};
+88
View File
@@ -0,0 +1,88 @@
import Radio from "@mui/material/Radio";
import type { RadioProps } from "@mui/material/Radio";
import { useTheme } from "@mui/material/styles";
import { Circle, CircleDot } from "lucide-react";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
interface RadioInputProps extends RadioProps {}
export const RadioInput = ({ ...props }: RadioInputProps) => {
const theme = useTheme();
return (
<Radio
{...props}
icon={
<Circle
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<CircleDot
size={14}
strokeWidth={1.5}
/>
}
sx={{
padding: 0,
mt: theme.spacing(0.5),
color: theme.palette.text.secondary,
"&.Mui-checked": {
color: theme.palette.primary.main,
"& svg circle": {
fill: theme.palette.primary.main,
},
},
"& .MuiSvgIcon-root": {
fontSize: 16,
},
"& svg": {
stroke: "currentColor",
},
"& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
stroke: "currentColor",
fill: "none",
},
}}
/>
);
};
export const RadioWithDescription = ({
label,
description,
...props
}: RadioInputProps & { label: string; description: string }) => {
const theme = useTheme();
return (
<FormControlLabel
control={<RadioInput {...props} />}
label={
<>
<Typography component="p">{label}</Typography>
<Typography
component="h6"
color={theme.palette.text.secondary}
>
{description}
</Typography>
</>
}
sx={{
alignItems: "flex-start",
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.background.paper,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
/>
);
};
+14 -12
View File
@@ -1,7 +1,7 @@
import Select from "@mui/material/Select";
import React from "react";
import { useTheme } from "@mui/material/styles";
import type { SelectProps } from "@mui/material/Select";
import React, { forwardRef } from "react";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
@@ -14,18 +14,15 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
placeholderColor?: string;
}
export const SelectInput = <T,>({
fieldLabel,
required,
placeholder,
placeholderColor,
...props
}: SelectInputProps<T>) => {
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, placeholderColor, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
const renderValue = (selected: any) => {
const isMultiple = Boolean((props as any).multiple);
const renderValue = (selected: unknown) => {
const isMultiple = Boolean((props as { multiple?: boolean }).multiple);
const isEmpty = isMultiple
? !Array.isArray(selected) || selected.length === 0
: selected === undefined || selected === null || selected === "";
@@ -39,7 +36,7 @@ export const SelectInput = <T,>({
const capitalized = items.map(
(item) => item.charAt(0).toUpperCase() + item.slice(1)
);
return (<Typography>{capitalized.join(" | ")}</Typography>) as any;
return <Typography>{capitalized.join(" | ")}</Typography>;
}
const nodes = React.Children.toArray(props.children as React.ReactNode);
@@ -59,6 +56,7 @@ export const SelectInput = <T,>({
const select = (
<Select<T>
{...props}
ref={ref}
displayEmpty
renderValue={renderValue}
inputProps={{
@@ -100,3 +98,7 @@ export const SelectInput = <T,>({
return select;
};
export const SelectInput = forwardRef(SelectInputInner) as <T>(
props: SelectInputProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => React.ReactElement;
@@ -0,0 +1,89 @@
import Typography from "@mui/material/Typography";
import { forwardRef } from "react";
import Slider from "@mui/material/Slider";
import type { SliderProps } from "@mui/material/Slider";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import Box from "@mui/material/Box";
import type { ResponsiveStyleValue } from "@mui/system";
interface SliderInputProps extends SliderProps {
sx?: SliderProps["sx"];
showValue?: boolean;
}
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
({ sx, showValue = false, ...props }, ref) => {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Stack
gap={theme.spacing(8)}
direction={"row"}
alignItems={"center"}
>
{showValue && <Typography>{props.value}</Typography>}
<Slider
{...props}
ref={ref}
sx={[
{
"& .MuiSlider-track": {
backgroundColor: theme.palette.primary.main,
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: theme.palette.grey[300],
opacity: 1,
},
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
"&:hover, &.Mui-focusVisible": {
boxShadow: `0 0 0 8px ${theme.palette.primary.main}20`,
},
},
"& .MuiSlider-valueLabel": {
backgroundColor: theme.palette.primary.main,
},
},
...additionalSx,
]}
/>
</Stack>
);
}
);
interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string;
required?: boolean;
showValue?: boolean;
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
}
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
(
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
ref
) => {
const theme = useTheme();
const labelText = fieldLabel;
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{labelText}</FieldLabel>}
<Box maxWidth={sliderMaxWidth}>
<SliderInput
{...props}
showValue={showValue}
value={value}
ref={ref}
/>
</Box>
</Stack>
);
}
);
@@ -0,0 +1,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}
@@ -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";
+39 -2
View File
@@ -1,7 +1,7 @@
import useSWR from "swr";
import type { SWRConfiguration } from "swr";
import type { AxiosRequestConfig } from "axios";
import { get, patch, post, 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);
+19 -403
View File
@@ -1,107 +1,8 @@
import { useEffect, useState } from "react";
import { networkService } from "../main.jsx";
import { createToast } from "../Utils/toastUtils.jsx";
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "./useMonitorUtils.js";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export const useFetchMonitorsWithSummary = ({ types, monitorUpdateTrigger }) => {
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState(undefined);
const [monitorsSummary, setMonitorsSummary] = useState(undefined);
const [networkError, setNetworkError] = useState(false);
useEffect(() => {
const fetchMonitors = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorsWithSummaryByTeamId({
types,
});
const { monitors, summary } = res?.data?.data ?? {};
setMonitors(monitors);
setMonitorsSummary(summary);
} catch (error) {
console.error(error);
setNetworkError(true);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [types, monitorUpdateTrigger]);
return [monitors, monitorsSummary, isLoading, networkError];
};
export const useFetchMonitorsWithChecks = ({
types,
limit,
page,
rowsPerPage,
filter,
field,
order,
monitorUpdateTrigger,
}) => {
const [isLoading, setIsLoading] = useState(false);
const [count, setCount] = useState(undefined);
const [monitors, setMonitors] = useState(undefined);
const [summary, setSummary] = useState(undefined);
const [networkError, setNetworkError] = useState(false);
const theme = useTheme();
const { getMonitorWithPercentage } = useMonitorUtils();
useEffect(() => {
const fetchMonitors = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorsWithChecksByTeamId({
limit,
types,
page,
rowsPerPage,
filter,
field,
order,
});
const { count, monitors, summary } = res?.data?.data ?? {};
const mappedMonitors = monitors.map((monitor) =>
getMonitorWithPercentage(monitor, theme)
);
setSummary(summary);
setMonitors(mappedMonitors);
setCount(count || 0);
} catch (error) {
console.error(error);
setNetworkError(true);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [
field,
filter,
getMonitorWithPercentage,
limit,
order,
page,
rowsPerPage,
theme,
types,
monitorUpdateTrigger,
]);
return [summary, monitors, count, isLoading, networkError];
};
export const useFetchMonitorsByTeamId = ({ types, filter, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(false);
const [monitors, setMonitors] = useState(undefined);
@@ -132,225 +33,17 @@ export const useFetchMonitorsByTeamId = ({ types, filter, updateTrigger }) => {
return [monitors, isLoading, networkError];
};
export const useFetchStatsByMonitorId = ({
monitorId,
sortOrder,
limit,
dateRange,
numToDisplay,
normalize,
updateTrigger,
}) => {
const [monitor, setMonitor] = useState(undefined);
const [audits, setAudits] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
useEffect(() => {
const fetchMonitor = async () => {
try {
setIsLoading(true);
const res = await networkService.getStatsByMonitorId({
monitorId: monitorId,
sortOrder,
limit,
dateRange,
numToDisplay,
normalize,
});
setMonitor(res?.data?.data ?? undefined);
setAudits(res?.data?.data?.checks?.[0]?.audits ?? undefined);
} catch (error) {
setNetworkError(true);
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, dateRange, numToDisplay, normalize, sortOrder, limit, updateTrigger]);
return [monitor, audits, isLoading, networkError];
};
export const useFetchMonitorGames = ({ setGames, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchGames = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorGames();
setGames(res.data.data);
} catch (error) {
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchGames();
}, [setGames, updateTrigger]);
return [isLoading];
};
export const useFetchMonitorById = ({ monitorId, setMonitor, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (typeof monitorId === "undefined") {
setIsLoading(false);
return;
}
const fetchMonitor = async () => {
try {
setIsLoading(true);
const res = await networkService.getMonitorById({ monitorId: monitorId });
setMonitor(res.data.data);
} catch (error) {
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, setMonitor, updateTrigger]);
return [isLoading];
};
export const useFetchHardwareMonitorById = ({ monitorId, dateRange, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [monitor, setMonitor] = useState(undefined);
useEffect(() => {
const fetchMonitor = async () => {
try {
if (!monitorId) {
return { monitor: undefined, isLoading: false, networkError: undefined };
}
const response = await networkService.getHardwareDetailsByMonitorId({
monitorId: monitorId,
dateRange: dateRange,
});
setMonitor(response.data.data);
} catch (error) {
setNetworkError(true);
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, dateRange, updateTrigger]);
return [monitor, isLoading, networkError];
};
export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigger }) => {
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [monitor, setMonitor] = useState(undefined);
const [monitorStats, setMonitorStats] = useState(undefined);
useEffect(() => {
const fetchMonitor = async () => {
try {
if (!monitorId) {
return { monitor: undefined, isLoading: false, networkError: undefined };
}
const response = await networkService.getPageSpeedDetailsByMonitorId({
monitorId: monitorId,
dateRange: dateRange,
});
setMonitor(response.data.data.monitor);
setMonitorStats(response.data.data.monitorStats);
} catch (error) {
setNetworkError(true);
} finally {
setIsLoading(false);
}
};
fetchMonitor();
}, [monitorId, dateRange, updateTrigger]);
return [monitor, monitorStats, isLoading, networkError];
};
export const useFetchUptimeMonitorById = ({ monitorId, dateRange, trigger }) => {
const [networkError, setNetworkError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [monitor, setMonitor] = useState(undefined);
const [monitorStats, setMonitorStats] = useState(undefined);
useEffect(() => {
const fetchMonitors = async () => {
try {
const res = await networkService.getUptimeDetailsById({
monitorId: monitorId,
dateRange: dateRange,
normalize: true,
});
const { monitorData, monitorStats } = res?.data?.data ?? {};
setMonitor(monitorData);
setMonitorStats(monitorStats);
} catch (error) {
setNetworkError(true);
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [dateRange, monitorId, trigger]);
return [monitor, monitorStats, isLoading, networkError];
};
export const useCreateMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const createMonitor = async ({ monitor, redirect }) => {
try {
setIsLoading(true);
await networkService.createMonitor({ monitor });
createToast({ body: "Monitor created successfully!" });
if (redirect) {
navigate(redirect);
}
} catch (error) {
createToast({ body: "Failed to create monitor." });
} finally {
setIsLoading(false);
}
};
return [createMonitor, isLoading];
};
export const useFetchGlobalSettings = () => {
const [isLoading, setIsLoading] = useState(true);
const [globalSettings, setGlobalSettings] = useState(undefined);
useEffect(() => {
const fetchGlobalSettings = async () => {
try {
const res = await networkService.getAppSettings();
setGlobalSettings(res?.data);
} catch (error) {
console.error("Failed to fetch global settings:", error);
createToast({ body: "Failed to load global settings" });
} finally {
setIsLoading(false);
}
};
fetchGlobalSettings();
}, []);
return [globalSettings, isLoading];
};
export const useDeleteMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const deleteMonitor = async ({ monitor, redirect }) => {
const { t } = useTranslation();
const deleteMonitor = async (monitorId, successCallback = () => {}) => {
try {
setIsLoading(true);
await networkService.deleteMonitorById({ monitorId: monitor.id });
createToast({ body: "Monitor deleted successfully!" });
if (redirect) {
navigate(redirect);
}
await networkService.deleteMonitorById({ monitorId });
successCallback();
createToast({ body: t("monitorDeleted") });
} catch (error) {
createToast({ body: "Failed to delete monitor." });
createToast({ body: t("failedDeleteMonitor") });
} finally {
setIsLoading(false);
}
@@ -358,68 +51,24 @@ export const useDeleteMonitor = () => {
return [deleteMonitor, isLoading];
};
export const useUpdateMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const updateMonitor = async ({ monitor, redirect }) => {
try {
setIsLoading(true);
const updatedFields = {
name: monitor.name,
statusWindowSize: monitor.statusWindowSize,
statusWindowThreshold: monitor.statusWindowThreshold,
description: monitor.description,
interval: monitor.interval,
notifications: monitor.notifications,
matchMethod: monitor.matchMethod,
expectedValue: monitor.expectedValue,
ignoreTlsErrors: monitor.ignoreTlsErrors,
jsonPath: monitor.jsonPath,
...((monitor.type === "port" || monitor.type === "game") && {
port: monitor.port,
}),
...(monitor.type == "game" && {
gameId: monitor.gameId,
}),
...(monitor.type === "hardware" && {
thresholds: monitor.thresholds,
secret: monitor.secret,
selectedDisks: monitor.selectedDisks,
}),
};
await networkService.updateMonitor({
monitorId: monitor.id,
updatedFields,
});
createToast({ body: "Monitor updated successfully!" });
if (redirect) {
navigate(redirect);
}
} catch (error) {
createToast({ body: "Failed to update monitor." });
} finally {
setIsLoading(false);
}
};
return [updateMonitor, isLoading];
};
export const usePauseMonitor = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const pauseMonitor = async ({ monitorId, triggerUpdate }) => {
const [error, setError] = useState(null);
const { t } = useTranslation();
const pauseMonitor = async (monitorId, successCallback = () => {}) => {
try {
setIsLoading(true);
const res = await networkService.pauseMonitorById({ monitorId });
createToast({
body: res.data.data.isActive
? "Monitor resumed successfully"
: "Monitor paused successfully",
});
triggerUpdate();
successCallback();
if (res.data.data.isActive === false) {
createToast({ body: t("monitorPaused") });
} else {
createToast({ body: t("monitorResumed") });
}
} catch (error) {
setError(error);
setError(error.message);
createToast({ body: t("failedPauseMonitor") });
} finally {
setIsLoading(false);
}
@@ -491,7 +140,7 @@ export const useCreateBulkMonitors = () => {
try {
const response = await networkService.createBulkMonitors(formData);
return [true, response.data, null]; // [success, data, error]
return [true, response.data, null];
} catch (err) {
const errorMessage = err?.response?.data?.msg || err.message;
return [false, null, errorMessage];
@@ -503,39 +152,6 @@ export const useCreateBulkMonitors = () => {
return [createBulkMonitors, isLoading];
};
export const useExportMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const exportMonitors = async () => {
setIsLoading(true);
try {
const response = await networkService.exportMonitors();
// Create a download link
const url = window.URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "monitors.csv");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
createToast({ body: t("export.success") });
return [true, null];
} catch (err) {
const errorMessage = err?.response?.data?.msg || err.message;
createToast({ body: errorMessage || t("export.failed") });
return [false, errorMessage];
} finally {
setIsLoading(false);
}
};
return [exportMonitors, isLoading];
};
export const useFetchJson = () => {
const [isLoading, setIsLoading] = useState(false);
const fetchJson = async () => {
+108
View File
@@ -0,0 +1,108 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
data?: Monitor | null;
defaultType?: MonitorType;
}
const getBaseDefaults = (data?: Monitor | null) => ({
name: data?.name || "",
description: data?.description || "",
interval: data?.interval || 60000,
notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
});
export const useMonitorForm = ({
data = null,
defaultType = "http",
}: UseMonitorFormOptions = {}) => {
return useMemo(() => {
const type = data?.type || defaultType;
const base = getBaseDefaults(data);
let defaults: MonitorFormData;
switch (type) {
case "http":
defaults = {
...base,
type: "http",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
};
break;
case "ping":
defaults = {
...base,
type: "ping",
url: data?.url || "",
};
break;
case "port":
defaults = {
...base,
type: "port",
url: data?.url || "",
port: data?.port || 80,
};
break;
case "docker":
defaults = {
...base,
type: "docker",
url: data?.url || "",
};
break;
case "game":
defaults = {
...base,
type: "game",
url: data?.url || "",
port: data?.port || 27015,
gameId: data?.gameId || "",
};
break;
case "pagespeed":
defaults = {
...base,
type: "pagespeed",
url: data?.url || "",
};
break;
case "hardware":
defaults = {
...base,
type: "hardware",
url: data?.url || "",
secret: data?.secret || "",
cpuAlertThreshold: data?.cpuAlertThreshold ?? 80,
memoryAlertThreshold: data?.memoryAlertThreshold ?? 80,
diskAlertThreshold: data?.diskAlertThreshold ?? 80,
tempAlertThreshold: data?.tempAlertThreshold ?? 80,
selectedDisks: data?.selectedDisks || [],
};
break;
default:
defaults = {
...base,
type: "http",
url: "",
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: "",
expectedValue: "",
jsonPath: "",
};
}
return { schema: monitorSchema, defaults };
}, [data, defaultType]);
};
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions {
data?: Notification | null;
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => {
const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
return { schema: notificationSchema, defaults };
}, [data]);
};
+1 -151
View File
@@ -1,36 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { createToast } from "../Utils/toastUtils.jsx";
import { networkService } from "../main.jsx";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { NOTIFICATION_TYPES } from "../Pages/Notifications/utils.js";
const useCreateNotification = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const createNotification = async (notification) => {
try {
setIsLoading(true);
await networkService.createNotification({ notification });
createToast({
body: t("notifications.create.success"),
});
navigate("/notifications");
} catch (error) {
setError(error);
createToast({
body: t("notifications.create.failed"),
});
} finally {
setIsLoading(false);
}
};
return [createNotification, isLoading, error];
};
const useGetNotificationsByTeamId = (updateTrigger) => {
const [notifications, setNotifications] = useState([]);
@@ -60,119 +31,6 @@ const useGetNotificationsByTeamId = (updateTrigger) => {
return [notifications, isLoading, error];
};
const useDeleteNotification = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const { t } = useTranslation();
const deleteNotification = async (id, callback) => {
try {
setIsLoading(true);
await networkService.deleteNotificationById({ id });
createToast({
body: t("notifications.delete.success"),
});
if (callback) {
callback();
}
} catch (error) {
setError(error);
createToast({
body: t("notifications.delete.failed"),
});
} finally {
setIsLoading(false);
}
};
return [deleteNotification, isLoading, error];
};
const useGetNotificationById = (id, setNotification) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const getNotificationById = useCallback(async () => {
try {
setIsLoading(true);
const response = await networkService.getNotificationById({ id });
const notification = response?.data?.data ?? null;
const notificationData = {
address: notification?.address,
notificationName: notification?.notificationName,
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?.id,
};
setNotification(notificationData);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
}, [id, setNotification]);
useEffect(() => {
if (id) {
getNotificationById();
}
}, [getNotificationById, id]);
return [isLoading, error];
};
const useEditNotification = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const { t } = useTranslation();
const navigate = useNavigate();
const editNotification = async (id, notification) => {
try {
setIsLoading(true);
await networkService.editNotification({ id, notification });
createToast({
body: t("notifications.edit.success"),
});
navigate(`/notifications`);
} catch (error) {
setError(error);
createToast({
body: t("notifications.edit.failed"),
});
} finally {
setIsLoading(false);
}
};
return [editNotification, isLoading, error];
};
const useTestNotification = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const { t } = useTranslation();
const testNotification = async (notification) => {
try {
setIsLoading(true);
await networkService.testNotification({ notification });
createToast({
body: t("notifications.test.success"),
});
} catch (error) {
setError(error);
createToast({
body: error?.response?.data?.msg || t("notifications.test.failed"),
});
} finally {
setIsLoading(false);
}
};
return [testNotification, isLoading, error];
};
const useTestAllNotifications = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
@@ -197,12 +55,4 @@ const useTestAllNotifications = () => {
return [testAllNotifications, isLoading, error];
};
export {
useCreateNotification,
useGetNotificationsByTeamId,
useDeleteNotification,
useGetNotificationById,
useEditNotification,
useTestNotification,
useTestAllNotifications,
};
export { useGetNotificationsByTeamId, useTestAllNotifications };
+42
View File
@@ -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;
-100
View File
@@ -1,100 +0,0 @@
// Components
import { Stack } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import IncidentTable from "./Components/IncidentTable/index.jsx";
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
//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;
+176
View File
@@ -0,0 +1,176 @@
import Stack from "@mui/material/Stack";
import {
BasePage,
TotalChecksBox,
UpChecksBox,
DownChecksBox,
} from "@/Components/v2/design-elements";
import { HeaderTimeRange } from "@/Components/v2/common";
import { Select } from "@/Components/v2/inputs";
import { ChecksTable } from "./Components/ChecksTable";
import { MenuItem } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
const Checks = () => {
const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>();
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<string>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
const monitorsUrl = "/monitors/team";
const summaryUrl = `/checks/team/summary?dateRange=${dateRange}`;
const { data: monitorsResponse, isLoading: isLoadingMonitors } =
useGet<Monitor[]>(monitorsUrl);
const { data: summaryResponse, isLoading: isLoadingSummary } =
useGet<ChecksSummary>(summaryUrl);
const selectedMonitorType = monitorsResponse?.find(
(m) => m.id === selectedMonitor
)?.type;
const teamChecksUrl = useMemo(() => {
if (selectedMonitor !== "0") return null;
const params = new URLSearchParams();
params.append("sortOrder", "desc");
if (dateRange) params.append("dateRange", dateRange);
if (statusFilter) params.append("filter", statusFilter);
params.append("page", String(page));
params.append("rowsPerPage", String(rowsPerPage));
return `/checks/team?${params.toString()}`;
}, [selectedMonitor, dateRange, statusFilter, page, rowsPerPage]);
const monitorChecksUrl = useMemo(() => {
if (selectedMonitor === "0" || !selectedMonitorType) return null;
const params = new URLSearchParams();
params.append("type", selectedMonitorType);
params.append("sortOrder", "desc");
if (statusFilter) params.append("filter", statusFilter);
if (dateRange) params.append("dateRange", dateRange);
params.append("page", String(page));
params.append("rowsPerPage", String(rowsPerPage));
return `/checks/${selectedMonitor}?${params.toString()}`;
}, [selectedMonitor, selectedMonitorType, dateRange, statusFilter, page, rowsPerPage]);
const {
data: teamChecksData,
isLoading: isLoadingTeamChecks,
isValidating: isValidatingTeamChecks,
} = useGet<ChecksResponse>(
teamChecksUrl,
{},
{ keepPreviousData: true, refreshInterval: 30000 }
);
const {
data: monitorChecksData,
isLoading: isLoadingMonitorChecks,
isValidating: isValidatingMonitorChecks,
} = useGet<ChecksResponse>(
monitorChecksUrl,
{},
{ keepPreviousData: true, refreshInterval: 30000 }
);
const checks =
selectedMonitor === "0"
? (teamChecksData?.checks ?? [])
: (monitorChecksData?.checks ?? []);
const checksCount =
selectedMonitor === "0"
? (teamChecksData?.checksCount ?? 0)
: (monitorChecksData?.checksCount ?? 0);
const isLoadingChecks =
isLoadingTeamChecks ||
isLoadingMonitorChecks ||
isValidatingTeamChecks ||
isValidatingMonitorChecks;
const isLoading = isLoadingMonitors || isLoadingSummary || isLoadingChecks;
const totalChecks = summaryResponse?.totalChecks || 0;
const downChecks = summaryResponse?.downChecks || 0;
const upChecks = totalChecks - (summaryResponse?.downChecks || 0);
return (
<BasePage>
<Stack
direction={{ xs: "column", md: "row" }}
gap={4}
>
<TotalChecksBox n={totalChecks} />
<UpChecksBox n={upChecks} />
<DownChecksBox n={downChecks || 0} />
</Stack>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "stretch", md: "center" }}
gap={2}
>
<Stack
gap={2}
direction={{ xs: "column", md: "row" }}
>
<Select
fullWidth
value={selectedMonitor}
onChange={(e: any) => {
setSelectedMonitor(e.target.value);
setPage(0);
}}
>
<MenuItem value="0">{t("pages.checks.selects.monitor.all")}</MenuItem>
{monitorsResponse?.map((monitor) => (
<MenuItem
key={monitor.id}
value={monitor.id}
>
{monitor.name}
</MenuItem>
))}
</Select>
<Select
value={statusFilter}
onChange={(e: any) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="all">{t("pages.checks.selects.status.all")}</MenuItem>
<MenuItem value="up">{t("pages.checks.selects.status.up")}</MenuItem>
<MenuItem value="down">{t("pages.checks.selects.status.down")}</MenuItem>
</Select>
</Stack>
<HeaderTimeRange
isLoading={isLoading || isLoadingChecks}
dateRange={dateRange}
setDateRange={setDateRange}
/>
</Stack>
<ChecksTable
monitors={monitorsResponse ?? null}
checks={checks}
checksCount={checksCount}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
</BasePage>
);
};
export default Checks;
+710
View File
@@ -0,0 +1,710 @@
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
import RadioGroup from "@mui/material/RadioGroup";
import FormControl from "@mui/material/FormControl";
import { Trans, useTranslation } from "react-i18next";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link";
import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import { Trash2 } from "lucide-react";
import { HeaderDeleteControls } from "@/Components/v2/monitors";
import { BasePage, ConfigBox } from "@/Components/v2/design-elements";
import {
RadioWithDescription,
Button,
TextField,
Select,
Autocomplete,
SwitchComponent as Switch,
SliderWithLabel,
Dialog,
} from "@/Components/v2/inputs";
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
import { useMonitorForm } from "@/Hooks/useMonitorForm";
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { MonitorFormData } from "@/Validation/monitor";
interface GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
namePlaceholder: string;
showUrl: boolean;
showPort: boolean;
showGameSelect: boolean;
showSecret: boolean;
}
const getGeneralSettingsConfig = (
type: MonitorType,
t: (key: string) => string
): GeneralSettingsConfig => {
const configs: Record<string, GeneralSettingsConfig> = {
http: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
},
ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
},
port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: true,
showGameSelect: false,
showSecret: false,
},
game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: true,
showGameSelect: true,
showSecret: false,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: true,
},
};
return configs[type] || configs.http;
};
const CreateMonitorPage = () => {
const theme = useTheme();
const { t } = useTranslation();
const { monitorId } = useParams();
const location = useLocation();
const navigate = useNavigate();
const isEditMode = Boolean(monitorId);
// Extract page type from URL path (e.g., /pagespeed/create -> pagespeed)
const pageType = useMemo(() => {
const pathSegments = location.pathname.split("/").filter(Boolean);
const firstSegment = pathSegments[0];
if (firstSegment === "pagespeed") return "pagespeed";
if (firstSegment === "infrastructure") return "hardware";
return "uptime";
}, [location.pathname]);
const showTypeSelector = pageType === "uptime" && !isEditMode;
const defaultType: MonitorType =
pageType === "pagespeed"
? "pagespeed"
: pageType === "hardware"
? "hardware"
: "http";
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
isEditMode ? `/monitors/${monitorId}` : null
);
const { data: notifications } = useGet<Notification[]>("/notifications/team");
const { data: games } = useGet<GamesMap>("/monitors/games");
const { schema, defaults } = useMonitorForm({
data: existingMonitor ?? null,
defaultType,
});
const form = useForm<MonitorFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { control, watch, handleSubmit, clearErrors } = form;
useEffect(() => {
form.reset(defaults);
}, [defaults, form]);
const watchedType = watch("type") as MonitorType;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
useEffect(() => {
clearErrors();
}, [watchedType, clearErrors]);
const generalSettingsConfig = useMemo(
() => getGeneralSettingsConfig(watchedType, t),
[watchedType, t]
);
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;
// Delete functionality
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { deleteFn, loading: isDeleting } = useDelete();
const handleDeleteClick = () => {
setIsDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!monitorId) return;
await deleteFn(`/monitors/${monitorId}`);
setIsDeleteDialogOpen(false);
// Navigate based on page type
if (pageType === "pagespeed") {
navigate("/pagespeed");
} else if (pageType === "hardware") {
navigate("/infrastructure");
} else {
navigate("/uptime");
}
};
const handleDeleteCancel = () => {
setIsDeleteDialogOpen(false);
};
const onSubmit = async (data: MonitorFormData) => {
let result;
if (isEditMode && monitorId) {
result = await patch(`/monitors/${monitorId}`, data);
} else {
result = await post("/monitors", data);
}
if (result?.success) {
if (pageType === "pagespeed") {
navigate("/pagespeed");
} else if (pageType === "hardware") {
navigate("/infrastructure");
} else {
navigate("/uptime");
}
}
};
const onError = (errors: unknown) => {
console.log(errors);
};
return (
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
<HeaderDeleteControls
monitor={existingMonitor}
isAdmin={true}
refetch={refetchMonitor}
onDelete={handleDeleteClick}
/>
{/* Monitor Type Selection - only shown for uptime monitors */}
{showTypeSelector && (
<ConfigBox
title={t("pages.createMonitor.form.type.title")}
subtitle={t("pages.createMonitor.form.type.description")}
rightContent={
<Controller
name="type"
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
<RadioGroup
{...field}
sx={{ gap: theme.spacing(6) }}
>
<RadioWithDescription
value="http"
label={t("pages.createMonitor.form.type.optionHttp")}
description={t(
"pages.createMonitor.form.type.optionHttpDescription"
)}
/>
<RadioWithDescription
value="ping"
label={t("pages.createMonitor.form.type.optionPing")}
description={t(
"pages.createMonitor.form.type.optionPingDescription"
)}
/>
<RadioWithDescription
value="docker"
label={t("pages.createMonitor.form.type.optionDocker")}
description={t(
"pages.createMonitor.form.type.optionDockerDescription"
)}
/>
<RadioWithDescription
value="port"
label={t("pages.createMonitor.form.type.optionPort")}
description={t(
"pages.createMonitor.form.type.optionPortDescription"
)}
/>
<RadioWithDescription
value="game"
label={t("pages.createMonitor.form.type.optionGame")}
description={t(
"pages.createMonitor.form.type.optionGameDescription"
)}
/>
</RadioGroup>
</FormControl>
)}
/>
}
/>
)}
<ConfigBox
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
rightContent={
<Stack spacing={theme.spacing(8)}>
{/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && (
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={generalSettingsConfig.urlLabel}
placeholder={generalSettingsConfig.urlPlaceholder}
fullWidth
disabled={isEditMode}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{/* Port field - only for port and game types */}
{generalSettingsConfig.showPort && (
<Controller
name="port"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
onChange={(e) => field.onChange(Number(e.target.value) || 0)}
type="number"
fieldLabel={t("portToMonitor")}
placeholder="5173"
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{/* Game select - only for game type */}
{generalSettingsConfig.showGameSelect && (
<Controller
name="gameId"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? ""}
fieldLabel={t("chooseGame")}
error={!!fieldState.error}
>
<MenuItem value="">Select a game</MenuItem>
{games &&
Object.entries(games).map(([key, game]) => (
<MenuItem
key={key}
value={key}
>
{game.name}
</MenuItem>
))}
</Select>
)}
/>
)}
{/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && (
<Controller
name="secret"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
placeholder={t(
"pages.createMonitor.form.general.option.secret.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
placeholder={generalSettingsConfig.namePlaceholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
subtitle={t("pages.createMonitor.form.frequency.description")}
rightContent={
<Controller
name="interval"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? 60000}
fieldLabel={t(
"pages.createMonitor.form.frequency.option.frequency.label"
)}
error={!!fieldState.error}
>
<MenuItem value={15000}>{t("time.fifteenSeconds")}</MenuItem>
<MenuItem value={30000}>{t("time.thirtySeconds")}</MenuItem>
<MenuItem value={60000}>{t("time.oneMinute")}</MenuItem>
<MenuItem value={120000}>{t("time.twoMinutes")}</MenuItem>
<MenuItem value={180000}>{t("time.threeMinutes")}</MenuItem>
<MenuItem value={240000}>{t("time.fourMinutes")}</MenuItem>
<MenuItem value={300000}>{t("time.fiveMinutes")}</MenuItem>
<MenuItem value={600000}>{t("time.tenMinutes")}</MenuItem>
<MenuItem value={900000}>{t("time.fifteenMinutes")}</MenuItem>
<MenuItem value={1800000}>{t("time.thirtyMinutes")}</MenuItem>
</Select>
)}
/>
}
/>
<ConfigBox
title={t("pages.createMonitor.form.incidents.title")}
subtitle={t("pages.createMonitor.form.incidents.description")}
rightContent={
<Stack spacing={theme.spacing(12)}>
<Controller
name="statusWindowSize"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t("pages.createMonitor.form.incidents.option.checks.label")}
min={1}
max={25}
valueLabelDisplay="auto"
/>
)}
/>
<Controller
name="statusWindowThreshold"
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
fieldLabel={t(
"pages.createMonitor.form.incidents.option.percentage.label"
)}
min={1}
max={100}
valueLabelDisplay="auto"
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.createMonitor.form.notifications.title")}
subtitle={t("pages.createMonitor.form.notifications.description")}
rightContent={
<Controller
name="notifications"
control={control}
render={({ field }) => {
// Map notifications to have 'name' property for v2 Autocomplete
const notificationOptions = (notifications ?? []).map((n) => ({
...n,
name: n.notificationName,
}));
const selectedNotifications = notificationOptions.filter((n) =>
(field.value ?? []).includes(n.id)
);
return (
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={notificationOptions}
value={selectedNotifications}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof notificationOptions) => {
field.onChange(newValue.map((n) => n.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{selectedNotifications.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedNotifications.map((notification, index) => (
<Stack
direction="row"
alignItems="center"
key={notification.id}
width="100%"
>
<Typography flexGrow={1}>
{notification.notificationName}
</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== notification.id
)
);
}}
aria-label="Remove notification"
>
<Trash2 size={16} />
</IconButton>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")}
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
rightContent={
<Controller
name="ignoreTlsErrors"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t("pages.createMonitor.form.ignoreTls.option.tls.label")}
</Typography>
</Stack>
)}
/>
}
/>
)}
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.advanced.title")}
subtitle={t("pages.createMonitor.form.advanced.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="useAdvancedMatching"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
)}
</Typography>
</Stack>
)}
/>
{watchedUseAdvancedMatching && (
<Stack spacing={theme.spacing(8)}>
<Controller
name="matchMethod"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value ?? "equal"}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.matchMethod.label"
)}
>
<MenuItem value="equal">{t("matchMethodOptions.equal")}</MenuItem>
<MenuItem value="include">
{t("matchMethodOptions.include")}
</MenuItem>
<MenuItem value="regex">{t("matchMethodOptions.regex")}</MenuItem>
</Select>
)}
/>
<Controller
name="expectedValue"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.expectedValue.label"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="jsonPath"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.jsonPath.label"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Typography
component="span"
color="text.secondary"
sx={{ opacity: 0.8 }}
>
<Trans
i18nKey="pages.createMonitor.form.advanced.option.jsonPath.description"
components={{
jmesLink: (
<Link
href="https://jmespath.org/"
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
</Typography>
</Stack>
)}
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
</BasePage>
);
};
export default CreateMonitorPage;
@@ -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;
-178
View File
@@ -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;
+245
View File
@@ -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;
@@ -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