Compare commits
408
Commits
v3.4.0
...
feat/zh-tw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22498756cd | ||
|
|
7c61ca490c | ||
|
|
96f9360280 | ||
|
|
eb4a92c84f | ||
|
|
42e82af9bb | ||
|
|
7636d2d090 | ||
|
|
72728748f8 | ||
|
|
4222d39a8b | ||
|
|
fb69e29adf | ||
|
|
0d90083e63 | ||
|
|
8ab049aa6c | ||
|
|
69b6955ced | ||
|
|
04dfff4ebd | ||
|
|
15b0deb0bd | ||
|
|
a895912b99 | ||
|
|
22d2626a93 | ||
|
|
214fc4899c | ||
|
|
e6088fe06f | ||
|
|
60b1d95b74 | ||
|
|
94ca9896db | ||
|
|
13a7ae82e3 | ||
|
|
de3af64254 | ||
|
|
317d531775 | ||
|
|
2b2db2df6f | ||
|
|
c558c4b635 | ||
|
|
be29cce788 | ||
|
|
e87879970a | ||
|
|
4c94a7c21e | ||
|
|
40968c370b | ||
|
|
a864bc0c05 | ||
|
|
62e86434c2 | ||
|
|
07957d3a1f | ||
|
|
bc088c3589 | ||
|
|
5ea86d8e71 | ||
|
|
0944dba386 | ||
|
|
86acc0c30a | ||
|
|
dcdde845db | ||
|
|
3039fb9b69 | ||
|
|
729600a86f | ||
|
|
51853cb360 | ||
|
|
f7db732d67 | ||
|
|
9e686a2319 | ||
|
|
da6f03ba59 | ||
|
|
276b11603e | ||
|
|
f09f0630dc | ||
|
|
1defa52ae8 | ||
|
|
f43b7584a6 | ||
|
|
ee74584699 | ||
|
|
2422fcc83b | ||
|
|
0ecc2e21e5 | ||
|
|
fe242b1977 | ||
|
|
d1f1d31ea2 | ||
|
|
eacbb227c3 | ||
|
|
c24b41ecd9 | ||
|
|
94cf4f17d1 | ||
|
|
0a6d876f4f | ||
|
|
ec129e3604 | ||
|
|
833e2b63ad | ||
|
|
bc2bab87ba | ||
|
|
ee5f7427e7 | ||
|
|
d5968b841e | ||
|
|
26bac6d35a | ||
|
|
3e010f1c0d | ||
|
|
1a504a3cd9 | ||
|
|
120c5cd472 | ||
|
|
cdf17bf600 | ||
|
|
7144c20c5b | ||
|
|
7456609e35 | ||
|
|
05c293d689 | ||
|
|
80f3168837 | ||
|
|
332300dde4 | ||
|
|
d4a8023a76 | ||
|
|
c7e449d18c | ||
|
|
0495bef469 | ||
|
|
6fb7ad63f6 | ||
|
|
8322adbb26 | ||
|
|
4470c87d42 | ||
|
|
95bda35ec6 | ||
|
|
928ab7551e | ||
|
|
465cea21bc | ||
|
|
404bd5d06b | ||
|
|
05814854df | ||
|
|
a4a3249b5b | ||
|
|
1e6cf858c4 | ||
|
|
533a8911cd | ||
|
|
2fb7516ca1 | ||
|
|
6d2008e8d3 | ||
|
|
7332b15242 | ||
|
|
d618a861e3 | ||
|
|
f69752ccb4 | ||
|
|
210881a375 | ||
|
|
d8ec8d7775 | ||
|
|
bfd3eee94d | ||
|
|
bfbb4c390a | ||
|
|
1285a641e8 | ||
|
|
78566c0226 | ||
|
|
f5ba387261 | ||
|
|
48b415a67c | ||
|
|
b53f0e56ea | ||
|
|
07f828a01a | ||
|
|
b09203664d | ||
|
|
5a12105c3d | ||
|
|
22f79144d8 | ||
|
|
f749ed46a6 | ||
|
|
5c23ebe315 | ||
|
|
115fcc67f6 | ||
|
|
622446c308 | ||
|
|
294a149fd1 | ||
|
|
d3d00353a1 | ||
|
|
894b6a7a7d | ||
|
|
3a9f9f0ce5 | ||
|
|
45618a2ab0 | ||
|
|
d80e232a10 | ||
|
|
1706012e54 | ||
|
|
db9d5cf5e4 | ||
|
|
52a6b4c9fa | ||
|
|
9646e7dbdc | ||
|
|
d6ceedc2dd | ||
|
|
501664509b | ||
|
|
2ef184c58a | ||
|
|
22f917034d | ||
|
|
81f623b632 | ||
|
|
d3605b0149 | ||
|
|
48e71d4345 | ||
|
|
411270d256 | ||
|
|
6404a06999 | ||
|
|
a2bc2d8442 | ||
|
|
e9cb302d6e | ||
|
|
fa8764fde2 | ||
|
|
84ca5db0fa | ||
|
|
8ec285ed41 | ||
|
|
d070a33d7f | ||
|
|
aa61e0f321 | ||
|
|
88a39fe502 | ||
|
|
a6b92d3c51 | ||
|
|
1af609a569 | ||
|
|
61cc0c2bef | ||
|
|
e4a228e61e | ||
|
|
76f550843b | ||
|
|
15d8a8ba1f | ||
|
|
842fa92cec | ||
|
|
e878f51887 | ||
|
|
9466090332 | ||
|
|
16b267d3a5 | ||
|
|
01f2aa294e | ||
|
|
c156a49cc0 | ||
|
|
20ff76df9d | ||
|
|
ff90953f17 | ||
|
|
545926cfcf | ||
|
|
365b6cc60a | ||
|
|
42f6c42ae1 | ||
|
|
456237ba4e | ||
|
|
e887c2308a | ||
|
|
24d571a8db | ||
|
|
d41c5e9ebf | ||
|
|
d07f259829 | ||
|
|
566011c4e9 | ||
|
|
3dd0e0a998 | ||
|
|
cda0ec3a4c | ||
|
|
d00889ff9b | ||
|
|
eafd97b9ad | ||
|
|
7a0e0bccf4 | ||
|
|
01401d56b8 | ||
|
|
f69b7f6a35 | ||
|
|
cb5316e6f5 | ||
|
|
25baff3013 | ||
|
|
bcff3e15a8 | ||
|
|
1ad39fcac6 | ||
|
|
85d2d34a90 | ||
|
|
f6d9b7566b | ||
|
|
943ef92053 | ||
|
|
84cac870d5 | ||
|
|
2884ab524d | ||
|
|
eac470cb68 | ||
|
|
a15b9837c6 | ||
|
|
562d723fcf | ||
|
|
2fdd5a72f0 | ||
|
|
f9a4659cb2 | ||
|
|
3e26387b9d | ||
|
|
dc7d442ace | ||
|
|
839f1beb41 | ||
|
|
bbdadb0bf5 | ||
|
|
cadf68740a | ||
|
|
0fc338db2f | ||
|
|
6ad0a6a29d | ||
|
|
77af9d2e93 | ||
|
|
b1147b8bf9 | ||
|
|
5ab230f77b | ||
|
|
d9cb81e71d | ||
|
|
0058772f61 | ||
|
|
bbc2e4073b | ||
|
|
7967b363c2 | ||
|
|
27dffe9fa6 | ||
|
|
7213bace08 | ||
|
|
2cbc667f30 | ||
|
|
978dc4cda6 | ||
|
|
5578fdf17f | ||
|
|
a960e3aba2 | ||
|
|
ff1d473b81 | ||
|
|
bd384bde4a | ||
|
|
e7f052dfa0 | ||
|
|
9903c72ff5 | ||
|
|
d7d237d37b | ||
|
|
5d03f6f0f0 | ||
|
|
38e2a64255 | ||
|
|
1f56338697 | ||
|
|
172467c0a6 | ||
|
|
ff2b6dff2b | ||
|
|
9f600f87af | ||
|
|
7eeff7a396 | ||
|
|
afd6a12e7d | ||
|
|
125d70c4b1 | ||
|
|
5ebe46a81c | ||
|
|
19174ab3e5 | ||
|
|
7c953978ed | ||
|
|
db5441d0e2 | ||
|
|
1d529e3be4 | ||
|
|
23247d4319 | ||
|
|
216d9b2996 | ||
|
|
62f1e62a26 | ||
|
|
55bdae77f9 | ||
|
|
82998a7795 | ||
|
|
9fe8b7572f | ||
|
|
a424092fbd | ||
|
|
ad427dc765 | ||
|
|
116eb80fc9 | ||
|
|
c094a5fd73 | ||
|
|
206e28d1b9 | ||
|
|
2f1a164c26 | ||
|
|
124a8c0b07 | ||
|
|
188445c57a | ||
|
|
35f018623f | ||
|
|
d64526fed2 | ||
|
|
9a8a175f7c | ||
|
|
6ecb27d522 | ||
|
|
24b9b64979 | ||
|
|
a4e4e1313e | ||
|
|
990dc7875a | ||
|
|
69e98a51ec | ||
|
|
2a1645baf2 | ||
|
|
8483a7bc55 | ||
|
|
27dd565bc6 | ||
|
|
c5f0316990 | ||
|
|
08fd09f59b | ||
|
|
a40a76fb3c | ||
|
|
6c421717f8 | ||
|
|
083db95151 | ||
|
|
b11ba68ff8 | ||
|
|
12a4112055 | ||
|
|
3063b9cf22 | ||
|
|
bd32854c7a | ||
|
|
0718e63e2f | ||
|
|
cdb70f9171 | ||
|
|
69e07975e3 | ||
|
|
c5c9a1a88b | ||
|
|
31efa791bd | ||
|
|
f8c0b57d21 | ||
|
|
605c969d2f | ||
|
|
ad7d4e54c3 | ||
|
|
c7689cc64a | ||
|
|
27c44a0fbd | ||
|
|
190d9dd2b1 | ||
|
|
49672db563 | ||
|
|
80603c9a42 | ||
|
|
f3b156f2d0 | ||
|
|
4d7de2d3a1 | ||
|
|
ec82efebb4 | ||
|
|
9c17fbe79c | ||
|
|
8eca81c8be | ||
|
|
eb2d8b8d3b | ||
|
|
33201096b7 | ||
|
|
4827f8faec | ||
|
|
a1068f1d7e | ||
|
|
8dd846d6cd | ||
|
|
82175f166f | ||
|
|
3c99809e8e | ||
|
|
26db2b3a41 | ||
|
|
232a71c502 | ||
|
|
8df263fd93 | ||
|
|
d2f3335b1a | ||
|
|
75eca743b4 | ||
|
|
7bfd68e374 | ||
|
|
b2e2b11ef2 | ||
|
|
93b9abd508 | ||
|
|
888b399a4f | ||
|
|
189293b04d | ||
|
|
13e9185011 | ||
|
|
a7cf78cecc | ||
|
|
0b30e23783 | ||
|
|
1155619835 | ||
|
|
e0608ad3ed | ||
|
|
d665e95f02 | ||
|
|
afc27c804d | ||
|
|
1deb22bf63 | ||
|
|
af8b128ac2 | ||
|
|
00150ba6f4 | ||
|
|
7f04dd5d9a | ||
|
|
ab5389190d | ||
|
|
cba9f43032 | ||
|
|
1cd6e723cc | ||
|
|
f79a87f98a | ||
|
|
a0ea50d745 | ||
|
|
bfa11216f0 | ||
|
|
07a2f36dd7 | ||
|
|
8e44718787 | ||
|
|
9e06758997 | ||
|
|
83572aa12a | ||
|
|
332a21cd99 | ||
|
|
128d1d12b0 | ||
|
|
f73b07cd3c | ||
|
|
a7d14e0e43 | ||
|
|
6622a990be | ||
|
|
3854931682 | ||
|
|
09534757a2 | ||
|
|
7b2ce0e350 | ||
|
|
19887332c6 | ||
|
|
a1858e8302 | ||
|
|
9fd006cfff | ||
|
|
75a7e144cd | ||
|
|
16cf23baf1 | ||
|
|
8236b02034 | ||
|
|
64242322a2 | ||
|
|
c1e03a45b0 | ||
|
|
f1466df56c | ||
|
|
3654c58469 | ||
|
|
b997c43bd5 | ||
|
|
a5d3c95145 | ||
|
|
003106348a | ||
|
|
c3a41272a3 | ||
|
|
0bff26fe47 | ||
|
|
9169410c1a | ||
|
|
9174e66134 | ||
|
|
04c3c57a59 | ||
|
|
f69b6bbf31 | ||
|
|
05aa1e7112 | ||
|
|
8aead0a00c | ||
|
|
db8ed2994a | ||
|
|
8571ccea78 | ||
|
|
4d273a6dce | ||
|
|
4aabc16096 | ||
|
|
94f9c3d118 | ||
|
|
a719364e40 | ||
|
|
308a439790 | ||
|
|
42a001b24e | ||
|
|
f48fc24b9e | ||
|
|
e3057ad039 | ||
|
|
32f41a3cc4 | ||
|
|
177d6f80d0 | ||
|
|
ecef55f1b3 | ||
|
|
dede689990 | ||
|
|
85edea713a | ||
|
|
7e3da7add7 | ||
|
|
dfeba1e4fb | ||
|
|
0442c8dd0f | ||
|
|
7960f1e82c | ||
|
|
f7f1602423 | ||
|
|
78d1451621 | ||
|
|
a9f005b2c4 | ||
|
|
b68214fc47 | ||
|
|
64c0c93c3e | ||
|
|
40a1f0c6e0 | ||
|
|
1bb2dd323a | ||
|
|
17238c982e | ||
|
|
cc1d63f91a | ||
|
|
ba6e931c59 | ||
|
|
885acf6fe6 | ||
|
|
477c30286d | ||
|
|
88e28b9819 | ||
|
|
e7ba3cb5f9 | ||
|
|
651fbd7cbb | ||
|
|
64fdd04cc7 | ||
|
|
c3e4b5b95f | ||
|
|
10313102ee | ||
|
|
868cbae66f | ||
|
|
2989968fa1 | ||
|
|
89a7fdbe34 | ||
|
|
432d1d8ccf | ||
|
|
5a74411ea2 | ||
|
|
705ecf41c2 | ||
|
|
1d4e9d6806 | ||
|
|
8e37ed7ba2 | ||
|
|
b870f25d1a | ||
|
|
7d3abb3f98 | ||
|
|
9baa6692dd | ||
|
|
dd7dfba1cf | ||
|
|
858aecce62 | ||
|
|
42fdc47d86 | ||
|
|
af8f913e62 | ||
|
|
ea086d6956 | ||
|
|
22b33d789c | ||
|
|
42e7bc1dbf | ||
|
|
13aa600852 | ||
|
|
c06f81ef76 | ||
|
|
c95db26437 | ||
|
|
94a85f8bbb | ||
|
|
05f8e6a07e | ||
|
|
dac78c7647 | ||
|
|
2d023de568 | ||
|
|
f2c88cfd23 | ||
|
|
910c675a49 | ||
|
|
882e91f6a7 | ||
|
|
06be65ec52 | ||
|
|
ea54736a39 | ||
|
|
da38f2ee76 | ||
|
|
83d56f1ff6 | ||
|
|
f94afb63c6 | ||
|
|
673f47983a | ||
|
|
b708e83cce |
No files matched your search
+5
-1
@@ -3,6 +3,10 @@
|
||||
.VSCodeCounter
|
||||
*.sh
|
||||
mongo
|
||||
timescaledb
|
||||
node_modules/
|
||||
docs/architecture
|
||||
docs/reviews
|
||||
docs/reviews
|
||||
docs/todo
|
||||
docs/frontend
|
||||
docs/timescale
|
||||
@@ -42,15 +42,19 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
|
||||
|
||||
## Demo
|
||||
|
||||
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
|
||||
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
|
||||
|
||||
## User's guide
|
||||
|
||||
Usage instructions can be found [here](https://docs.checkmate.so/checkmate-2.1). It's still WIP and some of the information there might be outdated as we continuously add features weekly. Rest assured, we are doing our best! :)
|
||||
Usage instructions can be found [here](https://checkmate.so/docs).
|
||||
|
||||
## Prerequisites
|
||||
- [Docker](https://www.docker.com/) installed
|
||||
- [Git](https://git-scm.com/) installed
|
||||
|
||||
## Installation
|
||||
|
||||
See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
|
||||
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
||||
|
||||
Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||
|
||||
@@ -80,7 +84,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
|
||||
If you have any questions, suggestions or comments, you have several options:
|
||||
|
||||
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
|
||||
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
|
||||
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
|
||||
|
||||
Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
|
||||
@@ -157,27 +161,20 @@ Here's how you can contribute:
|
||||
|
||||
0. Star this repo :)
|
||||
1. Check [Contributor's guideline](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). First timers are encouraged to check `good-first-issue` tag.
|
||||
2. Check [project structure](https://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) and [high level overview](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
|
||||
3. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
|
||||
4. Open an issue if you believe you've encountered a bug.
|
||||
5. Check for good-first-issue's if you are a newcomer.
|
||||
6. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
|
||||
7. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
|
||||
2. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
|
||||
3. Open an issue if you believe you've encountered a bug.
|
||||
4. Check for good-first-issue's if you are a newcomer.
|
||||
5. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
|
||||
6. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
|
||||
|
||||
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
|
||||
</a>
|
||||
|
||||
[](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
|
||||
[](https://star-history.com/#bluewave-labs/Checkmate&Date)
|
||||
|
||||
## Our sponsors
|
||||
|
||||
Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca
|
||||
|
||||
If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
|
||||
|
||||
Also check other developer and contributor-friendly projects of BlueWave:
|
||||
|
||||
- [VerifyWise](https://github.com/bluewave-labs/verifywise), the first open source AI governance platform.
|
||||
- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom), an secure file sharing application, aka dataroom.
|
||||
- [Guidefox](https://github.com/bluewave-labs/guidefox), an application that helps new users learn how to use your product via hints, tours, popups and banners.
|
||||
Generated
+690
-123
File diff suppressed because it is too large.
Load diff
+6
-3
@@ -13,7 +13,7 @@
|
||||
"format-check": "prettier --check ."
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "npm run format"
|
||||
"**/*": "prettier --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
@@ -25,6 +25,7 @@
|
||||
"@mui/x-charts": "7.29.1",
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@reduxjs/toolkit": "2.7.0",
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"axios": "^1.7.4",
|
||||
"dayjs": "1.11.13",
|
||||
"flag-icons": "7.3.2",
|
||||
@@ -33,6 +34,7 @@
|
||||
"i18next": "25.4.2",
|
||||
"joi": "17.13.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"mui-color-input": "^7.0.0",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
@@ -41,6 +43,7 @@
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-redux": "9.2.0",
|
||||
"react-router": "^6.23.0",
|
||||
"react-router-dom": "^6.23.1",
|
||||
@@ -49,7 +52,7 @@
|
||||
"redux-persist": "6.0.0",
|
||||
"swr": "^2.3.6",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.5.2",
|
||||
@@ -63,6 +66,6 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.3.6"
|
||||
"vite": "6.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Locale Migration Script
|
||||
*
|
||||
* Restructures all non-English locale files to match en.json's nested key structure.
|
||||
* Recovers translations via safe key-matching strategies, uses English placeholders
|
||||
* for unmatched keys.
|
||||
*
|
||||
* Usage: npx tsx client/scripts/migrate-locales.ts
|
||||
*
|
||||
* SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
|
||||
const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
|
||||
const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Flatten a nested object into { "a.b.c": value } pairs */
|
||||
function flattenKeys(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullPath = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenKeys(value as Record<string, unknown>, fullPath));
|
||||
} else {
|
||||
result[fullPath] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Set a value at a dot-separated path in a nested object */
|
||||
function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
keyPath: string,
|
||||
value: string
|
||||
): void {
|
||||
const parts = keyPath.split(".");
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
// ── Build mapping indexes from en.json ───────────────────────────────────────
|
||||
|
||||
function buildIndexes(enFlat: Record<string, string>) {
|
||||
const enKeys = Object.keys(enFlat);
|
||||
|
||||
// last segment -> en key paths
|
||||
const lastSegIndex: Record<string, string[]> = {};
|
||||
// last 2 segments -> en key paths
|
||||
const suffixIndex: Record<string, string[]> = {};
|
||||
|
||||
for (const keyPath of enKeys) {
|
||||
const parts = keyPath.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
|
||||
lastSegIndex[lastSeg].push(keyPath);
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const suffix = parts.slice(-2).join(".").toLowerCase();
|
||||
if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
|
||||
suffixIndex[suffix].push(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { lastSegIndex, suffixIndex };
|
||||
}
|
||||
|
||||
// ── Mapping logic ────────────────────────────────────────────────────────────
|
||||
|
||||
type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
|
||||
|
||||
interface KeyMatch {
|
||||
enKey: string;
|
||||
value: string;
|
||||
strategy: MatchStrategy;
|
||||
}
|
||||
|
||||
function buildMigratedLocale(
|
||||
enFlat: Record<string, string>,
|
||||
langFlat: Record<string, string>,
|
||||
indexes: ReturnType<typeof buildIndexes>
|
||||
): { matches: KeyMatch[]; result: Record<string, unknown> } {
|
||||
const { lastSegIndex, suffixIndex } = indexes;
|
||||
const matches: KeyMatch[] = [];
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
|
||||
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
|
||||
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
|
||||
|
||||
for (const [langKey, langValue] of Object.entries(langFlat)) {
|
||||
const parts = langKey.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
|
||||
langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const suffix = parts.slice(-2).join(".").toLowerCase();
|
||||
if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
|
||||
langBySuffix[suffix].push({ key: langKey, value: langValue });
|
||||
}
|
||||
}
|
||||
|
||||
for (const enKey of Object.keys(enFlat)) {
|
||||
const enValue = enFlat[enKey];
|
||||
const parts = enKey.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
|
||||
|
||||
// Strategy 1: Exact path match
|
||||
if (langFlat[enKey] !== undefined) {
|
||||
const value = langFlat[enKey];
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "exact" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
|
||||
if (suffix) {
|
||||
const enCandidates = suffixIndex[suffix];
|
||||
const langCandidates = langBySuffix[suffix];
|
||||
if (
|
||||
enCandidates &&
|
||||
enCandidates.length === 1 &&
|
||||
langCandidates &&
|
||||
langCandidates.length === 1
|
||||
) {
|
||||
const value = langCandidates[0].value;
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "suffix-2" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Unique last-segment match
|
||||
const enCandidates = lastSegIndex[lastSeg];
|
||||
const langCandidates = langByLastSeg[lastSeg];
|
||||
if (
|
||||
enCandidates &&
|
||||
enCandidates.length === 1 &&
|
||||
langCandidates &&
|
||||
langCandidates.length === 1
|
||||
) {
|
||||
const value = langCandidates[0].value;
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "unique-name" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// No match — use English placeholder
|
||||
setNestedValue(result, enKey, enValue);
|
||||
matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
|
||||
}
|
||||
|
||||
return { matches, result };
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
// Load en.json
|
||||
const enPath = path.join(LOCALES_DIR, "en.json");
|
||||
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
|
||||
const enFlat = flattenKeys(en);
|
||||
const enKeyCount = Object.keys(enFlat).length;
|
||||
const indexes = buildIndexes(enFlat);
|
||||
|
||||
console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
|
||||
|
||||
// Find all non-English locale files
|
||||
const localeFiles = fs
|
||||
.readdirSync(LOCALES_DIR)
|
||||
.filter(
|
||||
(f: string) =>
|
||||
f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
|
||||
);
|
||||
|
||||
if (localeFiles.length === 0) {
|
||||
console.log("No locale files to migrate.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create backup directory
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const report: Record<
|
||||
string,
|
||||
{
|
||||
total: number;
|
||||
exact: number;
|
||||
suffix: number;
|
||||
uniqueName: number;
|
||||
placeholder: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const lang = file.replace(".json", "");
|
||||
const filePath = path.join(LOCALES_DIR, file);
|
||||
|
||||
// Backup original
|
||||
const backupPath = path.join(BACKUP_DIR, file);
|
||||
fs.copyFileSync(filePath, backupPath);
|
||||
|
||||
// Load and flatten
|
||||
const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const langFlat = flattenKeys(langData);
|
||||
|
||||
// Migrate
|
||||
const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
|
||||
|
||||
// Write migrated file
|
||||
fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
|
||||
|
||||
// Compute stats
|
||||
const stats = {
|
||||
total: enKeyCount,
|
||||
exact: matches.filter((m) => m.strategy === "exact").length,
|
||||
suffix: matches.filter((m) => m.strategy === "suffix-2").length,
|
||||
uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
|
||||
placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
|
||||
};
|
||||
report[lang] = stats;
|
||||
|
||||
const translated = stats.exact + stats.suffix + stats.uniqueName;
|
||||
const pct = ((translated / enKeyCount) * 100).toFixed(1);
|
||||
console.log(
|
||||
`${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
|
||||
`exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
|
||||
`placeholder: ${stats.placeholder}`
|
||||
);
|
||||
}
|
||||
|
||||
// Write report
|
||||
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
|
||||
|
||||
const totalLangs = localeFiles.length;
|
||||
console.log(
|
||||
`\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
|
||||
);
|
||||
console.log(`Report written to ${REPORT_PATH}`);
|
||||
}
|
||||
|
||||
main();
|
||||
+36
-23
@@ -4,39 +4,52 @@ import "react-toastify/dist/ReactToastify.css";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import { CssBaseline, GlobalStyles } from "@mui/material";
|
||||
import { SWRConfig } from "swr";
|
||||
import { Routes } from "./Routes";
|
||||
import AppLayout from "@/Components/layout/AppLayout";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { lightTheme, darkTheme } from "@/Utils/Theme/Theme";
|
||||
|
||||
const swrConfig = {
|
||||
dedupingInterval: 5000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
shouldRetryOnError: false,
|
||||
errorRetryCount: 2,
|
||||
errorRetryInterval: 1000,
|
||||
focusThrottleInterval: 5000,
|
||||
};
|
||||
|
||||
function App() {
|
||||
const mode = useSelector((state: RootState) => state.ui.mode);
|
||||
const theme = mode === "light" ? lightTheme : darkTheme;
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
body: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<AppLayout>
|
||||
<Routes />
|
||||
</AppLayout>
|
||||
<ToastContainer
|
||||
newestOnTop={true}
|
||||
theme={mode}
|
||||
style={
|
||||
{
|
||||
"--toastify-color-progress-light": "#7C8BA1",
|
||||
"--toastify-color-progress-dark": "#7C8BA1",
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
<SWRConfig value={swrConfig}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
body: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<AppLayout>
|
||||
<Routes />
|
||||
</AppLayout>
|
||||
<ToastContainer
|
||||
newestOnTop={true}
|
||||
theme={mode}
|
||||
style={
|
||||
{
|
||||
"--toastify-color-progress-light": "#7C8BA1",
|
||||
"--toastify-color-progress-dark": "#7C8BA1",
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const HeatmapResponseTime = ({
|
||||
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
const latestChecks = checks.slice(-25).reverse();
|
||||
const latestChecks = checks.slice(-25);
|
||||
|
||||
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
||||
if (latestChecks.length !== 25) {
|
||||
|
||||
@@ -57,7 +57,8 @@ export const HeatmapResponseTimeTooltip = ({
|
||||
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
<Typography>
|
||||
{t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
|
||||
{t("common.labels.responseTime")}:{" "}
|
||||
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
|
||||
</Typography>
|
||||
<Typography textTransform={"capitalize"}>
|
||||
Status:{" "}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BaseBox } from ".";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
@@ -26,7 +27,7 @@ export const BaseChart = ({
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
padding: padding ?? theme.spacing(8),
|
||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
width: width,
|
||||
@@ -34,13 +35,13 @@ export const BaseChart = ({
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(8)}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
flex={1}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(4)}
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
{icon && (
|
||||
<BaseBox
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import {
|
||||
ErrorFallback,
|
||||
EmptyFallback,
|
||||
@@ -91,7 +92,7 @@ export const BasePage = ({
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={theme.spacing(10)}
|
||||
spacing={theme.spacing(LAYOUT.LG)}
|
||||
{...props}
|
||||
>
|
||||
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
|
||||
@@ -220,7 +221,7 @@ export const BaseAuthPage = ({
|
||||
return (
|
||||
<BasePage
|
||||
breadcrumbOverride={[]}
|
||||
gap={theme.spacing(8)}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
alignItems={"center"}
|
||||
justifyContent={"center"}
|
||||
minHeight="100vh"
|
||||
@@ -229,7 +230,7 @@ export const BaseAuthPage = ({
|
||||
>
|
||||
<HeaderAuthControls
|
||||
hideLogo
|
||||
py={theme.spacing(4)}
|
||||
py={theme.spacing(LAYOUT.XS)}
|
||||
position={"absolute"}
|
||||
top={0}
|
||||
left={0}
|
||||
@@ -243,14 +244,14 @@ export const BaseAuthPage = ({
|
||||
<Stack alignItems={"center"}>
|
||||
<Typography
|
||||
variant="h1"
|
||||
mb={theme.spacing(2)}
|
||||
mb={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h2">{subtitle}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
gap={theme.spacing(8)}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
width={{
|
||||
xs: "80%",
|
||||
md: "25%",
|
||||
|
||||
@@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
export const BulletPointCheck = ({
|
||||
text,
|
||||
@@ -21,7 +22,7 @@ export const BulletPointCheck = ({
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(6)}
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Check
|
||||
|
||||
@@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
import { useNavigate } from "react-router";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -27,7 +28,7 @@ export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
|
||||
lg: "50%",
|
||||
xl: "40%",
|
||||
}}
|
||||
padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
|
||||
padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={1}
|
||||
borderColor={theme.palette.divider}
|
||||
@@ -55,7 +56,7 @@ export const ErrorFallback = ({
|
||||
<BaseFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
marginY={theme.spacing(LAYOUT.XS)}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{title}
|
||||
@@ -81,7 +82,7 @@ export const EmptyFallback = ({
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Stack
|
||||
gap={theme.spacing(10)}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
zIndex={1}
|
||||
alignItems="center"
|
||||
>
|
||||
@@ -89,7 +90,7 @@ export const EmptyFallback = ({
|
||||
<Stack
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing(2),
|
||||
gap: theme.spacing(SPACING.MD),
|
||||
maxWidth: "1100px",
|
||||
width: "100%",
|
||||
}}
|
||||
@@ -134,7 +135,7 @@ export const EmptyMonitorFallback = ({
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Stack
|
||||
gap={theme.spacing(10)}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
zIndex={1}
|
||||
alignItems="center"
|
||||
>
|
||||
@@ -147,7 +148,7 @@ export const EmptyMonitorFallback = ({
|
||||
<Stack
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing(2),
|
||||
gap: theme.spacing(SPACING.MD),
|
||||
maxWidth: "1100px",
|
||||
width: "100%",
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
export const SplitBox = ({
|
||||
left,
|
||||
right,
|
||||
@@ -18,10 +19,10 @@ export const SplitBox = ({
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={1}
|
||||
borderColor={theme.palette.divider}
|
||||
borderRadius={theme.spacing(2)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
>
|
||||
<Box
|
||||
padding={theme.spacing(15)}
|
||||
padding={theme.spacing(LAYOUT.XXL)}
|
||||
borderRight={isSmall ? 0 : 1}
|
||||
borderBottom={isSmall ? 1 : 0}
|
||||
borderColor={theme.palette.divider}
|
||||
@@ -37,7 +38,7 @@ export const SplitBox = ({
|
||||
</Box>
|
||||
<Box
|
||||
flex={1}
|
||||
padding={theme.spacing(15)}
|
||||
padding={theme.spacing(LAYOUT.XXL)}
|
||||
>
|
||||
{right}
|
||||
</Box>
|
||||
@@ -60,7 +61,7 @@ export const ConfigBox = ({
|
||||
return (
|
||||
<SplitBox
|
||||
left={
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.XS)}>
|
||||
<Typography
|
||||
textTransform={"capitalize"}
|
||||
component="h2"
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, Fragment, useEffect } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
@@ -90,20 +91,20 @@ export function DataTable<
|
||||
// Return stack of cards for small screens
|
||||
if (isSmall && cardsOnSmallScreens) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.XS)}>
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
keys.push(key);
|
||||
return (
|
||||
<Stack
|
||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
||||
spacing={theme.spacing(4)}
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
sx={{
|
||||
borderStyle: "solid",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(4),
|
||||
padding: theme.spacing(LAYOUT.XS),
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
}}
|
||||
key={key}
|
||||
@@ -155,7 +156,7 @@ export function DataTable<
|
||||
<Table
|
||||
sx={{
|
||||
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
|
||||
paddingLeft: theme.spacing(8),
|
||||
paddingLeft: theme.spacing(LAYOUT.MD),
|
||||
},
|
||||
"& .MuiTableCell-root": {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
@@ -168,13 +169,13 @@ export function DataTable<
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
|
||||
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
||||
fontSize: theme.typography.fontSize,
|
||||
},
|
||||
"& :is(td)": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.secondary,
|
||||
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
|
||||
padding: `${theme.spacing(LAYOUT.MD)} ${theme.spacing(LAYOUT.MD)}`,
|
||||
fontSize: theme.typography.fontSize,
|
||||
overflowX: "hidden",
|
||||
},
|
||||
@@ -514,7 +515,7 @@ export const Pagination = ({ ...props }: PaginationProps) => {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gridAutoRows: "auto",
|
||||
rowGap: theme.spacing(1),
|
||||
rowGap: theme.spacing(SPACING.SM),
|
||||
alignItems: "center",
|
||||
justifyItems: "center",
|
||||
paddingLeft: 0,
|
||||
@@ -556,8 +557,8 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
|
||||
alignItems={"center"}
|
||||
justifyContent={"center"}
|
||||
sx={{
|
||||
py: theme.spacing(12),
|
||||
px: theme.spacing(4),
|
||||
py: theme.spacing(LAYOUT.XL),
|
||||
px: theme.spacing(LAYOUT.XS),
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.divider,
|
||||
@@ -574,7 +575,7 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
mb: theme.spacing(2),
|
||||
mb: theme.spacing(SPACING.LG),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -11,20 +11,26 @@ import type { ResponsiveStyleValue } from "@mui/system";
|
||||
interface SliderInputProps extends SliderProps {
|
||||
sx?: SliderProps["sx"];
|
||||
showValue?: boolean;
|
||||
formatDisplayValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
|
||||
({ sx, showValue = false, ...props }, ref) => {
|
||||
({ sx, showValue = false, formatDisplayValue, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
|
||||
|
||||
const displayValue =
|
||||
showValue && formatDisplayValue && typeof props.value === "number"
|
||||
? formatDisplayValue(props.value)
|
||||
: props.value;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(8)}
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
>
|
||||
{showValue && <Typography>{props.value}</Typography>}
|
||||
{showValue && <Typography>{displayValue}</Typography>}
|
||||
<Slider
|
||||
{...props}
|
||||
ref={ref}
|
||||
@@ -63,12 +69,21 @@ interface SliderWithLabelProps extends SliderProps {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
showValue?: boolean;
|
||||
formatDisplayValue?: (value: number) => string;
|
||||
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
|
||||
}
|
||||
|
||||
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
|
||||
(
|
||||
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
|
||||
{
|
||||
fieldLabel,
|
||||
required,
|
||||
showValue = true,
|
||||
formatDisplayValue,
|
||||
value,
|
||||
sliderMaxWidth = "100%",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
@@ -82,6 +97,7 @@ export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>
|
||||
<SliderInput
|
||||
{...props}
|
||||
showValue={showValue}
|
||||
formatDisplayValue={formatDisplayValue}
|
||||
value={value}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import BackgroundSVG from "@/assets/Images/background.svg";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { OfflineBanner } from "@/Components/design-elements";
|
||||
import { setServerUnreachableCallback, get } from "@/Utils/ApiClient";
|
||||
|
||||
@@ -13,7 +10,6 @@ interface AppLayoutProps {
|
||||
|
||||
const AppLayout = ({ children }: AppLayoutProps) => {
|
||||
const theme = useTheme();
|
||||
const mode = useSelector((state: RootState) => state.ui.mode);
|
||||
const [serverUnreachable, setServerUnreachable] = useState(false);
|
||||
const retryIntervalRef = useRef<number | null>(null);
|
||||
|
||||
@@ -47,10 +43,6 @@ const AppLayout = ({ children }: AppLayoutProps) => {
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
backgroundImage: mode === "dark" ? `url("${BackgroundSVG}")` : "none",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<OfflineBanner visible={serverUnreachable} />
|
||||
|
||||
@@ -5,8 +5,18 @@ import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import type { MonitorType } from "@/Types/Monitor";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const types = ["http", "ping", "port", "docker", "game"];
|
||||
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
|
||||
const typeDisplayNames: Record<string, string> = {
|
||||
http: "HTTP",
|
||||
ping: "Ping",
|
||||
port: "Port",
|
||||
docker: "Docker",
|
||||
game: "Game",
|
||||
grpc: "gRPC",
|
||||
websocket: "WebSocket",
|
||||
};
|
||||
const statuses = ["up", "down"];
|
||||
const states = ["active", "paused"];
|
||||
|
||||
@@ -30,6 +40,7 @@ export const ControlsFilter = ({
|
||||
onClearFilters: () => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isFilterActive =
|
||||
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
|
||||
@@ -50,7 +61,7 @@ export const ControlsFilter = ({
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
<Typography textTransform={"capitalize"}>{type}</Typography>
|
||||
<Typography>{typeDisplayNames[type] ?? type}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -88,7 +99,7 @@ export const ControlsFilter = ({
|
||||
variant="contained"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear Filters
|
||||
{t("common.buttons.clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
|
||||
import Map, { Marker, Popup } from "react-map-gl/maplibre";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
interface GeoChecksMapProps {
|
||||
geoChecks: FlatGeoCheck[];
|
||||
}
|
||||
|
||||
export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
|
||||
const theme = useTheme();
|
||||
const isDarkMode = theme.palette.mode === "dark";
|
||||
|
||||
const mapPopupStyles = (
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
".maplibregl-popup-content": {
|
||||
background: "transparent !important",
|
||||
padding: "0 !important",
|
||||
boxShadow: "none !important",
|
||||
},
|
||||
".maplibregl-popup-tip": {
|
||||
display: "none",
|
||||
},
|
||||
".maplibregl-ctrl-attrib": {
|
||||
display: "none",
|
||||
},
|
||||
".maplibregl-ctrl-logo": {
|
||||
display: "none",
|
||||
},
|
||||
".maplibregl-popup-close-button": {
|
||||
color: theme.palette.text.primary,
|
||||
background: "transparent",
|
||||
fontSize: "20px",
|
||||
padding: "4px",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (geoChecks.length === 0 || !mapRef.current) return;
|
||||
|
||||
const bounds = geoChecks.reduce(
|
||||
(acc, check) => {
|
||||
return {
|
||||
minLng: Math.min(acc.minLng, check.location.longitude),
|
||||
maxLng: Math.max(acc.maxLng, check.location.longitude),
|
||||
minLat: Math.min(acc.minLat, check.location.latitude),
|
||||
maxLat: Math.max(acc.maxLat, check.location.latitude),
|
||||
};
|
||||
},
|
||||
{
|
||||
minLng: Infinity,
|
||||
maxLng: -Infinity,
|
||||
minLat: Infinity,
|
||||
maxLat: -Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
if (bounds.minLng !== Infinity) {
|
||||
mapRef.current.fitBounds(
|
||||
[
|
||||
[bounds.minLng, bounds.minLat],
|
||||
[bounds.maxLng, bounds.maxLat],
|
||||
],
|
||||
{ padding: 50, duration: 1000 }
|
||||
);
|
||||
}
|
||||
}, [geoChecks]);
|
||||
|
||||
const getMarkerColor = (status: boolean): string => {
|
||||
return status ? theme.palette.success.main : theme.palette.error.main;
|
||||
};
|
||||
|
||||
const formatResponseTime = (timing: number): string => {
|
||||
return `${timing.toFixed(0)}ms`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{mapPopupStyles}
|
||||
<Box
|
||||
height={500}
|
||||
width={"100%"}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
overflow={"hidden"}
|
||||
>
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{
|
||||
longitude: 0,
|
||||
latitude: 20,
|
||||
zoom: 1.5,
|
||||
}}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
mapStyle={
|
||||
isDarkMode
|
||||
? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
|
||||
: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
|
||||
}
|
||||
>
|
||||
{geoChecks.map((check, index) => (
|
||||
<Marker
|
||||
key={`${check.id}-${index}`}
|
||||
longitude={check.location.longitude}
|
||||
latitude={check.location.latitude}
|
||||
anchor="bottom"
|
||||
onClick={(e: any) => {
|
||||
e.originalEvent.stopPropagation();
|
||||
setSelectedCheck(check);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: getMarkerColor(check.status),
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{selectedCheck && (
|
||||
<Popup
|
||||
longitude={selectedCheck.location.longitude}
|
||||
latitude={selectedCheck.location.latitude}
|
||||
anchor="top"
|
||||
onClose={() => setSelectedCheck(null)}
|
||||
closeOnClick={false}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
p: theme.spacing(4),
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight="bold"
|
||||
gutterBottom
|
||||
>
|
||||
{selectedCheck.location.city}, {selectedCheck.location.country}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="body2">
|
||||
<Typography
|
||||
component="span"
|
||||
fontWeight="medium"
|
||||
>
|
||||
Status:
|
||||
</Typography>{" "}
|
||||
{selectedCheck.status ? "Up" : "Down"}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Typography
|
||||
component="span"
|
||||
fontWeight="medium"
|
||||
>
|
||||
Status Code:
|
||||
</Typography>{" "}
|
||||
{selectedCheck.statusCode}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Typography
|
||||
component="span"
|
||||
fontWeight="medium"
|
||||
>
|
||||
Response Time:
|
||||
</Typography>{" "}
|
||||
{formatResponseTime(selectedCheck.timings.total)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
{new Date(selectedCheck.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Popup>
|
||||
)}
|
||||
</Map>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Tabs, Tab } from "@/Components/design-elements";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
|
||||
interface HeaderGeoTabsProps {
|
||||
geoCheckEnabled: boolean;
|
||||
locations: GeoContinent[] | undefined;
|
||||
selectedLocation: GeoContinent;
|
||||
onLocationChange: (location: GeoContinent) => void;
|
||||
}
|
||||
|
||||
export const HeaderGeoTabs = ({
|
||||
geoCheckEnabled,
|
||||
locations,
|
||||
selectedLocation,
|
||||
onLocationChange,
|
||||
}: HeaderGeoTabsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!geoCheckEnabled || !locations || locations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: GeoContinent) => {
|
||||
onLocationChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={{ xs: theme.spacing(8), md: 0 }}
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
alignItems={"center"}
|
||||
justifyContent={"flex-start"}
|
||||
>
|
||||
<Tabs
|
||||
value={selectedLocation}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{locations.map((location) => (
|
||||
<Tab
|
||||
key={location}
|
||||
label={t(
|
||||
`pages.createMonitor.form.geoChecks.option.locations.options.${location}`
|
||||
)}
|
||||
value={location}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { Palette } from "@mui/material/styles";
|
||||
type PaletteColorKey = Extract<
|
||||
keyof Palette,
|
||||
@@ -52,7 +52,7 @@ export const HistogramPageSpeedDetails = ({
|
||||
checks,
|
||||
range,
|
||||
}: {
|
||||
checks: CheckSnapshot[];
|
||||
checks: PageSpeedGroupedCheck[];
|
||||
range: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
@@ -73,7 +73,7 @@ export const HistogramPageSpeedDetails = ({
|
||||
>
|
||||
<AreaChart data={checks}>
|
||||
<XAxis
|
||||
dataKey={"createdAt"}
|
||||
dataKey={"bucketDate"}
|
||||
tick={(props) => (
|
||||
<XTick
|
||||
{...props}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { ConfigItem } from "@/Components/monitors";
|
||||
import type { TooltipProps } from "recharts";
|
||||
@@ -30,8 +31,8 @@ export const HistogramPageSpeedScoresTooltip = ({
|
||||
border: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
py: theme.spacing(2),
|
||||
px: theme.spacing(4),
|
||||
py: theme.spacing(SPACING.LG),
|
||||
px: theme.spacing(LAYOUT.XS),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
@@ -54,12 +55,12 @@ export const HistogramPageSpeedScoresTooltip = ({
|
||||
key={`${key}-tooltip`}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(3)}
|
||||
mt={theme.spacing(1)}
|
||||
gap={theme.spacing(SPACING.XXL)}
|
||||
mt={theme.spacing(SPACING.SM)}
|
||||
>
|
||||
<Box
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
width={theme.spacing(LAYOUT.XS)}
|
||||
height={theme.spacing(LAYOUT.XS)}
|
||||
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
|
||||
/>
|
||||
<Typography
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from "./ControlsFilter";
|
||||
export * from "./MonitorStatBoxes";
|
||||
export * from "./HeaderMonitorControls";
|
||||
export * from "./HeaderGeoTabs";
|
||||
export * from "./GeoChecksMap";
|
||||
export * from "./charts/HistogramStatus";
|
||||
export * from "./charts/RadialAvgResponse";
|
||||
export * from "./charts/HistogramDetails";
|
||||
|
||||
@@ -5,6 +5,7 @@ import IconButton from "@mui/material/IconButton";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
@@ -64,9 +65,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
||||
});
|
||||
|
||||
const menuItemSx = {
|
||||
gap: theme.spacing(2),
|
||||
gap: theme.spacing(LAYOUT.MD),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
pl: theme.spacing(4),
|
||||
pl: theme.spacing(LAYOUT.XS),
|
||||
"& svg": { stroke: theme.palette.text.secondary },
|
||||
};
|
||||
|
||||
@@ -74,9 +75,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
py={theme.spacing(4)}
|
||||
px={theme.spacing(8)}
|
||||
gap={theme.spacing(4)}
|
||||
py={theme.spacing(LAYOUT.XS)}
|
||||
px={theme.spacing(LAYOUT.MD)}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<Avatar
|
||||
small
|
||||
@@ -88,7 +89,7 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
gap={theme.spacing(SPACING.MD)}
|
||||
minWidth={0}
|
||||
flex={1}
|
||||
>
|
||||
@@ -132,10 +133,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
||||
disableScrollLock
|
||||
anchorOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
slotProps={{
|
||||
paper: { sx: { mt: theme.spacing(-4), ml: collapsed ? theme.spacing(2) : 0 } },
|
||||
paper: {
|
||||
sx: {
|
||||
mt: theme.spacing(-LAYOUT.XS),
|
||||
ml: collapsed ? theme.spacing(SPACING.LG) : 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
MenuListProps={{ sx: { p: 2, "& li": { m: 0 } } }}
|
||||
sx={{ ml: theme.spacing(4) }}
|
||||
sx={{ ml: theme.spacing(LAYOUT.XS) }}
|
||||
>
|
||||
{collapsed && (
|
||||
<Box
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Typography, IconButton, Stack, Box } from "@mui/material";
|
||||
import { Icon } from "@/Components/design-elements";
|
||||
import { X } from "lucide-react";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
@@ -34,25 +35,22 @@ export const StarPrompt = ({
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
sx={{
|
||||
width: "100%",
|
||||
padding: `${theme.spacing(6)} ${theme.spacing(6)}`,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
gap: theme.spacing(1.5),
|
||||
}}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
padding={theme.spacing(LAYOUT.SM)}
|
||||
width={"100%"}
|
||||
borderTop={`1px solid ${theme.palette.divider}`}
|
||||
borderBottom={`1px solid ${theme.palette.divider}`}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
width="100%"
|
||||
pl={theme.spacing(4)}
|
||||
pl={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
mt={theme.spacing(3)}
|
||||
mt={theme.spacing(SPACING.XXL)}
|
||||
>
|
||||
{t("components.sidebar.starPrompt.title")}
|
||||
</Typography>
|
||||
@@ -78,7 +76,7 @@ export const StarPrompt = ({
|
||||
<Typography
|
||||
sx={{
|
||||
mb: 1,
|
||||
px: theme.spacing(4),
|
||||
px: theme.spacing(LAYOUT.XS),
|
||||
}}
|
||||
>
|
||||
{t("components.sidebar.starPrompt.description")}
|
||||
@@ -96,7 +94,7 @@ export const StarPrompt = ({
|
||||
"&:hover": {
|
||||
opacity: 0.8,
|
||||
},
|
||||
pl: theme.spacing(4),
|
||||
pl: theme.spacing(LAYOUT.XS),
|
||||
filter: mode === "dark" ? "invert(1)" : "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Backdrop from "@mui/material/Backdrop";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
||||
import { Logo } from "@/Components/sidebar/Logo";
|
||||
import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
|
||||
@@ -65,9 +66,9 @@ export const Sidebar = () => {
|
||||
left={0}
|
||||
minHeight={"100vh"}
|
||||
maxHeight={"100vh"}
|
||||
paddingTop={theme.spacing(6)}
|
||||
paddingBottom={theme.spacing(6)}
|
||||
gap={theme.spacing(6)}
|
||||
paddingTop={theme.spacing(LAYOUT.SM)}
|
||||
paddingBottom={theme.spacing(LAYOUT.SM)}
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
borderRight={`1px solid ${theme.palette.divider}`}
|
||||
width={width}
|
||||
sx={{
|
||||
@@ -81,13 +82,13 @@ export const Sidebar = () => {
|
||||
component="nav"
|
||||
disablePadding
|
||||
sx={{
|
||||
px: theme.spacing(6),
|
||||
px: theme.spacing(LAYOUT.SM),
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Logo
|
||||
pt={theme.spacing(8)}
|
||||
pb={theme.spacing(10)}
|
||||
pt={theme.spacing(LAYOUT.MD)}
|
||||
pb={theme.spacing(LAYOUT.MD)}
|
||||
/>
|
||||
{menu.map((item) => {
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
@@ -105,7 +106,7 @@ export const Sidebar = () => {
|
||||
<List
|
||||
component="nav"
|
||||
disablePadding
|
||||
sx={{ px: theme.spacing(6) }}
|
||||
sx={{ px: theme.spacing(LAYOUT.SM) }}
|
||||
>
|
||||
{bottomMenu.map((item) => {
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AxiosRequestConfig } from "axios";
|
||||
import { get, patch, post, put, deleteOp } from "@/Utils/ApiClient";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { logger } from "@/Utils/logger";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
@@ -17,7 +18,7 @@ const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
|
||||
};
|
||||
|
||||
export const useGet = <T>(
|
||||
url: string | null,
|
||||
url: string | null | undefined,
|
||||
axiosConfig?: AxiosRequestConfig,
|
||||
swrConfig?: SWRConfiguration
|
||||
) => {
|
||||
@@ -61,7 +62,7 @@ export const usePost = <B = any, R = any>() => {
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
||||
console.error(err);
|
||||
logger.error("POST request failed", err, { endpoint, body });
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
return null;
|
||||
@@ -96,7 +97,7 @@ export const usePatch = <B = any, R = any>() => {
|
||||
toastSuccess(res.data?.msg || "Operation successful");
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
logger.error("PATCH request failed", err, { endpoint, body });
|
||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
@@ -132,7 +133,7 @@ export const usePut = <B = any, R = any>() => {
|
||||
toastSuccess(res.data?.msg || "Operation successful");
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
logger.error("PUT request failed", err, { endpoint, body });
|
||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
@@ -167,7 +168,7 @@ export const useDelete = <R = any>() => {
|
||||
toastSuccess(res.data?.msg || "Operation successful");
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
logger.error("DELETE request failed", err, { endpoint });
|
||||
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
@@ -201,7 +202,7 @@ export const useLazyGet = <R = any>() => {
|
||||
});
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
logger.error("GET request failed", err, { endpoint });
|
||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
|
||||
@@ -14,6 +14,9 @@ const getBaseDefaults = (data?: Monitor | null) => ({
|
||||
notifications: data?.notifications || [],
|
||||
statusWindowSize: data?.statusWindowSize || 5,
|
||||
statusWindowThreshold: data?.statusWindowThreshold || 60,
|
||||
geoCheckEnabled: data?.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: data?.geoCheckLocations || [],
|
||||
geoCheckInterval: data?.geoCheckInterval || 300000,
|
||||
});
|
||||
|
||||
export const useMonitorForm = ({
|
||||
@@ -70,6 +73,16 @@ export const useMonitorForm = ({
|
||||
gameId: data?.gameId || "",
|
||||
};
|
||||
break;
|
||||
case "grpc":
|
||||
defaults = {
|
||||
...base,
|
||||
type: "grpc",
|
||||
url: data?.url || "",
|
||||
port: data?.port || 50051,
|
||||
grpcServiceName: data?.grpcServiceName || "",
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
};
|
||||
break;
|
||||
case "pagespeed":
|
||||
defaults = {
|
||||
...base,
|
||||
@@ -90,6 +103,14 @@ export const useMonitorForm = ({
|
||||
selectedDisks: data?.selectedDisks || [],
|
||||
};
|
||||
break;
|
||||
case "websocket":
|
||||
defaults = {
|
||||
...base,
|
||||
type: "websocket",
|
||||
url: data?.url || "",
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
defaults = {
|
||||
...base,
|
||||
|
||||
@@ -1,28 +1,76 @@
|
||||
import { useMemo } from "react";
|
||||
import { notificationSchema } from "@/Validation/notifications";
|
||||
import type { NotificationFormData } from "@/Validation/notifications";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
|
||||
interface UseNotificationFormOptions {
|
||||
data?: Notification | null;
|
||||
}
|
||||
|
||||
function buildDefaults(data: Notification | null): NotificationFormData {
|
||||
if (data?.type === "matrix") {
|
||||
return {
|
||||
type: "matrix",
|
||||
notificationName: data.notificationName || "",
|
||||
homeserverUrl: data.homeserverUrl || "",
|
||||
roomId: data.roomId || "",
|
||||
accessToken: data.accessToken || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "telegram") {
|
||||
return {
|
||||
type: "telegram",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
accessToken: data.accessToken || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "slack") {
|
||||
return {
|
||||
type: "slack",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "discord") {
|
||||
return {
|
||||
type: "discord",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "webhook") {
|
||||
return {
|
||||
type: "webhook",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "pager_duty") {
|
||||
return {
|
||||
type: "pager_duty",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "teams") {
|
||||
return {
|
||||
type: "teams",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
// Default: email (covers both data === null and data.type === "email")
|
||||
return {
|
||||
type: "email",
|
||||
notificationName: data?.notificationName || "",
|
||||
address: data?.address || "",
|
||||
};
|
||||
}
|
||||
|
||||
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
|
||||
return useMemo(() => {
|
||||
const defaults =
|
||||
data?.type === "matrix"
|
||||
? {
|
||||
type: "matrix" as const,
|
||||
notificationName: data.notificationName || "",
|
||||
homeserverUrl: data.homeserverUrl || "",
|
||||
roomId: data.roomId || "",
|
||||
accessToken: data.accessToken || "",
|
||||
}
|
||||
: {
|
||||
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
|
||||
notificationName: data?.notificationName || "",
|
||||
address: data?.address || "",
|
||||
};
|
||||
|
||||
const defaults = buildDefaults(data);
|
||||
return { schema: notificationSchema, defaults };
|
||||
}, [data]);
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { settingsSchema } from "@/Validation/settings";
|
||||
import type { Settings } from "@/Types/Settings";
|
||||
import type { SettingsFormData } from "@/Validation/settings";
|
||||
import type { SettingsFormInput } from "@/Validation/settings";
|
||||
|
||||
interface UseSettingsFormOptions {
|
||||
data?: Settings | null;
|
||||
}
|
||||
export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => {
|
||||
return useMemo(() => {
|
||||
const defaults: SettingsFormData = {
|
||||
const defaults: SettingsFormInput = {
|
||||
systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false,
|
||||
systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false,
|
||||
systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true,
|
||||
@@ -22,10 +22,22 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
||||
systemEmailTLSServername: data?.systemEmailTLSServername || "",
|
||||
systemEmailPort: data?.systemEmailPort,
|
||||
globalThresholds: {
|
||||
cpu: data?.globalThresholds?.cpu ?? 0,
|
||||
memory: data?.globalThresholds?.memory ?? 0,
|
||||
disk: data?.globalThresholds?.disk ?? 0,
|
||||
temperature: data?.globalThresholds?.temperature ?? 0,
|
||||
cpu:
|
||||
data?.globalThresholds?.cpu && data.globalThresholds.cpu >= 1
|
||||
? data.globalThresholds.cpu
|
||||
: 80,
|
||||
memory:
|
||||
data?.globalThresholds?.memory && data.globalThresholds.memory >= 1
|
||||
? data.globalThresholds.memory
|
||||
: 80,
|
||||
disk:
|
||||
data?.globalThresholds?.disk && data.globalThresholds.disk >= 1
|
||||
? data.globalThresholds.disk
|
||||
: 80,
|
||||
temperature:
|
||||
data?.globalThresholds?.temperature && data.globalThresholds.temperature >= 1
|
||||
? data.globalThresholds.temperature
|
||||
: 80,
|
||||
},
|
||||
checkTTL: data?.checkTTL ?? 30,
|
||||
pagespeedApiKey: "",
|
||||
|
||||
@@ -27,12 +27,14 @@ export const useStatusPageForm = ({
|
||||
companyName: data?.companyName || "",
|
||||
url: data?.url || generateDefaultUrl(),
|
||||
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
type: data?.type || ["uptime"],
|
||||
color: data?.color || "#4169E1",
|
||||
monitors: data?.monitors || [],
|
||||
isPublished: data?.isPublished ?? false,
|
||||
showCharts: data?.showCharts ?? true,
|
||||
showUptimePercentage: data?.showUptimePercentage ?? true,
|
||||
showAdminLoginLink: data?.showAdminLoginLink ?? false,
|
||||
showInfrastructure: data?.showInfrastructure ?? false,
|
||||
customCSS: data?.customCSS || "",
|
||||
logo: transformLogo(data?.logo),
|
||||
};
|
||||
|
||||
@@ -11,12 +11,17 @@ import { ConfigBox, BasePage } from "@/Components/design-elements";
|
||||
import { UserRoles } from "@/Types/User";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UserRole, User } from "@/Types/User";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useEditUserForm } from "@/Hooks/useEditUserForm";
|
||||
import { useGet, usePatch } from "@/Hooks/UseApi";
|
||||
import { useGet, usePatch, useDelete } from "@/Hooks/UseApi";
|
||||
import type { EditUserFormData } from "@/Validation/editUser";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DialogInput } from "@/Components/inputs/Dialog";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
||||
|
||||
interface RoleOption {
|
||||
id: UserRole;
|
||||
@@ -29,6 +34,12 @@ const EditUserPage = () => {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const { resolver, defaults } = useEditUserForm();
|
||||
const { patch, loading: isSaving } = usePatch();
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
const navigate = useNavigate();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const currentUser = useSelector((state: RootState) => state.auth.user);
|
||||
const isSuperAdmin = useIsSuperAdmin();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
|
||||
|
||||
@@ -60,6 +71,20 @@ const EditUserPage = () => {
|
||||
setValue("role", newRoles, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const canDeleteUser =
|
||||
isAdmin &&
|
||||
userId !== currentUser?.id &&
|
||||
!user?.role?.includes("demo") &&
|
||||
(isSuperAdmin || user?.role?.every((r) => r !== "admin" && r !== "superadmin"));
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const result = await deleteFn(`/auth/users/${userId}`);
|
||||
setShowDeleteDialog(false);
|
||||
if (result) {
|
||||
navigate("/account", { state: { tab: "team" } });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: EditUserFormData) => {
|
||||
await patch(`/auth/users/${userId}`, data);
|
||||
};
|
||||
@@ -165,17 +190,45 @@ const EditUserPage = () => {
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
||||
<Stack
|
||||
gap={LAYOUT.XS}
|
||||
direction="row"
|
||||
justifyContent={"flex-end"}
|
||||
width="100%"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
{canDeleteUser && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{t("common.buttons.removeUser")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
<DialogInput
|
||||
open={showDeleteDialog}
|
||||
title={t("pages.editUser.dialog.removeUser.title")}
|
||||
content={t("pages.editUser.dialog.removeUser.content", {
|
||||
name: `${user?.firstName} ${user?.lastName}`,
|
||||
})}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
onConfirm={handleDeleteUser}
|
||||
confirmText={t("common.buttons.removeUser")}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { User } from "@/Types/User";
|
||||
|
||||
interface TeamTableProps {
|
||||
@@ -13,7 +13,7 @@ interface TeamTableProps {
|
||||
export const TeamTable = ({ users }: TeamTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isSuperAdmin = useIsSuperAdmin();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const headers: Header<User>[] = [
|
||||
{
|
||||
@@ -45,7 +45,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
|
||||
];
|
||||
|
||||
const handleRowClick = (row: User) => {
|
||||
if (isSuperAdmin) {
|
||||
if (isAdmin) {
|
||||
navigate(`/account/team/${row.id}`);
|
||||
}
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
|
||||
<Table
|
||||
headers={headers}
|
||||
data={users}
|
||||
onRowClick={isSuperAdmin ? handleRowClick : undefined}
|
||||
onRowClick={isAdmin ? handleRowClick : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { Check } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
@@ -29,7 +28,6 @@ export const ChecksTable = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const headers: Header<Check>[] = [
|
||||
@@ -109,9 +107,6 @@ export const ChecksTable = ({
|
||||
<Table
|
||||
headers={headers}
|
||||
data={checks}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/checks/${row.id}`);
|
||||
}}
|
||||
emptyViewText={t("pages.checks.table.empty")}
|
||||
/>
|
||||
<Pagination
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { useParams, useLocation, useNavigate } from "react-router";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -15,6 +16,7 @@ import Divider from "@mui/material/Divider";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { HeaderDeleteControls } from "@/Components/monitors";
|
||||
import { GeoContinents } from "@/Types/GeoCheck";
|
||||
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import {
|
||||
@@ -27,9 +29,15 @@ import {
|
||||
SliderWithLabel,
|
||||
Dialog,
|
||||
} from "@/Components/inputs";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
|
||||
import { useMonitorForm } from "@/Hooks/useMonitorForm";
|
||||
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
|
||||
import {
|
||||
type Monitor,
|
||||
type MonitorType,
|
||||
type GamesMap,
|
||||
supportsGeoCheck,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MonitorFormData } from "@/Validation/monitor";
|
||||
|
||||
@@ -41,6 +49,8 @@ interface GeneralSettingsConfig {
|
||||
showPort: boolean;
|
||||
showGameSelect: boolean;
|
||||
showSecret: boolean;
|
||||
showGrpcServiceName: boolean;
|
||||
showIgnoreTls: boolean;
|
||||
}
|
||||
|
||||
const getGeneralSettingsConfig = (
|
||||
@@ -56,6 +66,8 @@ const getGeneralSettingsConfig = (
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
ping: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||
@@ -65,6 +77,8 @@ const getGeneralSettingsConfig = (
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
docker: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||
@@ -74,6 +88,8 @@ const getGeneralSettingsConfig = (
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
port: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -83,6 +99,8 @@ const getGeneralSettingsConfig = (
|
||||
showPort: true,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
game: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -92,6 +110,19 @@ const getGeneralSettingsConfig = (
|
||||
showPort: true,
|
||||
showGameSelect: true,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
grpc: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showPort: true,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: true,
|
||||
showIgnoreTls: true,
|
||||
},
|
||||
pagespeed: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -101,6 +132,8 @@ const getGeneralSettingsConfig = (
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
hardware: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -110,6 +143,19 @@ const getGeneralSettingsConfig = (
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: true,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
websocket: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: true,
|
||||
},
|
||||
};
|
||||
return configs[type] || configs.http;
|
||||
@@ -165,6 +211,7 @@ const CreateMonitorPage = () => {
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
|
||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||
|
||||
useEffect(() => {
|
||||
clearErrors();
|
||||
@@ -224,7 +271,7 @@ const CreateMonitorPage = () => {
|
||||
};
|
||||
|
||||
const onError = (errors: unknown) => {
|
||||
console.log(errors);
|
||||
logger.debug("Monitor creation validation errors", errors);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -251,43 +298,57 @@ const CreateMonitorPage = () => {
|
||||
<FormControl error={!!fieldState.error}>
|
||||
<RadioGroup
|
||||
{...field}
|
||||
sx={{ gap: theme.spacing(6) }}
|
||||
sx={{ gap: theme.spacing(LAYOUT.MD) }}
|
||||
>
|
||||
<RadioWithDescription
|
||||
value="http"
|
||||
label={t("pages.createMonitor.form.type.optionHttp")}
|
||||
label={t("pages.common.monitors.monitorTypes.optionHttp")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionHttpDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="ping"
|
||||
label={t("pages.createMonitor.form.type.optionPing")}
|
||||
label={t("pages.common.monitors.monitorTypes.optionPing")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionPingDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="docker"
|
||||
label={t("pages.createMonitor.form.type.optionDocker")}
|
||||
label={t("pages.common.monitors.monitorTypes.optionDocker")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionDockerDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="port"
|
||||
label={t("pages.createMonitor.form.type.optionPort")}
|
||||
label={t("pages.common.monitors.monitorTypes.optionPort")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionPortDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="game"
|
||||
label={t("pages.createMonitor.form.type.optionGame")}
|
||||
label={t("pages.common.monitors.monitorTypes.optionGame")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionGameDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="grpc"
|
||||
label={t("pages.common.monitors.monitorTypes.optionGrpc")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionGrpcDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="websocket"
|
||||
label={t("pages.common.monitors.monitorTypes.optionWebSocket")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionWebSocketDescription"
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -300,7 +361,7 @@ const CreateMonitorPage = () => {
|
||||
title={t("pages.createMonitor.form.general.title")}
|
||||
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
{/* URL/Host/Container field - not shown for hardware */}
|
||||
{generalSettingsConfig.showUrl && (
|
||||
<Controller
|
||||
@@ -376,6 +437,30 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* gRPC Service Name field - only for grpc type */}
|
||||
{generalSettingsConfig.showGrpcServiceName && (
|
||||
<Controller
|
||||
name="grpcServiceName"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="text"
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.grpcServiceName.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.createMonitor.form.general.option.grpcServiceName.placeholder"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Secret field - only for hardware type */}
|
||||
{generalSettingsConfig.showSecret && (
|
||||
<Controller
|
||||
@@ -495,7 +580,7 @@ const CreateMonitorPage = () => {
|
||||
title={t("pages.createMonitor.form.thresholds.title")}
|
||||
subtitle={t("pages.createMonitor.form.thresholds.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="cpuAlertThreshold"
|
||||
control={control}
|
||||
@@ -577,7 +662,7 @@ const CreateMonitorPage = () => {
|
||||
title={t("pages.createMonitor.form.incidents.title")}
|
||||
subtitle={t("pages.createMonitor.form.incidents.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(12)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="statusWindowSize"
|
||||
control={control}
|
||||
@@ -629,7 +714,7 @@ const CreateMonitorPage = () => {
|
||||
(field.value ?? []).includes(n.id)
|
||||
);
|
||||
return (
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={notificationOptions}
|
||||
@@ -680,7 +765,9 @@ const CreateMonitorPage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{watchedType === "http" && (
|
||||
{(watchedType === "http" ||
|
||||
watchedType === "grpc" ||
|
||||
watchedType === "websocket") && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.ignoreTls.title")}
|
||||
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
|
||||
@@ -692,7 +779,7 @@ const CreateMonitorPage = () => {
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(2)}
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
@@ -713,7 +800,7 @@ const CreateMonitorPage = () => {
|
||||
title={t("pages.createMonitor.form.advanced.title")}
|
||||
subtitle={t("pages.createMonitor.form.advanced.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="useAdvancedMatching"
|
||||
control={control}
|
||||
@@ -721,7 +808,7 @@ const CreateMonitorPage = () => {
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(2)}
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
@@ -736,7 +823,7 @@ const CreateMonitorPage = () => {
|
||||
)}
|
||||
/>
|
||||
{watchedUseAdvancedMatching && (
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="matchMethod"
|
||||
control={control}
|
||||
@@ -823,6 +910,140 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{supportsGeoCheck(watchedType) && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.geoChecks.title")}
|
||||
subtitle={t("pages.createMonitor.form.geoChecks.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="geoCheckEnabled"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
<Typography>
|
||||
{t("pages.createMonitor.form.geoChecks.option.enabled.label")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
{watchGeoCheckEnabled && (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="geoCheckLocations"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
// Map continents to have 'name' property for Autocomplete
|
||||
const locationOptions = GeoContinents.map((continent) => ({
|
||||
id: continent,
|
||||
name: t(
|
||||
`pages.createMonitor.form.geoChecks.option.locations.options.${continent}`
|
||||
),
|
||||
}));
|
||||
const selectedLocations = locationOptions.filter((loc) =>
|
||||
(field.value ?? []).includes(loc.id)
|
||||
);
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={locationOptions}
|
||||
value={selectedLocations}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_: unknown, newValue: typeof locationOptions) => {
|
||||
field.onChange(newValue.map((loc) => loc.id));
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value.id
|
||||
}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.geoChecks.option.locations.label"
|
||||
)}
|
||||
/>
|
||||
{selectedLocations.length > 0 && (
|
||||
<Stack
|
||||
flex={1}
|
||||
width="100%"
|
||||
>
|
||||
{selectedLocations.map((location, index) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
key={location.id}
|
||||
width="100%"
|
||||
>
|
||||
<Typography flexGrow={1}>{location.name}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
(field.value ?? []).filter(
|
||||
(id: string) => id !== location.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
aria-label="Remove location"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
{index < selectedLocations.length - 1 && <Divider />}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="geoCheckInterval"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value ?? 300000}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.geoChecks.option.interval.label"
|
||||
)}
|
||||
>
|
||||
<MenuItem value={300000}>
|
||||
{t(
|
||||
"pages.createMonitor.form.geoChecks.option.interval.value.fiveMinutes"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={600000}>
|
||||
{t(
|
||||
"pages.createMonitor.form.geoChecks.option.interval.value.tenMinutes"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={900000}>
|
||||
{t(
|
||||
"pages.createMonitor.form.geoChecks.option.interval.value.fifteenMinutes"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={1800000}>
|
||||
{t(
|
||||
"pages.createMonitor.form.geoChecks.option.interval.value.thirtyMinutes"
|
||||
)}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
|
||||
@@ -3,6 +3,7 @@ import Grid from "@mui/material/Grid";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseBox, ValueLabel } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Incident } from "@/Types/Incident";
|
||||
@@ -29,14 +30,14 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
}
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
sx={sx}
|
||||
>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.title")}
|
||||
</Typography>
|
||||
<BaseBox padding={8}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<BaseBox padding={LAYOUT.MD}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.overview")}
|
||||
</Typography>
|
||||
@@ -44,7 +45,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(4)}
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
|
||||
@@ -75,15 +76,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
</Grid>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
<BaseBox padding={8}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<BaseBox padding={LAYOUT.MD}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.analysis")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(2)}
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<Grid size={6}>
|
||||
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
|
||||
@@ -127,15 +128,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
{!incident.status && (
|
||||
<BaseBox padding={8}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<BaseBox padding={LAYOUT.MD}>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.resolutionDetails")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(2)}
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid size={2}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const LogsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<number>(2);
|
||||
const [activeTab, setActiveTab] = useState<number>(1);
|
||||
return (
|
||||
<BasePage>
|
||||
<Tabs
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import {
|
||||
TextField,
|
||||
@@ -108,7 +110,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
const isLoading = isPosting || isPatching;
|
||||
|
||||
const onError = (errors: any) => {
|
||||
console.error("Form submission errors:", errors);
|
||||
logger.error("Maintenance form submission failed", undefined, { errors });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -120,7 +122,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
title={t("pages.maintenanceWindow.form.general.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.general.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
@@ -170,7 +172,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
title={t("pages.maintenanceWindow.form.startDate.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
@@ -197,7 +199,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
title={t("pages.maintenanceWindow.form.startTime.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="startTime"
|
||||
control={control}
|
||||
@@ -218,7 +220,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
spacing={theme.spacing(4)}
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<Controller
|
||||
name="duration"
|
||||
@@ -283,7 +285,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
.filter((m): m is Monitor => m !== undefined);
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={monitorsList}
|
||||
@@ -317,9 +319,8 @@ const CreateMaintenanceWindowPage = () => {
|
||||
key={monitor.id}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(4)}
|
||||
padding={theme.spacing(4)}
|
||||
marginTop={theme.spacing(2)}
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
marginTop={theme.spacing(SPACING.LG)}
|
||||
borderRadius={1}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
@@ -350,7 +351,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
|
||||
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{watchedType !== "matrix" && (
|
||||
{watchedType !== "matrix" && watchedType !== "telegram" && (
|
||||
<ConfigBox
|
||||
title={addressConfig.title}
|
||||
subtitle={addressConfig.description}
|
||||
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
defaultValue={defaults.address}
|
||||
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
@@ -171,6 +171,49 @@ const NotificationsCreatePage = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "telegram" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.telegram.title")}
|
||||
subtitle={t("pages.notifications.form.telegram.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="accessToken"
|
||||
control={control}
|
||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.telegram.placeholderBotToken"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
|
||||
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "matrix" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.matrix.title")}
|
||||
@@ -180,7 +223,7 @@ const NotificationsCreatePage = () => {
|
||||
<Controller
|
||||
name="homeserverUrl"
|
||||
control={control}
|
||||
defaultValue={defaults.homeserverUrl}
|
||||
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
@@ -196,7 +239,7 @@ const NotificationsCreatePage = () => {
|
||||
<Controller
|
||||
name="roomId"
|
||||
control={control}
|
||||
defaultValue={defaults.roomId}
|
||||
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
@@ -212,7 +255,7 @@ const NotificationsCreatePage = () => {
|
||||
<Controller
|
||||
name="accessToken"
|
||||
control={control}
|
||||
defaultValue={defaults.accessToken}
|
||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BasePage } from "@/Components/design-elements";
|
||||
import { HeaderTimeRange } from "@/Components/common";
|
||||
import type { PageSpeedDetailsResponse } from "@/Types/Monitor";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import {
|
||||
@@ -13,23 +14,36 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
const PageSpeedDetails = () => {
|
||||
const { monitorId } = useParams();
|
||||
const isAdmin = useIsAdmin();
|
||||
const theme = useTheme();
|
||||
const [dateRange, setDateRange] = useState<string>("day");
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
if (!monitorId) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.append("dateRange", dateRange);
|
||||
return `/monitors/pagespeed/details/${monitorId}?${params.toString()}`;
|
||||
}, [monitorId, dateRange]);
|
||||
|
||||
const {
|
||||
data: monitorData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGet<PageSpeedDetailsResponse>(
|
||||
monitorId ? `/monitors/pagespeed/details/${monitorId}?dateRange=day` : null,
|
||||
monitorDetailsUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
const monitor = monitorData?.monitor;
|
||||
const monitor = monitorData?.monitorData?.monitor;
|
||||
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
|
||||
const monitorStats = monitorData?.monitorStats || null;
|
||||
|
||||
return (
|
||||
@@ -47,9 +61,15 @@ const PageSpeedDetails = () => {
|
||||
monitor={monitor}
|
||||
monitorStats={monitorStats}
|
||||
/>
|
||||
<HeaderTimeRange
|
||||
isLoading={isLoading}
|
||||
hasDateRange={true}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
<HistogramPageSpeedDetails
|
||||
checks={monitor?.recentChecks || []}
|
||||
range="day"
|
||||
checks={groupedChecks}
|
||||
range={dateRange}
|
||||
/>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/Pa
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
|
||||
@@ -20,14 +20,6 @@ const PageSpeedMonitorsPage = () => {
|
||||
const isAdmin = useIsAdmin();
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
|
||||
const monitorsUrl = "/monitors/team/with-checks?type=pagespeed&limit=10";
|
||||
const {
|
||||
data: monitorsData,
|
||||
isLoading: monitorsIsLoading,
|
||||
error: monitorsError,
|
||||
refetch,
|
||||
} = useGet<MonitorsWithChecksResponse>(monitorsUrl, {}, { refreshInterval: 30000 });
|
||||
|
||||
const settingsUrl = "/settings";
|
||||
const {
|
||||
data: settingsData,
|
||||
@@ -42,6 +34,28 @@ const PageSpeedMonitorsPage = () => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
|
||||
const monitorsWithChecksUrl = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.append("type", "pagespeed");
|
||||
params.append("limit", "25");
|
||||
if (page !== undefined) params.append("page", String(page));
|
||||
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
|
||||
if (sortField) params.append("field", sortField);
|
||||
if (sortOrder) params.append("order", sortOrder);
|
||||
return `/monitors/team/with-checks?${params.toString()}`;
|
||||
}, [page, rowsPerPage, sortField, sortOrder]);
|
||||
|
||||
const {
|
||||
data: monitorsData,
|
||||
isLoading: monitorsIsLoading,
|
||||
error: monitorsError,
|
||||
refetch,
|
||||
} = useGet<MonitorsWithChecksResponse>(
|
||||
monitorsWithChecksUrl,
|
||||
{},
|
||||
{ refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
const monitors = monitorsData?.monitors;
|
||||
const monitorsCount = monitorsData?.count ?? 0;
|
||||
const summary = monitorsData?.summary ?? null;
|
||||
|
||||
+183
-106
@@ -1,6 +1,15 @@
|
||||
import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements";
|
||||
import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs";
|
||||
import { Stack, useTheme, MenuItem, Link, type SelectChangeEvent } from "@mui/material";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import {
|
||||
Stack,
|
||||
useTheme,
|
||||
MenuItem,
|
||||
Link,
|
||||
Alert,
|
||||
type SelectChangeEvent,
|
||||
} from "@mui/material";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -8,12 +17,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import DummyChart from "@/Pages/Settings/DummyChart";
|
||||
import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { useSettingsForm } from "@/Hooks/useSettingsForm";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
|
||||
import type { SettingsFormData } from "@/Validation/settings";
|
||||
import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings";
|
||||
import { useState } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { TextField, Button, FieldLabel } from "@/Components/inputs";
|
||||
import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { useDelete } from "@/Hooks/UseApi";
|
||||
|
||||
@@ -27,6 +37,7 @@ import {
|
||||
} from "@/Features/UI/uiSlice.js";
|
||||
import timezones from "@/Utils/timezones.json";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
|
||||
|
||||
interface Timezone {
|
||||
id: string;
|
||||
@@ -44,10 +55,13 @@ export const SettingsPage = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { toastError } = useToast();
|
||||
// Local state for demo monitors dialog
|
||||
const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false);
|
||||
const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost();
|
||||
const { deleteFn: deleteAllMonitors, loading: isDeletingAllMonitors } = useDelete();
|
||||
// Import monitors functionality
|
||||
const { post: importMonitors, loading: isImportingMonitors } = usePost();
|
||||
|
||||
// Fetch settings data from API
|
||||
const { data: fetchedSettings } = useGet<SettingsResponse>("/settings");
|
||||
@@ -75,7 +89,7 @@ export const SettingsPage = () => {
|
||||
// Initialize form with schema and defaults
|
||||
const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings });
|
||||
|
||||
const form = useForm<SettingsFormData>({
|
||||
const form = useForm<SettingsFormInput, unknown, SettingsFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
mode: "onChange",
|
||||
@@ -202,6 +216,36 @@ export const SettingsPage = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type !== "application/json") {
|
||||
toastError("Please select a valid JSON file");
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const monitors = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(monitors)) {
|
||||
toastError("Invalid file format: expected an array of monitors");
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
await importMonitors("/monitors/import/json", { monitors });
|
||||
|
||||
event.target.value = "";
|
||||
} catch (error) {
|
||||
toastError("Error parsing JSON file. Please check the file format.");
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: SettingsFormData) => {
|
||||
// Don't send pagespeedApiKey if it's already set and user hasn't clicked reset
|
||||
const dataToSend = { ...data };
|
||||
@@ -212,29 +256,7 @@ export const SettingsPage = () => {
|
||||
delete (dataToSend as any).systemEmailPassword;
|
||||
}
|
||||
|
||||
// Convert undefined to empty string for backend unsetting
|
||||
const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => {
|
||||
const typedKey = key as keyof SettingsFormData;
|
||||
if (value === undefined) {
|
||||
(acc as any)[typedKey] = "";
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
// Handle nested objects like globalThresholds
|
||||
// Convert 0 to "" for unsetting thresholds
|
||||
(acc as any)[typedKey] = Object.entries(value).reduce(
|
||||
(nested, [nestedKey, nestedValue]) => ({
|
||||
...nested,
|
||||
[nestedKey]:
|
||||
nestedValue === undefined || nestedValue === 0 ? "" : nestedValue,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
} else {
|
||||
(acc as any)[typedKey] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Partial<SettingsFormData>);
|
||||
|
||||
const result = await patch("/settings", processedData as SettingsFormData);
|
||||
const result = await patch("/settings", dataToSend as SettingsFormData);
|
||||
|
||||
if (result?.success) {
|
||||
// Update API key state from response
|
||||
@@ -248,7 +270,7 @@ export const SettingsPage = () => {
|
||||
};
|
||||
|
||||
const onError = (errors: unknown) => {
|
||||
console.log("Form validation errors:", errors);
|
||||
logger.debug("Form validation errors", errors);
|
||||
};
|
||||
|
||||
const languages = Object.keys(i18n.options.resources || {});
|
||||
@@ -258,7 +280,7 @@ export const SettingsPage = () => {
|
||||
component="form"
|
||||
onSubmit={form.handleSubmit(onSubmit, onError)}
|
||||
>
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.timezone.title")}
|
||||
subtitle={t("pages.settings.form.timezone.description")}
|
||||
@@ -281,7 +303,7 @@ export const SettingsPage = () => {
|
||||
title={t("pages.settings.form.ui.title")}
|
||||
subtitle={t("pages.settings.form.ui.description")}
|
||||
rightContent={
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Select
|
||||
value={mode}
|
||||
onChange={handleModeChange}
|
||||
@@ -420,6 +442,43 @@ export const SettingsPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Check Retention */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.retention.title")}
|
||||
subtitle={t("pages.settings.form.retention.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="checkTTL"
|
||||
control={form.control}
|
||||
defaultValue={defaults.checkTTL}
|
||||
render={({ field }) => (
|
||||
<SliderWithLabel
|
||||
{...field}
|
||||
fieldLabel={t("pages.settings.form.retention.option.days.label")}
|
||||
min={1}
|
||||
max={CHECK_TTL_SENTINEL}
|
||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
||||
value={field.value || 30}
|
||||
onChange={(_, value) => field.onChange(value)}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value: number) =>
|
||||
value >= CHECK_TTL_SENTINEL
|
||||
? t("pages.settings.form.retention.option.days.unlimited")
|
||||
: `${value}`
|
||||
}
|
||||
formatDisplayValue={(value: number) =>
|
||||
value >= CHECK_TTL_SENTINEL
|
||||
? t("pages.settings.form.retention.option.days.unlimited")
|
||||
: `${value}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Global Thresholds */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
@@ -431,24 +490,16 @@ export const SettingsPage = () => {
|
||||
name="globalThresholds.cpu"
|
||||
control={form.control}
|
||||
defaultValue={defaults.globalThresholds?.cpu}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
render={({ field }) => (
|
||||
<SliderWithLabel
|
||||
{...field}
|
||||
value={
|
||||
field.value === undefined || field.value === 0 ? "" : field.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
field.onChange(val === "" ? 0 : Number(val));
|
||||
}}
|
||||
fieldLabel={t("pages.settings.form.thresholds.option.cpu.label")}
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
placeholder={t(
|
||||
"pages.settings.form.thresholds.option.cpu.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
min={1}
|
||||
max={100}
|
||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
||||
value={field.value || 1}
|
||||
onChange={(_, value) => field.onChange(value)}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -456,24 +507,16 @@ export const SettingsPage = () => {
|
||||
name="globalThresholds.memory"
|
||||
control={form.control}
|
||||
defaultValue={defaults.globalThresholds?.memory}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
render={({ field }) => (
|
||||
<SliderWithLabel
|
||||
{...field}
|
||||
value={
|
||||
field.value === undefined || field.value === 0 ? "" : field.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
field.onChange(val === "" ? 0 : Number(val));
|
||||
}}
|
||||
fieldLabel={t("pages.settings.form.thresholds.option.memory.label")}
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
placeholder={t(
|
||||
"pages.settings.form.thresholds.option.memory.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
min={1}
|
||||
max={100}
|
||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
||||
value={field.value || 1}
|
||||
onChange={(_, value) => field.onChange(value)}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -481,24 +524,16 @@ export const SettingsPage = () => {
|
||||
name="globalThresholds.disk"
|
||||
control={form.control}
|
||||
defaultValue={defaults.globalThresholds?.disk}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
render={({ field }) => (
|
||||
<SliderWithLabel
|
||||
{...field}
|
||||
value={
|
||||
field.value === undefined || field.value === 0 ? "" : field.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
field.onChange(val === "" ? 0 : Number(val));
|
||||
}}
|
||||
fieldLabel={t("pages.settings.form.thresholds.option.disk.label")}
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
placeholder={t(
|
||||
"pages.settings.form.thresholds.option.disk.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
min={1}
|
||||
max={100}
|
||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
||||
value={field.value || 1}
|
||||
onChange={(_, value) => field.onChange(value)}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -506,26 +541,18 @@ export const SettingsPage = () => {
|
||||
name="globalThresholds.temperature"
|
||||
control={form.control}
|
||||
defaultValue={defaults.globalThresholds?.temperature}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
render={({ field }) => (
|
||||
<SliderWithLabel
|
||||
{...field}
|
||||
value={
|
||||
field.value === undefined || field.value === 0 ? "" : field.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
field.onChange(val === "" ? 0 : Number(val));
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.settings.form.thresholds.option.temperature.label"
|
||||
)}
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
placeholder={t(
|
||||
"pages.settings.form.thresholds.option.temperature.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
min={1}
|
||||
max={150}
|
||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
||||
value={field.value || 1}
|
||||
onChange={(_, value) => field.onChange(value)}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -541,7 +568,7 @@ export const SettingsPage = () => {
|
||||
title={t("pages.settings.form.email.title")}
|
||||
subtitle={t("pages.settings.form.email.description")}
|
||||
leftContent={
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<TextLink
|
||||
text={t("pages.settings.form.email.descriptionTransport")}
|
||||
linkText={t("pages.settings.form.email.descriptionTransportLink")}
|
||||
@@ -589,7 +616,7 @@ export const SettingsPage = () => {
|
||||
</Stack>
|
||||
}
|
||||
rightContent={
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{/* Email Host */}
|
||||
<Controller
|
||||
name="systemEmailHost"
|
||||
@@ -676,7 +703,7 @@ export const SettingsPage = () => {
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
@@ -751,7 +778,7 @@ export const SettingsPage = () => {
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(4),
|
||||
gap: theme.spacing(LAYOUT.XS),
|
||||
}}
|
||||
>
|
||||
{[
|
||||
@@ -866,17 +893,34 @@ export const SettingsPage = () => {
|
||||
{/* Export Monitors - Admin Only */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.exportMonitors.title")}
|
||||
subtitle={t("pages.settings.form.exportMonitors.description")}
|
||||
title={t("pages.settings.form.importExportMonitors.title")}
|
||||
subtitle={t("pages.settings.form.importExportMonitors.description")}
|
||||
rightContent={
|
||||
<Box>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
direction={"row"}
|
||||
>
|
||||
<input
|
||||
id="monitor-import-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => document.getElementById("monitor-import-input")?.click()}
|
||||
disabled={isImportingMonitors}
|
||||
>
|
||||
{t("common.buttons.importFromJSON")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleExportMonitors}
|
||||
>
|
||||
{t("common.buttons.exportToJSON")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -939,13 +983,46 @@ export const SettingsPage = () => {
|
||||
bottom: 0,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
padding: theme.spacing(8),
|
||||
marginLeft: theme.spacing(-8),
|
||||
marginRight: theme.spacing(-8),
|
||||
marginBottom: theme.spacing(-8),
|
||||
padding: theme.spacing(LAYOUT.MD),
|
||||
marginLeft: theme.spacing(-LAYOUT.MD),
|
||||
marginRight: theme.spacing(-LAYOUT.MD),
|
||||
marginBottom: theme.spacing(-LAYOUT.MD),
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{/* Validation Error Display */}
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2, flexGrow: 1, mr: 2 }}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, mb: 1 }}
|
||||
>
|
||||
{t("pages.settings.form.validation.errorMessage")}
|
||||
</Typography>
|
||||
<Box
|
||||
component="ul"
|
||||
sx={{ m: 0, pl: 2 }}
|
||||
>
|
||||
{Object.entries(form.formState.errors).map(([field, error]) => {
|
||||
const message =
|
||||
typeof error === "object" && error?.message
|
||||
? error.message
|
||||
: "Invalid value";
|
||||
return (
|
||||
<li key={field}>
|
||||
<Typography variant="body2">
|
||||
<strong>{field}:</strong> {message}
|
||||
</Typography>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
loading={isSaving}
|
||||
type="submit"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
@@ -19,19 +21,28 @@ import {
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
|
||||
import type { StatusPageFormData } from "@/Validation/statusPage";
|
||||
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPageResponse } from "@/Types/StatusPage";
|
||||
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import timezones from "@/Utils/timezones.json";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||
|
||||
const monitorsUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
|
||||
(type) => params.append("type", type)
|
||||
);
|
||||
return `/monitors/team?${params.toString()}`;
|
||||
})();
|
||||
|
||||
interface TimezoneOption {
|
||||
_id: string;
|
||||
name: string;
|
||||
@@ -47,11 +58,11 @@ const CreateStatusPage = () => {
|
||||
|
||||
// Fetch existing status page data when configuring
|
||||
const { data: statusPageData, isLoading: isLoadingStatusPage } =
|
||||
useGet<StatusPageResponse>(isCreate ? null : `/status-page/${url}?type=uptime`);
|
||||
useGet<StatusPageResponse>(
|
||||
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
|
||||
);
|
||||
|
||||
const { data: monitorsResponse } = useGet<Monitor[]>(
|
||||
"/monitors/team?type=http&type=ping&type=port&type=docker"
|
||||
);
|
||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||
const monitors = monitorsResponse ?? [];
|
||||
|
||||
const { post, loading: isSubmittingPost } = usePost();
|
||||
@@ -79,8 +90,26 @@ const CreateStatusPage = () => {
|
||||
reset(defaults);
|
||||
}, [defaults, reset]);
|
||||
|
||||
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
|
||||
const computedTypes: MonitorDisplayType[] = useMemo(() => {
|
||||
const selectedMonitors = (watchedMonitorIds ?? [])
|
||||
.map((id) => monitors.find((m) => m.id === id))
|
||||
.filter((m): m is Monitor => m !== undefined);
|
||||
|
||||
const typesSet = new Set<MonitorDisplayType>();
|
||||
selectedMonitors.forEach((m) => {
|
||||
typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime");
|
||||
});
|
||||
|
||||
return typesSet.size ? Array.from(typesSet) : ["uptime"];
|
||||
}, [watchedMonitorIds, monitors]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("type", computedTypes);
|
||||
}, [computedTypes]);
|
||||
|
||||
const onError = (errors: any) => {
|
||||
console.log(errors);
|
||||
logger.debug("Status page validation errors", errors);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
@@ -101,7 +130,6 @@ const CreateStatusPage = () => {
|
||||
|
||||
const onSubmit = async (data: StatusPageFormData) => {
|
||||
const fd = new FormData();
|
||||
fd.append("type", "uptime");
|
||||
fd.append("isPublished", String(data.isPublished));
|
||||
if (data.companyName) fd.append("companyName", data.companyName);
|
||||
if (data.url) fd.append("url", data.url);
|
||||
@@ -110,11 +138,16 @@ const CreateStatusPage = () => {
|
||||
fd.append("showCharts", String(data.showCharts));
|
||||
fd.append("showUptimePercentage", String(data.showUptimePercentage));
|
||||
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
|
||||
fd.append("showInfrastructure", String(data.showInfrastructure));
|
||||
|
||||
data.monitors.forEach((monitorId) => {
|
||||
fd.append("monitors[]", monitorId);
|
||||
});
|
||||
|
||||
data.type.forEach((type) => {
|
||||
fd.append("type[]", type);
|
||||
});
|
||||
|
||||
// Handle logo upload
|
||||
if (data.logo === null) {
|
||||
// Signal to remove the logo
|
||||
@@ -128,7 +161,7 @@ const CreateStatusPage = () => {
|
||||
fd.append("logo", imageResult.data);
|
||||
URL.revokeObjectURL(data.logo.data);
|
||||
} catch (e) {
|
||||
console.error("Error fetching logo blob:", e);
|
||||
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,7 +198,7 @@ const CreateStatusPage = () => {
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(2)}
|
||||
spacing={theme.spacing(SPACING.MD)}
|
||||
>
|
||||
<Controller
|
||||
name="isPublished"
|
||||
@@ -187,7 +220,7 @@ const CreateStatusPage = () => {
|
||||
title={t("pages.statusPages.form.basicInfo.title")}
|
||||
subtitle={t("pages.statusPages.form.basicInfo.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(6)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="companyName"
|
||||
control={control}
|
||||
@@ -242,7 +275,7 @@ const CreateStatusPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={monitors}
|
||||
@@ -290,9 +323,9 @@ const CreateStatusPage = () => {
|
||||
{...provided.dragHandleProps}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(4)}
|
||||
padding={theme.spacing(4)}
|
||||
marginTop={theme.spacing(2)}
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
marginTop={theme.spacing(SPACING.LG)}
|
||||
borderRadius={1}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
@@ -301,7 +334,9 @@ const CreateStatusPage = () => {
|
||||
}}
|
||||
>
|
||||
<GripVertical size={20} />
|
||||
<Typography flexGrow={1}>{monitor.name}</Typography>
|
||||
<Typography
|
||||
flexGrow={1}
|
||||
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
@@ -366,7 +401,7 @@ const CreateStatusPage = () => {
|
||||
title={t("pages.statusPages.form.appearance.title")}
|
||||
subtitle={t("pages.statusPages.form.appearance.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack alignItems={"center"}>
|
||||
<Controller
|
||||
name="logo"
|
||||
@@ -407,7 +442,7 @@ const CreateStatusPage = () => {
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
subtitle={t("pages.statusPages.form.features.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="showCharts"
|
||||
control={control}
|
||||
@@ -423,6 +458,23 @@ const CreateStatusPage = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="showInfrastructure"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label={t(
|
||||
"pages.statusPages.form.features.option.showInfrastructure.label"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <Controller
|
||||
name="showUptimePercentage"
|
||||
control={control}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Gauge } from "@/Components/design-elements";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import Box from "@mui/material/Box";
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
const GAUGE_RADIUS = 60;
|
||||
const GAUGE_STROKE_WIDTH = 12;
|
||||
const PERCENTAGE_MULTIPLIER = 100;
|
||||
|
||||
interface StatusPageMonitor extends Monitor {
|
||||
checks?: Monitor["recentChecks"];
|
||||
}
|
||||
|
||||
interface MetricDetail {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface MetricConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
hasData: boolean;
|
||||
progress: number;
|
||||
details: MetricDetail[];
|
||||
}
|
||||
|
||||
const MetricDetailRow = ({ label, value }: MetricDetail) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface MetricItemProps {
|
||||
label: string;
|
||||
progress: number;
|
||||
details?: MetricDetail[];
|
||||
}
|
||||
|
||||
const MetricItem = ({ label, progress, details }: MetricItemProps) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
return (
|
||||
<Grid
|
||||
size={isSmall ? 12 : 4}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
gap: theme.spacing(SPACING.LG),
|
||||
padding: theme.spacing(LAYOUT.XS),
|
||||
borderRight: isSmall ? "none" : `1px solid ${theme.palette.divider}`,
|
||||
borderBottom: isSmall ? `1px solid ${theme.palette.divider}` : "none",
|
||||
"&:last-child": {
|
||||
borderRight: "none",
|
||||
borderBottom: "none",
|
||||
paddingBottom: theme.spacing(SPACING.LG),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<Gauge
|
||||
progress={progress}
|
||||
radius={GAUGE_RADIUS}
|
||||
strokeWidth={GAUGE_STROKE_WIDTH}
|
||||
/>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Box>
|
||||
{details && details.length > 0 && (
|
||||
<Box
|
||||
width="100%"
|
||||
paddingX={theme.spacing(LAYOUT.LG)}
|
||||
>
|
||||
{details.map((detail) => (
|
||||
<MetricDetailRow
|
||||
key={detail.label}
|
||||
label={detail.label}
|
||||
value={detail.value}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type LatestCheck = NonNullable<Monitor["recentChecks"]>[number];
|
||||
|
||||
const buildCpuMetric = (
|
||||
check: LatestCheck,
|
||||
t: (key: string) => string
|
||||
): MetricConfig | null => {
|
||||
if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
|
||||
return null;
|
||||
}
|
||||
const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
|
||||
return {
|
||||
key: "cpu",
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
|
||||
hasData: true,
|
||||
progress: usagePercent,
|
||||
details: [
|
||||
{
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.usage"),
|
||||
value: `${usagePercent.toFixed(2)}%`,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const buildMemoryMetric = (
|
||||
check: LatestCheck,
|
||||
t: (key: string) => string
|
||||
): MetricConfig | null => {
|
||||
if (
|
||||
!check.memory ||
|
||||
typeof check.memory.usage_percent !== "number" ||
|
||||
typeof check.memory.used_bytes !== "number" ||
|
||||
typeof check.memory.total_bytes !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: "memory",
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.memory"),
|
||||
hasData: true,
|
||||
progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
|
||||
details: [
|
||||
{
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.used"),
|
||||
value: prettyBytes(check.memory.used_bytes ?? 0),
|
||||
},
|
||||
{
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.total"),
|
||||
value: prettyBytes(check.memory.total_bytes ?? 0),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const buildDiskMetric = (
|
||||
check: LatestCheck,
|
||||
t: (key: string) => string
|
||||
): MetricConfig | null => {
|
||||
if (!check.disk || check.disk.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const disks = check.disk;
|
||||
const avgUsagePercent =
|
||||
(disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
|
||||
PERCENTAGE_MULTIPLIER;
|
||||
const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
|
||||
const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
|
||||
|
||||
return {
|
||||
key: "disk",
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.disk"),
|
||||
hasData: true,
|
||||
progress: avgUsagePercent,
|
||||
details: [
|
||||
{
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.used"),
|
||||
value: prettyBytes(totalUsedBytes),
|
||||
},
|
||||
{
|
||||
label: t("pages.statusPages.monitorsList.infrastructure.total"),
|
||||
value: prettyBytes(totalTotalBytes),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
|
||||
|
||||
if (!latestCheck) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.noData")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics: MetricConfig[] = [
|
||||
buildCpuMetric(latestCheck, t),
|
||||
buildMemoryMetric(latestCheck, t),
|
||||
buildDiskMetric(latestCheck, t),
|
||||
].filter((m): m is MetricConfig => m !== null);
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.noData")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
alignItems="center"
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
{metrics.map(({ key, label, progress, details }) => (
|
||||
<MetricItem
|
||||
key={key}
|
||||
label={label}
|
||||
progress={progress}
|
||||
details={details}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -5,13 +5,16 @@ import Typography from "@mui/material/Typography";
|
||||
import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
|
||||
import { StatusLabel, BaseBox } from "@/Components/design-elements";
|
||||
import { SwitchComponent } from "@/Components/inputs";
|
||||
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTheme, type Theme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useState } from "react";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
interface StatusPageMonitor extends Monitor {
|
||||
checks?: Monitor["recentChecks"];
|
||||
@@ -22,29 +25,124 @@ interface MonitorsListProps {
|
||||
monitors: StatusPageMonitor[];
|
||||
}
|
||||
|
||||
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
|
||||
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
|
||||
const bg =
|
||||
monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
|
||||
return {
|
||||
backgroundColor: bg,
|
||||
color: theme.palette.background.paper,
|
||||
padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
};
|
||||
};
|
||||
|
||||
const MonitorHeader = ({
|
||||
monitor,
|
||||
showURL,
|
||||
}: {
|
||||
monitor: StatusPageMonitor;
|
||||
showURL: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showURL = useSelector((state: RootState) => state.ui?.showURL);
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
mb={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(SPACING.LG)}
|
||||
mb={theme.spacing(SPACING.SM)}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{showURL && monitor.url && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{monitor.url}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<StatusLabel status={monitor.status} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const MonitorContent = ({
|
||||
monitor,
|
||||
statusPage,
|
||||
chartType,
|
||||
}: {
|
||||
monitor: StatusPageMonitor;
|
||||
statusPage: StatusPage;
|
||||
chartType: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (monitor.type === "hardware") {
|
||||
if (statusPage.showInfrastructure === false) return null;
|
||||
return <InfrastructureMetrics monitor={monitor} />;
|
||||
}
|
||||
|
||||
if (statusPage.showCharts === false) return null;
|
||||
|
||||
const checks = monitor.checks?.slice().reverse() ?? [];
|
||||
return (
|
||||
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1, mb: theme.spacing(SPACING.LG) }}>
|
||||
{chartType === "histogram" ? (
|
||||
<HistogramResponseTime
|
||||
height={{ xs: 50, md: 100 }}
|
||||
gap={{ xs: theme.spacing(SPACING.SM), md: theme.spacing(LAYOUT.SM) }}
|
||||
checks={checks}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapResponseTime checks={checks} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const showURL = useSelector((state: RootState) => state.ui?.showURL);
|
||||
const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
{statusPage.showCharts !== false && (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{statusPage.showCharts && (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
|
||||
<SwitchComponent
|
||||
dualOption
|
||||
value={chartType}
|
||||
checked={chartType === "histogram"}
|
||||
onChange={(e) => {
|
||||
setChartType(e.target.checked ? "histogram" : "heatmap");
|
||||
}}
|
||||
onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")}
|
||||
/>
|
||||
<Typography>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
@@ -52,59 +150,22 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{monitors?.map((monitor) => {
|
||||
return (
|
||||
<BaseBox
|
||||
key={monitor.id}
|
||||
padding={theme.spacing(4)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(4)}
|
||||
mb={statusPage.showCharts !== false ? theme.spacing(4) : 0}
|
||||
>
|
||||
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
{showURL && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{monitor.url}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<StatusLabel status={monitor.status} />
|
||||
</Stack>
|
||||
{statusPage.showCharts !== false && (
|
||||
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
|
||||
{chartType === "histogram" ? (
|
||||
<HistogramResponseTime
|
||||
height={{ xs: 50, md: 100 }}
|
||||
gap={{ xs: theme.spacing(0.5), md: theme.spacing(5) }}
|
||||
checks={monitor?.checks?.slice().reverse() ?? []}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapResponseTime
|
||||
checks={monitor?.checks?.slice().reverse() ?? []}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</BaseBox>
|
||||
);
|
||||
})}
|
||||
{monitors.map((monitor) => (
|
||||
<BaseBox
|
||||
key={monitor.id}
|
||||
padding={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<MonitorHeader
|
||||
monitor={monitor}
|
||||
showURL={showURL}
|
||||
/>
|
||||
<MonitorContent
|
||||
monitor={monitor}
|
||||
statusPage={statusPage}
|
||||
chartType={chartType}
|
||||
/>
|
||||
</BaseBox>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -23,16 +23,22 @@ const StatusPageView = () => {
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isPublic = location.pathname.startsWith("/status/public");
|
||||
|
||||
const apiUrl = url ? `/status-page/${url}?type=uptime` : null;
|
||||
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
|
||||
|
||||
const { data, isLoading, error } = useGet<StatusPageResponse>(apiUrl);
|
||||
const { data, isLoading, error } = useGet<StatusPageResponse>(
|
||||
apiUrl,
|
||||
{},
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
const statusPage = data?.statusPage;
|
||||
const monitors = data?.monitors ?? [];
|
||||
|
||||
if (!statusPage) return null;
|
||||
|
||||
if (monitors?.length === 0) {
|
||||
if (monitors.length === 0) {
|
||||
return (
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const StatusPagesTable = ({
|
||||
{
|
||||
id: "type",
|
||||
content: t("common.table.headers.type"),
|
||||
render: (row) => row.type,
|
||||
render: (row) => row.type.join(", "),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
|
||||
@@ -20,7 +20,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
content: t("pages.checks.table.headers.dateTime"),
|
||||
content: t("common.table.headers.dateTime"),
|
||||
render: (row) => {
|
||||
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const headers: Header<FlatGeoCheck>[] = [
|
||||
{
|
||||
id: "status",
|
||||
content: t("common.table.headers.status"),
|
||||
render: (row) => {
|
||||
const status = row.status ? "up" : "down";
|
||||
return <StatusLabel status={status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
content: t("common.table.headers.dateTime"),
|
||||
render: (row) => {
|
||||
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
return row.statusCode || "N/A";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
content: t("pages.checks.table.headers.location"),
|
||||
render: (row) => {
|
||||
const location = row.location;
|
||||
if (!location) return "N/A";
|
||||
const { continent, country, city } = location;
|
||||
return `${continent} - ${country}, ${city}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "responseTime",
|
||||
content: t("common.table.headers.responseTime"),
|
||||
render: (row) => {
|
||||
if (!row.timings?.total) return "N/A";
|
||||
return prettyMilliseconds(row.timings.total, { compact: true });
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const GeoChecksTable = ({
|
||||
geoChecks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
geoChecks: FlatGeoCheck[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
newPage: number
|
||||
) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleRowsPerPageChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
|
||||
) => {
|
||||
const value = Number(e.target.value);
|
||||
setPage(0);
|
||||
setRowsPerPage(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={geoChecks}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
count={count}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
RadialAvgResponse,
|
||||
HistogramDetails,
|
||||
HeaderMonitorControls,
|
||||
HeaderGeoTabs,
|
||||
GeoChecksMap,
|
||||
} from "@/Components/monitors";
|
||||
import { TrendingUp, AlertTriangle } from "lucide-react";
|
||||
import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable";
|
||||
import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable";
|
||||
import { MonitorStatBoxes } from "@/Components/monitors";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
@@ -17,11 +20,17 @@ import { useState, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { MonitorDetailsResponse } from "@/Types/Monitor";
|
||||
import { type MonitorDetailsResponse, supportsGeoCheck } from "@/Types/Monitor";
|
||||
import type { ChecksResponse } from "@/Types/Check";
|
||||
import type {
|
||||
GeoChecksResult,
|
||||
FlatGeoChecksResponse,
|
||||
GeoContinent,
|
||||
} from "@/Types/GeoCheck";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { t } from "i18next";
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
const certificateDateFormat = "MMM D, YYYY h A";
|
||||
|
||||
@@ -37,7 +46,10 @@ const UptimeDetailsPage = () => {
|
||||
|
||||
const [page, setPage] = useState<number>(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
|
||||
const [geoPage, setGeoPage] = useState<number>(0);
|
||||
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
if (!monitorId) {
|
||||
@@ -56,7 +68,7 @@ const UptimeDetailsPage = () => {
|
||||
} = useGet<MonitorDetailsResponse>(
|
||||
monitorDetailsUrl,
|
||||
{},
|
||||
{ refreshInterval: 10000, keepPreviousData: true }
|
||||
{ refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const monitorData = monitorDetailsData?.monitorData;
|
||||
@@ -71,7 +83,11 @@ const UptimeDetailsPage = () => {
|
||||
return `/monitors/certificate/${monitorId}`;
|
||||
}, [monitorId, monitor?.type]);
|
||||
|
||||
const { data: certificateData } = useGet<CertificateResponse>(certificateUrl);
|
||||
const { data: certificateData } = useGet<CertificateResponse>(
|
||||
certificateUrl,
|
||||
{},
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const certificateExpiry = useMemo(() => {
|
||||
if (!certificateData?.certificateDate) {
|
||||
@@ -102,9 +118,58 @@ const UptimeDetailsPage = () => {
|
||||
const { data: checksData, isLoading: checksIsLoading } = useGet<ChecksResponse>(
|
||||
checksUrl,
|
||||
{},
|
||||
{ keepPreviousData: true }
|
||||
{ keepPreviousData: true, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const geoChecksUrl = useMemo(() => {
|
||||
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.append("dateRange", dateRange);
|
||||
params.append("continent", selectedLocation);
|
||||
return `/monitors/${monitorId}/geo-checks?${params.toString()}`;
|
||||
}, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]);
|
||||
|
||||
const { data: geoGroupedData } = useGet<GeoChecksResult>(
|
||||
geoChecksUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const geoGroupedChecks = geoGroupedData?.groupedGeoChecks ?? [];
|
||||
|
||||
// Fetch paginated geo checks for the table
|
||||
const geoChecksTableUrl = useMemo(() => {
|
||||
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.append("sortOrder", "desc");
|
||||
params.append("dateRange", dateRange);
|
||||
params.append("page", String(geoPage));
|
||||
params.append("rowsPerPage", String(geoRowsPerPage));
|
||||
return `/geo-checks/${monitorId}?${params.toString()}`;
|
||||
}, [
|
||||
monitorId,
|
||||
monitor?.type,
|
||||
monitor?.geoCheckEnabled,
|
||||
dateRange,
|
||||
geoPage,
|
||||
geoRowsPerPage,
|
||||
]);
|
||||
|
||||
const { data: geoChecksTableData } = useGet<FlatGeoChecksResponse>(
|
||||
geoChecksTableUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
|
||||
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
|
||||
|
||||
const geoLocations = monitor?.geoCheckLocations;
|
||||
|
||||
const checks = checksData?.checks ?? [];
|
||||
const checksCount = checksData?.checksCount ?? 0;
|
||||
|
||||
@@ -127,6 +192,7 @@ const UptimeDetailsPage = () => {
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(8)}
|
||||
@@ -160,6 +226,31 @@ const UptimeDetailsPage = () => {
|
||||
rowsPerPage={rowsPerPage}
|
||||
setRowsPerPage={setRowsPerPage}
|
||||
/>
|
||||
|
||||
{monitor?.geoCheckEnabled && (
|
||||
<>
|
||||
<Typography variant="h1">Location breakdown</Typography>
|
||||
<HeaderGeoTabs
|
||||
geoCheckEnabled={monitor?.geoCheckEnabled ?? false}
|
||||
locations={geoLocations}
|
||||
selectedLocation={selectedLocation}
|
||||
onLocationChange={setSelectedLocation}
|
||||
/>
|
||||
<HistogramDetails
|
||||
checks={geoGroupedChecks}
|
||||
range={dateRange}
|
||||
/>
|
||||
<GeoChecksTable
|
||||
geoChecks={geoChecksForTable}
|
||||
count={geoChecksCount}
|
||||
page={geoPage}
|
||||
setPage={setGeoPage}
|
||||
rowsPerPage={geoRowsPerPage}
|
||||
setRowsPerPage={setGeoRowsPerPage}
|
||||
/>
|
||||
<GeoChecksMap geoChecks={geoChecksForTable} />
|
||||
</>
|
||||
)}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -181,6 +181,7 @@ export const MonitorTable = ({
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Stack>
|
||||
@@ -211,6 +212,7 @@ export const MonitorTable = ({
|
||||
onClick={(e) => handleSort(e, "type")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.type")}
|
||||
{renderSortIcon(sortField === "type")}
|
||||
</Stack>
|
||||
|
||||
@@ -42,11 +42,11 @@ const UptimeMonitorsPage = () => {
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
|
||||
// Convert filter selections to API filter values
|
||||
// Status: "up" -> true, "down" -> false
|
||||
// Status: pass "up"/"down" directly to the API
|
||||
// State: "active" -> true, "paused" -> false
|
||||
const toFilterStatus = useMemo(() => {
|
||||
if (selectedStatus === "up") return "true";
|
||||
if (selectedStatus === "down") return "false";
|
||||
if (selectedStatus === "up") return "up";
|
||||
if (selectedStatus === "down") return "down";
|
||||
return undefined;
|
||||
}, [selectedStatus]);
|
||||
|
||||
@@ -68,7 +68,9 @@ const UptimeMonitorsPage = () => {
|
||||
|
||||
// Default to all types when none selected
|
||||
const effectiveTypes =
|
||||
selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port", "game"];
|
||||
selectedTypes.length > 0
|
||||
? selectedTypes
|
||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
|
||||
|
||||
// Build URL for monitors with checks
|
||||
const monitorsWithChecksUrl = useMemo(() => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const CHECK_TTL_SENTINEL = 366;
|
||||
|
||||
export interface CheckMetadata {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
@@ -125,9 +127,6 @@ export interface Check {
|
||||
timings?: CheckTimings;
|
||||
statusCode: number;
|
||||
message: string;
|
||||
ack: boolean;
|
||||
ackAt?: string | null;
|
||||
expiry: string;
|
||||
cpu?: CheckCpuInfo;
|
||||
memory?: CheckMemoryInfo;
|
||||
disk?: CheckDiskInfo[];
|
||||
@@ -140,7 +139,6 @@ export interface Check {
|
||||
seo?: number;
|
||||
performance?: number;
|
||||
audits?: CheckAudits;
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -151,6 +149,15 @@ export interface GroupedCheck {
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface PageSpeedGroupedCheck {
|
||||
bucketDate: string;
|
||||
performance: number;
|
||||
accessibility: number;
|
||||
bestPractices: number;
|
||||
seo: number;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface LatestCheck {
|
||||
status: boolean;
|
||||
responseTime: number;
|
||||
@@ -234,10 +241,7 @@ export interface ChecksSummary {
|
||||
downChecks: number;
|
||||
}
|
||||
|
||||
export type CheckSnapshot = Omit<
|
||||
Check,
|
||||
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
|
||||
> & {
|
||||
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
|
||||
originalResponseTime: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
|
||||
export type GeoContinent = (typeof GeoContinents)[number];
|
||||
|
||||
export interface GeoCheckMetadata {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface GeoCheckTimings {
|
||||
total: number;
|
||||
dns: number;
|
||||
tcp: number;
|
||||
tls: number;
|
||||
firstByte: number;
|
||||
download: number;
|
||||
}
|
||||
|
||||
export interface GeoCheckLocation {
|
||||
continent: GeoContinent;
|
||||
region: string;
|
||||
country: string;
|
||||
state: string;
|
||||
city: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
}
|
||||
|
||||
export interface GeoCheckResult {
|
||||
location: GeoCheckLocation;
|
||||
status: boolean;
|
||||
statusCode: number;
|
||||
timings: GeoCheckTimings;
|
||||
}
|
||||
|
||||
export interface GeoCheck {
|
||||
id: string;
|
||||
metadata: GeoCheckMetadata;
|
||||
results: GeoCheckResult[];
|
||||
expiry: string;
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FlatGeoCheck {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
type: string;
|
||||
location: GeoCheckLocation;
|
||||
status: boolean;
|
||||
statusCode: number;
|
||||
timings: GeoCheckTimings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GroupedGeoCheck {
|
||||
bucketDate: string;
|
||||
continent: GeoContinent;
|
||||
avgResponseTime: number;
|
||||
totalChecks: number;
|
||||
uptimePercentage: number;
|
||||
}
|
||||
|
||||
export interface GeoChecksResult {
|
||||
monitorType: string;
|
||||
groupedGeoChecks: GroupedGeoCheck[];
|
||||
}
|
||||
|
||||
export interface GeoChecksResponse {
|
||||
geoChecks: GeoCheck[];
|
||||
geoChecksCount: number;
|
||||
}
|
||||
|
||||
export interface FlatGeoChecksResponse {
|
||||
geoChecks: FlatGeoCheck[];
|
||||
geoChecksCount: number;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
export type { GeoContinent } from "@/Types/GeoCheck";
|
||||
|
||||
export const MonitorTypes = [
|
||||
"http",
|
||||
@@ -8,10 +11,21 @@ export const MonitorTypes = [
|
||||
"docker",
|
||||
"port",
|
||||
"game",
|
||||
"grpc",
|
||||
"websocket",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
|
||||
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
return GeoCheckSupportedTypes.includes(type);
|
||||
};
|
||||
|
||||
export const MonitorStatuses = [
|
||||
"up",
|
||||
"down",
|
||||
@@ -57,7 +71,11 @@ export interface Monitor {
|
||||
tempAlertCounter: number;
|
||||
selectedDisks: string[];
|
||||
gameId?: string;
|
||||
grpcServiceName?: string;
|
||||
group: string | null;
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
recentChecks: CheckSnapshot[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -112,7 +130,10 @@ export interface MonitorDetailsResponse {
|
||||
}
|
||||
|
||||
export interface PageSpeedDetailsResponse {
|
||||
monitor: MonitorWithChecks;
|
||||
monitorData: {
|
||||
monitor: Monitor;
|
||||
groupedChecks: PageSpeedGroupedCheck[];
|
||||
};
|
||||
monitorStats: MonitorStats | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export const NotificationChannels = [
|
||||
"webhook",
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
"telegram",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||
export type MonitorDisplayType = "uptime" | "infrastructure";
|
||||
|
||||
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
|
||||
http: "pages.common.monitors.monitorTypes.optionHttp",
|
||||
ping: "pages.common.monitors.monitorTypes.optionPing",
|
||||
docker: "pages.common.monitors.monitorTypes.optionDocker",
|
||||
port: "pages.common.monitors.monitorTypes.optionPort",
|
||||
game: "pages.common.monitors.monitorTypes.optionGame",
|
||||
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
|
||||
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
|
||||
hardware: "pages.common.monitors.monitorTypes.optionHardware",
|
||||
pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed",
|
||||
};
|
||||
|
||||
export const getMonitorTypeLabel = (
|
||||
type: MonitorType,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
const key = MONITOR_TYPE_KEYS[type];
|
||||
return key ? t(key) : type;
|
||||
};
|
||||
|
||||
export interface StatusPage {
|
||||
id: string;
|
||||
userId: string;
|
||||
teamId: string;
|
||||
type: string;
|
||||
type: MonitorDisplayType[];
|
||||
companyName: string;
|
||||
url: string;
|
||||
timezone?: string;
|
||||
@@ -20,6 +41,7 @@ export interface StatusPage {
|
||||
showCharts: boolean;
|
||||
showUptimePercentage: boolean;
|
||||
showAdminLoginLink: boolean;
|
||||
showInfrastructure: boolean;
|
||||
customCSS: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Vendored
+6
@@ -4,6 +4,7 @@ declare const __APP_VERSION__: string;
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_API_V2_BASE_URL?: string;
|
||||
readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
@@ -17,3 +18,8 @@ declare module "*.svg?react" {
|
||||
>;
|
||||
export default ReactComponent;
|
||||
}
|
||||
|
||||
declare module "*.css" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export const getMonitorPath = (type: MonitorType): string => {
|
||||
port: "uptime",
|
||||
ping: "uptime",
|
||||
game: "uptime",
|
||||
grpc: "uptime",
|
||||
websocket: "uptime",
|
||||
unknown: "uptime",
|
||||
docker: "uptime",
|
||||
hardware: "infrastructure",
|
||||
|
||||
@@ -12,6 +12,8 @@ export const typographyLevels = {
|
||||
export const colors = {
|
||||
gray200: "#EFEFEF",
|
||||
gray700: "#313131",
|
||||
gray900: "#151518",
|
||||
gray850: "#1c1c21",
|
||||
blueBlueWave: "#1570EF",
|
||||
};
|
||||
|
||||
@@ -31,4 +33,8 @@ export const darkPalette = {
|
||||
secondary: {
|
||||
main: colors.gray700,
|
||||
},
|
||||
background: {
|
||||
default: colors.gray900,
|
||||
paper: colors.gray850,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export const SPACING = {
|
||||
XS: 0.5,
|
||||
SM: 1,
|
||||
MD: 1.5,
|
||||
LG: 2,
|
||||
XL: 2.5,
|
||||
XXL: 3,
|
||||
} as const;
|
||||
|
||||
export const LAYOUT = {
|
||||
XS: 4,
|
||||
SM: 6,
|
||||
MD: 8,
|
||||
LG: 10,
|
||||
XL: 12,
|
||||
XXL: 16,
|
||||
} as const;
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { LogLevel } from "@/Types/Log";
|
||||
|
||||
interface LogContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
interface ErrorLogData {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
error?: {
|
||||
message: string;
|
||||
name: string;
|
||||
stack?: string;
|
||||
};
|
||||
context?: LogContext;
|
||||
url: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
|
||||
const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
|
||||
|
||||
const shouldLog = (level: LogLevel): boolean => {
|
||||
return LOG_LEVEL_PRIORITY[level] >= configuredPriority;
|
||||
};
|
||||
|
||||
const formatError = (error?: Error) => {
|
||||
if (!error) return undefined;
|
||||
|
||||
return {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
};
|
||||
};
|
||||
|
||||
const createLogData = (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
error?: Error,
|
||||
context?: LogContext
|
||||
): ErrorLogData => {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
error: formatError(error),
|
||||
context,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
};
|
||||
};
|
||||
|
||||
const error = (message: string, error?: Error, context?: LogContext): void => {
|
||||
if (!shouldLog("error")) return;
|
||||
|
||||
const logData = createLogData("error", message, error, context);
|
||||
|
||||
if (configuredLevel === "debug") {
|
||||
console.group(`ERROR: ${message}`);
|
||||
if (error) {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
console.log("Context:", context);
|
||||
}
|
||||
console.log("URL:", logData.url);
|
||||
console.log("Timestamp:", logData.timestamp);
|
||||
console.groupEnd();
|
||||
} else {
|
||||
console.error(JSON.stringify(logData));
|
||||
}
|
||||
};
|
||||
|
||||
const warn = (message: string, context?: LogContext): void => {
|
||||
if (!shouldLog("warn")) return;
|
||||
|
||||
const logData = createLogData("warn", message, undefined, context);
|
||||
|
||||
if (configuredLevel === "debug") {
|
||||
console.group(`WARN: ${message}`);
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
console.log("Context:", context);
|
||||
}
|
||||
console.groupEnd();
|
||||
} else {
|
||||
console.warn(JSON.stringify(logData));
|
||||
}
|
||||
};
|
||||
|
||||
const debug = (message: string, data?: any): void => {
|
||||
if (!shouldLog("debug")) return;
|
||||
|
||||
console.group(`DEBUG: ${message}`);
|
||||
if (data !== undefined) {
|
||||
console.log(data);
|
||||
}
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
const info = (message: string, data?: any): void => {
|
||||
if (!shouldLog("info")) return;
|
||||
|
||||
if (configuredLevel === "debug") {
|
||||
console.log(`INFO: ${message}`, data);
|
||||
} else {
|
||||
const logData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info" as LogLevel,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
console.log(JSON.stringify(logData));
|
||||
}
|
||||
};
|
||||
|
||||
export interface ILogger {
|
||||
error(message: string, error?: Error, context?: LogContext): void;
|
||||
warn(message: string, context?: LogContext): void;
|
||||
debug(message: string, data?: any): void;
|
||||
info(message: string, data?: any): void;
|
||||
}
|
||||
|
||||
export const logger: ILogger = {
|
||||
error,
|
||||
warn,
|
||||
debug,
|
||||
info,
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { GeoContinents } from "@/Types/GeoCheck";
|
||||
|
||||
// URL schema with custom error message
|
||||
const urlSchema = z.url({ message: "Please enter a valid URL" });
|
||||
@@ -20,6 +21,12 @@ const baseSchema = z.object({
|
||||
.number({ message: "Threshold percentage is required" })
|
||||
.min(1, "Incident percentage must be at least 1")
|
||||
.max(100, "Incident percentage must be at most 100"),
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z
|
||||
.number()
|
||||
.min(300000, "Interval must be at least 5 minutes")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// HTTP monitor schema
|
||||
@@ -66,6 +73,18 @@ const gameSchema = baseSchema.extend({
|
||||
gameId: z.string().min(1, "Game type is required"),
|
||||
});
|
||||
|
||||
// gRPC monitor schema
|
||||
const grpcSchema = baseSchema.extend({
|
||||
type: z.literal("grpc"),
|
||||
url: z.string().min(1, "Host is required"),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, "Port must be at least 1")
|
||||
.max(65535, "Port must be at most 65535"),
|
||||
grpcServiceName: z.string().optional(),
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
});
|
||||
|
||||
// PageSpeed monitor schema
|
||||
const pagespeedSchema = baseSchema.extend({
|
||||
type: z.literal("pagespeed"),
|
||||
@@ -96,6 +115,13 @@ const hardwareSchema = baseSchema.extend({
|
||||
selectedDisks: z.array(z.string()),
|
||||
});
|
||||
|
||||
// WebSocket monitor schema
|
||||
const websocketSchema = baseSchema.extend({
|
||||
type: z.literal("websocket"),
|
||||
url: z.string().min(1, "WebSocket URL is required"),
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
});
|
||||
|
||||
// Discriminated union of all monitor types
|
||||
export const monitorSchema = z.discriminatedUnion("type", [
|
||||
httpSchema,
|
||||
@@ -103,8 +129,10 @@ export const monitorSchema = z.discriminatedUnion("type", [
|
||||
portSchema,
|
||||
dockerSchema,
|
||||
gameSchema,
|
||||
grpcSchema,
|
||||
pagespeedSchema,
|
||||
hardwareSchema,
|
||||
websocketSchema,
|
||||
]);
|
||||
|
||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||
@@ -116,6 +144,8 @@ export {
|
||||
portSchema,
|
||||
dockerSchema,
|
||||
gameSchema,
|
||||
grpcSchema,
|
||||
pagespeedSchema,
|
||||
hardwareSchema,
|
||||
websocketSchema,
|
||||
};
|
||||
@@ -45,6 +45,17 @@ const matrixSchema = baseSchema.extend({
|
||||
accessToken: z.string().min(1, "Access token is required"),
|
||||
});
|
||||
|
||||
const teamsSchema = baseSchema.extend({
|
||||
type: z.literal("teams"),
|
||||
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
||||
});
|
||||
|
||||
const telegramSchema = baseSchema.extend({
|
||||
type: z.literal("telegram"),
|
||||
address: z.string().min(1, "Chat ID is required"),
|
||||
accessToken: z.string().min(1, "Bot token is required"),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
emailSchema,
|
||||
slackSchema,
|
||||
@@ -52,6 +63,8 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
webhookSchema,
|
||||
pagerDutySchema,
|
||||
matrixSchema,
|
||||
teamsSchema,
|
||||
telegramSchema,
|
||||
]);
|
||||
|
||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
|
||||
import { z } from "zod";
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
@@ -6,68 +7,50 @@ export const settingsSchema = z.object({
|
||||
systemEmailRejectUnauthorized: z.boolean(),
|
||||
systemEmailConnectionHost: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailSecure: z.boolean().optional(),
|
||||
systemEmailPool: z.boolean().optional(),
|
||||
showURL: z.boolean().optional(),
|
||||
checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"),
|
||||
checkTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a value")
|
||||
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
|
||||
pagespeedApiKey: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailHost: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
|
||||
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailPort: z.number().int().min(1).max(65535).optional(),
|
||||
systemEmailAddress: z
|
||||
.string()
|
||||
.email("Please enter a valid email address")
|
||||
.or(z.literal(""))
|
||||
.transform((val) => (val === "" ? undefined : val.toLowerCase().trim()))
|
||||
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
|
||||
.optional(),
|
||||
systemEmailUser: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailPassword: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailTLSServername: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
|
||||
.optional(),
|
||||
globalThresholds: z
|
||||
.object({
|
||||
cpu: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a valid value")
|
||||
.max(100, "Maximum value is 100")
|
||||
.optional(),
|
||||
memory: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a valid value")
|
||||
.max(100, "Maximum value is 100")
|
||||
.optional(),
|
||||
disk: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a valid value")
|
||||
.max(100, "Maximum value is 100")
|
||||
.optional(),
|
||||
temperature: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a valid value")
|
||||
.max(150, "Maximum value is 150")
|
||||
.optional(),
|
||||
})
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
globalThresholds: z.object({
|
||||
cpu: z.number().int().min(1).max(100),
|
||||
memory: z.number().int().min(1).max(100),
|
||||
disk: z.number().int().min(1).max(100),
|
||||
temperature: z.number().int().min(1).max(150),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SettingsFormInput = z.input<typeof settingsSchema>;
|
||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
||||
@@ -14,12 +14,16 @@ export const statusPageSchema = z.object({
|
||||
"URL can only contain lowercase letters, numbers, and hyphens"
|
||||
),
|
||||
timezone: z.string().optional(),
|
||||
type: z
|
||||
.array(z.enum(["uptime", "infrastructure"]))
|
||||
.min(1, "At least one type is required"),
|
||||
color: z.string().min(1, "Color is required"),
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
isPublished: z.boolean(),
|
||||
showCharts: z.boolean(),
|
||||
showUptimePercentage: z.boolean(),
|
||||
showAdminLoginLink: z.boolean(),
|
||||
showInfrastructure: z.boolean(),
|
||||
customCSS: z.string().optional(),
|
||||
logo: z
|
||||
.object({
|
||||
|
||||
@@ -288,7 +288,13 @@ const logoImageValidation = joi
|
||||
.optional(); // Make entire object optional
|
||||
|
||||
const statusPageValidation = joi.object({
|
||||
type: joi.string().valid("uptime").required(),
|
||||
type: joi
|
||||
.alternatives()
|
||||
.try(
|
||||
joi.string().valid("uptime", "infrastructure"),
|
||||
joi.array().items(joi.string().valid("uptime", "infrastructure"))
|
||||
)
|
||||
.required(),
|
||||
isPublished: joi.bool(),
|
||||
companyName: joi
|
||||
.string()
|
||||
@@ -317,6 +323,7 @@ const statusPageValidation = joi.object({
|
||||
showUptimePercentage: joi.boolean(),
|
||||
showCharts: joi.boolean(),
|
||||
showAdminLoginLink: joi.boolean(),
|
||||
showInfrastructure: joi.boolean(),
|
||||
});
|
||||
|
||||
const settingsValidation = joi.object({
|
||||
|
||||
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+114
-15
@@ -44,7 +44,10 @@
|
||||
"addDemo": "Add demo monitors",
|
||||
"removeMonitors": "Remove monitors",
|
||||
"sendTestEmail": "Send test email",
|
||||
"exportToJSON": "Export to JSON"
|
||||
"exportToJSON": "Export to JSON",
|
||||
"importFromJSON": "Import from JSON",
|
||||
"clearFilters": "Clear filters",
|
||||
"removeUser": "Remove user"
|
||||
},
|
||||
"charts": {
|
||||
"labels": {
|
||||
@@ -114,7 +117,8 @@
|
||||
"type": "Type",
|
||||
"url": "Url",
|
||||
"interval": "Interval",
|
||||
"active": "Active"
|
||||
"active": "Active",
|
||||
"responseTime": "Response time"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -371,8 +375,8 @@
|
||||
"table": {
|
||||
"empty": "No down checks in this time range",
|
||||
"headers": {
|
||||
"dateTime": "Date & time",
|
||||
"statusCode": "Status code"
|
||||
"statusCode": "Status code",
|
||||
"location": "Location"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -397,12 +401,23 @@
|
||||
},
|
||||
"status": {
|
||||
"down": "down",
|
||||
"breached": "breached",
|
||||
"breached": "threshold exceeded",
|
||||
"initializing": "initializing",
|
||||
"maintenance": "maintenance",
|
||||
"paused": "paused",
|
||||
"total": "total",
|
||||
"up": "up"
|
||||
},
|
||||
"monitorTypes": {
|
||||
"optionDocker": "Docker",
|
||||
"optionGame": "Game",
|
||||
"optionHttp": "HTTP(S)",
|
||||
"optionPing": "Ping",
|
||||
"optionPort": "Port",
|
||||
"optionPagespeed": "PageSpeed",
|
||||
"optionHardware": "Infrastructure",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -480,6 +495,14 @@
|
||||
"port": {
|
||||
"label": "Port to monitor",
|
||||
"placeholder": 80
|
||||
},
|
||||
"grpcServiceName": {
|
||||
"label": "Service name",
|
||||
"placeholder": "e.g. my.service.v1 (leave empty for overall health)"
|
||||
},
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
}
|
||||
},
|
||||
"title": "General settings",
|
||||
@@ -490,6 +513,8 @@
|
||||
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
|
||||
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
|
||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
|
||||
}
|
||||
},
|
||||
@@ -520,15 +545,12 @@
|
||||
},
|
||||
"type": {
|
||||
"description": "Select the type of check to perform",
|
||||
"optionDocker": "Docker",
|
||||
"optionDockerDescription": "Use Docker to monitor if a container is running.",
|
||||
"optionGame": "Game",
|
||||
"optionGameDescription": "Monitor if a specific game server is online.",
|
||||
"optionHttp": "HTTP(S)",
|
||||
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
|
||||
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
|
||||
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
|
||||
"optionPing": "Ping",
|
||||
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
|
||||
"optionPort": "Port",
|
||||
"optionPortDescription": "Monitor if a specific port on a server is open.",
|
||||
"title": "Type"
|
||||
},
|
||||
@@ -550,6 +572,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"geoChecks": {
|
||||
"title": "Geo-Distributed Checks",
|
||||
"description": "Run checks from multiple geographic locations to monitor global availability and performance.",
|
||||
"option": {
|
||||
"enabled": {
|
||||
"label": "Enable geo-distributed checks"
|
||||
},
|
||||
"locations": {
|
||||
"label": "Locations",
|
||||
"placeholder": "Select locations",
|
||||
"options": {
|
||||
"EU": "Europe",
|
||||
"NA": "North America",
|
||||
"AS": "Asia",
|
||||
"SA": "South America",
|
||||
"AF": "Africa",
|
||||
"OC": "Oceania"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
"label": "Check interval",
|
||||
"value": {
|
||||
"fiveMinutes": "5 minutes",
|
||||
"tenMinutes": "10 minutes",
|
||||
"fifteenMinutes": "15 minutes",
|
||||
"thirtyMinutes": "30 minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"title": "Monitor IP/URL on Status Page",
|
||||
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
|
||||
@@ -579,6 +631,12 @@
|
||||
"title": "Roles",
|
||||
"description": "Assign roles to the user. Multiple roles can be selected."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"removeUser": {
|
||||
"title": "Remove user",
|
||||
"content": "Are you sure you want to remove {{name}} from your team? This action cannot be undone."
|
||||
}
|
||||
}
|
||||
},
|
||||
"incidents": {
|
||||
@@ -619,7 +677,6 @@
|
||||
},
|
||||
"filters": {
|
||||
"allMonitors": "All monitors",
|
||||
"clearFilters": "Clear filters",
|
||||
"monitor": "Monitor",
|
||||
"resolutionType": "Resolution type",
|
||||
"resolutionTypes": {
|
||||
@@ -894,6 +951,14 @@
|
||||
"description": "Select the type of notification channel to create.",
|
||||
"optionType": "Type",
|
||||
"title": "Channel type"
|
||||
},
|
||||
"telegram": {
|
||||
"title": "Telegram configuration",
|
||||
"description": "Configure your Telegram bot to send notifications.",
|
||||
"optionBotToken": "Bot token",
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "Chat ID",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -988,7 +1053,7 @@
|
||||
},
|
||||
"stats": {
|
||||
"title": "Monitor history",
|
||||
"description": "Define how long you want to retain historical data. You can also clear all existing data.",
|
||||
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
||||
"option": {
|
||||
"clear": {
|
||||
"label": "Clear all stats. This is irreversible."
|
||||
@@ -999,6 +1064,16 @@
|
||||
"description": "This cannot be undone"
|
||||
}
|
||||
},
|
||||
"retention": {
|
||||
"title": "Check retention",
|
||||
"description": "Set how long check data is retained before being automatically cleaned up.",
|
||||
"option": {
|
||||
"days": {
|
||||
"label": "Retention period (days)",
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Global Thresholds",
|
||||
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
|
||||
@@ -1086,12 +1161,18 @@
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
"title": "Export monitors",
|
||||
"description": "Export your monitors data as a JSON file for backup or transfer."
|
||||
"title": "Export monitors"
|
||||
},
|
||||
"importExportMonitors": {
|
||||
"title": "Import / Export monitors",
|
||||
"description": "Import or export your monitors data as a JSON file for backup or transfer."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"developedBy": "Developed by Bluewave Labs"
|
||||
},
|
||||
"validation": {
|
||||
"errorMessage": "Please fix the following validation errors:"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1109,7 +1190,22 @@
|
||||
},
|
||||
"monitorsList": {
|
||||
"chartTypeHeatmap": "Heatmap",
|
||||
"chartTypeHistogram": "Histogram"
|
||||
"chartTypeHistogram": "Histogram",
|
||||
"noData": "No data available",
|
||||
"infrastructure": {
|
||||
"title": "Infrastructure",
|
||||
"cpu": "CPU",
|
||||
"memory": "RAM",
|
||||
"memoryText": "Memory",
|
||||
"disk": "Disk",
|
||||
"usage": "Usage",
|
||||
"used": "Used",
|
||||
"total": "Total"
|
||||
},
|
||||
"uptime": {
|
||||
"title": "Uptime",
|
||||
"responseTime": "Response time"
|
||||
}
|
||||
},
|
||||
"statusBar": {
|
||||
"allDown": "All systems are down",
|
||||
@@ -1201,6 +1297,9 @@
|
||||
},
|
||||
"showAdminLoginLink": {
|
||||
"label": "Show admin login link"
|
||||
},
|
||||
"showInfrastructure": {
|
||||
"label": "Show infrastructure metrics"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1271
-1085
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1268
-801
File diff suppressed because it is too large.
Load diff
+1268
-801
File diff suppressed because it is too large.
Load diff
+1271
-1098
File diff suppressed because it is too large.
Load diff
+1299
-1088
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default defineConfig(({}) => {
|
||||
let version = "3.4.0";
|
||||
let version = "3.5.1";
|
||||
|
||||
return {
|
||||
base: "/",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# ==============================================
|
||||
# Checkmate Server Configuration
|
||||
# ==============================================
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Server Configuration
|
||||
# --------------------
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database
|
||||
# --------
|
||||
DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
|
||||
|
||||
# JWT Authentication
|
||||
# ------------------
|
||||
JWT_SECRET=my_secret
|
||||
TOKEN_TTL=99d
|
||||
|
||||
# Client Configuration
|
||||
# --------------------
|
||||
CLIENT_HOST=http://localhost:5173
|
||||
|
||||
# Optional Configuration
|
||||
# ----------------------
|
||||
ORIGIN=localhost
|
||||
@@ -1,6 +1,7 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import mochaPlugin from "eslint-plugin-mocha";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
/*
|
||||
Please do not forget to look at the latest eslint configurations and rules.
|
||||
@@ -10,6 +11,9 @@ ESlint v9 configuration is different than v8.
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "coverage/**", "test/**"],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
@@ -21,9 +25,22 @@ export default [
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended, // Core JS rules
|
||||
...tseslint.configs.recommended, // TypeScript recommended rules
|
||||
mochaPlugin.configs.flat.recommended, // Mocha rules
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ["jest.config.ts"],
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // Ignore unused variables that start with '_'
|
||||
"@typescript-eslint/no-explicit-any": "warn", // Warn on 'any' types
|
||||
"mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing
|
||||
},
|
||||
},
|
||||
|
||||
+174
-177
@@ -432,46 +432,6 @@
|
||||
}
|
||||
},
|
||||
"/monitors": {
|
||||
"get": {
|
||||
"tags": ["monitors"],
|
||||
"summary": "Get all monitors",
|
||||
"description": "Retrieve all monitors for the authenticated user",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Monitors retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SuccessResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Monitor"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalServerError"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": ["monitors"],
|
||||
"summary": "Create monitor",
|
||||
@@ -550,42 +510,24 @@
|
||||
"summary": "Get monitors by team",
|
||||
"description": "Get monitors filtered by team with optional parameters",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"description": "Filter by monitor status (up/down)",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"description": "Filter by monitor type",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "Page number for pagination",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rowsPerPage",
|
||||
"in": "query",
|
||||
"description": "Number of monitors per page",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 10
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -595,15 +537,6 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "field",
|
||||
"in": "query",
|
||||
"description": "Field to filter on",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["name", "url", "description"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -796,7 +729,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"patch": {
|
||||
"tags": ["monitors"],
|
||||
"summary": "Update monitor",
|
||||
"description": "Update an existing monitor",
|
||||
@@ -3608,22 +3541,19 @@
|
||||
},
|
||||
"CreateMonitorRequest": {
|
||||
"type": "object",
|
||||
"required": ["name", "description", "type", "url", "interval"],
|
||||
"required": ["name", "type", "url"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100,
|
||||
"example": "My Website Monitor"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"example": "Monitors the main website homepage"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"],
|
||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"],
|
||||
"example": "http"
|
||||
},
|
||||
"url": {
|
||||
@@ -3633,14 +3563,34 @@
|
||||
},
|
||||
"interval": {
|
||||
"type": "integer",
|
||||
"minimum": 30,
|
||||
"maximum": 3600,
|
||||
"example": 300,
|
||||
"description": "Check interval in seconds"
|
||||
},
|
||||
"statusWindowSize": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"default": 5
|
||||
},
|
||||
"statusWindowThreshold": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 60
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ignoreTlsErrors": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
"default": false
|
||||
},
|
||||
"useAdvancedMatching": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
},
|
||||
"notifications": {
|
||||
"type": "array",
|
||||
@@ -3649,51 +3599,62 @@
|
||||
},
|
||||
"description": "Array of notification IDs to associate with this monitor"
|
||||
},
|
||||
"httpOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE", "HEAD"],
|
||||
"default": "GET"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Request body for POST/PUT requests"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 30000,
|
||||
"default": 5000,
|
||||
"description": "Request timeout in milliseconds"
|
||||
}
|
||||
}
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"assertions": {
|
||||
"jsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"expectedValue": {
|
||||
"type": "string"
|
||||
},
|
||||
"matchMethod": {
|
||||
"type": "string",
|
||||
"enum": ["equal", "include", "regex", ""]
|
||||
},
|
||||
"cpuAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"memoryAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"diskAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"tempAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"gameId": {
|
||||
"type": "string"
|
||||
},
|
||||
"grpcServiceName": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"selectedDisks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["status-code", "response-time", "body-contains", "header-contains"]
|
||||
},
|
||||
"comparison": {
|
||||
"type": "string",
|
||||
"enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 50
|
||||
},
|
||||
"geoCheckEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"geoCheckLocations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
|
||||
}
|
||||
},
|
||||
"geoCheckInterval": {
|
||||
"type": "integer",
|
||||
"minimum": 300000
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3701,21 +3662,32 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"statusWindowSize": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"default": 5
|
||||
},
|
||||
"statusWindowThreshold": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 60
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 500
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "integer",
|
||||
"minimum": 30,
|
||||
"maximum": 3600
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
"type": "integer"
|
||||
},
|
||||
"notifications": {
|
||||
"type": "array",
|
||||
@@ -3723,47 +3695,72 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"httpOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE", "HEAD"]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 30000
|
||||
}
|
||||
}
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"assertions": {
|
||||
"ignoreTlsErrors": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"useAdvancedMatching": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"jsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"expectedValue": {
|
||||
"type": "string"
|
||||
},
|
||||
"matchMethod": {
|
||||
"type": "string",
|
||||
"enum": ["equal", "include", "regex", ""]
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"cpuAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"memoryAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"diskAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"tempAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
"gameId": {
|
||||
"type": "string"
|
||||
},
|
||||
"grpcServiceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"selectedDisks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["status-code", "response-time", "body-contains", "header-contains"]
|
||||
},
|
||||
"comparison": {
|
||||
"type": "string",
|
||||
"enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 50
|
||||
},
|
||||
"geoCheckEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"geoCheckLocations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
|
||||
}
|
||||
},
|
||||
"geoCheckInterval": {
|
||||
"type": "integer",
|
||||
"minimum": 300000
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+1341
-898
File diff suppressed because it is too large.
Load diff
+17
-8
@@ -8,14 +8,14 @@
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
|
||||
"dev": "nodemon --exec tsx src/index.js",
|
||||
"start": "node --watch ./dist/index.js",
|
||||
"build": "tsc && tsc-alias",
|
||||
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint --fix .",
|
||||
"format": "prettier --write .",
|
||||
"format-check": "prettier --check ."
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "npm run format"
|
||||
"**/*": "prettier --write"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -24,6 +24,8 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@pulsecron/pulse": "1.6.8",
|
||||
"axios": "^1.7.2",
|
||||
"bcryptjs": "3.0.2",
|
||||
@@ -36,27 +38,29 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "8.0.1",
|
||||
"gamedig": "^5.3.1",
|
||||
"gamedig": "5.2.0",
|
||||
"got": "14.4.7",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"isomorphic-dompurify": "^2.26.0",
|
||||
"jmespath": "^0.16.0",
|
||||
"joi": "^17.13.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"mailersend": "^2.2.0",
|
||||
"mjml": "^5.0.0-alpha.4",
|
||||
"mongoose": "^8.3.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.14",
|
||||
"nodemailer": "8.0.1",
|
||||
"pg": "8.20.0",
|
||||
"ping": "0.4.4",
|
||||
"sharp": "0.33.5",
|
||||
"ssl-checker": "2.0.10",
|
||||
"super-simple-scheduler": "1.4.5",
|
||||
"super-simple-scheduler": "1.9.1",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"winston": "^3.13.0"
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
@@ -74,8 +78,12 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/nodemailer": "7.0.1",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/ping": "0.4.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"c8": "10.1.3",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-mocha": "^10.5.0",
|
||||
@@ -89,6 +97,7 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "5.9.2"
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "^8.56.1"
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { MonitorModel } from "../dist/db/models/Monitor.js";
|
||||
import { CheckModel } from "../dist/db/models/Check.js";
|
||||
|
||||
const DEFAULT_MONITOR_ID = "000000000000000000000001";
|
||||
const DEFAULT_TEAM_ID = "0000000000000000000000aa";
|
||||
const DEFAULT_USER_ID = "0000000000000000000000bb";
|
||||
const DEFAULT_MONITOR_TYPE = "http";
|
||||
const DEFAULT_TOTAL = 1_000_000;
|
||||
const DEFAULT_BATCH_SIZE = 5_000;
|
||||
@@ -12,7 +10,7 @@ const DEFAULT_BATCH_SIZE = 5_000;
|
||||
const parseObjectId = (value, fallback) => {
|
||||
try {
|
||||
return new mongoose.Types.ObjectId(value || fallback);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.warn(`Invalid ObjectId '${value}', falling back to '${fallback}'.`);
|
||||
return new mongoose.Types.ObjectId(fallback);
|
||||
}
|
||||
|
||||
+18
-4
@@ -4,7 +4,7 @@ import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import swaggerUi, { type JsonObject } from "swagger-ui-express";
|
||||
import { handleErrors } from "@/middleware/handleErrors.js";
|
||||
import { generalApiLimiter } from "@/middleware/rateLimiter.js";
|
||||
import { sanitizeBody, sanitizeQuery } from "@/middleware/sanitization.js";
|
||||
@@ -24,7 +24,7 @@ export const createApp = ({
|
||||
controllers: InitializedControllers;
|
||||
envSettings: EnvConfig;
|
||||
frontendPath: string;
|
||||
openApiSpec: any;
|
||||
openApiSpec: JsonObject;
|
||||
}) => {
|
||||
const allowedOrigin = envSettings.clientHost;
|
||||
const app = express();
|
||||
@@ -74,8 +74,22 @@ export const createApp = ({
|
||||
},
|
||||
})
|
||||
);
|
||||
// Swagger UI
|
||||
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openApiSpec));
|
||||
// Swagger UI — dynamically set server URL from request
|
||||
app.use("/api-docs", swaggerUi.serve, (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
const protocol = req.protocol;
|
||||
const host = req.get("host");
|
||||
const dynamicSpec = {
|
||||
...openApiSpec,
|
||||
servers: [
|
||||
{
|
||||
url: `${protocol}://${host}/api/v1`,
|
||||
description: "Current Server",
|
||||
},
|
||||
...openApiSpec.servers,
|
||||
],
|
||||
};
|
||||
swaggerUi.setup(dynamicSpec)(req, res, next);
|
||||
});
|
||||
|
||||
app.use("/api/v1/health", (req, res) => {
|
||||
res.json({
|
||||
|
||||
@@ -2,6 +2,7 @@ import MonitorController from "../controllers/monitorController.js";
|
||||
import AuthController from "../controllers/authController.js";
|
||||
import SettingsController from "../controllers/settingsController.js";
|
||||
import CheckController from "../controllers/checkController.js";
|
||||
import GeoCheckController from "../controllers/geoCheckController.js";
|
||||
import InviteController from "../controllers/inviteController.js";
|
||||
import MaintenanceWindowController from "../controllers/maintenanceWindowController.js";
|
||||
import QueueController from "../controllers/queueController.js";
|
||||
@@ -17,6 +18,7 @@ export interface InitializedControllers {
|
||||
monitorController: MonitorController;
|
||||
settingsController: SettingsController;
|
||||
checkController: CheckController;
|
||||
geoCheckController: GeoCheckController;
|
||||
inviteController: InviteController;
|
||||
maintenanceWindowController: MaintenanceWindowController;
|
||||
queueController: QueueController;
|
||||
@@ -29,9 +31,10 @@ export interface InitializedControllers {
|
||||
export const initializeControllers = (services: InitializedServices): InitializedControllers => {
|
||||
return {
|
||||
authController: new AuthController(services.userService),
|
||||
monitorController: new MonitorController(services.monitorService),
|
||||
monitorController: new MonitorController(services.monitorService, services.notificationsService),
|
||||
settingsController: new SettingsController(services.settingsService, services.emailService),
|
||||
checkController: new CheckController(services.checkService),
|
||||
geoCheckController: new GeoCheckController(services.geoChecksService),
|
||||
inviteController: new InviteController(services.inviteService),
|
||||
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
|
||||
queueController: new QueueController(services.jobQueue),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { Application } from "express";
|
||||
import { createVerifyJWT } from "../middleware/verifyJWT.js";
|
||||
import { createVerifyStatusPageAccess } from "../middleware/verifyStatusPageAccess.js";
|
||||
import { authApiLimiter } from "../middleware/rateLimiter.js";
|
||||
import type { InitializedControllers } from "./controllers.js";
|
||||
import type { InitializedServices } from "./services.js";
|
||||
|
||||
import AuthRoutes from "../routes/authRoute.js";
|
||||
import InviteRoutes from "../routes/inviteRoute.js";
|
||||
import MonitorRoutes from "../routes/monitorRoute.js";
|
||||
import CheckRoutes from "../routes/checkRoute.js";
|
||||
import GeoCheckRoutes from "../routes/geoCheckRoutes.js";
|
||||
import SettingsRoutes from "../routes/settingsRoute.js";
|
||||
import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js";
|
||||
import StatusPageRoutes from "../routes/statusPageRoute.js";
|
||||
@@ -16,12 +20,13 @@ import NotificationRoutes from "../routes/notificationRoute.js";
|
||||
|
||||
import IncidentRoutes from "../routes/incidentRoute.js";
|
||||
|
||||
export const setupRoutes = (app: any, controllers: Record<string, any>, services: Record<string, any>) => {
|
||||
export const setupRoutes = (app: Application, controllers: InitializedControllers, services: InitializedServices) => {
|
||||
const verifyJWT = createVerifyJWT(services.settingsService);
|
||||
const authRoutes = new AuthRoutes(controllers.authController, verifyJWT);
|
||||
const monitorRoutes = new MonitorRoutes(controllers.monitorController);
|
||||
const settingsRoutes = new SettingsRoutes(controllers.settingsController);
|
||||
const checkRoutes = new CheckRoutes(controllers.checkController);
|
||||
const geoCheckRoutes = new GeoCheckRoutes(controllers.geoCheckController);
|
||||
const inviteRoutes = new InviteRoutes(controllers.inviteController, verifyJWT);
|
||||
const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController);
|
||||
const queueRoutes = new QueueRoutes(controllers.queueController);
|
||||
@@ -36,6 +41,7 @@ export const setupRoutes = (app: any, controllers: Record<string, any>, services
|
||||
app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter());
|
||||
app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter());
|
||||
app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter());
|
||||
app.use("/api/v1/geo-checks", verifyJWT, geoCheckRoutes.getRouter());
|
||||
app.use("/api/v1/invite", inviteRoutes.getRouter());
|
||||
app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter());
|
||||
app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter());
|
||||
|
||||
+207
-83
@@ -1,34 +1,72 @@
|
||||
import MongoDB from "../db/MongoDB.js";
|
||||
import NetworkService from "../service/infrastructure/networkService.js";
|
||||
import EmailService from "../service/infrastructure/emailService.js";
|
||||
import BufferService from "../service/infrastructure/bufferService.js";
|
||||
import { IDb } from "@/db/IDb.js";
|
||||
import {
|
||||
// Service classes
|
||||
NetworkService,
|
||||
EmailService,
|
||||
BufferService,
|
||||
GlobalPingService,
|
||||
SuperSimpleQueue,
|
||||
SuperSimpleQueueHelper,
|
||||
NotificationsService,
|
||||
StatusService,
|
||||
NotificationMessageBuilder,
|
||||
MonitorService,
|
||||
StatusPageService,
|
||||
UserService,
|
||||
CheckService,
|
||||
GeoChecksService,
|
||||
DiagnosticService,
|
||||
InviteService,
|
||||
MaintenanceWindowService,
|
||||
IncidentService,
|
||||
// Notification providers
|
||||
WebhookProvider,
|
||||
SlackProvider,
|
||||
EmailProvider,
|
||||
DiscordProvider,
|
||||
PagerDutyProvider,
|
||||
MatrixProvider,
|
||||
TeamsProvider,
|
||||
TelegramProvider,
|
||||
// Interfaces
|
||||
INetworkService,
|
||||
IEmailService,
|
||||
IBufferService,
|
||||
ISuperSimpleQueue,
|
||||
INotificationsService,
|
||||
IStatusService,
|
||||
IMonitorService,
|
||||
IUserService,
|
||||
ICheckService,
|
||||
IGeoChecksService,
|
||||
IDiagnosticService,
|
||||
IInviteService,
|
||||
IMaintenanceWindowService,
|
||||
IStatusPageService,
|
||||
IIncidentService,
|
||||
INotificationMessageBuilder,
|
||||
ISettingsService,
|
||||
SettingsService,
|
||||
EnvConfig,
|
||||
} from "@/service/index.js";
|
||||
import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
import UserService from "../service/business/userService.js";
|
||||
import CheckService from "../service/business/checkService.js";
|
||||
import DiagnosticService from "../service/business/diagnosticService.js";
|
||||
import InviteService from "../service/business/inviteService.js";
|
||||
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
|
||||
import { MonitorService } from "@/service/index.js";
|
||||
import { StatusPageService, IStatusPageService } from "../service/business/statusPageService.js";
|
||||
import IncidentService from "../service/business/incidentService.js";
|
||||
import { NotificationMessageBuilder, INotificationMessageBuilder } from "../service/infrastructure/notificationMessageBuilder.js";
|
||||
|
||||
// Network providers
|
||||
import { PingProvider } from "@/service/infrastructure/network/PingProvider.js";
|
||||
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
|
||||
import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js";
|
||||
import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js";
|
||||
import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js";
|
||||
import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js";
|
||||
import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
|
||||
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
|
||||
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
|
||||
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
|
||||
|
||||
// Third-party
|
||||
import axios from "axios";
|
||||
import got from "got";
|
||||
import ping from "ping";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import Docker from "dockerode";
|
||||
import net from "net";
|
||||
import fs from "fs";
|
||||
@@ -41,22 +79,41 @@ import jwt from "jsonwebtoken";
|
||||
import crypto from "crypto";
|
||||
import { games, GameDig } from "gamedig";
|
||||
import jmespath from "jmespath";
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
import WebSocket from "ws";
|
||||
|
||||
// repositories
|
||||
// Repositories
|
||||
import {
|
||||
MongoMonitorsRepository,
|
||||
MongoChecksRepository,
|
||||
MongoGeoChecksRepository,
|
||||
MongoMonitorStatsRepository,
|
||||
MongoStatusPagesRepository,
|
||||
MongoUsersRepository,
|
||||
MongoInvitesRepository,
|
||||
MongoRecoveryTokensRepository,
|
||||
MongoNotificationsRepository,
|
||||
MongoIncidentRepository,
|
||||
MongoIncidentsRepository,
|
||||
MongoTeamsRepository,
|
||||
MongoMaintenanceWindowsRepository,
|
||||
MongoSettingsRepository,
|
||||
TimescaleMonitorsRepository,
|
||||
TimescaleChecksRepository,
|
||||
TimescaleGeoChecksRepository,
|
||||
TimescaleMonitorStatsRepository,
|
||||
TimescaleStatusPagesRepository,
|
||||
TimescaleUsersRepository,
|
||||
TimescaleInvitesRepository,
|
||||
TimescaleRecoveryTokensRepository,
|
||||
TimescaleNotificationsRepository,
|
||||
TimescaleIncidentsRepository,
|
||||
TimescaleTeamsRepository,
|
||||
TimescaleMaintenanceWindowsRepository,
|
||||
TimescaleSettingsRepository,
|
||||
IMonitorsRepository,
|
||||
IChecksRepository,
|
||||
IGeoChecksRepository,
|
||||
IMonitorStatsRepository,
|
||||
IStatusPagesRepository,
|
||||
IUsersRepository,
|
||||
@@ -69,24 +126,26 @@ import {
|
||||
IMaintenanceWindowsRepository,
|
||||
} from "@/repositories/index.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { EnvConfig } from "@/service/system/settingsService.js";
|
||||
import TimescaleDB from "@/db/TimescaleDB.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
|
||||
export type InitializedServices = {
|
||||
settingsService: any;
|
||||
db: any;
|
||||
networkService: any;
|
||||
emailService: any;
|
||||
bufferService: any;
|
||||
statusService: any;
|
||||
jobQueue: any;
|
||||
userService: any;
|
||||
checkService: any;
|
||||
diagnosticService: any;
|
||||
inviteService: any;
|
||||
maintenanceWindowService: any;
|
||||
monitorService: any;
|
||||
incidentService: any;
|
||||
logger: any;
|
||||
settingsService: ISettingsService;
|
||||
db: IDb;
|
||||
networkService: INetworkService;
|
||||
emailService: IEmailService;
|
||||
bufferService: IBufferService;
|
||||
statusService: IStatusService;
|
||||
jobQueue: ISuperSimpleQueue;
|
||||
userService: IUserService;
|
||||
checkService: ICheckService;
|
||||
geoChecksService: IGeoChecksService;
|
||||
diagnosticService: IDiagnosticService;
|
||||
inviteService: IInviteService;
|
||||
maintenanceWindowService: IMaintenanceWindowService;
|
||||
monitorService: IMonitorService;
|
||||
incidentService: IIncidentService;
|
||||
logger: ILogger;
|
||||
notificationsService: INotificationsService;
|
||||
statusPageService: IStatusPageService;
|
||||
notificationMessageBuilder: INotificationMessageBuilder;
|
||||
@@ -94,6 +153,7 @@ export type InitializedServices = {
|
||||
// Repositories
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
checksRepository: IChecksRepository;
|
||||
geoChecksRepository: IGeoChecksRepository;
|
||||
monitorStatsRepository: IMonitorStatsRepository;
|
||||
statusPagesRepository: IStatusPagesRepository;
|
||||
usersRepository: IUsersRepository;
|
||||
@@ -110,73 +170,134 @@ export const initializeServices = async ({
|
||||
logger,
|
||||
envSettings,
|
||||
settingsService,
|
||||
settingsRepository,
|
||||
}: {
|
||||
logger: ILogger;
|
||||
envSettings: EnvConfig;
|
||||
settingsService: any;
|
||||
settingsRepository: ISettingsRepository;
|
||||
settingsService: ISettingsService;
|
||||
}): Promise<InitializedServices> => {
|
||||
// Create DB
|
||||
|
||||
const db = new MongoDB(logger, envSettings);
|
||||
const dbType = envSettings.dbType;
|
||||
|
||||
let db: IDb | null = null;
|
||||
|
||||
if (dbType === "mongodb") {
|
||||
db = new MongoDB(logger, envSettings);
|
||||
} else if (dbType === "timescaledb") {
|
||||
db = new TimescaleDB(logger, envSettings);
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
throw new AppError({ message: "Unsupported database type", status: 500 });
|
||||
}
|
||||
|
||||
await db.connect();
|
||||
|
||||
// Repositories
|
||||
const monitorsRepository = new MongoMonitorsRepository();
|
||||
const checksRepository = new MongoChecksRepository(logger);
|
||||
const monitorStatsRepository = new MongoMonitorStatsRepository();
|
||||
const statusPagesRepository = new MongoStatusPagesRepository();
|
||||
const usersRepository = new MongoUsersRepository();
|
||||
const invitesRepository = new MongoInvitesRepository();
|
||||
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
|
||||
const notificationsRepository = new MongoNotificationsRepository();
|
||||
const incidentsRepository = new MongoIncidentRepository();
|
||||
const teamsRepository = new MongoTeamsRepository();
|
||||
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
||||
let monitorsRepository: IMonitorsRepository;
|
||||
let checksRepository: IChecksRepository;
|
||||
let geoChecksRepository: IGeoChecksRepository;
|
||||
let monitorStatsRepository: IMonitorStatsRepository;
|
||||
let statusPagesRepository: IStatusPagesRepository;
|
||||
let usersRepository: IUsersRepository;
|
||||
let invitesRepository: IInvitesRepository;
|
||||
let recoveryTokensRepository: IRecoveryTokensRepository;
|
||||
let settingsRepository: ISettingsRepository;
|
||||
let notificationsRepository: INotificationsRepository;
|
||||
let incidentsRepository: IIncidentsRepository;
|
||||
let teamsRepository: ITeamsRepository;
|
||||
let maintenanceWindowsRepository: IMaintenanceWindowsRepository;
|
||||
|
||||
const networkService = new NetworkService({
|
||||
axios,
|
||||
got,
|
||||
https,
|
||||
jmespath,
|
||||
GameDig,
|
||||
ping,
|
||||
logger,
|
||||
http,
|
||||
Docker,
|
||||
net,
|
||||
settingsService,
|
||||
});
|
||||
// Repositories
|
||||
|
||||
if (dbType === "mongodb") {
|
||||
monitorsRepository = new MongoMonitorsRepository();
|
||||
checksRepository = new MongoChecksRepository(logger);
|
||||
geoChecksRepository = new MongoGeoChecksRepository(logger);
|
||||
monitorStatsRepository = new MongoMonitorStatsRepository();
|
||||
statusPagesRepository = new MongoStatusPagesRepository();
|
||||
usersRepository = new MongoUsersRepository();
|
||||
invitesRepository = new MongoInvitesRepository();
|
||||
recoveryTokensRepository = new MongoRecoveryTokensRepository();
|
||||
settingsRepository = new MongoSettingsRepository();
|
||||
notificationsRepository = new MongoNotificationsRepository();
|
||||
incidentsRepository = new MongoIncidentsRepository();
|
||||
teamsRepository = new MongoTeamsRepository();
|
||||
maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
||||
} else {
|
||||
const pool = db.getPool();
|
||||
if (!pool) {
|
||||
throw new Error("Failed to get database pool");
|
||||
}
|
||||
monitorsRepository = new TimescaleMonitorsRepository(pool);
|
||||
checksRepository = new TimescaleChecksRepository(pool);
|
||||
geoChecksRepository = new TimescaleGeoChecksRepository(pool);
|
||||
monitorStatsRepository = new TimescaleMonitorStatsRepository(pool);
|
||||
statusPagesRepository = new TimescaleStatusPagesRepository(pool);
|
||||
usersRepository = new TimescaleUsersRepository(pool);
|
||||
invitesRepository = new TimescaleInvitesRepository(pool);
|
||||
recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool);
|
||||
settingsRepository = new TimescaleSettingsRepository(pool);
|
||||
notificationsRepository = new TimescaleNotificationsRepository(pool);
|
||||
incidentsRepository = new TimescaleIncidentsRepository(pool);
|
||||
teamsRepository = new TimescaleTeamsRepository(pool);
|
||||
maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool);
|
||||
}
|
||||
|
||||
// Inject settings repository into settings service (now that DB is connected)
|
||||
(settingsService as SettingsService).setRepository(settingsRepository);
|
||||
|
||||
// Network providers
|
||||
const pingProvider = new PingProvider(ping);
|
||||
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
|
||||
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
|
||||
const hardwareProvider = new HardwareProvider(httpProvider);
|
||||
const dockerProvider = new DockerProvider(logger, Docker);
|
||||
const portProvider = new PortProvider(net);
|
||||
const gameProvider = new GameProvider(logger, GameDig);
|
||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||
|
||||
const networkService = new NetworkService(axios, logger, [
|
||||
pingProvider,
|
||||
httpProvider,
|
||||
pageSpeedProvider,
|
||||
hardwareProvider,
|
||||
dockerProvider,
|
||||
portProvider,
|
||||
gameProvider,
|
||||
grpcProvider,
|
||||
webSocketProvider,
|
||||
]);
|
||||
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
|
||||
|
||||
const notificationMessageBuilder = new NotificationMessageBuilder();
|
||||
|
||||
const incidentService = new IncidentService({
|
||||
const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
|
||||
|
||||
const checkService = new CheckService(monitorsRepository, logger, checksRepository);
|
||||
|
||||
const globalPingService = new GlobalPingService(logger);
|
||||
|
||||
const geoChecksService = new GeoChecksService({
|
||||
logger,
|
||||
incidentsRepository,
|
||||
geoChecksRepository,
|
||||
globalPingService,
|
||||
monitorsRepository,
|
||||
usersRepository,
|
||||
notificationMessageBuilder,
|
||||
});
|
||||
|
||||
const checkService = new CheckService({
|
||||
monitorsRepository,
|
||||
logger,
|
||||
checksRepository,
|
||||
});
|
||||
|
||||
const bufferService = new BufferService({ logger, checkService, settingsService });
|
||||
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
|
||||
|
||||
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
|
||||
|
||||
// Notification providers
|
||||
const webhookProvider = new WebhookProvider(logger);
|
||||
const slackProvider = new SlackProvider(logger);
|
||||
const emailProvider = new EmailProvider(emailService, logger);
|
||||
const discordProvider = new DiscordProvider(logger);
|
||||
const pagerDutyProvider = new PagerDutyProvider(logger);
|
||||
const matrixProvider = new MatrixProvider(logger);
|
||||
const teamsProvider = new TeamsProvider(logger);
|
||||
const telegramProvider = new TelegramProvider(logger);
|
||||
|
||||
const notificationsService = new NotificationsService(
|
||||
notificationsRepository,
|
||||
@@ -187,18 +308,21 @@ export const initializeServices = async ({
|
||||
discordProvider,
|
||||
pagerDutyProvider,
|
||||
matrixProvider,
|
||||
teamsProvider,
|
||||
telegramProvider,
|
||||
settingsService,
|
||||
logger,
|
||||
notificationMessageBuilder
|
||||
);
|
||||
|
||||
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
|
||||
const superSimpleQueueHelper = new SuperSimpleQueueHelper(
|
||||
logger,
|
||||
networkService,
|
||||
statusService,
|
||||
notificationsService,
|
||||
checkService,
|
||||
buffer: bufferService,
|
||||
settingsService,
|
||||
bufferService,
|
||||
incidentService,
|
||||
maintenanceWindowsRepository,
|
||||
monitorsRepository,
|
||||
@@ -206,13 +330,11 @@ export const initializeServices = async ({
|
||||
monitorStatsRepository,
|
||||
checksRepository,
|
||||
incidentsRepository,
|
||||
});
|
||||
geoChecksService,
|
||||
geoChecksRepository
|
||||
);
|
||||
|
||||
const superSimpleQueue = await SuperSimpleQueue.create({
|
||||
logger,
|
||||
helper: superSimpleQueueHelper,
|
||||
monitorsRepository,
|
||||
});
|
||||
const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository);
|
||||
|
||||
// Business services
|
||||
const userService = new UserService({
|
||||
@@ -247,6 +369,7 @@ export const initializeServices = async ({
|
||||
games,
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
@@ -255,7 +378,6 @@ export const initializeServices = async ({
|
||||
const statusPageService = new StatusPageService(statusPagesRepository);
|
||||
|
||||
const services = {
|
||||
//v1
|
||||
settingsService,
|
||||
db,
|
||||
networkService,
|
||||
@@ -265,6 +387,7 @@ export const initializeServices = async ({
|
||||
jobQueue: superSimpleQueue,
|
||||
userService,
|
||||
checkService,
|
||||
geoChecksService,
|
||||
diagnosticService,
|
||||
inviteService,
|
||||
maintenanceWindowService,
|
||||
@@ -278,6 +401,7 @@ export const initializeServices = async ({
|
||||
// Repositories
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
usersRepository,
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { requireTeamId, requireUserRoles } from "./controllerUtils.js";
|
||||
import type { UserRole } from "@/types/user.js";
|
||||
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/controllers/controllerUtils.js";
|
||||
|
||||
import {
|
||||
registrationBodyValidation,
|
||||
loginValidation,
|
||||
editUserBodyValidation,
|
||||
createUserBodyValidation,
|
||||
recoveryValidation,
|
||||
recoveryTokenBodyValidation,
|
||||
newPasswordValidation,
|
||||
} from "@/validation/authValidation.js";
|
||||
|
||||
import {
|
||||
editUserBodyValidation,
|
||||
createUserBodyValidation,
|
||||
getUserByIdParamValidation,
|
||||
editUserByIdParamValidation,
|
||||
editUserByIdBodyValidation,
|
||||
editSuperadminUserByIdBodyValidation,
|
||||
editUserPasswordByIdBodyValidation,
|
||||
} from "@/validation/joi.js";
|
||||
} from "@/validation/userValidation.js";
|
||||
import { IUserService } from "@/service/index.js";
|
||||
|
||||
const SERVICE_NAME = "authController";
|
||||
|
||||
class AuthController {
|
||||
export interface IAuthController {
|
||||
registerUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
createUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
loginUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
editUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
checkSuperadminExists(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
requestRecovery(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
validateRecovery(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
resetPassword(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
deleteUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
deleteUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
getAllUsers(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
getUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
editUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
editUserPasswordById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
}
|
||||
|
||||
class AuthController implements IAuthController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private userService: any;
|
||||
private userService: IUserService;
|
||||
|
||||
constructor(userService: any) {
|
||||
constructor(userService: IUserService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@@ -38,11 +58,12 @@ class AuthController {
|
||||
const newUser = req.body.user;
|
||||
const newUserToken = req.body.token;
|
||||
if (newUser?.email) {
|
||||
newUser.email = newUser.email.toLowerCase();
|
||||
const newUserEmail = requireUserEmail(newUser.email);
|
||||
newUser.email = newUserEmail.toLowerCase();
|
||||
}
|
||||
await registrationBodyValidation.validateAsync(newUser);
|
||||
const validatedBody = registrationBodyValidation.parse(newUser);
|
||||
|
||||
const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file);
|
||||
const { user, token } = await this.userService.registerUser(validatedBody, newUserToken, req?.file ?? null);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
msg: "User registered successfully",
|
||||
@@ -59,11 +80,11 @@ class AuthController {
|
||||
if (userData?.email) {
|
||||
userData.email = userData.email.toLowerCase();
|
||||
}
|
||||
await createUserBodyValidation.validateAsync(userData);
|
||||
const validatedBody = createUserBodyValidation.parse(userData);
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const actorRoles = requireUserRoles(req.user?.role) as UserRole[];
|
||||
const newUser = await this.userService.createUser(userData, teamId, actorRoles, req.file);
|
||||
const actorRoles = requireUserRoles(req.user?.role);
|
||||
const newUser = await this.userService.createUser(validatedBody, teamId, actorRoles, req?.file ?? null);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
msg: "User created successfully",
|
||||
@@ -79,7 +100,7 @@ class AuthController {
|
||||
if (req.body?.email) {
|
||||
req.body.email = req.body.email?.toLowerCase();
|
||||
}
|
||||
await loginValidation.validateAsync(req.body);
|
||||
loginValidation.parse(req.body);
|
||||
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -97,8 +118,10 @@ class AuthController {
|
||||
|
||||
editUser = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await editUserBodyValidation.validateAsync(req.body);
|
||||
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
|
||||
const validatedBody = editUserBodyValidation.parse(req.body);
|
||||
const userId = requireUserId(req.user?.id);
|
||||
const userEmail = requireUserEmail(req.user?.email);
|
||||
const updatedUser = await this.userService.editUser(validatedBody, req?.file ?? null, userId, userEmail);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
@@ -125,7 +148,7 @@ class AuthController {
|
||||
|
||||
requestRecovery = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await recoveryValidation.validateAsync(req.body);
|
||||
recoveryValidation.parse(req.body);
|
||||
const email = req?.body?.email;
|
||||
const msgId = await this.userService.requestRecovery(email);
|
||||
return res.status(200).json({
|
||||
@@ -140,7 +163,7 @@ class AuthController {
|
||||
|
||||
validateRecovery = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await recoveryTokenBodyValidation.validateAsync(req.body);
|
||||
recoveryTokenBodyValidation.parse(req.body);
|
||||
await this.userService.validateRecovery(req.body.recoveryToken);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -153,7 +176,7 @@ class AuthController {
|
||||
|
||||
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await newPasswordValidation.validateAsync(req.body);
|
||||
newPasswordValidation.parse(req.body);
|
||||
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -167,7 +190,15 @@ class AuthController {
|
||||
|
||||
deleteUser = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await this.userService.deleteUser(req.user);
|
||||
const userId = requireUserId(req.user?.id);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const roles = requireUserRoles(req.user?.role);
|
||||
|
||||
await this.userService.deleteUser({
|
||||
userId,
|
||||
teamId,
|
||||
roles,
|
||||
});
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "User deleted successfully",
|
||||
@@ -177,6 +208,23 @@ class AuthController {
|
||||
}
|
||||
};
|
||||
|
||||
deleteUserById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validatedParams = getUserByIdParamValidation.parse(req.params);
|
||||
const targetUserId = validatedParams.userId;
|
||||
const actorId = requireUserId(req.user?.id);
|
||||
const actorTeamId = requireTeamId(req.user?.teamId);
|
||||
const actorRoles = requireUserRoles(req.user?.role);
|
||||
await this.userService.deleteUserById({ actorId, actorTeamId, actorRoles, targetUserId });
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "User removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getAllUsers = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const allUsers = await this.userService.getAllUsers();
|
||||
@@ -192,19 +240,9 @@ class AuthController {
|
||||
|
||||
getUserById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await getUserByIdParamValidation.validateAsync(req.params);
|
||||
const userId = req?.params?.userId;
|
||||
const roles = req?.user?.role;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No user ID in request");
|
||||
}
|
||||
|
||||
if (!roles || roles.length === 0) {
|
||||
throw new Error("No roles in request");
|
||||
}
|
||||
|
||||
const user = await this.userService.getUserById(roles, userId);
|
||||
const validatedParams = getUserByIdParamValidation.parse(req.params);
|
||||
const actorRoles = requireUserRoles(req.user?.role);
|
||||
const user = await this.userService.getUserById(actorRoles, validatedParams.userId);
|
||||
|
||||
return res.status(200).json({ success: true, msg: "ok", data: user });
|
||||
} catch (error) {
|
||||
@@ -214,24 +252,19 @@ class AuthController {
|
||||
|
||||
editUserById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const roles = req?.user?.role;
|
||||
const actorRoles = requireUserRoles(req.user?.role);
|
||||
const actorId = requireUserId(req.user?.id);
|
||||
|
||||
if (!roles || !roles.includes("superadmin")) {
|
||||
if (!actorRoles.includes("superadmin")) {
|
||||
throw new AppError({ message: "Unauthorized", status: 403 });
|
||||
}
|
||||
|
||||
const userId = req.params.userId as string;
|
||||
const user = { ...req.body };
|
||||
|
||||
await editUserByIdParamValidation.validateAsync(req.params);
|
||||
const validatedParams = editUserByIdParamValidation.parse(req.params);
|
||||
// If this is superadmin self edit, allow "superadmin" role
|
||||
if (userId === req.user?.id) {
|
||||
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
|
||||
} else {
|
||||
await editUserByIdBodyValidation.validateAsync(req.body);
|
||||
}
|
||||
const validatedBody =
|
||||
validatedParams.userId === actorId ? editSuperadminUserByIdBodyValidation.parse(req.body) : editUserByIdBodyValidation.parse(req.body);
|
||||
|
||||
await this.userService.editUserById(userId, user);
|
||||
await this.userService.editUserById(validatedParams.userId, validatedBody);
|
||||
return res.status(200).json({ success: true, msg: "ok" });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -240,16 +273,14 @@ class AuthController {
|
||||
|
||||
editUserPasswordById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const roles = req?.user?.role;
|
||||
if (!roles || !roles.includes("superadmin")) {
|
||||
const actorRoles = requireUserRoles(req.user?.role);
|
||||
if (!actorRoles.includes("superadmin")) {
|
||||
throw new AppError({ message: "Unauthorized", status: 403 });
|
||||
}
|
||||
|
||||
const userId = req.params.userId as string;
|
||||
await editUserByIdParamValidation.validateAsync(req.params);
|
||||
await editUserPasswordByIdBodyValidation.validateAsync(req.body);
|
||||
const updatedPassword = req.body.password;
|
||||
await this.userService.setPasswordByUserId(userId, updatedPassword);
|
||||
const validatedParams = editUserByIdParamValidation.parse(req.params);
|
||||
const validatedBody = editUserPasswordByIdBodyValidation.parse(req.body);
|
||||
await this.userService.setPasswordByUserId(validatedParams.userId, validatedBody.password);
|
||||
return res.status(200).json({ success: true, msg: "Password reset successfully" });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -4,21 +4,28 @@ import {
|
||||
getChecksParamValidation,
|
||||
getChecksQueryValidation,
|
||||
getTeamChecksQueryValidation,
|
||||
getChecksSummaryByTeamIdQueryValidation,
|
||||
deleteChecksParamValidation,
|
||||
deleteChecksByTeamIdParamValidation,
|
||||
updateChecksTTLBodyValidation,
|
||||
ackCheckBodyValidation,
|
||||
ackAllChecksParamValidation,
|
||||
ackAllChecksBodyValidation,
|
||||
} from "@/validation/joi.js";
|
||||
} from "@/validation/checkValidation.js";
|
||||
import { ICheckService } from "@/service/index.js";
|
||||
import { requireTeamId } from "@/controllers/controllerUtils.js";
|
||||
|
||||
const SERVICE_NAME = "checkController";
|
||||
|
||||
class CheckController {
|
||||
export interface ICheckController {
|
||||
serviceName: string;
|
||||
getChecksByMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getChecksByTeam: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getChecksSummaryByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
deleteChecks: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
deleteChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
}
|
||||
|
||||
class CheckController implements ICheckController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private checkService: any;
|
||||
constructor(checkService: any) {
|
||||
private checkService: ICheckService;
|
||||
constructor(checkService: ICheckService) {
|
||||
this.checkService = checkService;
|
||||
}
|
||||
|
||||
@@ -28,13 +35,20 @@ class CheckController {
|
||||
|
||||
getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await getChecksParamValidation.validateAsync(req.params);
|
||||
await getChecksQueryValidation.validateAsync(req.query);
|
||||
const validatedParams = getChecksParamValidation.parse(req.params);
|
||||
const validatedQuery = getChecksQueryValidation.parse(req.query);
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const result = await this.checkService.getChecksByMonitor({
|
||||
monitorId: req?.params?.monitorId,
|
||||
query: req?.query,
|
||||
teamId: req?.user?.teamId,
|
||||
monitorId: validatedParams.monitorId,
|
||||
teamId,
|
||||
sortOrder: validatedQuery.sortOrder,
|
||||
dateRange: validatedQuery.dateRange,
|
||||
filter: validatedQuery.filter,
|
||||
page: validatedQuery.page,
|
||||
rowsPerPage: validatedQuery.rowsPerPage,
|
||||
status: validatedQuery.status,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -49,10 +63,16 @@ class CheckController {
|
||||
|
||||
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await getTeamChecksQueryValidation.validateAsync(req.query);
|
||||
const validatedQuery = getTeamChecksQueryValidation.parse(req.query);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const checkData = await this.checkService.getChecksByTeam({
|
||||
teamId: req?.user?.teamId,
|
||||
query: req?.query,
|
||||
teamId,
|
||||
sortOrder: validatedQuery.sortOrder,
|
||||
dateRange: validatedQuery.dateRange,
|
||||
page: validatedQuery.page,
|
||||
rowsPerPage: validatedQuery.rowsPerPage,
|
||||
filter: validatedQuery.filter,
|
||||
});
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -66,8 +86,11 @@ class CheckController {
|
||||
|
||||
getChecksSummaryByTeamId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const dateRange = req.query?.dateRange || "hour";
|
||||
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId, dateRange });
|
||||
const validatedQuery = getChecksSummaryByTeamIdQueryValidation.parse(req.query);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const dateRange = validatedQuery.dateRange ?? "hour";
|
||||
|
||||
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId, dateRange });
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Checks summary retrieved successfully",
|
||||
@@ -80,11 +103,12 @@ class CheckController {
|
||||
|
||||
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await deleteChecksParamValidation.validateAsync(req.params);
|
||||
const validatedParams = deleteChecksParamValidation.parse(req.params);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const deletedCount = await this.checkService.deleteChecks({
|
||||
monitorId: req.params.monitorId as string,
|
||||
teamId: req?.user?.teamId,
|
||||
monitorId: validatedParams.monitorId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -99,9 +123,9 @@ class CheckController {
|
||||
|
||||
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId });
|
||||
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -112,24 +136,6 @@ class CheckController {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await updateChecksTTLBodyValidation.validateAsync(req.body);
|
||||
|
||||
await this.checkService.updateChecksTTL({
|
||||
teamId: req?.user?.teamId,
|
||||
ttl: req?.body?.ttl,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Checks TTL updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default CheckController;
|
||||
@@ -1,24 +1,25 @@
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { type MonitorType, MonitorTypes } from "@/types/index.js";
|
||||
import { Monitor, type MonitorType, MonitorTypes, UserRole } from "@/types/index.js";
|
||||
import sslChecker, { SSLDetails } from "ssl-checker";
|
||||
type SSLCheckerType = typeof sslChecker;
|
||||
|
||||
const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<any> => {
|
||||
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
|
||||
const monitorUrl = new URL(monitor.url);
|
||||
const hostname = monitorUrl.hostname;
|
||||
const cert = await sslChecker(hostname);
|
||||
// Throw an error if no cert or if cert.validTo is not present
|
||||
const cert = await checker(hostname);
|
||||
if (cert?.validTo === null || cert?.validTo === undefined) {
|
||||
throw new Error("Certificate not found");
|
||||
}
|
||||
return cert;
|
||||
};
|
||||
const requireString = (value: unknown, fieldName: string): string => {
|
||||
export const requireString = (value: unknown, fieldName: string): string => {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
throw new AppError({ message: `${fieldName} is required`, status: 400 });
|
||||
};
|
||||
|
||||
const optionalString = (value: unknown, fieldName: string): string | undefined => {
|
||||
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -28,7 +29,7 @@ const optionalString = (value: unknown, fieldName: string): string | undefined =
|
||||
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
|
||||
};
|
||||
|
||||
const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
|
||||
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -44,7 +45,7 @@ const optionalNumber = (value: unknown, fieldName: string): number | undefined =
|
||||
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
|
||||
};
|
||||
|
||||
const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
|
||||
export const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -62,7 +63,7 @@ const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined
|
||||
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
|
||||
};
|
||||
|
||||
const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
|
||||
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
|
||||
const parseSingle = (input: unknown): MonitorType => {
|
||||
if (typeof input !== "string") {
|
||||
throw new AppError({ message: "Monitor type must be a string", status: 400 });
|
||||
@@ -82,7 +83,7 @@ const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | u
|
||||
return parseSingle(value);
|
||||
};
|
||||
|
||||
const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
|
||||
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -92,42 +93,36 @@ const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
|
||||
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
|
||||
};
|
||||
|
||||
const requireTeamId = (teamId?: string): string => {
|
||||
export const requireTeamId = (teamId?: string): string => {
|
||||
if (!teamId) {
|
||||
throw new AppError({ message: "Team ID is required", status: 400 });
|
||||
}
|
||||
return teamId;
|
||||
};
|
||||
|
||||
const requireUserId = (userId?: string): string => {
|
||||
export const requireUserId = (userId?: string): string => {
|
||||
if (!userId) {
|
||||
throw new AppError({ message: "User ID is required", status: 400 });
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
const requireUserEmail = (userEmail?: string): string => {
|
||||
export const requireUserEmail = (userEmail?: string): string => {
|
||||
if (!userEmail) {
|
||||
throw new AppError({ message: "User email is required", status: 400 });
|
||||
}
|
||||
return userEmail;
|
||||
};
|
||||
|
||||
export const requireUserRoles = (userRoles?: string[]): string[] => {
|
||||
export const requireFirstName = (firstName?: string): string => {
|
||||
if (!firstName) {
|
||||
throw new AppError({ message: "First name is required", status: 400 });
|
||||
}
|
||||
return firstName;
|
||||
};
|
||||
|
||||
export const requireUserRoles = (userRoles?: UserRole[]): UserRole[] => {
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
throw new AppError({ message: "User roles are required", status: 400 });
|
||||
}
|
||||
return userRoles;
|
||||
};
|
||||
|
||||
export {
|
||||
fetchMonitorCertificate,
|
||||
requireString,
|
||||
optionalString,
|
||||
optionalNumber,
|
||||
optionalBoolean,
|
||||
parseMonitorTypeFilter,
|
||||
parseSortOrder,
|
||||
requireTeamId,
|
||||
requireUserId,
|
||||
requireUserEmail,
|
||||
};
|
||||
@@ -1,12 +1,18 @@
|
||||
import { IDiagnosticService } from "@/service/index.js";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
const SERVICE_NAME = "diagnosticController";
|
||||
class DiagnosticController {
|
||||
|
||||
export interface IDiagnosticController {
|
||||
getSystemStats: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
}
|
||||
|
||||
class DiagnosticController implements IDiagnosticController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private diagnosticService: any;
|
||||
private diagnosticService: IDiagnosticService;
|
||||
|
||||
constructor(diagnosticService: any) {
|
||||
constructor(diagnosticService: IDiagnosticService) {
|
||||
this.diagnosticService = diagnosticService;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
|
||||
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
|
||||
import { requireTeamId } from "./controllerUtils.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
|
||||
const SERVICE_NAME = "geoCheckController";
|
||||
|
||||
export interface IGeoCheckController {
|
||||
getGeoChecksByMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
}
|
||||
class GeoCheckController implements IGeoCheckController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private geoChecksService: IGeoChecksService;
|
||||
constructor(geoChecksService: IGeoChecksService) {
|
||||
this.geoChecksService = geoChecksService;
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return GeoCheckController.SERVICE_NAME;
|
||||
}
|
||||
|
||||
getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validatedParams = getChecksParamValidation.parse(req.params);
|
||||
const validatedQuery = getChecksQueryValidation.parse(req.query);
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const result = await this.geoChecksService.getGeoChecksByMonitor({
|
||||
monitorId: validatedParams.monitorId,
|
||||
teamId: teamId,
|
||||
sortOrder: validatedQuery.sortOrder,
|
||||
dateRange: validatedQuery.dateRange,
|
||||
page: validatedQuery.page,
|
||||
rowsPerPage: validatedQuery.rowsPerPage,
|
||||
continent: validatedQuery.continent,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Geo checks retrieved successfully",
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default GeoCheckController;
|
||||
@@ -1,35 +1,37 @@
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js";
|
||||
import { IIncidentService } from "@/service/index.js";
|
||||
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
|
||||
|
||||
const SERVICE_NAME = "incidentController";
|
||||
const SERVICE_NAME = "IncidentController";
|
||||
|
||||
class IncidentController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private incidentService: any;
|
||||
constructor(incidentService: any) {
|
||||
export interface IIncidentController {
|
||||
getIncidentsByTeam: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getIncidentSummary: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getIncidentById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
resolveIncidentManually: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
}
|
||||
class IncidentController implements IIncidentController {
|
||||
private incidentService: IIncidentService;
|
||||
constructor(incidentService: IIncidentService) {
|
||||
this.incidentService = incidentService;
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return IncidentController.SERVICE_NAME;
|
||||
}
|
||||
|
||||
getIncidentsByTeam = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType } = req.query || {};
|
||||
const validatedQuery = getIncidentsByTeamQueryValidation.parse(req.query);
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const result = await this.incidentService.getIncidentsByTeam(
|
||||
teamId,
|
||||
sortOrder,
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
status,
|
||||
monitorId,
|
||||
resolutionType
|
||||
validatedQuery.sortOrder,
|
||||
validatedQuery.dateRange,
|
||||
validatedQuery.page,
|
||||
validatedQuery.rowsPerPage,
|
||||
validatedQuery.status,
|
||||
validatedQuery.monitorId,
|
||||
validatedQuery.resolutionType
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -45,10 +47,9 @@ class IncidentController {
|
||||
getIncidentSummary = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const validatedQuery = getIncidentSummaryQueryValidation.parse(req.query);
|
||||
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
|
||||
|
||||
const summary = await this.incidentService.getIncidentSummary(teamId, limit);
|
||||
const summary = await this.incidentService.getIncidentSummary(teamId, validatedQuery.limit);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Incident summary retrieved successfully",
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/joi.js";
|
||||
import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
|
||||
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
|
||||
import { requireFirstName, requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
|
||||
import { IInviteService } from "@/service/index.js";
|
||||
const SERVICE_NAME = "inviteController";
|
||||
|
||||
class InviteController {
|
||||
export interface IInviteController {
|
||||
getInviteToken: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
sendInviteEmail: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
verifyInviteToken: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
}
|
||||
|
||||
class InviteController implements IInviteController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
private inviteService: any;
|
||||
constructor(inviteService: any) {
|
||||
private inviteService: IInviteService;
|
||||
constructor(inviteService: IInviteService) {
|
||||
this.inviteService = inviteService;
|
||||
}
|
||||
|
||||
@@ -20,7 +27,7 @@ class InviteController {
|
||||
const userRoles = requireUserRoles(req.user?.role);
|
||||
const invite = req.body;
|
||||
invite.teamId = teamId;
|
||||
await inviteBodyValidation.validateAsync(invite);
|
||||
inviteBodyValidation.parse(invite);
|
||||
const inviteToken = await this.inviteService.getInviteToken({ invite, teamId, userRoles });
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -34,16 +41,17 @@ class InviteController {
|
||||
|
||||
sendInviteEmail = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
const userRoles = requireUserRoles(req?.user?.role);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const userRoles = requireUserRoles(req.user?.role);
|
||||
const firstName = requireFirstName(req.user?.firstName);
|
||||
|
||||
const inviteRequest = req.body;
|
||||
inviteRequest.teamId = teamId;
|
||||
await inviteBodyValidation.validateAsync(inviteRequest);
|
||||
inviteBodyValidation.parse(inviteRequest);
|
||||
|
||||
const inviteToken = await this.inviteService.sendInviteEmail({
|
||||
invite: inviteRequest,
|
||||
firstName: req?.user?.firstName,
|
||||
firstName,
|
||||
userRoles,
|
||||
});
|
||||
return res.status(200).json({
|
||||
@@ -58,7 +66,7 @@ class InviteController {
|
||||
|
||||
verifyInviteToken = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await inviteVerificationBodyValidation.validateAsync(req.body);
|
||||
inviteVerificationBodyValidation.parse(req.body);
|
||||
const invite = await this.inviteService.verifyInviteToken({ inviteToken: req?.body?.token });
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
|
||||
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user