Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98943ad461 | ||
|
|
c670653ab3 | ||
|
|
e75606aad3 | ||
|
|
07b0483352 | ||
|
|
8555e7c122 | ||
|
|
734ec41ca0 | ||
|
|
5152336591 | ||
|
|
78b920aaf6 | ||
|
|
2d84fae470 | ||
|
|
97e4b105a2 | ||
|
|
a053386f4a | ||
|
|
e0991e28e5 | ||
|
|
0f26648855 | ||
|
|
6a0f3426b3 | ||
|
|
16e8035299 | ||
|
|
31b09c316a | ||
|
|
8f2072e497 | ||
|
|
514570da78 | ||
|
|
ea68e660fc | ||
|
|
527543d79f | ||
|
|
1c390a8baf | ||
|
|
43ac5b57cf | ||
|
|
1558b27497 | ||
|
|
9caa6c830f | ||
|
|
ea115ede53 | ||
|
|
8470631162 | ||
|
|
597732e109 | ||
|
|
9974fa4010 | ||
|
|
b0686c0576 | ||
|
|
a148f56b22 | ||
|
|
3b25b37c22 | ||
|
|
1e04345aa0 | ||
|
|
174c3cd47a | ||
|
|
0e149fa477 | ||
|
|
5b4a99af3d | ||
|
|
2ecdc2096e | ||
|
|
6e66a18535 | ||
|
|
bd6aa0fa3a | ||
|
|
4e678a4ce7 | ||
|
|
5999315791 | ||
|
|
0c8b8a9358 | ||
|
|
dfe62bd301 | ||
|
|
747ff824cc | ||
|
|
f12c544751 | ||
|
|
278ced2a6c | ||
|
|
88d539b2a4 | ||
|
|
f0b152fc8f | ||
|
|
dc0e198a8e | ||
|
|
0a5067ef46 | ||
|
|
4f0017c031 | ||
|
|
f0b2292f0a | ||
|
|
d74d79c332 | ||
|
|
1a096b66f7 | ||
|
|
7cb866ce4a | ||
|
|
c54582ca70 | ||
|
|
362034ba26 | ||
|
|
b536099d03 | ||
|
|
04fef2ef75 | ||
|
|
a89e5ed565 | ||
|
|
ca153b65a9 | ||
|
|
cc188b2ba3 | ||
|
|
18018ceef1 | ||
|
|
4f3bcd4624 | ||
|
|
d469590ef3 | ||
|
|
090d002578 | ||
|
|
69f95723f1 | ||
|
|
464b16ec9d | ||
|
|
04cf47544a | ||
|
|
e1dfd98840 | ||
|
|
4ce4a124d6 | ||
|
|
f564636cb4 | ||
|
|
14a08a303b | ||
|
|
4645bde025 |
No files matched your search
@@ -3,3 +3,4 @@
|
||||
.VSCodeCounter
|
||||
*.sh
|
||||
mongo
|
||||
node_modules/
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
echo "Running lint-staged..."
|
||||
cd client && npx lint-staged && cd ..
|
||||
cd server && npx lint-staged && cd ..
|
||||
Generated
+577
-1
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { recoverySchema } from "@/Validation/recovery";
|
||||
|
||||
export const useRecoveryForm = () => {
|
||||
const defaults = {
|
||||
email: "",
|
||||
};
|
||||
|
||||
return {
|
||||
schema: recoverySchema,
|
||||
defaults,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -85,7 +85,7 @@ const Account = ({ open = "profile" }) => {
|
||||
))}
|
||||
</CustomTabList>
|
||||
<ProfilePanel />
|
||||
{user.role.includes("superadmin") && <PasswordPanel />}
|
||||
<PasswordPanel />
|
||||
{!hideTeams && <TeamPanel />}
|
||||
</TabContext>
|
||||
</Box>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
+13
-9
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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.
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export interface UIState {
|
||||
infrastructure: {
|
||||
rowsPerPage: number;
|
||||
};
|
||||
logs: {
|
||||
rowsPerPage: number;
|
||||
};
|
||||
sidebar: {
|
||||
collapsed: boolean;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
Reference in new issue
Block a user