Compare commits

..
Author SHA1 Message Date
mannilakash 98943ad461 fix: removing JSdoc for usedebounce and comments 2026-02-10 08:10:44 -05:00
mannilakash c670653ab3 fix: format fix 2026-02-09 12:40:44 -05:00
mannilakash e75606aad3 refactor: debounce.jsx to typescript and moving it to Hooks 2026-02-09 12:38:39 -05:00
mannilakash 07b0483352 fix: changes for debounce search in uptime/infrastructure pages 2026-02-09 10:43:29 -05:00
Alexander Holliday 8555e7c122 Merge pull request #3254 from bluewave-labs/feat/v2-register
feat/v2 register
2026-02-06 15:39:17 -08:00
Alex Holliday 734ec41ca0 cleanup 2026-02-06 23:37:25 +00:00
Alex Holliday 5152336591 fix validation, derrive teamID and Roles from tokens 2026-02-06 23:31:07 +00:00
Alex Holliday 78b920aaf6 missing fields 2026-02-06 23:07:17 +00:00
Alex Holliday 2d84fae470 form 2026-02-06 22:53:41 +00:00
Alex Holliday 97e4b105a2 Merge branch 'develop' into feat/v2-register 2026-02-06 22:35:19 +00:00
Alex Holliday a053386f4a ui 2026-02-06 22:34:50 +00:00
Alexander Holliday e0991e28e5 Merge pull request #3253 from bluewave-labs/feat/v2/password-reset
feat: v2/password reset
2026-02-06 14:22:37 -08:00
Alex Holliday 0f26648855 remove old page 2026-02-06 22:18:44 +00:00
Alex Holliday 6a0f3426b3 recovery form, fix email service 2026-02-06 22:14:42 +00:00
Alex Holliday 16e8035299 ui 2026-02-06 21:50:45 +00:00
Alexander Holliday 31b09c316a Merge pull request #3251 from bluewave-labs/feat/v2-auth-login
feat: v2 auth login
2026-02-06 13:36:27 -08:00
Alex Holliday 8f2072e497 cleanup 2026-02-06 21:35:25 +00:00
Alex Holliday 514570da78 login page refactor 2026-02-06 21:11:46 +00:00
Alex Holliday ea68e660fc login UI 2026-02-06 20:53:48 +00:00
Alex Holliday 527543d79f base page, auth header, language and theme switches 2026-02-06 20:32:10 +00:00
Alexander Holliday 1c390a8baf Merge pull request #3250 from bluewave-labs/fix/unit-formatting
Fix/unit formatting
2026-02-06 11:43:48 -08:00
Alex Holliday 43ac5b57cf time formatting 2026-02-06 19:42:06 +00:00
Alex Holliday 1558b27497 bytes and percentage 2026-02-06 19:25:53 +00:00
Alexander Holliday 9caa6c830f Merge pull request #3249 from bluewave-labs/feat/v2-logs
Feat/v2 logs
2026-02-06 11:00:31 -08:00
Alex Holliday ea115ede53 cleanup 2026-02-06 18:56:10 +00:00
Alex Holliday 8470631162 diagnostics 2026-02-06 18:46:06 +00:00
Alex Holliday 597732e109 stats 2026-02-06 18:20:26 +00:00
Alex Holliday 9974fa4010 headers 2026-02-06 17:24:16 +00:00
Alex Holliday b0686c0576 queue tab 2026-02-05 21:40:36 +00:00
Alex Holliday a148f56b22 logs tab 2026-02-05 20:23:04 +00:00
Alexander Holliday 3b25b37c22 Merge pull request #3247 from bluewave-labs/fix/empty
Fix: empty state
2026-02-05 10:59:44 -08:00
Alexander Holliday 1e04345aa0 Merge pull request #3185 from llpingll/change-password
feat: Allow all users to change password (not just admins)
2026-02-05 10:59:20 -08:00
Alexander Holliday 174c3cd47a Merge pull request #3246 from bluewave-labs/fix/alignment
Fix/alignment
2026-02-05 10:56:59 -08:00
Alex Holliday 0e149fa477 fix empty state 2026-02-05 18:56:54 +00:00
Alex Holliday 5b4a99af3d fix select alignment 2026-02-05 18:53:51 +00:00
Alex Holliday 2ecdc2096e empty field label 2026-02-05 18:49:47 +00:00
Alexander Holliday 6e66a18535 Merge pull request #3245 from bluewave-labs/fix/pagination-delete
fix: pagination and empty state fix
2026-02-05 10:33:49 -08:00
Alex Holliday bd6aa0fa3a pagination and empty state fix 2026-02-05 18:30:43 +00:00
Alexander Holliday 4e678a4ce7 Merge pull request #3244 from bluewave-labs/feat/husky-lint
feat: add husky
2026-02-05 09:55:50 -08:00
Alex Holliday 5999315791 add gitignore 2026-02-05 17:54:06 +00:00
Alexander Holliday 0c8b8a9358 Merge pull request #3243 from bluewave-labs/feat/v2-create-maintenance
feat: v2 create maintenance
2026-02-05 09:45:20 -08:00
Alex Holliday dfe62bd301 remove old 2026-02-05 17:29:27 +00:00
Alex Holliday 747ff824cc format 2026-02-05 17:23:14 +00:00
Alex Holliday f12c544751 validation fixes 2026-02-05 17:22:24 +00:00
llpingll 278ced2a6c Removed additional lines to avoid en.json conflict 2026-02-04 23:56:16 +00:00
llpingll 88d539b2a4 Hard coded strings to avoid en.json conflicts 2026-02-04 23:32:14 +00:00
Alex Holliday f0b152fc8f duration 2026-02-04 22:55:54 +00:00
Alex Holliday dc0e198a8e monitors 2026-02-04 22:17:04 +00:00
Alex Holliday 0a5067ef46 grab monitors 2026-02-04 22:10:52 +00:00
Alex Holliday 4f0017c031 strings 2026-02-04 21:47:56 +00:00
Alex Holliday f0b2292f0a general 2026-02-04 21:45:39 +00:00
Alexander Holliday d74d79c332 Merge pull request #3242 from bluewave-labs/feat/v2-maintenance
feat: v2 maintenance
2026-02-04 12:06:22 -08:00
Alex Holliday 1a096b66f7 remove unused 2026-02-04 20:03:05 +00:00
Alex Holliday 7cb866ce4a legacy put -> patch 2026-02-04 19:57:36 +00:00
Alex Holliday c54582ca70 format 2026-02-04 19:54:01 +00:00
Alex Holliday 362034ba26 pretty-ms 2026-02-04 19:53:19 +00:00
Alex Holliday b536099d03 create header 2026-02-04 19:40:32 +00:00
Alex Holliday 04fef2ef75 put -> patch 2026-02-04 19:30:41 +00:00
Alex Holliday a89e5ed565 MaintenanceWindow table 2026-02-04 19:22:05 +00:00
Alexander Holliday ca153b65a9 Merge pull request #3241 from bluewave-labs/fix/incidents-selector
fix/incidents selector
2026-02-04 10:31:57 -08:00
Alex Holliday cc188b2ba3 Merge branch 'develop' into fix/incidents-selector 2026-02-04 18:29:49 +00:00
Alexander Holliday 18018ceef1 Merge pull request #3240 from bluewave-labs/fix/invite-permisisons
fix/invite permisisons
2026-02-04 10:13:25 -08:00
Alex Holliday 4f3bcd4624 incident page fixed 2026-02-03 23:45:23 +00:00
Alex Holliday d469590ef3 filter fix 2026-02-03 23:37:16 +00:00
llpingll 090d002578 Add comment to PasswordPanel to encourage additional error handling and remove helperText comments | Remove explicit special characters from en.json 2026-02-03 16:44:15 +00:00
llpingll 69f95723f1 Combine useNavigate and useLocation into one import 2026-02-02 22:23:48 +00:00
Lui Duarte 464b16ec9d Merge branch 'develop' into change-password 2026-02-02 15:49:30 +00:00
llpingll 04cf47544a Fix ForgotPassword format 2026-01-25 23:48:12 +00:00
Lui Duarte e1dfd98840 Merge branch 'develop' into change-password 2026-01-25 23:23:04 +00:00
llpingll 4ce4a124d6 Refactor PasswordPanel to pass navigation context via state to ForgotPassword using TextLink | Update ForgotPassword and CheckEmail to handle navigation context for Back links, ensuring users return to the correct page after password reset | Fix TextLink prop usage to avoid warnings by passing state and href correctly | Ensure error messages and navigation links use translation keys for localization 2026-01-25 22:57:36 +00:00
llpingll f564636cb4 Fix missingKey warning with helper text 2026-01-25 19:11:25 +00:00
llpingll 14a08a303b Fix helper text to correctly use translation to display message not key 2026-01-25 15:45:34 +00:00
llpingll 4645bde025 Allow all users to change password and handle expired sessions 2026-01-25 15:19:21 +00:00
123 changed files with 4049 additions and 3366 deletions

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
.VSCodeCounter
*.sh
mongo
node_modules/
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
echo "Running lint-staged..."
cd client && npx lint-staged && cd ..
cd server && npx lint-staged && cd ..
+577 -1
View File
@@ -26,6 +26,7 @@
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
@@ -51,6 +52,7 @@
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.3.6"
@@ -2277,6 +2279,22 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"dev": true,
@@ -2503,6 +2521,19 @@
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"funding": [
@@ -2628,6 +2659,39 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^7.1.0",
"string-width": "^8.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"license": "MIT",
@@ -2651,6 +2715,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"license": "MIT",
@@ -2661,6 +2732,16 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"dev": true,
@@ -3006,6 +3087,13 @@
"version": "1.5.283",
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"license": "BSD-2-Clause",
@@ -3016,6 +3104,19 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"license": "MIT",
@@ -3512,6 +3613,19 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-root": {
"version": "1.1.0",
"license": "MIT"
@@ -3667,6 +3781,19 @@
"node": ">=6.9.0"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
@@ -4149,6 +4276,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"dev": true,
@@ -4200,6 +4343,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-number-object": {
"version": "1.1.1",
"dev": true,
@@ -4478,6 +4631,56 @@
"version": "1.2.4",
"license": "MIT"
},
"node_modules/lint-staged": {
"version": "16.2.7",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
"integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^14.0.2",
"listr2": "^9.0.5",
"micromatch": "^4.0.8",
"nano-spawn": "^2.0.0",
"pidtree": "^0.6.0",
"string-argv": "^0.3.2",
"yaml": "^2.8.1"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
"eventemitter3": "^5.0.1",
"log-update": "^6.1.0",
"rfdc": "^1.4.1",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/listr2/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"dev": true,
@@ -4501,6 +4704,55 @@
"dev": true,
"license": "MIT"
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
"cli-cursor": "^5.0.0",
"slice-ansi": "^7.1.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/log-update/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"license": "MIT",
@@ -4539,6 +4791,33 @@
"node": ">= 0.4"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",
@@ -4556,6 +4835,19 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"dev": true,
@@ -4593,6 +4885,19 @@
}
}
},
"node_modules/nano-spawn": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"funding": [
@@ -4731,6 +5036,22 @@
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"dev": true,
@@ -4876,6 +5197,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidtree": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true,
"license": "MIT",
"bin": {
"pidtree": "bin/pidtree.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"dev": true,
@@ -4932,6 +5266,18 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-7.1.0.tgz",
"integrity": "sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pretty-ms": {
"version": "9.3.0",
"license": "MIT",
@@ -5280,6 +5626,23 @@
"node": ">=4"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"dev": true,
@@ -5289,6 +5652,13 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"dev": true,
@@ -5565,6 +5935,49 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/snake-case": {
"version": "3.0.4",
"license": "MIT",
@@ -5599,6 +6012,62 @@
"node": ">= 0.4"
}
},
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz",
"integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/string-width/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"dev": true,
@@ -5779,6 +6248,19 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"license": "0BSD"
@@ -6175,6 +6657,84 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrap-ansi/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"dev": true,
@@ -6184,6 +6744,22 @@
"version": "3.1.1",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"devOptional": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"dev": true,
@@ -6203,4 +6779,4 @@
}
}
}
}
}
+5
View File
@@ -12,6 +12,9 @@
"format": "prettier --write .",
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "npm run format"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
@@ -31,6 +34,7 @@
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
@@ -56,6 +60,7 @@
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.3.6"
+3 -1
View File
@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Link as RouterLink } from "react-router-dom";
const TextLink = ({ text, linkText, href, target = "_self" }) => {
const TextLink = ({ text, linkText, href, state, target = "_self" }) => {
const theme = useTheme();
return (
@@ -19,6 +19,7 @@ const TextLink = ({ text, linkText, href, target = "_self" }) => {
to={href}
component={RouterLink}
target={target}
state={state}
>
{linkText}
</Link>
@@ -30,6 +31,7 @@ TextLink.propTypes = {
text: PropTypes.string,
linkText: PropTypes.string,
href: PropTypes.string,
state: PropTypes.object,
target: PropTypes.string,
};
@@ -1,4 +1,6 @@
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import {
@@ -9,6 +11,7 @@ import {
} from "@/Components/v2/design-elements/Fallback";
import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb";
import CircularProgress from "@mui/material/CircularProgress";
import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
@@ -100,7 +103,7 @@ export const BasePage = ({
interface BasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
items: any[];
totalCount: number;
bullets: string[] | unknown;
page: string;
actionButtonText: string;
@@ -111,7 +114,7 @@ interface BasePageWithStatesProps extends StackProps {
export const BasePageWithStates = ({
loading,
error,
items,
totalCount,
page,
bullets,
actionButtonText,
@@ -119,9 +122,9 @@ export const BasePageWithStates = ({
children,
...props
}: BasePageWithStatesProps) => {
const showLoading = loading && (!items || items.length === 0);
const showLoading = loading && totalCount === 0;
if (isEmpty(items)) {
if (!loading && totalCount === 0) {
return (
<EmptyFallback
bullets={bullets}
@@ -146,23 +149,17 @@ export const BasePageWithStates = ({
interface MonitorBasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
items: any[];
totalCount: number;
page: string;
actionLink?: string;
children: React.ReactNode;
priorityFallback?: React.ReactNode;
}
const isEmpty = (items: any[]) => {
if (!items) return true;
if (Array.isArray(items) && items.length === 0) return true;
return false;
};
export const MonitorBasePageWithStates = ({
loading,
error,
items,
totalCount,
page,
actionLink,
children,
@@ -171,7 +168,7 @@ export const MonitorBasePageWithStates = ({
}: MonitorBasePageWithStatesProps) => {
const { t } = useTranslation();
const showLoading = loading && (!items || items.length === 0);
const showLoading = loading && totalCount === 0;
if (priorityFallback) {
return (
@@ -185,7 +182,7 @@ export const MonitorBasePageWithStates = ({
);
}
if (!loading && isEmpty(items)) {
if (!loading && totalCount === 0) {
return (
<EmptyMonitorFallback
page={page}
@@ -207,3 +204,61 @@ export const MonitorBasePageWithStates = ({
</BasePage>
);
};
interface BaseAuthPageProps extends React.PropsWithChildren, StackProps {
title: string;
subtitle: string;
}
export const BaseAuthPage = ({
title,
subtitle,
children,
...props
}: BaseAuthPageProps) => {
const theme = useTheme();
return (
<BasePage
breadcrumbOverride={[]}
gap={theme.spacing(8)}
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
position={"relative"}
{...props}
>
<HeaderAuthControls
hideLogo
py={theme.spacing(4)}
position={"absolute"}
top={0}
left={0}
/>
<Box width={{ xs: 60, sm: 70, md: 80 }}>
<Logo
width={"100%"}
height={"100%"}
/>
</Box>
<Stack alignItems={"center"}>
<Typography
variant="h1"
mb={theme.spacing(2)}
>
{title}
</Typography>
<Typography variant="h2">{subtitle}</Typography>
</Stack>
<Stack
gap={theme.spacing(8)}
width={{
xs: "80%",
md: "25%",
lg: "15%",
}}
>
{children}
</Stack>
</BasePage>
);
};
@@ -1,3 +1,5 @@
import { BaseChart } from "@/Components/v2/design-elements";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
@@ -104,3 +106,50 @@ export const Gauge = ({
</Box>
);
};
export const DetailGauge = ({
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>
);
};
@@ -27,8 +27,9 @@ import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
import { useTranslation } from "react-i18next";
import { useState, Fragment } from "react";
import { useState, Fragment, useEffect } from "react";
import { useTheme } from "@mui/material/styles";
import type { SxProps, Theme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
export type Header<T> = {
@@ -47,6 +48,7 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
renderExpandedContent?: (row: T) => React.ReactNode;
emptyViewText?: string;
emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps<Theme>;
};
export function DataTable<
@@ -64,6 +66,7 @@ export function DataTable<
renderExpandedContent,
emptyViewText,
emptyViewPositive,
getRowSx,
}: DataTableProps<T>) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
@@ -204,6 +207,7 @@ export function DataTable<
<TableRow
sx={{
cursor: onRowClick ? "pointer" : "default",
...(getRowSx?.(row) as object),
}}
onClick={() => {
if (expandableRows) handleExpand(row);
@@ -454,12 +458,26 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
interface PaginationProps extends TablePaginationOwnProps {
component?: React.ElementType;
hasMore?: boolean;
itemsOnPage?: number;
}
export const Pagination = ({ ...props }: PaginationProps) => {
const { hasMore, ...rest } = props;
const { hasMore, itemsOnPage, ...rest } = props;
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const theme = useTheme();
useEffect(() => {
if (
typeof itemsOnPage === "number" &&
itemsOnPage === 0 &&
rest.count > 0 &&
rest.page > 0 &&
rest.onPageChange
) {
rest.onPageChange(null, rest.page - 1);
}
}, [itemsOnPage, rest.count, rest.page, rest.onPageChange]);
const labelDisplayedRows = ({
from,
to,
@@ -0,0 +1,42 @@
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { StackProps } from "@mui/material/Stack";
import Link from "@mui/material/Link";
import { Link as RouterLink } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
interface TextLinkProps extends StackProps {
text: string;
linkText: string;
href: string;
target?: string;
}
export const TextLink = ({
text,
linkText,
href,
target = "_self",
...props
}: TextLinkProps) => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
{...props}
>
<Typography>{text}</Typography>
<Link
color="primary"
to={href}
component={RouterLink}
target={target}
>
{linkText}
</Link>
</Stack>
);
};
@@ -18,3 +18,4 @@ export * from "./BaseChart";
export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
export * from "./TextLink";
@@ -0,0 +1,100 @@
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import type { DatePickerProps } from "@mui/x-date-pickers/DatePicker";
import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel";
import { Calendar } from "lucide-react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
error?: boolean;
helperText?: string;
}
export const DatePickerComponent = ({
fieldLabel,
required,
error,
helperText,
...props
}: DatePickerComponentProps) => {
const theme = useTheme();
const picker = (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
{...props}
slots={{
openPickerIcon: () => (
<Calendar
size={20}
stroke={theme.palette.text.secondary}
/>
),
...props.slots,
}}
slotProps={{
switchViewButton: { sx: { display: "none" } },
nextIconButton: { sx: { ml: theme.spacing(2) } },
day: {
sx: {
"&.MuiPickersDay-root.Mui-disabled": {
color: theme.palette.text.disabled,
},
},
},
field: {
sx: {
width: "fit-content",
"& > .MuiOutlinedInput-root": {
flexDirection: "row-reverse",
},
"& input": {
minHeight: 34,
p: 0,
pl: theme.spacing(3),
pr: theme.spacing(5),
},
"& fieldset": {
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: error ? theme.palette.error.main : theme.palette.divider,
},
},
},
inputAdornment: { sx: { ml: 0 } },
openPickerButton: {
sx: {
py: 0,
mr: 0,
},
},
...props.slotProps,
}}
/>
</LocalizationProvider>
);
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
{picker}
{helperText && (
<Typography
variant="caption"
color={error ? "error" : "text.secondary"}
>
{helperText}
</Typography>
)}
</Stack>
);
};
+10 -8
View File
@@ -59,14 +59,16 @@ export const DialogInput = ({
>
{cancelText ?? t("common.buttons.cancel")}
</Button>
<Button
loading={loading}
variant="contained"
color={confirmColor}
onClick={onConfirm}
>
{confirmText ?? t("common.buttons.confirm")}
</Button>
{onConfirm && (
<Button
loading={loading}
variant="contained"
color={confirmColor}
onClick={onConfirm}
>
{confirmText ?? t("common.buttons.confirm")}
</Button>
)}
</DialogActions>
</Dialog>
);
@@ -9,7 +9,9 @@ interface FieldLabelProps {
export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelProps) => {
const theme = useTheme();
if (children === " ") {
children = "\u00A0";
}
return (
<Typography
component="label"
@@ -0,0 +1,100 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/v2/inputs";
import "flag-icons/css/flag-icons.min.css";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { setLanguage } from "@/Features/UI/uiSlice";
import type { RootState } from "@/Types/state";
const langMap: Record<string, string> = {
cs: "cz",
ja: "jp",
uk: "ua",
vi: "vn",
};
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const { language = "en" } = useSelector((state: RootState) => state.ui);
const dispatch = useDispatch();
const handleChange = (event: any) => {
const newLang = event.target.value;
dispatch(setLanguage(newLang));
};
const languages = Object.keys(i18n.options.resources || {});
return (
<Select
value={language}
onChange={handleChange}
size="small"
// sx={{
// minWidth: 80,
// "& .MuiSelect-select": {
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// },
// "& .MuiSelect-icon": {
// alignSelf: "center",
// },
// }}
>
{languages.map((lang) => {
let parsedLang = lang === "en" ? "gb" : lang;
if (parsedLang.includes("-")) {
parsedLang = parsedLang.split("-")[1].toLowerCase();
}
parsedLang = langMap[parsedLang] || parsedLang;
const flag = parsedLang ? `fi fi-${parsedLang}` : null;
return (
<MenuItem
key={lang}
value={lang}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Stack
direction="row"
spacing={theme.spacing(2)}
alignItems="center"
justifyContent="center"
>
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
}}
>
{flag && <span className={flag} />}
</Box>
<Box
component="span"
sx={{ textTransform: "uppercase" }}
>
{lang}
</Box>
</Stack>
</MenuItem>
);
})}
</Select>
);
};
export default LanguageSelector;
@@ -72,6 +72,12 @@ const SelectInputInner = <T,>(
)}
sx={{
height: "34px",
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
height: "100%",
boxSizing: "border-box",
},
"& .MuiSelect-icon": {
right: theme.spacing(3),
},
@@ -0,0 +1,48 @@
import IconButton from "@mui/material/IconButton";
import { Moon, Sun } from "lucide-react";
import { setMode } from "@/Features/UI/uiSlice.js";
import { useTheme } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
export const SwitchTheme = () => {
const mode = useSelector((state: RootState) => state.ui.mode);
const dispatch = useDispatch();
const theme = useTheme();
const handleChange = () => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
};
return (
<IconButton
id="theme-toggle"
onClick={handleChange}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
"& svg": {
transition: "stroke 0.2s ease",
},
"&:hover svg path, &:hover svg line, &:hover svg polyline, &:hover svg rect, &:hover svg circle":
{
stroke: theme.palette.primary.main,
},
}}
>
{mode === "light" ? (
<Moon
size={16}
strokeWidth={1.5}
/>
) : (
<Sun
size={16}
strokeWidth={1.5}
/>
)}
</IconButton>
);
};
@@ -0,0 +1,61 @@
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker";
import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
}
export const TimePickerComponent = ({
fieldLabel,
required,
...props
}: TimePickerComponentProps) => {
const theme = useTheme();
const picker = (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<MobileTimePicker
{...props}
slotProps={{
field: {
sx: {
width: "fit-content",
"& input": {
minHeight: 34,
p: 0,
px: theme.spacing(5),
},
"& fieldset": {
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: theme.palette.divider,
},
},
},
...props.slotProps,
}}
/>
</LocalizationProvider>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{picker}
</Stack>
);
}
return picker;
};
@@ -14,3 +14,7 @@ export * from "./Switch";
export * from "./Slider";
export * from "./ImageUpload";
export * from "./ColorPicker";
export { DatePickerComponent as DatePicker } from "./DatePicker";
export { TimePickerComponent as TimePicker } from "./TimePicker";
export * from "./LanguageSelector";
export * from "./SwitchTheme";
@@ -1,9 +1,9 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next";
@@ -32,11 +32,16 @@ export const MonitorStatBoxes = ({
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = getHumanReadableDuration(timeSinceLastFailure);
const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const lastCheckTime = getHumanReadableDuration(timeSinceLastCheck);
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const palette = getStatusPalette(monitor?.status);
return (
@@ -55,7 +60,7 @@ export const MonitorStatBoxes = ({
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={monitorStats?.lastResponseTime + " ms"}
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
/>
{monitor?.type === "http" && (
+8 -1
View File
@@ -196,6 +196,13 @@ const authSlice = createSlice({
state.success = true;
state.msg = "Logged out successfully";
},
setAuthState: (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.authToken = action.payload.data.token;
state.user = action.payload.data.user;
},
},
extraReducers: (builder) => {
// Register thunk
@@ -249,4 +256,4 @@ const authSlice = createSlice({
});
export default authSlice.reducer;
export const { clearAuthState } = authSlice.actions;
export const { clearAuthState, setAuthState } = authSlice.actions;
+6 -2
View File
@@ -19,6 +19,9 @@ const initialState = {
infrastructure: {
rowsPerPage: 5,
},
logs: {
rowsPerPage: 15,
},
sidebar: {
collapsed: false,
},
@@ -41,9 +44,10 @@ const uiSlice = createSlice({
},
setRowsPerPage: (state, action) => {
const { table, value } = action.payload;
if (state[table]) {
state[table].rowsPerPage = value;
if (!state[table]) {
state[table] = {};
}
state[table].rowsPerPage = value;
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
const useDebounce = <T>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
@@ -12,6 +12,7 @@ const useDebounce = (value, delay) => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
+13
View File
@@ -0,0 +1,13 @@
import { useMemo } from "react";
import { loginSchema, type LoginFormData } from "@/Validation/login";
export const useLoginForm = () => {
return useMemo(() => {
const defaults: LoginFormData = {
email: "",
password: "",
};
return { schema: loginSchema, defaults };
}, []);
};
@@ -0,0 +1,52 @@
import { useMemo } from "react";
import dayjs from "dayjs";
import {
maintenanceWindowSchema,
repeatOptions,
type MaintenanceWindowFormData,
} from "@/Validation/maintenanceWindow";
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
interface UseMaintenanceWindowFormOptions {
data?: MaintenanceWindow | null;
}
const getRepeatId = (repeatMs: number): string => {
const option = repeatOptions.find((opt) => opt.value === repeatMs);
return option?.id ?? "none";
};
export const useMaintenanceWindowForm = ({
data = null,
}: UseMaintenanceWindowFormOptions = {}) => {
return useMemo(() => {
let defaults: MaintenanceWindowFormData;
if (data) {
const startDate = dayjs(data.start);
defaults = {
name: data.name,
repeat: getRepeatId(data.repeat),
startDate: startDate.format("YYYY-MM-DD"),
startTime: startDate.format("HH:mm"),
duration: data.duration ?? 0,
durationUnit: data.durationUnit ?? "minutes",
monitors: [data.monitorId],
};
} else {
const now = dayjs();
defaults = {
name: "",
repeat: "none",
startDate: now.format("YYYY-MM-DD"),
startTime: now.format("HH:mm"),
duration: 0,
durationUnit: "minutes",
monitors: [],
};
}
return { schema: maintenanceWindowSchema, defaults };
}, [data]);
};
+12
View File
@@ -0,0 +1,12 @@
import { recoverySchema } from "@/Validation/recovery";
export const useRecoveryForm = () => {
const defaults = {
email: "",
};
return {
schema: recoverySchema,
defaults,
};
};
+16
View File
@@ -0,0 +1,16 @@
import { registerSchema, type RegisterFormData } from "@/Validation/register";
export const useRegisterForm = () => {
const defaults: RegisterFormData = {
firstName: "",
lastName: "",
email: "",
password: "",
confirm: "",
};
return {
schema: registerSchema,
defaults,
};
};
-18
View File
@@ -1,18 +0,0 @@
import React from "react";
import AppBar from "@/Components/v1/Common/AppBar.jsx";
import Footer from "@/Components/v1/Common/Footer.jsx";
import { useTranslation } from "react-i18next";
const AboutUs = () => {
const { t } = useTranslation();
return (
<div>
<AppBar />
<h1>{t("aboutus")}</h1>
<Footer />
</div>
);
};
export default AboutUs;
@@ -11,6 +11,9 @@ import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../../Utils/toastUtils.jsx";
import { getTouchedFieldErrors } from "../../../Validation/error.js";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { clearAuthState } from "../../../Features/Auth/authSlice.js";
import TextLink from "@/Components/v1/TextLink/index.jsx";
const defaultPasswordsState = {
password: "",
@@ -28,11 +31,13 @@ const PasswordPanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const SPACING_GAP = theme.spacing(12);
//redux state
const { isLoading } = useSelector((state) => state.auth);
const { isLoading, authToken } = useSelector((state) => state.auth);
const idToName = {
"edit-current-password": "password",
@@ -79,6 +84,16 @@ const PasswordPanel = () => {
const handleSubmit = async (event) => {
event.preventDefault();
// Check for expired/invalid token before submitting
if (!authToken) {
createToast({
body: "Your session has expired. Please log in again.",
});
dispatch(clearAuthState());
navigate("/login");
return;
}
const { error } = newOrChangedCredentials.validate(localData, {
abortEarly: false,
context: { password: localData.newPassword },
@@ -249,8 +264,15 @@ const PasswordPanel = () => {
)}
<Stack
direction="row"
justifyContent="flex-end"
justifyContent="space-between"
alignItems="center"
>
<TextLink
text={"Forgot password?"}
linkText={"Reset password"}
state={{ from: location.pathname }}
href={"/forgot-password"}
/>
<Button
variant="contained"
color="accent"
@@ -263,7 +285,6 @@ const PasswordPanel = () => {
}
sx={{
px: theme.spacing(12),
mt: theme.spacing(20),
}}
>
{t("commonSave", "Save")}
+1 -1
View File
@@ -85,7 +85,7 @@ const Account = ({ open = "profile" }) => {
))}
</CustomTabList>
<ProfilePanel />
{user.role.includes("superadmin") && <PasswordPanel />}
<PasswordPanel />
{!hideTeams && <TeamPanel />}
</TabContext>
</Box>
+10 -3
View File
@@ -2,7 +2,7 @@ import { Box, Button, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import { createToast } from "../../Utils/toastUtils.jsx";
import { forgotPassword } from "../../Features/Auth/authSlice.js";
import { Trans, useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ const CheckEmail = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { t } = useTranslation();
const location = useLocation();
const [email, setEmail] = useState();
const [disabled, setDisabled] = useState(false);
@@ -83,7 +84,9 @@ const CheckEmail = () => {
const handleNavigate = () => {
sessionStorage.removeItem("email");
navigate("/login");
location.state?.from
? navigate(location.state.from, { replace: true })
: navigate("/login");
};
return (
@@ -219,7 +222,11 @@ const CheckEmail = () => {
>
<Typography display="inline-block">
<Trans
i18nKey="auth.forgotPassword.links.login"
i18nKey={
location.state?.from === "/account/password"
? "Go back to <a>Account Password</a>"
: "auth.forgotPassword.links.login"
}
components={{
a: (
<Typography
-234
View File
@@ -1,234 +0,0 @@
import { Box, Stack, Typography, Button } from "@mui/material";
import { useTheme } from "@emotion/react";
import { createToast } from "../../Utils/toastUtils.jsx";
import { useDispatch, useSelector } from "react-redux";
import { forgotPassword } from "../../Features/Auth/authSlice.js";
import { useEffect, useState } from "react";
import { newOrChangedCredentials } from "../../Validation/validation.js";
import { useNavigate } from "react-router-dom";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import Background from "@/assets/Images/background-grid.svg?react";
import IconBox from "@/Components/v1/IconBox/index.jsx";
import Icon from "@/Components/v1/Icon";
import { Trans, useTranslation } from "react-i18next";
import "./index.css";
const ForgotPassword = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useTheme();
const { isLoading } = useSelector((state) => state.auth);
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
email: "",
});
useEffect(() => {
const email = sessionStorage.getItem("email");
email && setForm({ email: sessionStorage.getItem("email") });
}, []);
const { t } = useTranslation();
const handleSubmit = async (event) => {
event.preventDefault();
const { error } = newOrChangedCredentials.validate(form, { abortEarly: false });
if (error) {
// validation errors
const err =
error.details && error.details.length > 0
? error.details[0].message // FIXME: Possibly untranslated string
: t("auth.common.errors.validation");
setErrors({ email: err });
createToast({
body: err,
});
} else {
const action = await dispatch(forgotPassword(form));
if (action.payload.success) {
sessionStorage.setItem("email", form.email);
navigate("/check-email");
createToast({
body: t("auth.forgotPassword.toasts.sent").replace("<email/>", form.email),
});
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg, // FIXME: Potentially untranslated string
});
} else {
// unknown errors
createToast({
body: t("common.toasts.unknownError"),
});
}
}
}
};
const handleChange = (event) => {
const { value } = event.target;
setForm({ email: value });
const { error } = newOrChangedCredentials.validate(
{ email: value },
{ abortEarly: false }
);
if (error) setErrors({ email: error.details[0].message });
else delete errors.email;
};
const handleNavigate = () => {
sessionStorage.removeItem("email");
navigate("/login");
};
return (
<Stack
className="forgot-password-page auth"
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 21,
},
"& p": {
/* TODO font size from theme */
fontSize: 14,
color: theme.palette.primary.contrastTextSecondary,
},
}}
>
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
direction="row"
alignItems="center"
px={theme.spacing(12)}
gap={theme.spacing(4)}
>
<Logo style={{ borderRadius: theme.shape.borderRadius }} />
<Typography sx={{ userSelect: "none" }}>{t("common.appName")}</Typography>
</Stack>
<Stack
width="100%"
maxWidth={600}
flex={1}
justifyContent="center"
px={{ xs: theme.spacing(12), lg: theme.spacing(20) }}
pb={theme.spacing(20)}
mx="auto"
sx={{
"& > .MuiStack-root": {
border: 1,
borderRadius: theme.spacing(5),
borderColor: theme.palette.primary.lowContrast,
backgroundColor: theme.palette.primary.main,
padding: {
xs: theme.spacing(12),
sm: theme.spacing(20),
},
},
}}
>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
alignItems="center"
textAlign="center"
>
<Box>
<Stack
direction="row"
justifyContent="center"
>
<IconBox
height={48}
width={48}
minWidth={48}
borderRadius={12}
svgWidth={24}
svgHeight={24}
mb={theme.spacing(4)}
>
<Icon name="Key" />
</IconBox>
</Stack>
<Typography component="h1">{t("auth.forgotPassword.heading")}</Typography>
<Typography>{t("auth.forgotPassword.subheadings.stepOne")}</Typography>
</Box>
<Box
component="form"
width="95%"
textAlign="left"
noValidate
spellCheck={false}
onSubmit={handleSubmit}
>
<TextInput
type="email"
id="forgot-password-email-input"
label={t("auth.common.inputs.email.label")}
isRequired={true}
placeholder={t("auth.common.inputs.email.placeholder")}
value={form.email}
onChange={handleChange}
error={errors.email ? true : false}
helperText={t(errors.email)} // Localization keys are in validation.js
/>
<Button
variant="contained"
color="accent"
loading={isLoading}
disabled={errors.email !== undefined}
onClick={handleSubmit}
sx={{
width: "100%",
mt: theme.spacing(15),
}}
>
{t("auth.common.navigation.continue")}
</Button>
</Box>
</Stack>
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">
<Trans
i18nKey="auth.forgotPassword.links.login"
components={{
a: (
<Typography
component="span"
color={theme.palette.accent.main}
ml={theme.spacing(2)}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
/>
),
}}
/>
</Typography>
</Box>
</Stack>
);
};
export default ForgotPassword;
@@ -1,22 +0,0 @@
// Hook to avoid double submits and manage loading state
import { useState, useCallback } from "react";
const useLoadingSubmit = () => {
const [submitting, setSubmitting] = useState(false);
const executeSubmit = useCallback(
async (submitFunction) => {
if (submitting) return;
setSubmitting(true);
try {
return await submitFunction();
} finally {
setSubmitting(false);
}
},
[submitting]
);
return { submitting, executeSubmit };
};
export default useLoadingSubmit;
@@ -1,19 +0,0 @@
import { useState } from "react";
const useLoginForm = () => {
const [form, setForm] = useState({
email: "",
password: "",
});
const onChange = (e) => {
let { name, value } = e.target;
if (name === "email") {
value = value.toLowerCase();
}
const updatedForm = { ...form, [name]: value };
setForm(updatedForm);
};
return [form, onChange];
};
export default useLoginForm;
@@ -1,40 +0,0 @@
import { useDispatch } from "react-redux";
import { login } from "../../../../Features/Auth/authSlice.js";
import { useNavigate } from "react-router-dom";
import { createToast } from "../../../../Utils/toastUtils.jsx";
import { useTranslation } from "react-i18next";
const useLoginSubmit = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const { t } = useTranslation();
const handleLoginSubmit = async (form, setErrors) => {
const action = await dispatch(login(form));
if (action.payload.success) {
navigate("/uptime");
createToast({
body: t("auth.login.toasts.success"),
});
} else {
if (action.payload) {
if (action.payload.msg === "Incorrect password")
setErrors({
password: t("auth.login.errors.password.incorrect"),
});
// dispatch errors
createToast({
body: t("auth.login.toasts.incorrectPassword"),
});
} else {
// unknown errors
createToast({
body: t("common.toasts.unknownError"),
});
}
}
};
return [handleLoginSubmit];
};
export default useLoginSubmit;
@@ -1,31 +0,0 @@
import { useState } from "react";
import { loginCredentials } from "../../../../Validation/validation.js";
const useValidateLoginForm = () => {
const [errors, setErrors] = useState({
email: "",
password: "",
});
const validateField = (name, value) => {
const { error } = loginCredentials.validate({ [name]: value });
setErrors((prev) => ({
...prev,
[name]: error?.details?.[0]?.message || "",
}));
};
const validateForm = (form) => {
const { error } = loginCredentials.validate(form, { abortEarly: false });
if (error) {
const formErrors = {};
for (const err of error.details) {
formErrors[err.path[0]] = err.message;
}
setErrors(formErrors);
return false;
}
return true;
};
return [errors, setErrors, validateField, validateForm];
};
export default useValidateLoginForm;
-102
View File
@@ -1,102 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
import Button from "@mui/material/Button";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx";
import TextLink from "@/Components/v1/TextLink/index.jsx";
import AuthPageWrapper from "../components/AuthPageWrapper.jsx";
// Utils
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import useLoginForm from "./hooks/useLoginForm.jsx";
import useValidateLoginForm from "./hooks/useValidateLoginForm.jsx";
import useLoginSubmit from "./hooks/useLoginSubmit.jsx";
import useLoadingSubmit from "./hooks/useLoadingSubmit.jsx";
const Login = () => {
// Hooks
const { t } = useTranslation();
const theme = useTheme();
const [form, onChange] = useLoginForm();
const [errors, setErrors, validateField, validateForm] = useValidateLoginForm();
const [handleLoginSubmit] = useLoginSubmit();
const { submitting, executeSubmit } = useLoadingSubmit();
// Handlers
const handleChange = (e) => {
onChange(e);
validateField(e.target.name, e.target.value);
};
const onSubmit = async (e) => {
e.preventDefault();
const isValid = validateForm(form);
if (!isValid) return;
await executeSubmit(() => handleLoginSubmit(form, setErrors));
};
return (
<AuthPageWrapper
welcome={t("auth.login.welcome")}
heading={t("auth.login.heading")}
>
<Stack
component="form"
width="100%"
padding={theme.spacing(8)}
gap={theme.spacing(12)}
onSubmit={onSubmit}
sx={{
width: {
sm: "80%",
md: "70%",
lg: "65%",
xl: "65%",
},
}}
>
<TextInput
type="email"
name="email"
label={t("auth.common.inputs.email.label")}
placeholder={t("auth.common.inputs.email.placeholder")}
autoComplete="email"
value={form.email}
onChange={handleChange}
error={errors.email ? true : false}
helperText={errors.email ? t(errors.email) : ""} // Localization keys are in validation.js
/>
<TextInput
type="password"
name="password"
label={t("auth.common.inputs.password.label")}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={handleChange}
error={errors.password ? true : false}
helperText={errors.password ? t(errors.password) : ""} // Localization keys are in validation.js
endAdornment={<PasswordEndAdornment />}
/>
<Button
variant="contained"
color="accent"
type="submit"
sx={{ width: "100%", alignSelf: "center", fontWeight: 700 }}
loading={submitting}
>
Login
</Button>
</Stack>
<TextLink
text={t("auth.login.links.forgotPassword")}
linkText={t("auth.login.links.forgotPasswordLink")}
href="/forgot-password"
/>
<TextLink
text={t("auth.login.links.register")}
linkText={t("auth.login.links.registerLink")}
href="/register"
/>
</AuthPageWrapper>
);
};
export default Login;
+95
View File
@@ -0,0 +1,95 @@
import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements";
import { Button, TextField } from "@/Components/v2/inputs";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
import { usePost } from "@/Hooks/UseApi";
const LoginPage = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const { post, loading } = usePost();
const { schema, defaults } = useLoginForm();
const { control, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const onSubmit = async (data: LoginFormData) => {
if (loading) return;
const result = await post("/auth/login", data);
if (result?.success) {
dispatch(setAuthState(result));
navigate("/uptime");
}
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.register.text")}
linkText={t("pages.auth.login.links.register.linkText")}
href="/register"
/>
</BaseAuthPage>
);
};
export default LoginPage;
+69
View File
@@ -0,0 +1,69 @@
import { Button, TextField } from "@/Components/v2/inputs";
import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
import type { RecoveryFormData } from "@/Validation/recovery";
import { usePost } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
const ForgotPasswordPage = () => {
const { t } = useTranslation();
const { post, loading } = usePost();
const { schema, defaults } = useRecoveryForm();
const { control, handleSubmit } = useForm<RecoveryFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const onSubmit = async (data: RecoveryFormData) => {
if (loading) return;
const result = await post("/auth/recovery/request", data);
if (result?.success) {
// Navigate to Check email page
}
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.forgotPassword.title")}
subtitle={t("pages.auth.forgotPassword.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
);
};
export default ForgotPasswordPage;
-330
View File
@@ -1,330 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import PasswordTooltip from "../components/PasswordTooltip.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import { networkService } from "../../../main.jsx";
import { newOrChangedCredentials } from "../../../Validation/validation.js";
import { register } from "../../../Features/Auth/authSlice.js";
import AuthPageWrapper from "../components/AuthPageWrapper.jsx";
import { createToast } from "../../../Utils/toastUtils.jsx";
import PropTypes from "prop-types";
const getFeedbackStatus = (form, errors, field, criteria) => {
const fieldErrors = errors?.[field];
const isFieldEmpty = form?.[field]?.length === 0;
const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty");
const isCorrect = !isFieldEmpty && !hasError;
if (isCorrect) {
return "success";
} else if (hasError) {
return "error";
} else {
return "info";
}
};
const Register = ({ superAdminExists }) => {
// Redux
const { isLoading } = useSelector((state) => state.auth);
// Local state
const [form, setForm] = useState({
firstName: "",
lastName: "",
email: "",
password: "",
confirm: "",
role: [],
teamId: "",
});
const [errors, setErrors] = useState({});
const [feedback, setFeedback] = useState({});
// Hooks
const theme = useTheme();
const { t } = useTranslation();
const { token } = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
// Effects
useEffect(() => {
const fetchInvite = async () => {
if (token !== undefined) {
try {
const res = await networkService.verifyInvitationToken(token);
const invite = res.data.data;
const { email } = invite;
setForm((prevForm) => {
if (!prevForm.email) {
return { ...prevForm, email };
}
return prevForm;
});
} catch (error) {
navigate("/register", { replace: true });
}
}
};
fetchInvite();
}, [form, token, navigate]);
// Handlers
const onChange = (e) => {
let { name, value } = e.target;
if (name === "email") value = value.toLowerCase();
const updatedForm = { ...form, [name]: value };
const { error } = newOrChangedCredentials.validate(
{ [name]: value },
{ abortEarly: false, context: { password: form.password } }
);
setErrors((prev) => ({
...prev,
[name]: error?.details?.[0]?.message || "",
}));
setForm(updatedForm);
};
const onPasswordChange = (e) => {
const { name, value } = e.target;
const updatedForm = { ...form, [name]: value };
setForm(updatedForm);
const validateValue = { [name]: value };
const validateOptions = { abortEarly: false, context: { password: form.password } };
if (name === "password" && form.confirm.length > 0) {
validateValue.confirm = form.confirm;
validateOptions.context = { password: value };
} else if (name === "confirm") {
validateValue.password = form.password;
}
const { error } = newOrChangedCredentials.validate(validateValue, validateOptions);
const pwdErrors = error?.details.map((error) => ({
path: error.path[0],
type: error.type,
}));
const errorsByPath =
pwdErrors &&
pwdErrors.reduce((acc, { path, type }) => {
if (!acc[path]) {
acc[path] = [];
}
acc[path].push(type);
return acc;
}, {});
const oldErrors = { ...errors };
if (name === "password") {
oldErrors.password = undefined;
} else if (name === "confirm") {
oldErrors.confirm = undefined;
}
const newErrors = { ...oldErrors, ...errorsByPath };
setErrors(newErrors);
const newFeedback = {
length: getFeedbackStatus(updatedForm, errorsByPath, "password", "string.min"),
special: getFeedbackStatus(updatedForm, errorsByPath, "password", "special"),
number: getFeedbackStatus(updatedForm, errorsByPath, "password", "number"),
uppercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "uppercase"),
lowercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "lowercase"),
confirm: getFeedbackStatus(updatedForm, errorsByPath, "confirm", "different"),
};
setFeedback(newFeedback);
};
const onSubmit = async (e) => {
e.preventDefault();
const toSubmit = {
...form,
role: superAdminExists ? form.role : ["superadmin"],
inviteToken: token ? token : "",
};
const { error } = newOrChangedCredentials.validate(toSubmit, {
abortEarly: false,
context: { password: form.password },
});
if (error) {
const formErrors = {};
for (const err of error.details) {
formErrors[err.path[0]] = err.message;
}
setErrors(formErrors);
return;
}
delete toSubmit.confirm;
const action = await dispatch(register({ user: toSubmit, token }));
if (action.payload.success) {
navigate("/uptime");
createToast({
body: t("auth.registration.toasts.success"),
});
} else {
if (action.payload) {
createToast({
body: action.payload.msg,
});
} else {
createToast({
body: t("common.toasts.unknownError"),
});
}
}
};
return (
<AuthPageWrapper
heading={t("auth.registration.heading.user")}
welcome={t("auth.registration.welcome")}
>
<Stack
component="form"
width="100%"
padding={theme.spacing(8)}
gap={theme.spacing(8)}
onSubmit={onSubmit}
sx={{
width: {
sm: "80%",
},
}}
>
<Typography variant="h2">
{superAdminExists
? t("auth.registration.description.user")
: t("auth.registration.description.superAdmin")}
</Typography>
<Stack
direction={{ xs: "column", lg: "row" }}
justifyContent="space-between"
gap={theme.spacing(4)}
>
<TextInput
name="firstName"
sx={{ flex: 1 }}
label={t("auth.common.inputs.firstName.label")}
width="100%"
gap={theme.spacing(4)}
isRequired={true}
placeholder={t("auth.common.inputs.firstName.placeholder")}
autoComplete="given-name"
value={form.firstName}
onChange={onChange}
error={errors.firstName ? true : false}
helperText={errors.firstName ? t(errors.firstName) : ""}
/>
<TextInput
name="lastName"
label={t("auth.common.inputs.lastName.label")}
sx={{ flex: 1 }}
width="100%"
gap={theme.spacing(4)}
isRequired={true}
placeholder={t("auth.common.inputs.lastName.placeholder")}
autoComplete="family-name"
value={form.lastName}
onChange={onChange}
error={errors.lastName ? true : false}
helperText={errors.lastName ? t(errors.lastName) : ""} // Localization keys are in validation.js
/>
</Stack>
<TextInput
type="email"
name="email"
gap={theme.spacing(4)}
label={t("auth.common.inputs.email.label")}
isRequired={true}
placeholder={t("auth.common.inputs.email.placeholder")}
autoComplete="email"
value={form.email}
onInput={(e) => (e.target.value = e.target.value.toLowerCase())}
onChange={onChange}
error={errors.email ? true : false}
helperText={errors.email ? t(errors.email) : ""} // Localization keys are in validation.js
/>
<PasswordTooltip
feedback={feedback}
form={form}
>
<Box>
<TextInput
type="password"
id="register-password-input"
name="password"
label={t("auth.common.inputs.password.label")}
gap={theme.spacing(4)}
isRequired={true}
placeholder="••••••••••"
autoComplete="current-password"
value={form.password}
onChange={onPasswordChange}
error={errors.password && errors.password[0] ? true : false}
helperText={
errors.password === "auth.common.inputs.password.errors.empty"
? t(errors.password)
: ""
}
/>
</Box>
</PasswordTooltip>
<TextInput
type="password"
id="register-confirm-input"
name="confirm"
label={t("auth.common.inputs.passwordConfirm.label")}
gap={theme.spacing(4)}
isRequired={true}
placeholder={t("auth.common.inputs.passwordConfirm.placeholder")}
autoComplete="current-password"
value={form.confirm}
onChange={onPasswordChange}
marginBottom={theme.spacing(4)}
error={errors.confirm && errors.confirm[0] ? true : false}
/>
<Button
disabled={isLoading}
variant="contained"
color="accent"
type="submit"
sx={{
width: "100%",
alignSelf: "center",
fontWeight: 700,
mt: theme.spacing(10),
}}
>
{t("auth.common.navigation.continue")}
</Button>
</Stack>
</AuthPageWrapper>
);
};
Register.propTypes = {
superAdminExists: PropTypes.bool,
};
export default Register;
+162
View File
@@ -0,0 +1,162 @@
import { BaseAuthPage } from "@/Components/v2/design-elements";
import { Button, TextField } from "@/Components/v2/inputs";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register";
import { useTranslation } from "react-i18next";
import { usePost } from "@/Hooks/UseApi";
import { setAuthState } from "@/Features/Auth/authSlice";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef } from "react";
interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">;
token?: string;
}
interface InviteVerifyResponse {
email: string;
}
const RegisterPage = () => {
const { t } = useTranslation();
const { schema, defaults } = useRegisterForm();
const { post, loading } = usePost<RegisterPayload, AuthResponse>();
const dispatch = useDispatch();
const navigate = useNavigate();
const { token } = useParams<{ token?: string }>();
const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>();
const hasVerified = useRef(false);
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
useEffect(() => {
if (!token || hasVerified.current) return;
hasVerified.current = true;
verifyToken("/invite/verify", { token }).then((result) => {
if (result?.success && result?.data) {
reset({
...defaults,
email: result.data.email ?? "",
});
} else {
navigate("/register", { replace: true });
}
});
}, [token]);
const onSubmit = async (data: RegisterFormData) => {
if (loading) return;
const { confirm, ...userData } = data;
const payload: RegisterPayload = { user: userData };
if (token) {
payload.token = token;
}
const result = await post("/auth/register", payload);
if (result?.success) {
dispatch(setAuthState(result));
navigate("/uptime");
} else if (result?.msg) {
if (result.msg.toLowerCase().includes("email")) {
setError("email", { message: result.msg });
}
}
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.register.form.option.name.label")}
placeholder={t("pages.auth.register.form.option.name.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.register.form.option.surname.label")}
placeholder={t("pages.auth.register.form.option.surname.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
disabled={!!token}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
);
};
export default RegisterPage;
@@ -1,107 +0,0 @@
import Background from "@/assets/Images/background-grid.svg?react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import AuthHeader from "./AuthHeader.jsx";
import { useTheme } from "@mui/material/styles";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import PropTypes from "prop-types";
const AuthPageWrapper = ({ children, heading, welcome }) => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(10)}
minHeight="100vh"
position="relative"
backgroundColor={theme.palette.primary.main}
sx={{ overflow: "hidden" }}
>
<AuthHeader hideLogo={true} />
<Box
sx={{
position: "absolute",
top: 0,
left: "0%",
transform: "translate(-40%, -40%)",
zIndex: 0,
width: "100%",
height: "100%",
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
transform: "translate(45%, 55%)",
zIndex: 0,
width: "100%",
height: "100%",
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
backgroundColor={theme.palette.primary.main}
sx={{
borderRadius: theme.spacing(8),
boxShadow: theme.palette.tertiary.cardShadow,
margin: "auto",
alignItems: "center",
gap: theme.spacing(10),
padding: theme.spacing(20),
zIndex: 1,
position: "relative",
width: {
sm: "60%",
md: "50%",
lg: "40%",
xl: "30%",
},
}}
>
<Box
mb={theme.spacing(10)}
mt={theme.spacing(5)}
>
<Box
sx={{
width: { xs: 60, sm: 70, md: 80 },
}}
/>
<Logo style={{ width: "100%", height: "100%" }} />
</Box>
<Stack
mb={theme.spacing(4)}
textAlign="center"
>
<Typography
variant="h1"
mb={theme.spacing(2)}
>
{welcome}
</Typography>
<Typography variant="h1">{heading}</Typography>
</Stack>
{children}
</Stack>
</Stack>
);
};
export default AuthPageWrapper;
AuthPageWrapper.propTypes = {
children: PropTypes.node,
heading: PropTypes.node,
welcome: PropTypes.node,
};
@@ -1,26 +1,32 @@
// Components
import Stack from "@mui/material/Stack";
import Stack, { type StackProps } from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import LanguageSelector from "@/Components/LanguageSelector.jsx";
import ThemeSwitch from "@/Components/v1/ThemeSwitch/index.jsx";
import { LanguageSelector, SwitchTheme } from "@/Components/v2/inputs";
// Utils
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
const AuthHeader = ({ hideLogo = false }) => {
interface HeaderAuthControlsProps extends StackProps {
hideLogo?: boolean;
}
export const HeaderAuthControls = ({
hideLogo = false,
...stackProps
}: HeaderAuthControlsProps) => {
// Hooks
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
width={"100%"}
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(12)}
gap={theme.spacing(4)}
{...stackProps}
>
<Stack
direction="row"
@@ -40,10 +46,8 @@ const AuthHeader = ({ hideLogo = false }) => {
alignItems="center"
>
<LanguageSelector />
<ThemeSwitch />
<SwitchTheme />
</Stack>
</Stack>
);
};
export default AuthHeader;
@@ -6,7 +6,7 @@ import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
const resolutionTypes = ["manual", "automatic"];
const resolutionTypes = ["all", "manual", "automatic"];
interface ControlsIncidentFilterProps {
monitors?: Monitor[];
@@ -22,13 +22,15 @@ export const DialogIncidentDetails = ({
}: DialogIncidentDetailsProps) => {
const { t } = useTranslation();
const isActive = incident?.status === true;
return (
<Dialog
open={open}
onCancel={onClose}
onConfirm={onResolve}
cancelText={t("common.buttons.cancel")}
confirmText={t("pages.incidents.dialog.details.resolve")}
onConfirm={isActive ? onResolve : undefined}
cancelText={t("common.buttons.close")}
confirmText={isActive ? t("pages.incidents.dialog.details.resolve") : undefined}
maxWidth="sm"
fullWidth
>
@@ -215,6 +215,7 @@ export const IncidentsTable = ({
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
itemsOnPage={incidents.length}
/>
</Box>
);
+5 -2
View File
@@ -1,5 +1,5 @@
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
import prettyMilliseconds from "pretty-ms";
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
@@ -24,5 +24,8 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte
return "-";
}
return getHumanReadableDuration(durationMs);
return prettyMilliseconds(durationMs, {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
});
};
@@ -1,60 +1,13 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BaseChart, Gauge } from "@/Components/v2/design-elements";
import { DetailGauge } from "@/Components/v2/design-elements";
import prettyBytes from "pretty-bytes";
import { useTranslation } from "react-i18next";
import { getGbs, getFrequency } from "@/Utils/InfraUtils";
import { 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,
}: {
@@ -74,15 +27,15 @@ export const InfraDetailsGauges = ({
spacing={theme.spacing(8)}
alignItems={"center"}
>
<InfraDetailGauge
<DetailGauge
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)}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
lowerValue={getGbs(snapshot?.memory?.total_bytes || 0)}
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
/>
<InfraDetailGauge
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
@@ -92,15 +45,15 @@ export const InfraDetailsGauges = ({
/>
{snapshot?.disk?.map((disk, idx) => {
return (
<InfraDetailGauge
<DetailGauge
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)}
upperValue={prettyBytes(disk?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
lowerValue={getGbs(disk?.total_bytes || 0)}
lowerValue={prettyBytes(disk?.total_bytes || 0)}
/>
);
})}
@@ -1,17 +1,11 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/design-elements";
import prettyBytes from "pretty-bytes";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import {
getAvgTemp,
getCores,
getFrequency,
getGbs,
getDiskTotalGbs,
getOsAndPlatform,
} from "@/Utils/InfraUtils";
import { getAvgTemp, getCores, getFrequency, getOsAndPlatform } from "@/Utils/InfraUtils";
export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const { t } = useTranslation();
@@ -24,8 +18,9 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const cpuFrequency = getFrequency(latestCheck?.cpu?.frequency);
const cpuTemps = latestCheck?.cpu?.temperature ?? [];
const cpuTemperature = getAvgTemp(cpuTemps);
const memoryTotalBytes = getGbs(latestCheck?.memory?.total_bytes);
const diskTotalBytes = getDiskTotalGbs(latestCheck?.disk);
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const diskTotalBytes =
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
const os = getOsAndPlatform(latestCheck?.host);
const platform = latestCheck?.host?.platform ?? undefined;
@@ -58,11 +53,11 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
/>
<StatBox
title={t("pages.infrastructure.statBoxes.memory")}
subtitle={memoryTotalBytes.toString()}
subtitle={prettyBytes(memoryTotalBytes)}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.disk")}
subtitle={diskTotalBytes.toString()}
subtitle={prettyBytes(diskTotalBytes)}
/>
<StatBox
title={t("pages.infrastructure.statBoxes.os")}
@@ -269,6 +269,7 @@ export const InfraMonitorsTable = ({
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
itemsOnPage={monitors.length}
/>
</Box>
);
@@ -20,6 +20,7 @@ import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
const InfrastructureMonitors = () => {
const { t } = useTranslation();
@@ -43,6 +44,8 @@ const InfrastructureMonitors = () => {
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
@@ -69,8 +72,8 @@ const InfrastructureMonitors = () => {
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (search ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || search || undefined;
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
@@ -85,13 +88,6 @@ const InfrastructureMonitors = () => {
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitors,
isLoading: monitorsLoading,
error,
refetch: refetchMonitors,
} = useGet<Monitor[]>("/monitors/team?type=hardware", {}, { keepPreviousData: true });
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
@@ -104,7 +100,16 @@ const InfrastructureMonitors = () => {
);
const { summary, count } = monitorsWithChecksData ?? {};
const isLoading = monitorsLoading || monitorsWithChecksLoading;
const isLoading = monitorsWithChecksLoading;
// Check if any filters are active
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
@@ -114,7 +119,6 @@ const InfrastructureMonitors = () => {
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
refetchMonitors();
};
const handleCancel = () => {
@@ -124,8 +128,8 @@ const InfrastructureMonitors = () => {
return (
<MonitorBasePageWithStates
loading={isLoading}
error={error ?? monitorsWithChecksError}
items={monitors || []}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure"
actionLink="/infrastructure/create"
>
@@ -1,162 +0,0 @@
import Stack from "@mui/material/Stack";
import CustomGauge from "@/Components/v1/Charts/CustomGauge/index.jsx";
import Typography from "@mui/material/Typography";
// Utils
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { getPercentage, formatBytes } from "../../utils/utils.js";
import { useTranslation } from "react-i18next";
import { Box } from "@mui/material";
const BaseContainer = ({children}) => {
const theme = useTheme()
return(
<Box
sx={{
padding: theme.spacing(3),
borderRadius: 4,
border: `1px solid ${theme.palette.primary.lowContrast}`,
minWidth: 200,
width: `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
[theme.breakpoints.down("md")]: {
width: `calc(50% - (1 * ${theme.spacing(8)} / 2))`,
},
}}>
{children}
</Box>
);
};
const InfrastructureStyleGauge = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => {
const theme = useTheme();
const MetricRow = ({ label, value }) => (
<Stack
justifyContent="space-between"
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Typography>{label}</Typography>
<Typography sx={{
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.tertiary.main,
width: "40%",
mb: theme.spacing(2),
mt: theme.spacing(2),
pr: theme.spacing(2),
textAlign: "right",
}}>
{value}
</Typography>
</Stack>
);
return(
<BaseContainer>
<Stack direction="column" gap={theme.spacing(2)} alignItems="center">
<Box
sx = {{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
}}
>
<CustomGauge progress={value} radius={100}/>
<Typography component="h2" sx={{fontWeight: 600}}>
{heading}
</Typography>
</Box>
<Box sx={{ width:"100%", borderTop:`1px solid ${theme.palette.divider}`}}>
<MetricRow label={metricOne} value={valueOne} />
{metricTwo && valueTwo && (
<MetricRow label={metricTwo} value={valueTwo} />
)}
</Box>
</Stack>
</BaseContainer>
);
};
const Gauges = ({ diagnostics, isLoading }) => {
const heapTotalSize = getPercentage(
diagnostics?.v8HeapStats?.totalHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const heapUsedSize = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const actualHeapUsed = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.totalHeapSizeBytes
);
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap="wrap"
>
<InfrastructureStyleGauge
value={heapTotalSize}
heading={t("diagnosticsPage.gauges.heapAllocationTitle")}
metricOne={t("diagnosticsPage.gauges.heapAllocationSubtitle")}
valueOne={`${heapTotalSize?.toFixed(1) || 0}%`}
metricTwo={t("total")}
valueTwo={formatBytes(diagnostics?.v8HeapStats?.heapSizeLimitBytes)}
/>
<InfrastructureStyleGauge
value={heapUsedSize}
heading={t("diagnosticsPage.gauges.heapUsageTitle")}
metricOne={t("diagnosticsPage.gauges.heapUsageSubtitle")}
valueOne={`${heapUsedSize?.toFixed(1) || 0}%`}
metricTwo={t("used")}
valueTwo={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
/>
<InfrastructureStyleGauge
value={actualHeapUsed}
heading={t("diagnosticsPage.gauges.heapUtilizationTitle")}
metricOne={t("diagnosticsPage.gauges.heapUtilizationSubtitle")}
valueOne={`${actualHeapUsed?.toFixed(1) || 0}%`}
metricTwo={t("total")}
valueTwo={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
/>
<InfrastructureStyleGauge
value={diagnostics?.cpuUsage?.usagePercentage}
heading={t("diagnosticsPage.gauges.instantCpuUsageTitle")}
metricOne={t("diagnosticsPage.gauges.instantCpuUsageSubtitle")}
valueOne={`${diagnostics?.cpuUsage?.usagePercentage?.toFixed(1) || 0}%`}
metricTwo=""
valueTwo=""
/>
</Stack>
);
};
Gauges.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
InfrastructureStyleGauge.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]),
};
BaseContainer.propTypes = {
children: PropTypes.node.isRequired,
};
export default Gauges;
@@ -1,97 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CircularProgress from "@mui/material/CircularProgress";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { getHumanReadableDuration } from "../../../../../Utils/timeUtilsLegacy.js";
import { formatBytes } from "../../utils/utils.js";
import { useTranslation } from "react-i18next";
const StatsCard = ({ title, value, unit = "", isLoading }) => {
const theme = useTheme();
return (
<Card sx={{ width: 150, maxWidth: 150, height: 80, maxHeight: 80 }}>
{isLoading ? (
<Stack
alignItems="center"
justifyContent="center"
height={80}
maxHeight={80}
>
<CircularProgress color="accent" />
</Stack>
) : (
<CardContent>
<Typography
variant="body1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Typography variant="body1">
{value} {unit}
</Typography>
</CardContent>
)}
</Card>
);
};
StatsCard.propTypes = {
title: PropTypes.string,
value: PropTypes.string,
unit: PropTypes.string,
isLoading: PropTypes.bool,
};
const Stats = ({ diagnostics, isLoading }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
flexWrap="wrap"
>
<StatsCard
title={t("diagnosticsPage.stats.eventLoopDelayTitle")}
value={getHumanReadableDuration(diagnostics?.eventLoopDelayMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.uptimeTitle")}
value={getHumanReadableDuration(diagnostics?.uptimeMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.usedHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.totalHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.osMemoryLimitTitle")}
value={formatBytes(diagnostics?.osStats?.totalMemoryBytes)}
isLoading={isLoading}
/>
</Stack>
);
};
Stats.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
export default Stats;
@@ -1,77 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Gauges from "./components/gauges/index.jsx";
import Button from "@mui/material/Button";
import StatBox from "@/Components/v1/StatBox/index.jsx";
import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useFetchDiagnostics } from "../../../Hooks/logHooks.js";
import { getHumanReadableDuration } from "../../../Utils/timeUtilsLegacy.js";
import { formatBytes, getPercentage } from "./utils/utils.js";
const Diagnostics = () => {
// Local state
// Hooks
const theme = useTheme();
const { t } = useTranslation();
const [diagnostics, fetchDiagnostics, isLoading, error] = useFetchDiagnostics();
// Setup
return (
<Stack gap={theme.spacing(10)}>
<StatusBoxes flexWrap="wrap">
<StatBox
gradient={true}
status="up"
heading={t("status")}
subHeading={
error
? t("logsPage.logLevelSelect.values.error")
: isLoading
? t("commonSaving")
: diagnostics
? t("diagnosticsPage.diagnosticDescription")
: t("general.noOptionsFound", { unit: "data" })
}
/>
<StatBox
heading={t("diagnosticsPage.stats.eventLoopDelayTitle")}
subHeading={getHumanReadableDuration(diagnostics?.eventLoopDelayMs)}
/>
<StatBox
heading={t("diagnosticsPage.stats.uptimeTitle")}
subHeading={getHumanReadableDuration(diagnostics?.uptimeMs)}
/>
<StatBox
heading={t("diagnosticsPage.stats.usedHeapSizeTitle")}
subHeading={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
/>
<StatBox
heading={t("diagnosticsPage.stats.totalHeapSizeTitle")}
subHeading={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
/>
<StatBox
heading={t("diagnosticsPage.stats.osMemoryLimitTitle")}
subHeading={formatBytes(diagnostics?.osStats?.totalMemoryBytes)}
/>
</StatusBoxes>
<Gauges
diagnostics={diagnostics}
isLoading={isLoading}
/>
<Box>
<Button
variant="contained"
color="accent"
onClick={fetchDiagnostics}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
</Box>
</Stack>
);
};
export default Diagnostics;
@@ -1,16 +0,0 @@
export const getPercentage = (value, total) => {
if (!value || !total) return 0;
return (value / total) * 100;
};
export const formatBytes = (bytes) => {
if (!bytes) return "N/A";
if (bytes === 0) return "0 Bytes";
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
-176
View File
@@ -1,176 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Select from "@/Components/v1/Inputs/Select/index.jsx";
import Typography from "@mui/material/Typography";
import DataTable from "@/Components/v1/Table/index.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import { useFetchLogs } from "../../../Hooks/logHooks.js";
import { useTheme } from "@emotion/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
const LevelBadge = ({ level }) => {
const theme = useTheme();
const levelColors = {
info: theme.palette.success.main,
warn: theme.palette.warning.main,
error: theme.palette.error.main,
debug: theme.palette.accent.main,
};
const color = levelColors[level] || theme.palette.primary.contrastText;
return (
<Box
component="span"
sx={{
color: color,
fontWeight: 600,
textTransform: "uppercase",
fontSize: 12,
}}
>
{level}
</Box>
);
};
const formatTimestamp = (timestamp) => {
if (!timestamp) return "-";
const date = new Date(timestamp);
return date.toLocaleString();
};
const Logs = () => {
const [logLevel, setLogLevel] = useState("all");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(15);
const theme = useTheme();
const { t } = useTranslation();
const [logs, isLoading, error] = useFetchLogs();
const LOG_LEVELS = [
{ _id: "all", name: t("logsPage.logLevelSelect.values.all") },
{ _id: "info", name: t("logsPage.logLevelSelect.values.info") },
{ _id: "warn", name: t("logsPage.logLevelSelect.values.warn") },
{ _id: "error", name: t("logsPage.logLevelSelect.values.error") },
{ _id: "debug", name: t("logsPage.logLevelSelect.values.debug") },
];
const headers = [
{
id: "timestamp",
content: t("logsPage.table.timestamp"),
render: (row) => (
<Typography sx={{ fontSize: 13, fontFamily: "monospace" }}>
{formatTimestamp(row.timestamp)}
</Typography>
),
},
{
id: "level",
content: t("logsPage.table.level"),
render: (row) => <LevelBadge level={row.level} />,
},
{
id: "service",
content: t("logsPage.table.service"),
render: (row) => (
<Typography sx={{ fontSize: 13 }}>{row.service || "-"}</Typography>
),
},
{
id: "method",
content: t("logsPage.table.method"),
render: (row) => (
<Typography sx={{ fontSize: 13, fontFamily: "monospace" }}>{row.method || "-"}</Typography>
),
},
{
id: "message",
content: t("logsPage.table.message"),
render: (row) => (
<Typography
sx={{
fontSize: 13,
maxWidth: 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.message || "-"}
</Typography>
),
},
];
const filteredLogs = logs
?.filter((log) => {
if (logLevel === "all") return true;
return log.level === logLevel;
})
.reverse()
.map((log, idx) => ({ ...log, id: idx }));
const paginatedLogs = filteredLogs?.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const handleLogLevelChange = (e) => {
setLogLevel(e.target.value);
setPage(0);
};
return (
<Stack gap={theme.spacing(4)}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
>
<Typography>{t("logsPage.logLevelSelect.title")}</Typography>
<Select
items={LOG_LEVELS}
value={logLevel}
onChange={handleLogLevelChange}
/>
</Stack>
<DataTable
shouldRender={!isLoading}
headers={headers}
data={paginatedLogs || []}
config={{
emptyView: t("logsPage.noLogs"),
}}
/>
{filteredLogs?.length > 0 && (
<Pagination
paginationLabel={t("logsPage.table.logs")}
itemCount={filteredLogs?.length || 0}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
)}
</Stack>
);
};
export default Logs;
@@ -1,82 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import DataTable from "@/Components/v1/Table/index.jsx";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { TypeToPathMap } from "../../../../../Utils/monitorUtilsLegacy.js";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const FailedJobTable = ({ metrics = {} }) => {
const { t } = useTranslation();
const theme = useTheme();
const jobsWithFailures = metrics?.jobsWithFailures;
const navigate = useNavigate();
const headers = [
{
id: "monitorId",
content: t("queuePage.failedJobTable.monitorIdHeader"),
render: (row) => {
return row.monitorId;
},
},
{
id: "monitorUrl",
content: t("queuePage.failedJobTable.monitorUrlHeader"),
render: (row) => {
return row.monitorUrl;
},
},
{
id: "failCount",
content: t("queuePage.failedJobTable.failCountHeader"),
render: (row) => {
return row.failCount;
},
},
{
id: "failedAt",
content: t("queuePage.failedJobTable.failedAtHeader"),
render: (row) => {
return row.failedAt;
},
},
{
id: "failReason",
content: t("queuePage.failedJobTable.failReasonHeader"),
render: (row) => {
return row.failReason;
},
},
];
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.failedJobTable.title")}</Typography>
<DataTable
headers={headers}
data={jobsWithFailures}
config={{
onRowClick: (row) => {
const path = TypeToPathMap[row.monitorType];
navigate(`/${path}/${row.monitorId}`);
},
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
}}
/>
</Stack>
);
};
FailedJobTable.propTypes = {
metrics: PropTypes.object,
};
export default FailedJobTable;
@@ -1,130 +0,0 @@
import Stack from "@mui/material/Stack";
import DataTable from "@/Components/v1/Table/index.jsx";
import Typography from "@mui/material/Typography";
// Utils
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { TypeToPathMap } from "../../../../../Utils/monitorUtilsLegacy.js";
import { useTranslation } from "react-i18next";
import { createHeaderFactory } from "@/Components/v1/Table/TableUtils.js";
const JobTable = ({ jobs = [] }) => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const buildSx = (row) => {
if (row.lockedAt) {
return {
color: `${theme.palette.success.main} !important`,
};
}
if (!row.active) {
return {
color: `${theme.palette.warning.main} !important`,
};
}
if (row.failCount > 0 && row.lastFailedAt >= row.lastFinishedAt) {
return {
color: `${theme.palette.error.main} !important`,
};
}
return {};
};
const createHeader = createHeaderFactory(buildSx);
const headersData = [
{
id: "id",
content: t("queuePage.jobTable.idHeader"),
render: (row) => row.monitorId,
},
{
id: "url",
content: t("queuePage.jobTable.urlHeader"),
render: (row) => row.monitorUrl,
},
{
id: "interval",
content: t("queuePage.jobTable.intervalHeader"),
render: (row) => `${row.monitorInterval} ms`,
},
{
id: "type",
content: t("queuePage.jobTable.typeHeader"),
render: (row) => row.monitorType,
},
{
id: "active",
content: t("queuePage.jobTable.activeHeader"),
render: (row) => row.active.toString(),
},
{
id: "runCount",
content: t("queuePage.jobTable.runCountHeader"),
render: (row) => row.runCount,
},
{
id: "failCount",
content: t("queuePage.jobTable.failCountHeader"),
render: (row) => row.failCount,
},
{
id: "lastRun",
content: t("queuePage.jobTable.lastRunHeader"),
render: (row) => row.lastRunAt || "-",
},
{
id: "lockedAt",
content: t("queuePage.jobTable.lockedAtHeader"),
render: (row) => row.lockedAt || "-",
},
{
id: "lastFinish",
content: t("queuePage.jobTable.lastFinishedAtHeader"),
render: (row) => row.lastFinishedAt || "-",
},
{
id: "lastRunTook",
content: t("queuePage.jobTable.lastRunTookHeader"),
render: (row) => {
const value = row.lastRunTook ? row.lastRunTook + " ms" : "-";
return value;
},
},
];
const headers = headersData.map((header) => createHeader(header));
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.jobTable.title")}</Typography>
<DataTable
headers={headers}
data={jobs}
config={{
onRowClick: (row) => {
const path = TypeToPathMap[row.monitorType];
navigate(`/${path}/${row.monitorId}`);
},
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
}}
/>
</Stack>
);
};
JobTable.propTypes = {
jobs: PropTypes.array,
};
export default JobTable;
@@ -1,38 +0,0 @@
import Stack from "@mui/material/Stack";
import StatBox from "@/Components/v1/StatBox/index.jsx";
import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
const camelToTitle = (str) => {
return str
.replace(/([A-Z])/g, " $1")
.toLowerCase()
.replace(/^./, (m) => m.toUpperCase());
};
const Metrics = ({ metrics = {} }) => {
const { t } = useTranslation();
const theme = useTheme();
const data = Object.keys(metrics)
.filter((key) => key !== "jobsWithFailures")
.map((key) => {
return { key, title: camelToTitle(key), value: metrics[key] };
});
return (
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
);
};
export default Metrics;
@@ -1,60 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import DataTable from "@/Components/v1/Table/index.jsx";
// Utils
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const camelToTitle = (str) => {
return str
.replace(/([A-Z])/g, " $1")
.toLowerCase()
.replace(/^./, (m) => m.toUpperCase());
};
const Metrics = ({ metrics = {} }) => {
const { t } = useTranslation();
const theme = useTheme();
const keys = Object.keys(metrics);
const headers = [
{
id: "metric",
content: t("queuePage.metricsTable.metricHeader"),
render: (row) => {
return <Typography>{row.key}</Typography>;
},
},
{
id: "value",
content: t("queuePage.metricsTable.valueHeader"),
render: (row) => {
return <Typography>{row.value}</Typography>;
},
},
];
const data = keys
.filter((key) => key !== "jobsWithFailures")
.map((key) => {
return { key: camelToTitle(key), value: metrics[key] };
});
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<DataTable
headers={headers}
data={data}
/>
</Stack>
);
};
Metrics.propTypes = {
metrics: PropTypes.object,
};
export default Metrics;
-73
View File
@@ -1,73 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
import JobTable from "./components/JobTable/index.jsx";
import Metrics from "./components/Metrics/index.jsx";
import FailedJobTable from "./components/FailedJobTable/index.jsx";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
// Utils
import { useState } from "react";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/logHooks.js";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
const QueueDetails = () => {
// Local state
const [trigger, setTrigger] = useState(false);
// Hooks
const { t } = useTranslation();
const theme = useTheme();
const [jobs, metrics, isLoading, error] = useFetchQueueData(trigger);
const [flushQueue, isFlushing, flushError] = useFlushQueue();
if (isLoading) return <div>Loading...</div>;
if (error || flushError) return <div>Error: {error.message}</div>;
return (
<Stack gap={theme.spacing(4)}>
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(10)}
>
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<FailedJobTable metrics={metrics} />
<ButtonGroup
variant="contained"
color="accent"
sx={{
position: "sticky",
bottom: 0,
zIndex: 1000,
backgroundColor: theme.palette.primary.main,
p: theme.spacing(4),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.spacing(2),
}}
>
<Button
onClick={() => {
setTrigger(!trigger);
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
export default QueueDetails;
+24
View File
@@ -0,0 +1,24 @@
import Stack from "@mui/material/Stack";
import { Stats } from "@/Pages/Logs/components/Stats";
import { StatGauges } from "@/Pages/Logs/components/StatGauges";
import { useTheme } from "@mui/material";
import { useGet } from "@/Hooks/UseApi";
import type { Diagnostics } from "@/Types/Diagnostics";
export const TabDiagnostics = () => {
const theme = useTheme();
const {
data: diagnostics,
isLoading: _isLoading,
error: _error,
refetch: _refetch,
} = useGet<Diagnostics>("/diagnostic/system", {}, { refreshInterval: 5000 });
return (
<Stack gap={theme.spacing(8)}>
<Stats diagnostics={diagnostics} />
<StatGauges diagnostics={diagnostics} />
</Stack>
);
};
+63
View File
@@ -0,0 +1,63 @@
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import { useSelector } from "react-redux";
import { Select } from "@/Components/v2/inputs";
import { TableLogs } from "@/Pages/Logs/components/TableLogs";
import { useTheme } from "@mui/material";
import { useGet } from "@/Hooks/UseApi";
import { useState } from "react";
import type { Log, LogLevelOption } from "@/Types/Log";
import { LOG_LEVEL_OPTIONS } from "@/Types/Log";
import { t } from "i18next";
import type { RootState } from "@/Types/state";
export const TabLogs = () => {
const theme = useTheme();
const { data: logs } = useGet<Log[]>("/logs");
const [selectedLogLevel, setSelectedLogLevel] = useState<LogLevelOption>("all");
const [page, setPage] = useState(0);
const rowsPerPage = useSelector(
(state: RootState) => state?.ui?.logs?.rowsPerPage ?? 15
);
const filteredLogs = logs
?.filter((log) => {
if (selectedLogLevel === "all") return true;
return log.level === selectedLogLevel;
})
.reverse()
.map((log, idx) => ({ ...log, id: idx }));
const paginatedLogs =
filteredLogs?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ?? [];
return (
<Stack gap={theme.spacing(8)}>
<Select
sx={{ maxWidth: 200 }}
fieldLabel={t("pages.logs.logLevelSelect.label")}
value={selectedLogLevel}
onChange={(e) => setSelectedLogLevel(e.target.value)}
>
{LOG_LEVEL_OPTIONS.map((level) => {
return (
<MenuItem
key={level}
value={level}
>
<Typography textTransform={"capitalize"}>{level}</Typography>
</MenuItem>
);
})}
</Select>
<TableLogs
logs={paginatedLogs}
logCount={filteredLogs?.length ?? 0}
page={page}
setPage={setPage}
/>
</Stack>
);
};
+60
View File
@@ -0,0 +1,60 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TableJobs, TableFailedJobs } from "./components/TableJobs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useGet, usePost } from "@/Hooks/UseApi";
import type { QueueData } from "@/Types/Queue";
import { Metrics } from "@/Pages/Logs/components/Metrics";
import { Button } from "@/Components/v2/inputs";
export const TabQueue = () => {
const theme = useTheme();
const { t } = useTranslation();
const {
data: queueData,
// isLoading,
// error,
refetch,
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
const { post, loading: isFlushing } = usePost();
const jobs = queueData?.jobs ?? [];
const metrics = queueData?.metrics ?? null;
const handleFlushQueue = async () => {
await post("/queue/flush", {});
refetch();
};
return (
<Stack gap={theme.spacing(8)}>
<Metrics metrics={metrics} />
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.jobQueue")}
</Typography>
<TableJobs jobs={jobs} />
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.failedJobs")}
</Typography>
<TableFailedJobs metrics={metrics} />
<Stack alignItems={"flex-end"}>
<Button
variant="contained"
onClick={handleFlushQueue}
loading={isFlushing}
>
{t("common.buttons.flushQueue")}
</Button>
</Stack>
</Stack>
);
};
@@ -0,0 +1,50 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/design-elements";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { QueueMetrics } from "@/Types/Queue";
interface MetricsProps {
metrics: QueueMetrics | null;
}
export const Metrics = ({ metrics }: MetricsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!metrics) {
return null;
}
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
>
<StatBox
title={t("pages.logs.metrics.jobs")}
subtitle={String(metrics.jobs)}
/>
<StatBox
palette={metrics.activeJobs > 0 ? "success" : undefined}
title={t("pages.logs.metrics.activeJobs")}
subtitle={String(metrics.activeJobs)}
/>
<StatBox
palette={metrics.failingJobs > 0 ? "error" : undefined}
title={t("pages.logs.metrics.failingJobs")}
subtitle={String(metrics.failingJobs)}
/>
<StatBox
title={t("pages.logs.metrics.totalRuns")}
subtitle={String(metrics.totalRuns)}
/>
<StatBox
palette={metrics.totalFailures > 0 ? "error" : undefined}
title={t("pages.logs.metrics.totalFailures")}
subtitle={String(metrics.totalFailures)}
/>
</Stack>
);
};
@@ -0,0 +1,72 @@
import Stack from "@mui/material/Stack";
import { DetailGauge } from "@/Components/v2/design-elements";
import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import type { Diagnostics } from "@/Types/Diagnostics";
interface StatGaugesProps {
diagnostics: Diagnostics | null;
}
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
const theme = useTheme();
const { t } = useTranslation();
if (!diagnostics) {
return null;
}
const heapTotalSize = getPercentage(
diagnostics?.v8HeapStats?.totalHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const heapUsedSize = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const actualHeapUsed = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.totalHeapSizeBytes
);
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
>
<DetailGauge
title={t("pages.logs.diagnostics.gauges.heapAllocation")}
progress={heapTotalSize}
upperValue={formatPercentageFromWhole(heapTotalSize)}
lowerLabel={t("pages.logs.diagnostics.gauges.total")}
lowerValue={prettyBytes(diagnostics.v8HeapStats?.heapSizeLimitBytes ?? 0)}
/>
<DetailGauge
title={t("pages.logs.diagnostics.gauges.heapUsage")}
progress={heapUsedSize}
upperLabel={t("pages.logs.diagnostics.gauges.availableMemoryPercentage")}
upperValue={formatPercentageFromWhole(heapUsedSize)}
lowerLabel={t("pages.logs.diagnostics.gauges.used")}
lowerValue={prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)}
/>
<DetailGauge
title={t("pages.logs.diagnostics.gauges.heapUtilization")}
progress={actualHeapUsed}
upperLabel={t("pages.logs.diagnostics.gauges.allocatedPercentage")}
upperValue={formatPercentageFromWhole(actualHeapUsed)}
lowerLabel={t("pages.logs.diagnostics.gauges.total")}
lowerValue={prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)}
/>
<DetailGauge
title={t("pages.logs.diagnostics.gauges.instantCpuUsage")}
progress={diagnostics.cpuUsage?.usagePercentage ?? 0}
upperLabel={t("pages.logs.diagnostics.gauges.usedSPercentage")}
upperValue={formatPercentageFromWhole(diagnostics.cpuUsage?.usagePercentage ?? 0)}
/>
</Stack>
);
};
@@ -0,0 +1,51 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/design-elements";
import prettyBytes from "pretty-bytes";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Diagnostics } from "@/Types/Diagnostics";
interface StatsProps {
diagnostics: Diagnostics | null;
}
export const Stats = ({ diagnostics }: StatsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!diagnostics) {
return null;
}
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
>
<StatBox
title={t("pages.logs.diagnostics.stats.eventLoopDelay")}
subtitle={prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
millisecondsDecimalDigits: 2,
})}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.uptime")}
subtitle={prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.usedHeapSize")}
subtitle={prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.totalHeapSize")}
subtitle={prettyBytes(diagnostics.v8HeapStats?.totalHeapSizeBytes ?? 0)}
/>
<StatBox
title={t("pages.logs.diagnostics.stats.osMemoryLimit")}
subtitle={prettyBytes(diagnostics.osStats?.totalMemoryBytes ?? 0)}
/>
</Stack>
);
};
@@ -0,0 +1,164 @@
import { Table } from "@/Components/v2/design-elements";
import { Typography, useTheme } from "@mui/material";
import prettyMilliseconds from "pretty-ms";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
import type { Header } from "@/Components/v2/design-elements";
type QueueJobWithId = QueueJobSummary & { id: string | number };
interface TableJobsProps {
jobs: QueueJobSummary[];
}
export const TableJobs = ({ jobs }: TableJobsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const jobsWithId: QueueJobWithId[] = jobs.map((job) => ({
...job,
id: job.monitorId,
}));
const headers: Header<QueueJobWithId>[] = [
{
id: "id",
content: t("common.table.headers.monitorId"),
render: (row) => <Typography fontFamily={"monospace"}>{row.monitorId}</Typography>,
},
{
id: "url",
content: t("common.table.headers.url"),
render: (row) => <Typography>{row.monitorUrl}</Typography>,
},
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => (
<Typography>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "type",
content: t("common.table.headers.type"),
render: (row) => <Typography>{row.monitorType}</Typography>,
},
{
id: "active",
content: t("common.table.headers.active"),
render: (row) => <Typography>{row.active.toString()}</Typography>,
},
{
id: "runCount",
content: t("pages.logs.table.headers.runCount"),
render: (row) => <Typography>{row.runCount}</Typography>,
},
{
id: "failCount",
content: t("pages.logs.table.headers.failCount"),
render: (row) => <Typography>{row.failCount}</Typography>,
},
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
render: (row) => <Typography>{formatTimestamp(row.lastRunAt)}</Typography>,
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => <Typography>{formatTimestamp(row.lockedAt)}</Typography>,
},
{
id: "lastFinish",
content: t("pages.logs.table.headers.lastFinishedAt"),
render: (row) => <Typography>{formatTimestamp(row.lastFinishedAt)}</Typography>,
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
return <Typography>{value}</Typography>;
},
},
];
return (
<Table
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
"& td": { backgroundColor: theme.palette.success.light },
}),
})}
/>
);
};
type QueueJobFailureWithId = QueueJobFailure & { id: string | number };
interface TableFailedJobsProps {
metrics: QueueMetrics | null;
}
export const TableFailedJobs = ({ metrics }: TableFailedJobsProps) => {
const { t } = useTranslation();
if (!metrics) {
return null;
}
const jobFailuresWithId: QueueJobFailureWithId[] = metrics.jobsWithFailures.map(
(job) => ({
...job,
id: job.monitorId,
})
);
const headers: Header<QueueJobFailureWithId>[] = [
{
id: "monitorId",
content: t("common.table.headers.monitorId"),
render: (row) => {
return <Typography fontFamily={"monospace"}>{row.monitorId}</Typography>;
},
},
{
id: "monitorUrl",
content: t("common.table.headers.url"),
render: (row) => {
return <Typography>{row.monitorUrl}</Typography>;
},
},
{
id: "failCount",
content: t("pages.logs.table.headers.failCount"),
render: (row) => {
return <Typography>{row.failCount}</Typography>;
},
},
{
id: "failedAt",
content: t("pages.logs.table.headers.lastFailedAt"),
render: (row) => {
return <Typography>{formatTimestamp(row.failedAt)}</Typography>;
},
},
{
id: "failReason",
content: t("pages.logs.table.headers.failReason"),
render: (row) => {
return <Typography>{row.failReason}</Typography>;
},
},
];
return (
<Table
headers={headers}
data={jobFailuresWithId}
/>
);
};
@@ -0,0 +1,128 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Table } from "@/Components/v2/design-elements";
import { Pagination } from "@/Components/v2/design-elements/Table";
import { useTheme } from "@mui/material";
import type { Header } from "@/Components/v2/design-elements/Table";
import type { Log, LogLevel } from "@/Types/Log";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import type { RootState } from "@/Types/state";
import { setRowsPerPage } from "@/Features/UI/uiSlice";
import { formatTimestamp } from "@/Utils/TimeUtils";
type LogWithId = Log & { id: number };
interface TableLogsProps {
logs: LogWithId[];
logCount: number;
page: number;
setPage: (page: number) => void;
}
const LevelBadge = ({ level }: { level: LogLevel }) => {
const theme = useTheme();
const levelColors: Record<LogLevel, string> = {
info: theme.palette.success.main,
warn: theme.palette.warning.main,
error: theme.palette.error.main,
debug: theme.palette.info.main,
};
const color = levelColors[level] || theme.palette.text.primary;
return (
<Typography
fontWeight={600}
color={color}
textTransform={"uppercase"}
>
{level}
</Typography>
);
};
export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) => state?.ui?.logs?.rowsPerPage ?? 15
);
const headers: Header<Log & { id: number }>[] = [
{
id: "timestamp",
content: t("pages.logs.table.headers.timestamp"),
render: (row) => (
<Typography sx={{ fontFamily: "monospace" }}>
{formatTimestamp(row.timestamp)}
</Typography>
),
},
{
id: "level",
content: t("pages.logs.table.headers.level"),
render: (row) => <LevelBadge level={row.level} />,
},
{
id: "service",
content: t("pages.logs.table.headers.service"),
render: (row) => <Typography>{row.service || "-"}</Typography>,
},
{
id: "method",
content: t("pages.logs.table.headers.method"),
render: (row) => (
<Typography fontFamily={"monospace"}>{row.method || "-"}</Typography>
),
},
{
id: "message",
content: t("common.table.headers.message"),
render: (row) => (
<Typography
maxWidth={400}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
>
{row.message || "-"}
</Typography>
),
},
];
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
dispatch(setRowsPerPage({ value: Number(e.target.value), table: "logs" }));
setPage(0);
};
return (
<Box>
<Table
headers={headers}
data={logs}
emptyViewText={t("pages.logs.noLogs")}
/>
<Pagination
itemsOnPage={logs.length}
component="div"
count={logCount}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
-50
View File
@@ -1,50 +0,0 @@
import Stack from "@mui/material/Stack";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Queue from "./Queue/index.jsx";
import LogsComponent from "./Logs/index.jsx";
import Diagnostics from "./Diagnostics/index.jsx";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
const Logs = () => {
const { t } = useTranslation();
const theme = useTheme();
// Local state
const [value, setValue] = useState(0);
// Handlers
const handleChange = (event, newValue) => {
setValue(newValue);
};
const BREADCRUMBS = [{ name: t("logsPage.title"), path: "/logs" }];
return (
<Stack gap={theme.spacing(20)}>
<Breadcrumbs list={BREADCRUMBS} />
<Tabs
value={value}
onChange={handleChange}
sx={{
position: "sticky",
top: theme.spacing(0),
backdropFilter: "blur(10px)",
zIndex: theme.zIndex.appBar,
}}
>
<Tab label={t("logsPage.tabs.logs")} />
<Tab label={t("logsPage.tabs.queue")} />
<Tab label={t("logsPage.tabs.diagnostics")} />
</Tabs>
{value === 0 && <LogsComponent />}
{value === 1 && <Queue />}
{value === 2 && <Diagnostics />}
</Stack>
);
};
export default Logs;
+29
View File
@@ -0,0 +1,29 @@
import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements";
import { TabLogs } from "@/Pages/Logs/TabLogs";
import { TabQueue } from "@/Pages/Logs/TabQueue";
import { TabDiagnostics } from "@/Pages/Logs/TabDiagnostics";
import { useState } from "react";
import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(2);
return (
<BasePage>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
>
<Tab label={t("pages.logs.tabs.logs")} />
<Tab label={t("pages.logs.tabs.queue")} />
<Tab label={t("pages.logs.tabs.diagnostics")} />
</Tabs>
{activeTab === 0 && <TabLogs />}
{activeTab === 1 && <TabQueue />}
{activeTab === 2 && <TabDiagnostics />}
</BasePage>
);
};
export default LogsPage;
@@ -1,38 +0,0 @@
import Select from "@/Components/v1/Inputs/Select/index.jsx";
import PropTypes from "prop-types";
const ConfigSelect = ({ configSelection, valueSelect, onChange, ...props }) => {
const getValueById = (config, id) => {
const item = config.find((config) => config._id === id);
return item ? (item.value ? item.value : item.name) : null;
};
const getIdByValue = (config, name) => {
const item = config.find((config) => {
if (config.value) {
return config.value === name;
} else {
return config.name === name;
}
});
return item ? item._id : null;
};
return (
<Select
value={getIdByValue(configSelection, valueSelect)}
onChange={(event) => {
const newValue = getValueById(configSelection, event.target.value);
onChange(newValue);
}}
items={configSelection}
{...props}
/>
);
};
ConfigSelect.propTypes = {
configSelection: PropTypes.array.isRequired,
valueSelect: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default ConfigSelect;
@@ -1,71 +0,0 @@
// Components
import { Stack, Typography } from "@mui/material";
import Icon from "@/Components/v1/Icon";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const MonitorListItem = ({ monitor, onDelete }) => {
const theme = useTheme();
return (
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(4)}
width="100%"
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<Icon
name="Trash2"
size={20}
style={{ cursor: "pointer" }}
onClick={() => onDelete(monitor)}
/>
</Stack>
);
};
MonitorListItem.propTypes = {
monitor: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
onDelete: PropTypes.func.isRequired,
};
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
const onDelete = (monitorToDelete) => {
const newMonitors = selectedMonitors.filter(
(monitor) => monitor.id !== monitorToDelete.id
);
setSelectedMonitors(newMonitors);
};
const theme = useTheme();
return (
<Stack
gap={theme.spacing(6)}
width="100%"
>
{selectedMonitors?.map((monitor) => (
<MonitorListItem
key={monitor.id}
monitor={monitor}
onDelete={onDelete}
/>
))}
</Stack>
);
};
MonitorList.propTypes = {
selectedMonitors: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
setSelectedMonitors: PropTypes.func.isRequired,
};
export default MonitorList;
@@ -1,68 +0,0 @@
import PropTypes from "prop-types";
import Search from "@/Components/v1/Inputs/Search/index.jsx";
import { useState } from "react";
import ConfigRow from "@/Components/v1/ConfigRow/index.jsx";
/**
* MonitorsConfig is a component that allows users to select and apply a maintenance window to a list of monitors.
* It provides a search input to filter monitors and select multiple options. The selected monitors are passed
* to the parent via the `onSelectMonitors` callback.
*
*
* @component
*
* @param {Array} props.monitors - List of available monitors that can be selected.
* @param {Array} props.selectedMonitors - List of monitors currently selected for applying the maintenance window.
* @param {string} [props.error] - Optional error message that will be displayed if there's an error in monitor selection.
* @param {boolean} props.isEditMode - A boolean flag indicating if the component is in edit mode (disabled search/input when true).
* @param {Function} props.onSelectMonitors - Callback function that is invoked when monitors are selected or deselected.
* @param {string} [props.secondaryLabel] - Optional label or description that provides additional context for the monitor search.
*/
const MonitorsConfig = ({
monitors,
selectedMonitors,
error,
isEditMode,
onSelectMonitors,
secondaryLabel,
}) => {
const [search, setSearch] = useState("");
const handleSearch = (value) => {
setSearch(value);
};
return (
<ConfigRow
title="Monitors for maintenance"
description="Select monitors to apply the maintenance window to. Use the search field to filter and choose multiple monitors."
>
<Search
id={"monitors"}
label="Add monitors"
multiple={true}
isAdorned={false}
options={monitors || []}
filteredBy="name"
secondaryLabel={secondaryLabel}
inputValue={search}
value={selectedMonitors}
handleInputChange={handleSearch}
handleChange={onSelectMonitors}
error={error}
disabled={isEditMode}
/>
</ConfigRow>
);
};
MonitorsConfig.propTypes = {
monitors: PropTypes.array.isRequired,
selectedMonitors: PropTypes.array.isRequired,
error: PropTypes.string,
isEditMode: PropTypes.bool.isRequired,
onSelectMonitors: PropTypes.func.isRequired,
secondaryLabel: PropTypes.string,
};
export default MonitorsConfig;
@@ -1,70 +0,0 @@
import { networkService } from "../../../../main.jsx";
import dayjs from "dayjs";
import {
MS_PER_SECOND,
MS_PER_MINUTE,
MS_PER_HOUR,
MS_PER_DAY,
MS_PER_WEEK,
} from "../../../../Utils/timeUtilsLegacy.js";
const useMaintenanceActions = () => {
const MS_LOOKUP = {
seconds: MS_PER_SECOND,
minutes: MS_PER_MINUTE,
hours: MS_PER_HOUR,
days: MS_PER_DAY,
weeks: MS_PER_WEEK,
};
const REPEAT_LOOKUP = {
none: 0,
daily: MS_PER_DAY,
weekly: MS_PER_DAY * 7,
};
const handleSubmitForm = async (maintenanceWindowId, form) => {
const toStringId = (monitor) => {
if (!monitor) {
return "";
}
return monitor._id ?? monitor.id ?? "";
};
const start = dayjs(form.startDate)
.set("hour", form.startTime.hour())
.set("minute", form.startTime.minute());
const MS_MULTIPLIER = MS_LOOKUP[form.durationUnit];
const durationInMs = form.duration * MS_MULTIPLIER;
const end = start.add(durationInMs);
const repeat = REPEAT_LOOKUP[form.repeat];
const submit = {
monitors: form.monitors.map((monitor) => toStringId(monitor)).filter(Boolean),
name: form.name,
start: start.toISOString(),
end: end.toISOString(),
repeat,
};
if (repeat === 0) {
submit.expiry = end;
}
const requestConfig = { maintenanceWindow: submit };
if (maintenanceWindowId !== undefined) {
requestConfig.maintenanceWindowId = maintenanceWindowId;
}
const request =
maintenanceWindowId === undefined
? networkService.createMaintenanceWindow(requestConfig)
: networkService.editMaintenanceWindow(requestConfig);
return request;
};
return {
handleSubmitForm,
};
};
export default useMaintenanceActions;
@@ -1,76 +0,0 @@
import { networkService } from "../../../../main.jsx";
import dayjs from "dayjs";
import {
MS_PER_SECOND,
MS_PER_MINUTE,
MS_PER_HOUR,
MS_PER_DAY,
MS_PER_WEEK,
} from "../../../../Utils/timeUtilsLegacy.js";
const useMaintenanceData = () => {
const REVERSE_REPEAT_LOOKUP = {
0: "none",
[MS_PER_DAY]: "daily",
[MS_PER_WEEK]: "weekly",
};
const getDurationAndUnit = (durationInMs) => {
if (durationInMs % MS_PER_DAY === 0) {
return {
duration: (durationInMs / MS_PER_DAY).toString(),
durationUnit: "days",
};
} else if (durationInMs % MS_PER_HOUR === 0) {
return {
duration: (durationInMs / MS_PER_HOUR).toString(),
durationUnit: "hours",
};
} else if (durationInMs % MS_PER_MINUTE === 0) {
return {
duration: (durationInMs / MS_PER_MINUTE).toString(),
durationUnit: "minutes",
};
} else {
return {
duration: (durationInMs / MS_PER_SECOND).toString(),
durationUnit: "seconds",
};
}
};
const fetchMonitorsMaintenance = async () => {
const response = await networkService.getMonitorsByTeamId({
limit: null,
types: ["http", "ping", "pagespeed", "port"],
});
const fetchedMonitors = response.data.data;
return fetchedMonitors;
};
const initializeMaintenanceForEdit = async (maintenanceWindowId, monitorList) => {
const res = await networkService.getMaintenanceWindowById({
maintenanceWindowId: maintenanceWindowId,
});
const maintenanceWindow = res.data.data;
const { name, start, end, repeat, monitorId } = maintenanceWindow;
const startTime = dayjs(start);
const endTime = dayjs(end);
const durationInMs = endTime.diff(startTime, "milliseconds").toString();
const { duration, durationUnit } = getDurationAndUnit(durationInMs);
const monitor = monitorList.find(
(monitor) => (monitor._id ?? monitor.id) === monitorId
);
const maintenanceWindowInformation = {
name,
repeat: REVERSE_REPEAT_LOOKUP[repeat],
startDate: startTime,
startTime,
duration,
durationUnit,
monitors: monitor ? [monitor] : [],
};
return maintenanceWindowInformation;
};
return { fetchMonitorsMaintenance, initializeMaintenanceForEdit };
};
export default useMaintenanceData;
@@ -1,3 +0,0 @@
.create-maintenance button {
height: 35px;
}
@@ -1,422 +0,0 @@
import { Box, Button, Stack, Typography } from "@mui/material";
import { useSelector } from "react-redux";
import { useTheme } from "@emotion/react";
import { useEffect, useState } from "react";
import ConfigBox from "@/Components/v1/ConfigBox/index.jsx";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
import { maintenanceWindowValidation } from "@/Validation/validation.js";
import { createToast } from "@/Utils/toastUtils.jsx";
import MonitorList from "./Components/MonitorList/index.jsx";
import ConfigSelect from "./Components/ConfigSelect/index.jsx";
import useMaintenanceData from "./hooks/useMaintenanceData.jsx";
import useMaintenanceActions from "./hooks/useMaintenanceActions.jsx";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import Icon from "@/Components/v1/Icon";
import "./index.css";
import Search from "@/Components/v1/Inputs/Search/index.jsx";
import { logger } from "@/Utils/Logger.js";
import { useNavigate, useParams } from "react-router-dom";
import { buildErrors, hasValidationErrors } from "@/Validation/error.js";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
const repeatConfig = [
{ _id: 0, name: "Don't repeat", value: "none" },
{
_id: 1,
name: "Repeat daily",
value: "daily",
},
{ _id: 2, name: "Repeat weekly", value: "weekly" },
];
const durationConfig = [
{ _id: 0, name: "seconds" },
{ _id: 1, name: "minutes" },
{ _id: 2, name: "hours" },
{
_id: 3,
name: "days",
},
];
const CreateMaintenance = () => {
const { maintenanceWindowId } = useParams();
const { handleSubmitForm } = useMaintenanceActions();
const { fetchMonitorsMaintenance, initializeMaintenanceForEdit } = useMaintenanceData();
const navigate = useNavigate();
const theme = useTheme();
const { t } = useTranslation();
const { user } = useSelector((state) => state.auth);
const [monitors, setMonitors] = useState([]);
const [search, setSearch] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({});
const [form, setForm] = useState({
repeat: "none",
startDate: dayjs(),
startTime: dayjs(),
duration: "",
durationUnit: "seconds",
name: "",
monitors: [],
});
const handleFormChange = (key, value) => {
setForm((prev) => ({ ...prev, [key]: value }));
const { error } = maintenanceWindowValidation.validate(
{ [key]: value },
{ abortEarly: false }
);
setErrors((prev) => {
return buildErrors(prev, key, error);
});
};
useEffect(() => {
const fetchMonitors = async () => {
setIsLoading(true);
try {
const fetchedMonitors = await fetchMonitorsMaintenance();
setMonitors(fetchedMonitors);
if (maintenanceWindowId === undefined) {
return;
}
const maintenanceWindowInformation = await initializeMaintenanceForEdit(
maintenanceWindowId,
fetchedMonitors
);
setForm((prev) => ({
...prev,
...maintenanceWindowInformation,
}));
} catch (error) {
createToast({ body: "Failed to fetch data" });
logger.error("Failed to fetch monitors", error);
} finally {
setIsLoading(false);
}
};
fetchMonitors();
}, [user]);
const handleSubmit = async () => {
if (hasValidationErrors(form, maintenanceWindowValidation, setErrors)) return;
const request = handleSubmitForm(maintenanceWindowId, form);
try {
setIsLoading(true);
await request;
createToast({
body: "Successfully created maintenance window",
});
navigate("/maintenance");
} catch (error) {
createToast({
body: `Failed to ${
maintenanceWindowId === undefined ? "create" : "edit"
} maintenance window`,
});
logger.error(error);
} finally {
setIsLoading(false);
}
};
return (
<Box className="create-maintenance">
<Breadcrumbs
list={[
{ name: "maintenance", path: "/maintenance" },
{
name: maintenanceWindowId === undefined ? "create" : "edit",
path: `/maintenance/create`,
},
]}
/>
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.spacing(12)}
mt={theme.spacing(6)}
>
<Box>
<Typography
component="h1"
variant="h1"
>
<Typography
component="span"
fontSize="inherit"
>
{`${maintenanceWindowId === undefined ? t("createA") : t("edit")}`}{" "}
</Typography>
<Typography
component="span"
variant="h2"
fontSize="inherit"
fontWeight="inherit"
>
{t("maintenance")}{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
>
{t("window")}
</Typography>
</Typography>
<Typography
component="h2"
variant="body2"
fontSize={14}
>
{t("maintenanceWindowDescription")}
</Typography>
</Box>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("settingsGeneralSettings")}
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<TextInput
id="name"
label={t("friendlyNameInput")}
placeholder={t("friendlyNamePlaceholder")}
value={form.name}
onChange={(event) => {
handleFormChange("name", event.target.value);
}}
error={errors["name"] ? true : false}
helperText={errors["name"]}
/>
<ConfigSelect
id="repeat"
name="maintenance-repeat"
label={t("maintenanceRepeat")}
valueSelect={form.repeat}
configSelection={repeatConfig}
onChange={(value) => handleFormChange("repeat", value)}
/>
<Stack>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
id="startDate"
disablePast
disableHighlightToday
value={form.startDate}
slots={{
openPickerIcon: () => (
<Icon
name="Calendar"
size={20}
/>
),
}}
slotProps={{
switchViewButton: { sx: { display: "none" } },
nextIconButton: { sx: { ml: theme.spacing(2) } },
day: {
sx: {
"&.MuiPickersDay-root.Mui-disabled": {
color: theme.palette.primary.contrastTextTertiary,
},
},
},
field: {
sx: {
width: "fit-content",
"& > .MuiOutlinedInput-root": {
flexDirection: "row-reverse",
},
"& input": {
minHeight: 34,
p: 0,
pr: theme.spacing(5),
},
"& fieldset": {
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: theme.palette.primary.lowContrast,
},
},
},
inputAdornment: { sx: { ml: 0, px: 3 } },
openPickerButton: {
sx: {
py: 0,
mr: 0,
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
strokeWidth: 1.1,
},
"&:hover": { backgroundColor: "transparent" },
},
},
}}
sx={{}}
onChange={(newDate) => {
handleFormChange("startDate", newDate);
}}
error={errors["startDate"]}
/>
</LocalizationProvider>
</Stack>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("startTime")}
</Typography>
<Typography> {t("timeZoneInfo")} </Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<MobileTimePicker
id="startTime"
label={t("startTime")}
value={form.startTime}
onChange={(newTime) => {
handleFormChange("startTime", newTime);
}}
slotProps={{
nextIconButton: { sx: { ml: theme.spacing(2) } },
field: {
sx: {
width: "fit-content",
"& > .MuiOutlinedInput-root": {
flexDirection: "row-reverse",
},
"& input": {
minHeight: 34,
p: 0,
pl: theme.spacing(5),
},
"& fieldset": {
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: theme.palette.primary.lowContrast,
},
},
},
}}
error={errors["startTime"]}
/>
</LocalizationProvider>
<Stack
direction="row"
alignItems="end"
spacing={theme.spacing(8)}
>
<TextInput
type="number"
id="duration"
label={t("duration")}
value={form.duration}
onChange={(event) => {
handleFormChange("duration", event.target.value);
}}
error={errors["duration"] ? true : false}
helperText={errors["duration"]}
/>
<ConfigSelect
id="durationUnit"
valueSelect={form.durationUnit}
configSelection={durationConfig}
onChange={(value) => handleFormChange("durationUnit", value)}
error={errors["durationUnit"]}
/>
</Stack>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography
component="h2"
variant="h2"
>
{t("monitorsToApply")}
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Search
id={"monitors"}
label={t("addMonitors")}
multiple={true}
isAdorned={true}
options={monitors}
filteredBy="name"
secondaryLabel={"type"}
inputValue={search}
value={form.monitors}
handleInputChange={setSearch}
handleChange={(selected) => {
handleFormChange("monitors", selected);
}}
error={errors["monitors"]}
/>
<MonitorList
selectedMonitors={form.monitors}
setSelectedMonitors={(monitors) =>
setForm((prev) => ({ ...prev, monitors }))
}
/>
</Stack>
</ConfigBox>
<Box
ml="auto"
display="inline-block"
>
<Button
variant="contained"
color="secondary"
onClick={() => navigate("/maintenance")}
sx={{ mr: theme.spacing(6) }}
>
{t("cancel")}
</Button>
<Button
loading={isLoading}
variant="contained"
color="accent"
onClick={handleSubmit}
disabled={false}
>
{`${
maintenanceWindowId === undefined
? t("createMaintenance")
: t("editMaintenance")
}`}
</Button>
</Box>
</Stack>
</Box>
);
};
export default CreateMaintenance;
@@ -1,178 +0,0 @@
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { IconButton, Menu, MenuItem } from "@mui/material";
import { logger } from "@/Utils/Logger.js";
import Icon from "@/Components/v1/Icon";
import PropTypes from "prop-types";
import { networkService } from "../../../../main.jsx";
import { createToast } from "@/Utils/toastUtils.jsx";
import { useTranslation } from "react-i18next";
import Dialog from "@/Components/v1/Dialog/index.jsx";
const ActionsMenu = ({ /* isAdmin, */ maintenanceWindow, updateCallback }) => {
maintenanceWindow;
const [anchorEl, setAnchorEl] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const theme = useTheme();
const { t } = useTranslation();
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
try {
setIsLoading(true);
await networkService.deleteMaintenanceWindow({
maintenanceWindowId: maintenanceWindow.id,
});
updateCallback();
createToast({ body: "Maintenance window deleted successfully." });
} catch (error) {
createToast({ body: "Failed to delete maintenance window." });
logger.error("Failed to delete maintenance window", error);
} finally {
setIsLoading(false);
}
setIsOpen(false);
};
const handlePause = async () => {
try {
setIsLoading(true);
const data = {
active: !maintenanceWindow.active,
};
await networkService.editMaintenanceWindow({
maintenanceWindowId: maintenanceWindow.id,
maintenanceWindow: data,
});
updateCallback();
} catch (error) {
logger.error(error);
createToast({ body: "Failed to pause maintenance window." });
} finally {
setIsLoading(false);
}
};
const handleEdit = () => {
navigate(`/maintenance/create/${maintenanceWindow.id}`);
};
const openMenu = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor 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="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.main,
},
},
},
}}
>
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
handleEdit();
}}
>
{t("edit")}
</MenuItem>
<MenuItem
onClick={(e) => {
handlePause();
closeMenu(e);
e.stopPropagation();
}}
>
{`${maintenanceWindow.active === true ? t("pause") : t("resume")}`}
</MenuItem>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
>
{t("remove")}
</MenuItem>
</Menu>
<Dialog
open={isOpen}
theme={theme}
title={t("maintenanceTableActionMenuDialogTitle")}
onCancel={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
confirmationButtonLabel={t("delete")}
onConfirm={(e) => {
e.stopPropagation(e);
handleRemove(e);
}}
isLoading={isLoading}
/>
</>
);
};
ActionsMenu.propTypes = {
maintenanceWindow: PropTypes.object,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
};
export default ActionsMenu;
@@ -1,237 +0,0 @@
import PropTypes from "prop-types";
import { Box } from "@mui/material";
import DataTable from "@/Components/v1/Table/index.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import Icon from "@/Components/v1/Icon";
import ActionsMenu from "./ActionsMenu/index.jsx";
import { memo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { formatDurationRounded } from "../../../Utils/timeUtilsLegacy.js";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { setRowsPerPage } from "../../../Features/UI/uiSlice.js";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
/**
* Component for pagination actions (first, previous, next, last).
*
* @component
* @param {Object} props
* @param {number} props.count - Total number of items.
* @param {number} props.page - Current page number.
* @param {number} props.rowsPerPage - Number of rows per page.
* @param {function} props.onPageChange - Callback function to handle page change.
*
* @returns {JSX.Element} Pagination actions component.
*/
const MaintenanceTable = ({
page,
setPage,
sort,
setSort,
maintenanceWindows,
maintenanceWindowCount,
updateCallback,
}) => {
const rowsPerPage = useSelector((state) => state?.ui?.maintenance?.rowsPerPage ?? 5);
const dispatch = useDispatch();
const theme = useTheme();
const navigate = useNavigate();
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "maintenance",
})
);
setPage(0);
};
const { t } = useTranslation();
const headers = [
{
id: "name",
content: (
<Box onClick={() => handleSort("name")}>
{t("maintenanceWindowName")}
<span
style={{
visibility: sort.field === "name" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<Icon
name="ArrowUp"
size={18}
/>
) : (
<Icon
name="ArrowDown"
size={18}
/>
)}
</span>
</Box>
),
render: (row) => row.name,
},
{
id: "status",
content: (
<Box onClick={() => handleSort("status")}>
{" "}
{t("status")}
<span
style={{
visibility: sort.field === "active" ? "visible" : "hidden",
}}
>
{sort.order === "asc" ? (
<Icon
name="ArrowUp"
size={18}
/>
) : (
<Icon
name="ArrowDown"
size={18}
/>
)}
</span>
</Box>
),
render: (row) => {
const status = row.active ? "up" : "paused";
const text = row.active ? "active" : "paused";
return (
<StatusLabel
status={status}
text={text}
customStyles={{ textTransform: "capitalize" }}
/>
);
},
},
{
id: "nextWindow",
content: t("nextWindow"),
render: (row) => {
return getTimeToNextWindow(row.start, row.end, row.repeat);
},
},
{
id: "repeat",
content: t("repeat"),
render: (row) => {
return row.repeat === 0 ? "N/A" : formatDurationRounded(row.repeat);
},
},
{
id: "actions",
content: t("actions"),
render: (row) => (
<ActionsMenu
maintenanceWindow={row}
updateCallback={updateCallback}
/>
),
},
];
const getTimeToNextWindow = (startTime, endTime, repeat) => {
//1. Advance time closest to next window as possible
const now = dayjs();
let start = dayjs(startTime);
let end = dayjs(endTime);
if (repeat > 0) {
// Advance time closest to next window as possible
while (start.isBefore(now) && end.isBefore(now)) {
start = start.add(repeat, "milliseconds");
end = end.add(repeat, "milliseconds");
}
}
//Check if we are in a window
if (now.isAfter(start) && now.isBefore(end)) {
return "In maintenance window";
}
if (start.isAfter(now)) {
const diffInMinutes = start.diff(now, "minutes");
const diffInHours = start.diff(now, "hours");
const diffInDays = start.diff(now, "days");
if (diffInMinutes < 60) {
return diffInMinutes + " minutes";
} else if (diffInHours < 24) {
return diffInHours + " hours";
} else if (diffInDays < 7) {
return diffInDays + " days";
} else {
return diffInDays + " days";
}
}
};
const handleSort = async (field) => {
let order = "";
if (sort.field !== field) {
order = "desc";
} else {
order = sort.order === "asc" ? "desc" : "asc";
}
setSort({ field, order });
};
return (
<>
<DataTable
config={{
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
onRowClick: (row) => {
navigate(`/maintenance/create/${row.id}`);
},
}}
headers={headers}
data={maintenanceWindows}
/>
<Pagination
itemCount={maintenanceWindowCount}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</>
);
};
MaintenanceTable.propTypes = {
isAdmin: PropTypes.bool,
page: PropTypes.number,
setPage: PropTypes.func,
rowsPerPage: PropTypes.number,
setRowsPerPage: PropTypes.func,
sort: PropTypes.object,
setSort: PropTypes.func,
maintenanceWindows: PropTypes.array,
maintenanceWindowCount: PropTypes.number,
updateCallback: PropTypes.func,
};
const MemoizedMaintenanceTable = memo(MaintenanceTable);
export default MemoizedMaintenanceTable;
@@ -0,0 +1,210 @@
import Typography from "@mui/material/Typography";
import { Table, ValueLabel } from "@/Components/v2/design-elements";
import { Pagination } from "@/Components/v2/design-elements/Table";
import { ActionsMenu } from "@/Components/v2/actions-menu";
import { DialogInput } from "@/Components/v2/inputs/Dialog";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material";
import type { Header } from "@/Components/v2/design-elements/Table";
import type { ActionMenuItem } from "@/Components/v2/actions-menu";
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";
import type { RootState } from "@/Types/state";
import Box from "@mui/material/Box";
import { setRowsPerPage } from "@/Features/UI/uiSlice";
import dayjs from "dayjs";
import { useState } from "react";
import { useDelete, usePatch } from "@/Hooks/UseApi";
interface MaintenanceWindowTableProps {
maintenanceWindows: MaintenanceWindow[];
maintenanceWindowCount: number;
page: number;
setPage: (page: number) => void;
refetch: () => void;
}
const getTimeToNextWindow = (
startTime: string,
endTime: string,
repeat: number
): string => {
const now = dayjs();
let start = dayjs(startTime);
let end = dayjs(endTime);
// For repeating windows, advance to next occurrence
if (repeat > 0) {
while (end.isBefore(now)) {
start = start.add(repeat, "milliseconds");
end = end.add(repeat, "milliseconds");
}
}
// Currently in maintenance window
if (now.isAfter(start) && now.isBefore(end)) {
return "In maintenance window";
}
// Window is in the future
if (start.isAfter(now)) {
return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true });
}
return "N/A";
};
export const MaintenanceWindowTable = ({
maintenanceWindows,
maintenanceWindowCount,
page,
setPage,
refetch,
}: MaintenanceWindowTableProps) => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) => state?.ui?.maintenance?.rowsPerPage ?? 5
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedWindow, setSelectedWindow] = useState<MaintenanceWindow | null>(null);
const { deleteFn, loading: deleteLoading } = useDelete();
const { patch } = usePatch();
const handleDelete = async () => {
if (!selectedWindow) return;
const result = await deleteFn(`/maintenance-window/${selectedWindow.id}`);
if (result) {
refetch();
setDeleteDialogOpen(false);
setSelectedWindow(null);
}
};
const handlePause = async (maintenanceWindow: MaintenanceWindow) => {
const result = await patch(`/maintenance-window/${maintenanceWindow.id}`, {
active: !maintenanceWindow.active,
});
if (result) {
refetch();
}
};
const getActions = (maintenanceWindow: MaintenanceWindow): ActionMenuItem[] => [
{
id: "edit",
label: t("pages.common.monitors.actions.configure"),
action: () => navigate(`/maintenance/create/${maintenanceWindow.id}`),
closeMenu: true,
},
{
id: "pause",
label: maintenanceWindow.active
? t("pages.common.monitors.actions.pause")
: t("pages.common.monitors.actions.resume"),
action: () => handlePause(maintenanceWindow),
closeMenu: true,
},
{
id: "remove",
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: () => {
setSelectedWindow(maintenanceWindow);
setDeleteDialogOpen(true);
},
closeMenu: true,
},
];
const getHeaders = (): Header<MaintenanceWindow>[] => [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => row.name,
},
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => (
<ValueLabel
value={row.active ? "positive" : "neutral"}
text={row.active ? t("common.labels.active") : t("common.labels.paused")}
/>
),
},
{
id: "nextWindow",
content: t("pages.maintenanceWindow.table.headers.nextWindow"),
render: (row) => getTimeToNextWindow(row.start, row.end, row.repeat),
},
{
id: "repeat",
content: t("pages.maintenanceWindow.table.headers.repeat"),
render: (row) =>
row.repeat === 0
? t("common.labels.na")
: prettyMilliseconds(row.repeat, { verbose: true }),
},
{
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>
) => {
dispatch(setRowsPerPage({ value: Number(e.target.value), table: "maintenance" }));
setPage(0);
};
return (
<Box>
<Table
headers={getHeaders()}
data={maintenanceWindows}
onRowClick={(row) => navigate(`/maintenance/create/${row.id}`)}
emptyViewText={t("common.table.empty")}
/>
<Pagination
itemsOnPage={maintenanceWindows.length}
component="div"
count={maintenanceWindowCount}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
<DialogInput
open={deleteDialogOpen}
title={t("maintenanceTableActionMenuDialogTitle")}
onCancel={() => {
setDeleteDialogOpen(false);
setSelectedWindow(null);
}}
onConfirm={handleDelete}
confirmText={t("delete")}
loading={deleteLoading}
/>
</Box>
);
};
@@ -0,0 +1,368 @@
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { BasePage, ConfigBox } from "@/Components/v2/design-elements";
import {
TextField,
Select,
DatePicker,
TimePicker,
Button,
Autocomplete,
} from "@/Components/v2/inputs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow";
import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow";
import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor";
import { useForm, Controller } from "react-hook-form";
import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Trash2 } from "lucide-react";
const CreateMaintenanceWindowPage = () => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const { maintenanceWindowId } = useParams<{ maintenanceWindowId: string }>();
const isEditMode = Boolean(maintenanceWindowId);
const { data: existingMaintenanceWindow } = useGet<MaintenanceWindow>(
isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null,
{},
{ keepPreviousData: false }
);
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
const { post, loading: isPosting } = usePost();
const { patch, loading: isPatching } = usePatch();
const { schema, defaults } = useMaintenanceWindowForm({
data: existingMaintenanceWindow,
});
const form = useForm<MaintenanceWindowFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { control, handleSubmit, trigger } = form;
useEffect(() => {
if (existingMaintenanceWindow) {
form.reset(defaults);
}
}, [existingMaintenanceWindow, defaults, form]);
const buildPayload = (data: MaintenanceWindowFormData) => {
const startDateTime = dayjs(data.startDate)
.set("hour", parseInt(data.startTime.split(":")[0], 10))
.set("minute", parseInt(data.startTime.split(":")[1], 10));
const durationUnit = durationUnitOptions.find((o) => o.id === data.durationUnit);
const durationInMs = data.duration * (durationUnit?.multiplier ?? 1000);
const endDateTime = startDateTime.add(durationInMs, "milliseconds");
const repeatOption = repeatOptions.find((o) => o.id === data.repeat);
const repeat = repeatOption?.value ?? 0;
const payload: Record<string, unknown> = {
name: data.name,
duration: data.duration,
durationUnit: data.durationUnit,
monitors: data.monitors,
start: startDateTime.toISOString(),
end: endDateTime.toISOString(),
repeat,
};
if (repeat === 0) {
payload.expiry = endDateTime.toISOString();
}
return payload;
};
const onSubmit = async (data: MaintenanceWindowFormData) => {
const payload = buildPayload(data);
let result;
if (isEditMode && maintenanceWindowId) {
result = await patch(`/maintenance-window/${maintenanceWindowId}`, payload);
} else {
result = await post("/maintenance-window", payload);
}
if (result?.success) {
navigate("/maintenance");
}
};
const isLoading = isPosting || isPatching;
const onError = (errors: any) => {
console.error("Form submission errors:", errors);
};
return (
<BasePage
component={"form"}
onSubmit={handleSubmit(onSubmit, onError)}
>
<ConfigBox
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="name"
control={control}
defaultValue={defaults.name}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
placeholder={t(
"pages.maintenanceWindow.form.general.option.name.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="repeat"
control={control}
defaultValue={defaults.repeat}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t(
"pages.maintenanceWindow.form.general.option.repeat.label"
)}
error={!!fieldState.error}
onChange={field.onChange}
>
{repeatOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="startDate"
control={control}
defaultValue={defaults.startDate}
render={({ field, fieldState }) => (
<DatePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
onChange={(date) => {
field.onChange(date ? date.format("YYYY-MM-DD") : "");
trigger("startDate");
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="startTime"
control={control}
defaultValue={defaults.startTime}
render={({ field }) => (
<TimePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
value={field.value ? dayjs(field.value, "HH:mm") : null}
onChange={(time) => {
field.onChange(time ? time.format("HH:mm") : "");
trigger("startDate");
}}
/>
)}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(4)}
>
<Controller
name="duration"
control={control}
defaultValue={defaults.duration}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)}
value={field.value === 0 ? "" : field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ width: 120 }}
/>
)}
/>
<Controller
name="durationUnit"
control={control}
defaultValue={defaults.durationUnit}
render={({ field }) => (
<Select
fieldLabel=" "
value={field.value}
onChange={field.onChange}
sx={{ minWidth: 120 }}
>
{durationUnitOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
defaultValue={defaults.monitors}
render={({ field, fieldState }) => {
const monitorsList = monitors ?? [];
const selectedMonitors = field.value
.map((id: string) => monitorsList.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
return (
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={monitorsList}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<Stack>
{selectedMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
spacing={theme.spacing(4)}
padding={theme.spacing(4)}
marginTop={theme.spacing(2)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter((id: string) => id !== monitor.id)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isLoading}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
);
};
export default CreateMaintenanceWindowPage;
Whitespace-only changes.
-95
View File
@@ -1,95 +0,0 @@
import { Stack, Button } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useState, useEffect } from "react";
import "./index.css";
import MaintenanceTable from "./MaintenanceTable/index.jsx";
import { useSelector } from "react-redux";
import { networkService } from "../../main.jsx";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
const Maintenance = () => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const rowsPerPage = useSelector((state) => state?.ui?.maintenance?.rowsPerPage ?? 5);
const [maintenanceWindows, setMaintenanceWindows] = useState(undefined);
const [maintenanceWindowCount, setMaintenanceWindowCount] = useState(0);
const [page, setPage] = useState(0);
const [sort, setSort] = useState({});
const [updateTrigger, setUpdateTrigger] = useState(false);
const [networkError, setNetworkError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
useEffect(() => {
const fetchMaintenanceWindows = async () => {
try {
setNetworkError(false);
setIsLoading(true);
const response = await networkService.getMaintenanceWindowsByTeamId({
page: page,
rowsPerPage: rowsPerPage,
});
const { maintenanceWindows, maintenanceWindowCount } = response.data.data;
setMaintenanceWindows(maintenanceWindows ?? []);
setMaintenanceWindowCount(maintenanceWindowCount ?? 0);
} catch (error) {
setNetworkError(true);
} finally {
setIsLoading(false);
}
};
fetchMaintenanceWindows();
}, [page, rowsPerPage, updateTrigger]);
return (
<>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={maintenanceWindows}
type="maintenanceWindow"
fallbackLink="/maintenance/create"
>
<Stack gap={theme.spacing(10)}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Breadcrumbs list={[{ name: "maintenance", path: "/maintenance" }]} />
<Button
variant="contained"
color="accent"
onClick={() => {
navigate("/maintenance/create");
}}
sx={{ fontWeight: 500 }}
>
{t("createMaintenanceWindow")}
</Button>
</Stack>
<MaintenanceTable
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sort={sort}
setSort={setSort}
maintenanceWindows={maintenanceWindows}
maintenanceWindowCount={maintenanceWindowCount}
updateCallback={handleActionMenuDelete}
/>
</Stack>
</PageStateWrapper>
</>
);
};
export default Maintenance;
+61
View File
@@ -0,0 +1,61 @@
import { BasePageWithStates } from "@/Components/v2/design-elements";
import { HeaderCreate } from "@/Components/v2/common";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import { MaintenanceWindowTable } from "./MaintenanceWindowTable";
import { useState } from "react";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
interface MaintenanceWindowsResponse {
maintenanceWindows: MaintenanceWindow[];
maintenanceWindowCount: number;
}
const MaintenanceWindowPage = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [page, setPage] = useState(0);
const rowsPerPage = useSelector(
(state: RootState) => state?.ui?.maintenance?.rowsPerPage ?? 5
);
const { data, isLoading, isValidating, error, refetch } =
useGet<MaintenanceWindowsResponse>(
`/maintenance-window/team?page=${page}&rowsPerPage=${rowsPerPage}`
);
const maintenanceWindows = data?.maintenanceWindows ?? [];
const maintenanceWindowCount = data?.maintenanceWindowCount ?? 0;
return (
<BasePageWithStates
page={t("pages.maintenanceWindow.fallback.title")}
totalCount={maintenanceWindowCount}
bullets={
t("pages.maintenanceWindow.fallback.checks", { returnObjects: true }) as string[]
}
loading={isLoading}
error={!!error}
actionButtonText={t("pages.maintenanceWindow.fallback.actionButton")}
actionLink="/maintenance/create"
>
<HeaderCreate
path="/maintenance/create"
isLoading={isLoading || isValidating}
isAdmin={isAdmin}
/>
<MaintenanceWindowTable
maintenanceWindows={maintenanceWindows}
maintenanceWindowCount={maintenanceWindowCount}
page={page}
setPage={setPage}
refetch={refetch}
/>
</BasePageWithStates>
);
};
export default MaintenanceWindowPage;
+1 -1
View File
@@ -45,7 +45,7 @@ const NotificationsPage = () => {
}
loading={isLoading || isValidating}
error={!!error}
items={notifications ?? []}
totalCount={notifications?.length ?? 0}
actionButtonText={t("pages.notifications.fallback.actionButton")}
actionLink="/notifications/create"
>
@@ -248,6 +248,7 @@ export const PageSpeedMonitorsTable = ({
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
itemsOnPage={monitors.length}
/>
</Box>
);
@@ -70,7 +70,7 @@ const PageSpeedMonitorsPage = () => {
<MonitorBasePageWithStates
loading={isLoading}
error={monitorsError || settingsError}
items={monitors || []}
totalCount={summary?.totalMonitors ?? 0}
page="pageSpeed"
actionLink="/pagespeed/create"
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
@@ -43,7 +43,7 @@ const StatusPages = () => {
t("pages.statusPages.fallback.checks", { returnObjects: true }) as string[]
}
error={!!error}
items={statusPages ?? []}
totalCount={statusPages?.length ?? 0}
actionButtonText={t("pages.statusPages.fallback.actionButton")}
actionLink="/status/create"
>
+1 -1
View File
@@ -20,7 +20,7 @@ import { useGet } from "@/Hooks/UseApi";
import type { MonitorDetailsResponse } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
const certificateDateFormat = "MMM D, YYYY h A";
@@ -253,6 +253,7 @@ export const MonitorTable = ({
rowsPerPage={rowsPerPage}
onPageChange={(_e, newPage) => setPage(newPage)}
onRowsPerPageChange={(e) => setRowsPerPage(Number(e.target.value))}
itemsOnPage={monitors.length}
/>
</Box>
);
+20 -18
View File
@@ -20,6 +20,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
const UptimeMonitorsPage = () => {
const { t } = useTranslation();
@@ -43,6 +44,8 @@ const UptimeMonitorsPage = () => {
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: "up" -> true, "down" -> false
// State: "active" -> true, "paused" -> false
@@ -65,8 +68,8 @@ const UptimeMonitorsPage = () => {
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (search ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || search;
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
// Default to all types when none selected
const effectiveTypes =
@@ -85,18 +88,6 @@ const UptimeMonitorsPage = () => {
return `/monitors/team/with-checks?${params.toString()}`;
}, [effectiveTypes, page, rowsPerPage, filter, field, sortOrder]);
// Data fetching
const {
data: monitors,
isLoading: monitorsLoading,
error,
refetch: refetchMonitorsAndSummary,
} = useGet<Monitor[]>(
"/monitors/team?type=http&type=ping&type=port&type=docker",
{},
{ keepPreviousData: true }
);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
@@ -126,20 +117,31 @@ const UptimeMonitorsPage = () => {
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
refetchMonitorsAndSummary();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const isLoading = monitorsLoading || monitorsWithChecksLoading;
const isLoading = monitorsWithChecksLoading;
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedTypes.length > 0 || selectedStatus || selectedState || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorBasePageWithStates
loading={isLoading}
error={error || monitorsWithChecksError}
items={monitors || []}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="uptime"
actionLink="/uptime/create"
>
+52 -13
View File
@@ -6,10 +6,11 @@ import { useSelector } from "react-redux";
import { Navigate, Route, Routes as LibRoutes } from "react-router";
import HomeLayout from "@/Components/v1/Layouts/HomeLayout";
import NotFound from "../Pages/NotFound/index.jsx";
// Auth
import AuthLogin from "../Pages/Auth/Login/index.jsx";
import AuthRegister from "../Pages/Auth/Register/index.jsx";
import AuthForgotPassword from "../Pages/Auth/ForgotPassword.jsx";
import AuthLogin from "../Pages/Auth/Login";
import AuthRegister from "@/Pages/Auth/Register";
import AuthForgotPassword from "@/Pages/Auth/Recovery";
import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx";
import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx";
import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed.jsx";
@@ -48,14 +49,14 @@ import Account from "../Pages/Account/index.jsx";
import EditUser from "../Pages/Account/EditUser/index.jsx";
import Settings from "../Pages/Settings";
import Maintenance from "../Pages/Maintenance/index.jsx";
import Maintenance from "../Pages/Maintenance";
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
import ProtectedRoute from "../Components/v1/ProtectedRoute";
import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute";
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance/index.jsx";
import withAdminCheck from "@/Components/v1/HOC/withAdminCheck";
import BulkImport from "../Pages/Uptime/BulkImport/index.jsx";
import Logs from "../Pages/Logs/index.jsx";
import Logs from "../Pages/Logs";
import CreateMonitor from "@/Pages/CreateMonitor";
@@ -303,11 +304,23 @@ const Routes = () => {
<Route
path="maintenance"
element={<Maintenance />}
element={
<>
<ThemeProvider theme={v2theme}>
<Maintenance />
</ThemeProvider>
</>
}
/>
<Route
path="/maintenance/create/:maintenanceWindowId?"
element={<CreateNewMaintenanceWindow />}
element={
<>
<ThemeProvider theme={v2theme}>
<CreateNewMaintenanceWindow />
</ThemeProvider>
</>
}
/>
<Route
path="settings"
@@ -338,7 +351,9 @@ const Routes = () => {
path="logs"
element={
<RoleProtectedRoute roles={["admin", "superadmin"]}>
<Logs />
<ThemeProvider theme={v2theme}>
<Logs />
</ThemeProvider>
</RoleProtectedRoute>
}
/>
@@ -346,23 +361,47 @@ const Routes = () => {
<Route
path="/login"
element={<AuthLogin />}
element={
<>
<ThemeProvider theme={v2theme}>
<AuthLogin />
</ThemeProvider>
</>
}
/>
<Route
path="/register"
element={<AdminCheckedRegister />}
element={
<>
<ThemeProvider theme={v2theme}>
<AdminCheckedRegister />
</ThemeProvider>
</>
}
/>
<Route
exact
path="/register/:token"
element={<AuthRegister superAdminExists={true} />}
element={
<>
<ThemeProvider theme={v2theme}>
<AuthRegister superAdminExists={true} />
</ThemeProvider>
</>
}
/>
<Route
path="/forgot-password"
element={<AuthForgotPassword />}
element={
<>
<ThemeProvider theme={v2theme}>
<AuthForgotPassword />
</ThemeProvider>
</>
}
/>
<Route
path="/check-email"
+33
View File
@@ -0,0 +1,33 @@
export interface Diagnostics {
osStats: OsStats;
memoryUsage: MemoryUsage;
cpuUsage: CpuUsage;
v8HeapStats: V8HeapStats;
eventLoopDelayMs: number;
uptimeMs: number;
}
export interface OsStats {
freeMemoryBytes: number;
totalMemoryBytes: number;
}
export interface MemoryUsage {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
arrayBuffers: number;
}
export interface CpuUsage {
userUsageMs: number;
systemUsageMs: number;
usagePercentage: number;
}
export interface V8HeapStats {
totalHeapSizeBytes: number;
usedHeapSizeBytes: number;
heapSizeLimitBytes: number;
}
+13
View File
@@ -0,0 +1,13 @@
export const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
export const LOG_LEVEL_OPTIONS = ["all", ...LOG_LEVELS] as const;
export type LogLevelOption = (typeof LOG_LEVEL_OPTIONS)[number];
export interface Log {
message: string;
service?: string;
method?: string;
details?: Record<string, unknown>;
stack?: string;
level: LogLevel;
timestamp: string;
}
+16
View File
@@ -0,0 +1,16 @@
export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
export interface MaintenanceWindow {
id: string;
monitorId: string;
teamId: string;
active: boolean;
name: string;
duration: number;
durationUnit: DurationUnit;
repeat: number;
start: string;
end: string;
createdAt: string;
updatedAt: string;
}
+40
View File
@@ -0,0 +1,40 @@
import type { MonitorType } from "./Monitor";
export interface QueueJobFailure {
monitorId: string | number;
monitorUrl: string | null;
monitorType: MonitorType | null;
failedAt: number | null;
failCount: number;
failReason: string | null;
}
export interface QueueMetrics {
jobs: number;
activeJobs: number;
failingJobs: number;
jobsWithFailures: QueueJobFailure[];
totalRuns: number;
totalFailures: number;
}
export interface QueueJobSummary {
monitorId: string | number;
monitorUrl: string | null;
monitorType: MonitorType | null;
monitorInterval: number | null;
active: boolean;
lockedAt: number | null;
runCount: number;
failCount: number;
failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
}
export interface QueueData {
jobs: QueueJobSummary[];
metrics: QueueMetrics;
}
+28
View File
@@ -0,0 +1,28 @@
export const UserRoles = ["user", "admin", "superadmin", "demo"] as const;
export type UserRole = (typeof UserRoles)[number];
export interface UserProfileImage {
data?: Buffer;
contentType?: string;
}
export interface User {
id: string;
firstName: string;
lastName: string;
email: string;
avatarImage?: string;
profileImage?: UserProfileImage;
isActive: boolean;
isVerified: boolean;
role: UserRole[];
teamId: string;
checkTTL?: number;
createdAt: string;
updatedAt: string;
}
export interface AuthResponse {
user: User;
token: string;
}
+3
View File
@@ -19,6 +19,9 @@ export interface UIState {
infrastructure: {
rowsPerPage: number;
};
logs: {
rowsPerPage: number;
};
sidebar: {
collapsed: boolean;
};
+3 -1
View File
@@ -42,7 +42,9 @@ export const initApiClient = (store: StoreType): void => {
const onSuccess = (response: AxiosResponse) => response;
const onError = (error: AxiosError) => {
if (error.response?.status === 401) {
window.location.href = "/login";
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
return Promise.reject(error);
};
+43
View File
@@ -0,0 +1,43 @@
/**
* Intl.NumberFormat instance for percentage formatting.
* Reused across all formatting calls for performance.
*/
const percentageFormatter = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
/**
* Formats a decimal value as a percentage string.
* @param value - Decimal value (e.g., 0.75 for 75%)
* @returns Formatted percentage string (e.g., "75.0%")
* @example
* formatPercentage(0.75) // "75.0%"
* formatPercentage(1) // "100.0%"
* formatPercentage(0.5432) // "54.3%"
*/
export const formatPercentage = (value: number): string => {
if (typeof value !== "number" || Number.isNaN(value)) {
return "0.0%";
}
return percentageFormatter.format(value);
};
/**
* Formats a whole number percentage value as a percentage string.
* @param value - Whole number percentage (e.g., 75 for 75%)
* @returns Formatted percentage string (e.g., "75.0%")
* @example
* formatPercentageFromWhole(75) // "75.0%"
* formatPercentageFromWhole(100) // "100.0%"
* formatPercentageFromWhole(54.32) // "54.3%"
*/
export const formatPercentageFromWhole = (value: number): string => {
return formatPercentage(value / 100);
};
export const getPercentage = (value: number, total: number) => {
if (!value || !total) return 0;
return (value / total) * 100;
};
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more