Compare commits

..
112 changed files with 2868 additions and 3978 deletions

No files matched your search

+14 -7
View File
@@ -46,11 +46,11 @@ You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.
## User's guide
Usage instructions can be found [here](https://checkmate.so/docs).
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! :)
## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
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.
@@ -157,11 +157,12 @@ 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. 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).
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).
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
@@ -174,3 +175,9 @@ Here's how you can contribute:
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.
+104 -116
View File
@@ -44,7 +44,7 @@
"redux-persist": "6.0.0",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "^4.3.6"
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "24.5.2",
@@ -58,7 +58,7 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.4.1"
"vite": "6.3.6"
}
},
"node_modules/@babel/code-frame": {
@@ -1639,9 +1639,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"cpu": [
"arm"
],
@@ -1652,9 +1652,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"cpu": [
"arm64"
],
@@ -1665,9 +1665,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"cpu": [
"arm64"
],
@@ -1678,9 +1678,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"cpu": [
"x64"
],
@@ -1691,9 +1691,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"cpu": [
"arm64"
],
@@ -1704,9 +1704,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"cpu": [
"x64"
],
@@ -1717,9 +1717,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"cpu": [
"arm"
],
@@ -1730,9 +1730,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"cpu": [
"arm"
],
@@ -1743,9 +1743,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"cpu": [
"arm64"
],
@@ -1756,9 +1756,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"cpu": [
"arm64"
],
@@ -1769,9 +1769,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"cpu": [
"loong64"
],
@@ -1782,9 +1782,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"cpu": [
"loong64"
],
@@ -1795,9 +1795,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"cpu": [
"ppc64"
],
@@ -1808,9 +1808,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"cpu": [
"ppc64"
],
@@ -1821,9 +1821,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"cpu": [
"riscv64"
],
@@ -1834,9 +1834,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"cpu": [
"riscv64"
],
@@ -1847,9 +1847,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"cpu": [
"s390x"
],
@@ -1860,9 +1860,7 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"version": "4.57.1",
"cpu": [
"x64"
],
@@ -1873,9 +1871,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"cpu": [
"x64"
],
@@ -1886,9 +1884,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"cpu": [
"x64"
],
@@ -1899,9 +1897,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"cpu": [
"arm64"
],
@@ -1912,9 +1910,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"cpu": [
"arm64"
],
@@ -1925,9 +1923,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"cpu": [
"ia32"
],
@@ -1938,9 +1936,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"cpu": [
"x64"
],
@@ -1951,9 +1949,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"cpu": [
"x64"
],
@@ -2463,9 +2461,7 @@
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"version": "6.12.6",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5213,9 +5209,7 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -6122,9 +6116,7 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"version": "4.57.1",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -6138,31 +6130,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"fsevents": "~2.3.2"
}
},
@@ -7047,9 +7039,7 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.3.6",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -7359,8 +7349,6 @@
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+3 -3
View File
@@ -13,7 +13,7 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "npm run format"
},
"dependencies": {
"@emotion/react": "^11.13.3",
@@ -52,7 +52,7 @@
"redux-persist": "6.0.0",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "^4.3.6"
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "24.5.2",
@@ -66,6 +66,6 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.4.1"
"vite": "6.3.6"
}
}
@@ -32,7 +32,7 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25);
const latestChecks = checks.slice(-25).reverse();
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) {
+3 -19
View File
@@ -11,26 +11,20 @@ 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, formatDisplayValue, ...props }, ref) => {
({ sx, showValue = false, ...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>{displayValue}</Typography>}
{showValue && <Typography>{props.value}</Typography>}
<Slider
{...props}
ref={ref}
@@ -69,21 +63,12 @@ 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,
formatDisplayValue,
value,
sliderMaxWidth = "100%",
...props
},
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
ref
) => {
const theme = useTheme();
@@ -97,7 +82,6 @@ export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>
<SliderInput
{...props}
showValue={showValue}
formatDisplayValue={formatDisplayValue}
value={value}
ref={ref}
/>
@@ -1,6 +1,9 @@
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";
@@ -10,6 +13,7 @@ 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);
@@ -43,6 +47,10 @@ 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} />
+8 -8
View File
@@ -83,6 +83,14 @@ export const useMonitorForm = ({
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "websocket":
defaults = {
...base,
type: "websocket",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "pagespeed":
defaults = {
...base,
@@ -103,14 +111,6 @@ export const useMonitorForm = ({
selectedDisks: data?.selectedDisks || [],
};
break;
case "websocket":
defaults = {
...base,
type: "websocket",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
default:
defaults = {
...base,
+11 -64
View File
@@ -11,17 +11,12 @@ 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, useNavigate } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material";
import { useEditUserForm } from "@/Hooks/useEditUserForm";
import { useGet, usePatch, useDelete } from "@/Hooks/UseApi";
import { useGet, usePatch } from "@/Hooks/UseApi";
import type { EditUserFormData } from "@/Validation/editUser";
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";
import { useEffect } from "react";
interface RoleOption {
id: UserRole;
@@ -34,12 +29,6 @@ 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}`);
@@ -71,20 +60,6 @@ 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);
};
@@ -190,45 +165,17 @@ const EditUserPage = () => {
</Stack>
}
/>
<Stack
gap={LAYOUT.XS}
direction="row"
justifyContent={"flex-end"}
width="100%"
<Button
type="submit"
variant="contained"
color="primary"
loading={isSaving}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{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>
{t("common.buttons.save")}
</Button>
</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 { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useIsSuperAdmin } 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 isAdmin = useIsAdmin();
const isSuperAdmin = useIsSuperAdmin();
const headers: Header<User>[] = [
{
@@ -45,7 +45,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
];
const handleRowClick = (row: User) => {
if (isAdmin) {
if (isSuperAdmin) {
navigate(`/account/team/${row.id}`);
}
};
@@ -54,7 +54,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
<Table
headers={headers}
data={users}
onRowClick={isAdmin ? handleRowClick : undefined}
onRowClick={isSuperAdmin ? handleRowClick : undefined}
/>
);
};
+13 -18
View File
@@ -32,12 +32,7 @@ import {
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
import { useMonitorForm } from "@/Hooks/useMonitorForm";
import {
type Monitor,
type MonitorType,
type GamesMap,
supportsGeoCheck,
} from "@/Types/Monitor";
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { MonitorFormData } from "@/Validation/monitor";
@@ -124,6 +119,17 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: true,
showIgnoreTls: true,
},
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,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
@@ -146,17 +152,6 @@ const getGeneralSettingsConfig = (
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;
};
@@ -910,7 +905,7 @@ const CreateMonitorPage = () => {
/>
)}
{supportsGeoCheck(watchedType) && (
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
subtitle={t("pages.createMonitor.form.geoChecks.description")}
-38
View File
@@ -37,7 +37,6 @@ 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;
@@ -463,43 +462,6 @@ 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
+3 -3
View File
@@ -20,7 +20,7 @@ import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { useGet } from "@/Hooks/UseApi";
import { type MonitorDetailsResponse, supportsGeoCheck } from "@/Types/Monitor";
import type { MonitorDetailsResponse } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check";
import type {
GeoChecksResult,
@@ -122,7 +122,7 @@ const UptimeDetailsPage = () => {
);
const geoChecksUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
@@ -141,7 +141,7 @@ const UptimeDetailsPage = () => {
// Fetch paginated geo checks for the table
const geoChecksTableUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
+8 -3
View File
@@ -1,5 +1,3 @@
export const CHECK_TTL_SENTINEL = 366;
export interface CheckMetadata {
monitorId: string;
teamId: string;
@@ -127,6 +125,9 @@ export interface Check {
timings?: CheckTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
@@ -139,6 +140,7 @@ export interface Check {
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
}
@@ -241,7 +243,10 @@ export interface ChecksSummary {
downChecks: number;
}
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
export type CheckSnapshot = Omit<
Check,
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
> & {
originalResponseTime: number;
};
-9
View File
@@ -17,15 +17,6 @@ export const MonitorTypes = [
] 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",
-1
View File
@@ -5,7 +5,6 @@ export const NotificationChannels = [
"webhook",
"pager_duty",
"matrix",
"teams",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
-6
View File
@@ -12,8 +12,6 @@ export const typographyLevels = {
export const colors = {
gray200: "#EFEFEF",
gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
blueBlueWave: "#1570EF",
};
@@ -33,8 +31,4 @@ export const darkPalette = {
secondary: {
main: colors.gray700,
},
background: {
default: colors.gray900,
paper: colors.gray850,
},
};
+9 -9
View File
@@ -85,6 +85,13 @@ const grpcSchema = baseSchema.extend({
ignoreTlsErrors: z.boolean(),
});
// WebSocket monitor schema
const websocketSchema = baseSchema.extend({
type: z.literal("websocket"),
url: z.string().min(1, "WebSocket URL is required"),
ignoreTlsErrors: z.boolean(),
});
// PageSpeed monitor schema
const pagespeedSchema = baseSchema.extend({
type: z.literal("pagespeed"),
@@ -115,13 +122,6 @@ 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,
@@ -130,9 +130,9 @@ export const monitorSchema = z.discriminatedUnion("type", [
dockerSchema,
gameSchema,
grpcSchema,
websocketSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
]);
export type MonitorFormData = z.infer<typeof monitorSchema>;
@@ -145,7 +145,7 @@ export {
dockerSchema,
gameSchema,
grpcSchema,
websocketSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
};
-6
View File
@@ -45,11 +45,6 @@ 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"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -57,7 +52,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookSchema,
pagerDutySchema,
matrixSchema,
teamsSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+1 -6
View File
@@ -1,4 +1,3 @@
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
import { z } from "zod";
export const settingsSchema = z.object({
@@ -12,11 +11,7 @@ export const settingsSchema = z.object({
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"),
pagespeedApiKey: z
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
+3 -20
View File
@@ -46,8 +46,7 @@
"sendTestEmail": "Send test email",
"exportToJSON": "Export to JSON",
"importFromJSON": "Import from JSON",
"clearFilters": "Clear filters",
"removeUser": "Remove user"
"clearFilters": "Clear filters"
},
"charts": {
"labels": {
@@ -503,7 +502,7 @@
"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.",
"websocket": "Enter the WebSocket URL to monitor for connection health and response time, and add a display name.",
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
}
},
@@ -627,12 +626,6 @@
"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": {
@@ -1041,7 +1034,7 @@
},
"stats": {
"title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
"description": "Define how long you want to retain historical data. You can also clear all existing data.",
"option": {
"clear": {
"label": "Clear all stats. This is irreversible."
@@ -1052,16 +1045,6 @@
"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.",
+1 -1
View File
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig(({}) => {
let version = "3.5.1";
let version = "3.4.0";
return {
base: "/",
+703 -642
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -15,7 +15,7 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "npm run format"
},
"keywords": [],
"author": "",
@@ -38,7 +38,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"gamedig": "5.2.0",
"gamedig": "^5.3.1",
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
@@ -51,7 +51,7 @@
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.1",
"nodemailer": "^6.9.14",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
+3 -1
View File
@@ -3,6 +3,8 @@ 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;
@@ -10,7 +12,7 @@ const DEFAULT_BATCH_SIZE = 5_000;
const parseObjectId = (value, fallback) => {
try {
return new mongoose.Types.ObjectId(value || fallback);
} catch {
} catch (error) {
console.warn(`Invalid ObjectId '${value}', falling back to '${fallback}'.`);
return new mongoose.Types.ObjectId(fallback);
}
+2 -16
View File
@@ -74,22 +74,8 @@ export const createApp = ({
},
})
);
// 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);
});
// Swagger UI
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openApiSpec));
app.use("/api/v1/health", (req, res) => {
res.json({
+53 -95
View File
@@ -1,70 +1,35 @@
import MongoDB from "../db/MongoDB.js";
import { IDb } from "@/db/IDb.js";
import NetworkService from "../service/infrastructure/networkService.js";
import EmailService from "../service/infrastructure/emailService.js";
import BufferService from "../service/infrastructure/bufferService.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,
// Interfaces
INetworkService,
IEmailService,
IBufferService,
ISuperSimpleQueue,
INotificationsService,
IStatusService,
IMonitorService,
IUserService,
ICheckService,
IGeoChecksService,
IDiagnosticService,
IInviteService,
IMaintenanceWindowService,
IStatusPageService,
IIncidentService,
INotificationMessageBuilder,
ISettingsService,
EnvConfig,
} from "@/service/index.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 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 GeoChecksService from "../service/business/geoChecksService.js";
import GlobalPingService from "../service/infrastructure/globalPingService.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";
import axios from "axios";
import got from "got";
import ping from "ping";
import https from "https";
import Docker from "dockerode";
import net from "net";
import fs from "fs";
@@ -81,7 +46,7 @@ import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import WebSocket from "ws";
// Repositories
// repositories
import {
MongoMonitorsRepository,
MongoChecksRepository,
@@ -110,23 +75,24 @@ import {
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
export type InitializedServices = {
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;
settingsService: any;
db: any;
networkService: any;
emailService: any;
bufferService: any;
statusService: any;
jobQueue: any;
userService: any;
checkService: any;
geoChecksService: any;
diagnosticService: any;
inviteService: any;
maintenanceWindowService: any;
monitorService: any;
incidentService: any;
logger: ILogger;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
@@ -156,7 +122,7 @@ export const initializeServices = async ({
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
settingsService: any;
settingsRepository: ISettingsRepository;
}): Promise<InitializedServices> => {
// Create DB
@@ -179,28 +145,23 @@ export const initializeServices = async ({
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
// 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 networkService = new NetworkService(
axios,
got,
https,
jmespath,
GameDig as unknown as {
query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }>;
},
ping,
logger,
Docker,
net,
settingsService,
grpc,
protoLoader,
WebSocket
);
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const notificationMessageBuilder = new NotificationMessageBuilder();
@@ -222,14 +183,12 @@ export const initializeServices = async ({
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 notificationsService = new NotificationsService(
notificationsRepository,
@@ -240,7 +199,6 @@ export const initializeServices = async ({
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
settingsService,
logger,
notificationMessageBuilder
@@ -252,7 +210,6 @@ export const initializeServices = async ({
statusService,
notificationsService,
checkService,
settingsService,
bufferService,
incidentService,
maintenanceWindowsRepository,
@@ -309,6 +266,7 @@ export const initializeServices = async ({
const statusPageService = new StatusPageService(statusPagesRepository);
const services = {
//v1
settingsService,
db,
networkService,
+46 -57
View File
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { requireTeamId, requireUserRoles } from "./controllerUtils.js";
import type { UserRole } from "@/types/user.js";
import {
registrationBodyValidation,
@@ -19,16 +20,15 @@ import {
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "@/validation/userValidation.js";
import { IUserService } from "@/service/index.js";
const SERVICE_NAME = "authController";
class AuthController {
static SERVICE_NAME = SERVICE_NAME;
private userService: IUserService;
private userService: any;
constructor(userService: IUserService) {
constructor(userService: any) {
this.userService = userService;
}
@@ -41,12 +41,11 @@ class AuthController {
const newUser = req.body.user;
const newUserToken = req.body.token;
if (newUser?.email) {
const newUserEmail = requireUserEmail(newUser.email);
newUser.email = newUserEmail.toLowerCase();
newUser.email = newUser.email.toLowerCase();
}
const validatedBody = registrationBodyValidation.parse(newUser);
registrationBodyValidation.parse(newUser);
const { user, token } = await this.userService.registerUser(validatedBody, newUserToken, req?.file ?? null);
const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file);
res.status(200).json({
success: true,
msg: "User registered successfully",
@@ -63,11 +62,11 @@ class AuthController {
if (userData?.email) {
userData.email = userData.email.toLowerCase();
}
const validatedBody = createUserBodyValidation.parse(userData);
createUserBodyValidation.parse(userData);
const teamId = requireTeamId(req.user?.teamId);
const actorRoles = requireUserRoles(req.user?.role);
const newUser = await this.userService.createUser(validatedBody, teamId, actorRoles, req?.file ?? null);
const actorRoles = requireUserRoles(req.user?.role) as UserRole[];
const newUser = await this.userService.createUser(userData, teamId, actorRoles, req.file);
res.status(201).json({
success: true,
msg: "User created successfully",
@@ -101,10 +100,8 @@ class AuthController {
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
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);
editUserBodyValidation.parse(req.body);
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
res.status(200).json({
success: true,
@@ -173,15 +170,7 @@ class AuthController {
deleteUser = async (req: Request, res: Response, next: NextFunction) => {
try {
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,
});
await this.userService.deleteUser(req.user);
return res.status(200).json({
success: true,
msg: "User deleted successfully",
@@ -191,23 +180,6 @@ 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();
@@ -223,9 +195,19 @@ class AuthController {
getUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getUserByIdParamValidation.parse(req.params);
const actorRoles = requireUserRoles(req.user?.role);
const user = await this.userService.getUserById(actorRoles, validatedParams.userId);
getUserByIdParamValidation.parse(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);
return res.status(200).json({ success: true, msg: "ok", data: user });
} catch (error) {
@@ -235,19 +217,24 @@ class AuthController {
editUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const actorRoles = requireUserRoles(req.user?.role);
const actorId = requireUserId(req.user?.id);
const roles = req?.user?.role;
if (!actorRoles.includes("superadmin")) {
if (!roles || !roles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const validatedParams = editUserByIdParamValidation.parse(req.params);
// If this is superadmin self edit, allow "superadmin" role
const validatedBody =
validatedParams.userId === actorId ? editSuperadminUserByIdBodyValidation.parse(req.body) : editUserByIdBodyValidation.parse(req.body);
const userId = req.params.userId as string;
const user = { ...req.body };
await this.userService.editUserById(validatedParams.userId, validatedBody);
editUserByIdParamValidation.parse(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user?.id) {
editSuperadminUserByIdBodyValidation.parse(req.body);
} else {
editUserByIdBodyValidation.parse(req.body);
}
await this.userService.editUserById(userId, user);
return res.status(200).json({ success: true, msg: "ok" });
} catch (error) {
next(error);
@@ -256,14 +243,16 @@ class AuthController {
editUserPasswordById = async (req: Request, res: Response, next: NextFunction) => {
try {
const actorRoles = requireUserRoles(req.user?.role);
if (!actorRoles.includes("superadmin")) {
const roles = req?.user?.role;
if (!roles || !roles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const validatedParams = editUserByIdParamValidation.parse(req.params);
const validatedBody = editUserPasswordByIdBodyValidation.parse(req.body);
await this.userService.setPasswordByUserId(validatedParams.userId, validatedBody.password);
const userId = req.params.userId as string;
editUserByIdParamValidation.parse(req.params);
editUserPasswordByIdBodyValidation.parse(req.body);
const updatedPassword = req.body.password;
await this.userService.setPasswordByUserId(userId, updatedPassword);
return res.status(200).json({ success: true, msg: "Password reset successfully" });
} catch (error) {
next(error);
+37 -37
View File
@@ -4,19 +4,18 @@ import {
getChecksParamValidation,
getChecksQueryValidation,
getTeamChecksQueryValidation,
getChecksSummaryByTeamIdQueryValidation,
deleteChecksParamValidation,
deleteChecksByTeamIdParamValidation,
updateChecksTTLBodyValidation,
} from "@/validation/checkValidation.js";
import { ICheckService } from "@/service/index.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "checkController";
class CheckController {
static SERVICE_NAME = SERVICE_NAME;
private checkService: ICheckService;
constructor(checkService: ICheckService) {
private checkService: any;
constructor(checkService: any) {
this.checkService = checkService;
}
@@ -26,20 +25,13 @@ class CheckController {
getChecksByMonitor = 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);
getChecksParamValidation.parse(req.params);
getChecksQueryValidation.parse(req.query);
const result = await this.checkService.getChecksByMonitor({
monitorId: validatedParams.monitorId,
teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
filter: validatedQuery.filter,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
status: validatedQuery.status,
monitorId: req?.params?.monitorId,
query: req?.query,
teamId: req?.user?.teamId,
});
return res.status(200).json({
@@ -54,16 +46,10 @@ class CheckController {
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getTeamChecksQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
getTeamChecksQueryValidation.parse(req.query);
const checkData = await this.checkService.getChecksByTeam({
teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
filter: validatedQuery.filter,
teamId: req?.user?.teamId,
query: req?.query,
});
return res.status(200).json({
success: true,
@@ -77,11 +63,8 @@ class CheckController {
getChecksSummaryByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
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 });
const dateRange = req.query?.dateRange || "hour";
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId, dateRange });
return res.status(200).json({
success: true,
msg: "Checks summary retrieved successfully",
@@ -94,12 +77,11 @@ class CheckController {
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = deleteChecksParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
deleteChecksParamValidation.parse(req.params);
const deletedCount = await this.checkService.deleteChecks({
monitorId: validatedParams.monitorId,
teamId,
monitorId: req.params.monitorId as string,
teamId: req?.user?.teamId,
});
return res.status(200).json({
@@ -114,9 +96,9 @@ class CheckController {
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
deleteChecksByTeamIdParamValidation.parse(req.params);
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId });
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId });
return res.status(200).json({
success: true,
@@ -127,6 +109,24 @@ class CheckController {
next(error);
}
};
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
try {
updateChecksTTLBodyValidation.parse(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;
+27 -22
View File
@@ -1,25 +1,24 @@
import { AppError } from "@/utils/AppError.js";
import { Monitor, type MonitorType, MonitorTypes, UserRole } from "@/types/index.js";
import sslChecker, { SSLDetails } from "ssl-checker";
type SSLCheckerType = typeof sslChecker;
import { type MonitorType, MonitorTypes } from "@/types/index.js";
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<any> => {
const monitorUrl = new URL(monitor.url);
const hostname = monitorUrl.hostname;
const cert = await checker(hostname);
const cert = await sslChecker(hostname);
// Throw an error if no cert or if cert.validTo is not present
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
return cert;
};
export const requireString = (value: unknown, fieldName: string): string => {
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 });
};
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
const optionalString = (value: unknown, fieldName: string): string | undefined => {
if (value === undefined) {
return undefined;
}
@@ -29,7 +28,7 @@ export const optionalString = (value: unknown, fieldName: string): string | unde
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
}
@@ -45,7 +44,7 @@ export const optionalNumber = (value: unknown, fieldName: string): number | unde
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
export const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
@@ -63,7 +62,7 @@ export const optionalBoolean = (value: unknown, fieldName: string): boolean | un
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
};
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
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 });
@@ -83,7 +82,7 @@ export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorTyp
return parseSingle(value);
};
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
if (value === undefined) {
return undefined;
}
@@ -93,36 +92,42 @@ export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
};
export const requireTeamId = (teamId?: string): string => {
const requireTeamId = (teamId?: string): string => {
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
return teamId;
};
export const requireUserId = (userId?: string): string => {
const requireUserId = (userId?: string): string => {
if (!userId) {
throw new AppError({ message: "User ID is required", status: 400 });
}
return userId;
};
export const requireUserEmail = (userEmail?: string): string => {
const requireUserEmail = (userEmail?: string): string => {
if (!userEmail) {
throw new AppError({ message: "User email is required", status: 400 });
}
return userEmail;
};
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[] => {
export const requireUserRoles = (userRoles?: string[]): string[] => {
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,13 +1,12 @@
import { IDiagnosticService } from "@/service/index.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "diagnosticController";
class DiagnosticController {
static SERVICE_NAME = SERVICE_NAME;
private diagnosticService: IDiagnosticService;
private diagnosticService: any;
constructor(diagnosticService: IDiagnosticService) {
constructor(diagnosticService: any) {
this.diagnosticService = diagnosticService;
}
+21 -15
View File
@@ -1,30 +1,35 @@
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 {
private incidentService: IIncidentService;
constructor(incidentService: IIncidentService) {
static SERVICE_NAME = SERVICE_NAME;
private incidentService: any;
constructor(incidentService: any) {
this.incidentService = incidentService;
}
get serviceName() {
return IncidentController.SERVICE_NAME;
}
getIncidentsByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getIncidentsByTeamQueryValidation.parse(req.query);
const { sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType } = req.query || {};
const teamId = requireTeamId(req.user?.teamId);
const result = await this.incidentService.getIncidentsByTeam(
teamId,
validatedQuery.sortOrder,
validatedQuery.dateRange,
validatedQuery.page,
validatedQuery.rowsPerPage,
validatedQuery.status,
validatedQuery.monitorId,
validatedQuery.resolutionType
sortOrder,
dateRange,
page,
rowsPerPage,
status,
monitorId,
resolutionType
);
return res.status(200).json({
@@ -40,9 +45,10 @@ class IncidentController {
getIncidentSummary = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const validatedQuery = getIncidentSummaryQueryValidation.parse(req.query);
const summary = await this.incidentService.getIncidentSummary(teamId, validatedQuery.limit);
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
const summary = await this.incidentService.getIncidentSummary(teamId, limit);
return res.status(200).json({
success: true,
msg: "Incident summary retrieved successfully",
+6 -8
View File
@@ -1,13 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { IInviteService } from "@/service/index.js";
import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "inviteController";
class InviteController {
static SERVICE_NAME = SERVICE_NAME;
private inviteService: IInviteService;
constructor(inviteService: IInviteService) {
private inviteService: any;
constructor(inviteService: any) {
this.inviteService = inviteService;
}
@@ -35,9 +34,8 @@ class InviteController {
sendInviteEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const userRoles = requireUserRoles(req.user?.role);
const firstName = requireFirstName(req.user?.firstName);
const teamId = requireTeamId(req?.user?.teamId);
const userRoles = requireUserRoles(req?.user?.role);
const inviteRequest = req.body;
inviteRequest.teamId = teamId;
@@ -45,7 +43,7 @@ class InviteController {
const inviteToken = await this.inviteService.sendInviteEmail({
invite: inviteRequest,
firstName,
firstName: req?.user?.firstName,
userRoles,
});
return res.status(200).json({
+3 -4
View File
@@ -1,12 +1,11 @@
import { ILogger } from "@/utils/logger.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "LogController";
class LogController {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
private logger: any;
constructor(logger: any) {
this.logger = logger;
}
get serviceName() {
@@ -15,7 +14,7 @@ class LogController {
getLogs = async (req: Request, res: Response, next: NextFunction) => {
try {
const logs = this.logger.getLogs();
const logs = await this.logger.getLogs();
res.status(200).json({
success: true,
msg: "Logs fetched successfully",
@@ -9,19 +9,26 @@ import {
deleteMaintenanceWindowByIdParamValidation,
} from "@/validation/maintenanceWindowValidation.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
import { IMaintenanceWindowService } from "@/service/index.js";
const SERVICE_NAME = "maintenanceWindowController";
class MaintenanceWindowController {
private maintenanceWindowService: IMaintenanceWindowService;
constructor(maintenanceWindowService: IMaintenanceWindowService) {
static SERVICE_NAME = SERVICE_NAME;
private maintenanceWindowService: any;
constructor(maintenanceWindowService: any) {
this.maintenanceWindowService = maintenanceWindowService;
}
get serviceName() {
return MaintenanceWindowController.SERVICE_NAME;
}
createMaintenanceWindows = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createMaintenanceWindowBodyValidation.parse(req.body);
createMaintenanceWindowBodyValidation.parse(req.body);
const teamId = requireTeamId(req?.user?.teamId);
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: validatedBody });
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body });
return res.status(200).json({
success: true,
@@ -33,11 +40,11 @@ class MaintenanceWindowController {
};
getMaintenanceWindowById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMaintenanceWindowByIdParamValidation.parse(req.params);
getMaintenanceWindowByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: validatedParams.id, teamId });
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id as string, teamId });
return res.status(200).json({
success: true,
@@ -51,18 +58,11 @@ class MaintenanceWindowController {
getMaintenanceWindowsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query);
getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({
teamId,
active: validatedQuery.active,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
field: validatedQuery.field,
order: validatedQuery.order,
});
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({ teamId, query: req.query });
return res.status(200).json({
success: true,
@@ -76,12 +76,12 @@ class MaintenanceWindowController {
getMaintenanceWindowsByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params);
getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({
monitorId: validatedParams.monitorId,
monitorId: req.params.monitorId as string,
teamId,
});
@@ -96,11 +96,11 @@ class MaintenanceWindowController {
};
deleteMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = deleteMaintenanceWindowByIdParamValidation.parse(req.params);
deleteMaintenanceWindowByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: validatedParams.id, teamId });
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id as string, teamId });
return res.status(200).json({
success: true,
@@ -113,14 +113,14 @@ class MaintenanceWindowController {
editMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = editMaintenanceWindowByIdParamValidation.parse(req.params);
const validatedBody = editMaintenanceByIdWindowBodyValidation.parse(req.body);
editMaintenanceWindowByIdParamValidation.parse(req.params);
editMaintenanceByIdWindowBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({
id: validatedParams.id,
body: validatedBody,
id: req.params.id as string,
body: req.body,
teamId,
});
return res.status(200).json({
+117 -74
View File
@@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from "express";
import {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByTeamIdParamValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
createMonitorBodyValidation,
@@ -11,14 +12,22 @@ import {
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
} from "@/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "./controllerUtils.js";
import {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
requireUserId,
} from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/service/index.js";
import { GeoContinent } from "@/types/geoCheck.js";
const SERVICE_NAME = "monitorController";
class MonitorController {
@@ -36,9 +45,10 @@ class MonitorController {
getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: validatedParams.monitorId });
getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
const certificate = await fetchMonitorCertificate(sslChecker, monitor);
return res.status(200).json({
@@ -55,14 +65,10 @@ class MonitorController {
getUptimeDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getUptimeDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getUptimeDetailsByIdQueryValidation.parse(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange;
const normalize = validatedQuery.normalize;
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const normalize = optionalBoolean(req?.query?.normalize, "normalize");
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getUptimeDetailsById({
teamId,
@@ -82,12 +88,12 @@ class MonitorController {
getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getHardwareDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getHardwareDetailsByIdQueryValidation.parse(req.query);
getHardwareDetailsByIdParamValidation.parse(req.params);
getHardwareDetailsByIdQueryValidation.parse(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent";
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getHardwareDetailsById({
teamId,
@@ -106,12 +112,12 @@ class MonitorController {
};
getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getHardwareDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getHardwareDetailsByIdQueryValidation.parse(req.query);
getHardwareDetailsByIdParamValidation.parse(req.params);
getHardwareDetailsByIdQueryValidation.parse(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getPageSpeedDetailsById({
teamId,
@@ -131,14 +137,18 @@ class MonitorController {
getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
getMonitorByIdParamValidation.parse(req.params);
getMonitorByIdQueryValidation.parse(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const continentParam = validatedQuery.continent;
const continents = continentParam ? (Array.isArray(continentParam) ? continentParam : [continentParam]) : undefined;
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const continentParam = req?.query?.continent;
const continents = continentParam
? Array.isArray(continentParam)
? (continentParam as GeoContinent[])
: [continentParam as GeoContinent]
: undefined;
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getGeoChecksByMonitorId({
teamId,
@@ -159,10 +169,11 @@ class MonitorController {
getMonitorById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
getMonitorByIdParamValidation.parse(req.params);
getMonitorByIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
@@ -178,12 +189,12 @@ class MonitorController {
createMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createMonitorBodyValidation.parse(req.body);
createMonitorBodyValidation.parse(req.body);
const userId = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const userId = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.createMonitor(teamId, userId, validatedBody);
const monitor = await this.monitorService.createMonitor(teamId, userId, req.body);
return res.status(200).json({
success: true,
@@ -197,10 +208,14 @@ class MonitorController {
importMonitorsFromJSON = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const validatedBody = importMonitorsBodyValidation.parse(req.body);
const monitors = validatedBody.monitors;
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req?.user?.id);
const { monitors } = req.body;
if (!monitors || !Array.isArray(monitors)) {
throw new AppError({ message: "Invalid request: monitors array is required", status: 400 });
}
const result = await this.monitorService.importMonitorsFromJSON({ teamId, userId, monitors });
@@ -216,9 +231,9 @@ class MonitorController {
deleteMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const monitorId = validatedParams.monitorId;
getMonitorByIdParamValidation.parse(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const deletedMonitor = await this.monitorService.deleteMonitor({ teamId, monitorId });
@@ -234,7 +249,8 @@ class MonitorController {
deleteAllMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = requireTeamId(req?.user?.teamId);
const deletedCount = await this.monitorService.deleteAllMonitors({ teamId });
return res.status(200).json({
@@ -248,12 +264,12 @@ class MonitorController {
editMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedBody = editMonitorBodyValidation.parse(req.body);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req.user?.teamId);
getMonitorByIdParamValidation.parse(req.params);
editMonitorBodyValidation.parse(req.body);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: validatedBody });
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: req.body });
return res.status(200).json({
success: true,
@@ -267,10 +283,10 @@ class MonitorController {
pauseMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = pauseMonitorParamValidation.parse(req.params);
pauseMonitorParamValidation.parse(req.params);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.pauseMonitor({ teamId, monitorId });
@@ -286,8 +302,8 @@ class MonitorController {
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const id = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const demoMonitors = await this.monitorService.addDemoMonitors({ userId: id, teamId });
return res.status(200).json({
@@ -300,13 +316,32 @@ class MonitorController {
}
};
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const { to } = req.body;
if (!to || typeof to !== "string") {
throw new AppError({ message: "Invalid 'to' email address", status: 400 });
}
const messageId = await this.monitorService.sendTestEmail({ to });
return res.status(200).json({
success: true,
msg: "Test email sent successfully",
data: { messageId },
});
} catch (error) {
next(error);
}
};
getMonitorsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getMonitorsByTeamIdQueryValidation.parse(req.query);
getMonitorsByTeamIdParamValidation.parse(req.params);
getMonitorsByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const type = validatedQuery.type;
const filter = validatedQuery.filter;
const teamId = requireTeamId(req?.user?.teamId);
const type = parseMonitorTypeFilter(req.query?.type);
const filter = optionalString(req.query?.filter, "filter");
const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter });
@@ -322,17 +357,17 @@ class MonitorController {
getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getMonitorsWithChecksQueryValidation.parse(req.query);
const explain = validatedQuery.explain;
const limit = validatedQuery.limit;
const page = validatedQuery.page;
const rowsPerPage = validatedQuery.rowsPerPage;
const filter = validatedQuery.filter;
const field = validatedQuery.field;
const order = validatedQuery.order;
const type = validatedQuery.type;
const teamId = requireTeamId(req.user?.teamId);
getMonitorsByTeamIdParamValidation.parse(req.params);
getMonitorsWithChecksQueryValidation.parse(req.query);
const explain = optionalBoolean(req?.query?.explain, "explain");
const limit = optionalNumber(req?.query?.limit, "limit");
const page = optionalNumber(req?.query?.page, "page");
const rowsPerPage = optionalNumber(req?.query?.rowsPerPage, "rowsPerPage");
const filter = optionalString(req?.query?.filter, "filter");
const field = optionalString(req?.query?.field, "field");
const order = parseSortOrder(req?.query?.order);
const type = parseMonitorTypeFilter(req?.query?.type);
const teamId = requireTeamId(req?.user?.teamId);
const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({
teamId,
@@ -357,7 +392,11 @@ class MonitorController {
exportMonitorsToJSON = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const json = await this.monitorService.exportMonitorsToJSON({ teamId });
return res.status(200).json({
@@ -385,7 +424,11 @@ class MonitorController {
getGroupsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const groups = await this.monitorService.getGroupsByTeamId({ teamId });
return res.status(200).json({
@@ -1,21 +1,16 @@
import { Request, Response, NextFunction } from "express";
import {
createNotificationBodyValidation,
deleteNotificationParamValidation,
getNotificationByIdParamValidation,
testNotificationBodyValidation,
editNotificationParamValidation,
testAllNotificationsBodyValidation,
} from "@/validation/notificationValidation.js";
import { Notification } from "@/types/index.js";
import { createNotificationBodyValidation } from "@/validation/notificationValidation.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { INotificationsService } from "@/service/index.js";
import { requireTeamId, requireUserId } from "./controllerUtils.js";
import { requireTeamId } from "./controllerUtils.js";
const SERVICE_NAME = "NotificationController";
class NotificationController {
static SERVICE_NAME = SERVICE_NAME;
private notificationsService: INotificationsService;
private monitorsRepository: IMonitorsRepository;
constructor(notificationsService: INotificationsService, monitorsRepository: IMonitorsRepository) {
@@ -23,9 +18,13 @@ class NotificationController {
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return NotificationController.SERVICE_NAME;
}
testNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const notification = testNotificationBodyValidation.parse(req.body);
const notification: Notification = req.body;
const success = await this.notificationsService.sendTestNotification(notification);
if (!success) {
@@ -35,7 +34,6 @@ class NotificationController {
return res.status(200).json({
success: true,
msg: "Notification sent successfully",
details: { service: SERVICE_NAME },
});
} catch (error) {
next(error);
@@ -44,12 +42,23 @@ class NotificationController {
createNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createNotificationBodyValidation.parse(req.body);
createNotificationBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const body = req.body;
const notification = await this.notificationsService.createNotification(validatedBody, userId, teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const userId = req?.user?.id;
if (!userId) {
throw new AppError({ message: "User ID is required", status: 400 });
}
body.userId = userId;
body.teamId = teamId;
const notification = await this.notificationsService.createNotification(body);
return res.status(200).json({
success: true,
msg: "Notification created successfully",
@@ -62,7 +71,11 @@ class NotificationController {
getNotificationsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const notifications = await this.notificationsService.findNotificationsByTeamId(teamId);
return res.status(200).json({
@@ -77,10 +90,17 @@ class NotificationController {
deleteNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const validatedParams = deleteNotificationParamValidation.parse(req.params);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
await this.notificationsService.deleteById(validatedParams.id, teamId);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
await this.notificationsService.deleteById(notificationId, teamId);
return res.status(200).json({
success: true,
msg: "Notification deleted successfully",
@@ -93,9 +113,12 @@ class NotificationController {
getNotificationById = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const validatedParams = getNotificationByIdParamValidation.parse(req.params);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
const notification = await this.notificationsService.findById(validatedParams.id, teamId);
const notification = await this.notificationsService.findById(notificationId, teamId);
return res.status(200).json({
success: true,
@@ -109,13 +132,14 @@ class NotificationController {
editNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createNotificationBodyValidation.parse(req.body);
const validatedParams = editNotificationParamValidation.parse(req.params);
createNotificationBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const notificationId = validatedParams.id;
const editedNotification = await this.notificationsService.updateById(notificationId, teamId, validatedBody);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
const editedNotification = await this.notificationsService.updateById(notificationId, teamId, req.body);
return res.status(200).json({
success: true,
msg: "Notification updated successfully",
@@ -128,11 +152,13 @@ class NotificationController {
testAllNotifications = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = testAllNotificationsBodyValidation.parse(req.body);
const monitorId = req.body.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorsRepository.findById(validatedBody.monitorId, teamId);
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
const notifications = monitor.notifications;
if (notifications.length === 0) {
@@ -11,6 +11,7 @@ import { requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { IStatusPageService } from "@/service/business/statusPageService.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { ParseBoolean } from "@/utils/utils.js";
import { NormalizeData } from "@/utils/dataUtils.js";
const SERVICE_NAME = "statusPageController";
@@ -109,7 +110,6 @@ class StatusPageController {
const normalizedMonitors = sortedMonitors.map((monitor) => {
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
if (!showURL) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { url, port, secret, notifications, ...rest } = monitor;
return { ...rest, checks: normalizedChecks };
}
-4
View File
@@ -1,4 +0,0 @@
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
}
+9 -11
View File
@@ -1,12 +1,10 @@
import mongoose from "mongoose";
import AppSettings from "@/db/models/AppSettings.js";
import { runMigrations } from "@/db/migration/index.js";
import AppSettings from "./models/AppSettings.js";
import { runMigrations } from "./migration/index.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
const SERVICE_NAME = "MongoDB";
import { IDb } from "@/db/IDb.js";
class MongoDB implements IDb {
class MongoDB {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
@@ -48,12 +46,12 @@ class MongoDB implements IDb {
});
await runMigrations();
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "connect",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
throw error;
}
@@ -65,12 +63,12 @@ class MongoDB implements IDb {
await mongoose.disconnect();
this.logger.info({ message: "Disconnected from MongoDB", service: SERVICE_NAME, method: "disconnect" });
return;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "disconnect",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
};
@@ -1,60 +0,0 @@
import { MonitorModel } from "../models/Monitor.js";
import { logger } from "@/utils/logger.js";
/**
* Fix infrastructure monitors that had thresholds incorrectly set to 0 or 5
* due to the old default value. Updates them to 100 (disabled).
*/
export async function fixInfrastructureThresholds(): Promise<void> {
const SERVICE_NAME = "Migration:FixInfrastructureThresholds";
try {
logger.info({ service: SERVICE_NAME, message: "Starting infrastructure threshold fix" });
const thresholdFields = ["cpuAlertThreshold", "memoryAlertThreshold", "diskAlertThreshold", "tempAlertThreshold"];
// Find hardware monitors with any threshold set to 0 or 5
const filter = {
type: "hardware",
$or: thresholdFields.flatMap((field) => [{ [field]: 0 }, { [field]: 5 }]),
};
const monitors = await MonitorModel.find(filter);
if (monitors.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No monitors with threshold value of 0 or 5 found" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Found ${monitors.length} monitors with threshold value of 0 or 5`,
});
let updatedCount = 0;
for (const monitor of monitors) {
const update: Record<string, number> = {};
for (const field of thresholdFields) {
if ((monitor as any)[field] === 0 || (monitor as any)[field] === 5) {
update[field] = 100;
}
}
if (Object.keys(update).length > 0) {
await MonitorModel.updateOne({ _id: monitor._id }, { $set: update });
updatedCount++;
}
}
logger.info({
service: SERVICE_NAME,
message: `Fixed ${updatedCount} monitors — thresholds updated from 0/5 to 100`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error fixing thresholds: ${errorMessage}` });
throw error;
}
}
-2
View File
@@ -1,7 +1,6 @@
import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js";
import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js";
import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js";
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
type MigrationEntry = {
@@ -13,7 +12,6 @@ const migrations: MigrationEntry[] = [
{ name: "0001_migrateStatusWindowThreshold", execute: migrateStatusWindowThreshold },
{ name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries },
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
];
const runMigrations = async (logger?: { info: Function; error: Function }) => {
+16 -2
View File
@@ -21,10 +21,13 @@ type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
type: MonitorType;
};
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "createdAt" | "updatedAt"> & {
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
metadata: CheckMetadataDocument;
expiry: Date;
ackAt?: Date | null;
createdAt: Date;
updatedAt: Date;
__v: number;
};
interface CheckDocument extends CheckDocumentBase {
@@ -222,7 +225,18 @@ const CheckSchema = new Schema<CheckDocument>(
message: {
type: String,
},
expiry: {
type: Date,
default: Date.now,
},
ack: {
type: Boolean,
default: false,
},
ackAt: {
type: Date,
default: undefined,
},
cpu: {
type: cpuSchema,
default: () => ({}),
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as NotificationChannel[],
required: true,
},
notificationName: {
+4 -3
View File
@@ -9,7 +9,7 @@ import fs from "fs";
import { runMigrations } from "./db/migration/index.js";
import Logger, { ILogger } from "@/utils/logger.js";
import { SettingsService } from "@/service/index.js";
import SettingsService from "@/service/system/settingsService.js";
import { MongoSettingsRepository } from "./repositories/index.js";
const SERVICE_NAME = "Server";
@@ -50,8 +50,9 @@ const startApp = async () => {
openApiSpec,
});
const server = app.listen(env.PORT, () => {
logger.info({ message: `Server started on port:${env.PORT}` });
const port = 52345;
const server = app.listen(port, () => {
logger.info({ message: `Server started on port:${port}` });
});
initShutdownListener(server, services);
@@ -20,19 +20,12 @@ export interface IChecksRepository {
monitorId: string,
sortOrder: string,
dateRange: string,
filter: string | undefined,
filter: string,
page: number,
rowsPerPage: number,
status: boolean | undefined
): Promise<ChecksQueryResult>;
findByTeamId(
sortOrder: string,
dateRange: string,
filter: string | undefined,
page: number,
rowsPerPage: number,
teamId: string
): Promise<ChecksQueryResult>;
findByTeamId(sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string): Promise<ChecksQueryResult>;
findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
findByDateRangeAndMonitorId(
monitorId: string,
@@ -47,5 +40,4 @@ export interface IChecksRepository {
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
}
@@ -13,10 +13,9 @@ import type {
GotTimings,
MonitorType,
} from "@/types/index.js";
import { CheckModel, type CheckDocument } from "@/db/models/index.js";
import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "StatusService";
@@ -27,8 +26,8 @@ type HardwareUpChecks = { totalChecks: number };
class MongoChecksRepository implements IChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
private logger: any;
constructor(logger: any) {
this.logger = logger;
}
@@ -47,6 +46,13 @@ class MongoChecksRepository implements IChecksRepository {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
const toOptionalDateString = (value?: Date | string | null): string | undefined => {
if (!value) {
return undefined;
}
return toDateString(value);
};
const mapTimings = (timings?: GotTimings): GotTimings => {
const phases = timings?.phases ?? {
wait: 0,
@@ -165,6 +171,9 @@ class MongoChecksRepository implements IChecksRepository {
timings: mapTimings(doc.timings),
statusCode: doc.statusCode ?? 0,
message: doc.message ?? "",
ack: doc.ack ?? false,
ackAt: toOptionalDateString(doc.ackAt),
expiry: toDateString(doc.expiry),
cpu: mapCpu(doc.cpu),
memory: mapMemory(doc.memory),
disk: mapDisks(doc.disk),
@@ -177,6 +186,7 @@ class MongoChecksRepository implements IChecksRepository {
seo: doc.seo,
performance: doc.performance,
audits: mapAudits(doc.audits),
__v: doc.__v ?? 0,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -221,13 +231,13 @@ class MongoChecksRepository implements IChecksRepository {
monitorId: string,
sortOrder: string,
dateRange: string,
filter: string | undefined,
filter: string,
page: number,
rowsPerPage: number,
status: boolean | undefined
) => {
// Match
const matchStage: Record<string, unknown> = {
const matchStage: Record<string, any> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(typeof status !== "undefined" && { status }),
...(getDateForRange(dateRange) && {
@@ -279,7 +289,7 @@ class MongoChecksRepository implements IChecksRepository {
};
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
const matchStage: Record<string, unknown> = {
const matchStage: Record<string, any> = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
...(getDateForRange(dateRange) && {
createdAt: {
@@ -348,7 +358,7 @@ class MongoChecksRepository implements IChecksRepository {
);
const mapped = results.reduce<LatestChecksMap>((acc, { monitorId, docs }) => {
acc[monitorId] = docs.map((doc: CheckDocument) => this.toEntity(doc));
acc[monitorId] = docs.map((doc: any) => this.toEntity(doc as CheckDocument));
return acc;
}, {});
return mapped;
@@ -402,31 +412,6 @@ class MongoChecksRepository implements IChecksRepository {
return result.deletedCount ?? 0;
};
deleteOlderThan = async (cutoffDate: Date, batchDays: number = 30): Promise<number> => {
// Find the oldest check that is older than the cutoff
const oldest = await CheckModel.findOne({ createdAt: { $lt: cutoffDate } })
.sort({ createdAt: 1 })
.select({ createdAt: 1 });
if (!oldest?.createdAt) return 0;
let totalDeleted = 0;
let batchStart = new Date(oldest.createdAt);
const batchMs = batchDays * 24 * 60 * 60 * 1000;
// Delete in 30 day chunks until we reach the cutoff
while (batchStart < cutoffDate) {
// Advance startTime by batchMs to get to the end of last batch
const nextStart = batchStart.getTime() + batchMs;
const batchEnd = new Date(Math.min(nextStart, cutoffDate.getTime()));
const result = await CheckModel.deleteMany({
createdAt: { $gte: batchStart, $lt: batchEnd },
});
totalDeleted += result.deletedCount ?? 0;
batchStart = batchEnd;
}
return totalDeleted;
};
private findUptimeDateRangeChecks = async (
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId,
@@ -1,18 +1,17 @@
import { IGeoChecksRepository } from "./IGeoChecksRepository.js";
import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js";
import type { FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
import type { GeoChecksQueryResult, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "GeoChecksRepository";
class MongoGeoChecksRepository implements IGeoChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
private logger: any;
constructor(logger: any) {
this.logger = logger;
}
@@ -36,7 +36,7 @@ class MongoInvitesRepository implements IInvitesRepository {
create = async (invite: Partial<Invite>) => {
await InviteModel.deleteMany({ email: invite.email });
invite.token = crypto.randomBytes(32).toString("hex");
const inviteToken = await InviteModel.create(invite);
let inviteToken = await InviteModel.create(invite);
return this.toEntity(inviteToken);
};
@@ -5,12 +5,12 @@ export interface IMaintenanceWindowsRepository {
// fetch
findById(id: string, teamId: string): Promise<MaintenanceWindow>;
findByMonitorId(monitorId: string, teamId: string): Promise<MaintenanceWindow[]>;
findByTeamId(teamId: string, page: number, rowsPerPage: number, field?: string, order?: string, active?: boolean): Promise<MaintenanceWindow[]>;
findByTeamId(teamId: string, active: boolean, page: number, rowsPerPage: number, field: string, order: string): Promise<MaintenanceWindow[]>;
// update
updateById(id: string, teamId: string, data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow>;
// delete
deleteById(id: string, teamId: string): Promise<MaintenanceWindow>;
// other
countByTeamId(teamId: string, active?: boolean): Promise<number>;
countByTeamId(teamId: string, active: boolean): Promise<number>;
}
@@ -75,11 +75,11 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
findByTeamId = async (
teamId: string,
active: boolean,
page: number,
rowsPerPage: number,
field?: string,
order?: string,
active?: boolean
field: string,
order: string
): Promise<MaintenanceWindow[]> => {
const maintenanceQuery: Record<string, any> = { teamId };
@@ -92,7 +92,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
}
// Sorting
const sort: Record<string, any> = {};
let sort: Record<string, any> = {};
if (field !== undefined && order !== undefined) {
sort[field] = order === "asc" ? 1 : -1;
}
@@ -126,7 +126,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
}
return this.toEntity(deleted);
};
countByTeamId = async (teamId: string, active?: boolean) => {
countByTeamId = async (teamId: string, active: boolean) => {
const maintenanceQuery: Record<string, any> = { teamId };
if (active !== undefined) maintenanceQuery.active = active;
@@ -52,7 +52,7 @@ class MongoSettingsRepository implements ISettingsRepository {
};
findSingleton = async () => {
const settings = await AppSettingsModel.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean();
let settings = await AppSettingsModel.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean();
if (!settings) {
return null;
}
+1 -2
View File
@@ -26,10 +26,9 @@ class AuthRoutes {
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById);
this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById);
this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
+1
View File
@@ -15,6 +15,7 @@ class CheckRoutes {
initRoutes() {
this.router.get("/team/summary", this.checkController.getChecksSummaryByTeamId);
this.router.get("/team", this.checkController.getChecksByTeam);
this.router.put("/team/ttl", isAllowed(["admin", "superadmin"]), this.checkController.updateChecksTTL);
this.router.delete("/team", isAllowed(["admin", "superadmin"]), this.checkController.deleteChecksByTeamId);
this.router.get("/:monitorId", this.checkController.getChecksByMonitor);
this.router.delete("/:monitorId", this.checkController.deleteChecks);
+1
View File
@@ -50,6 +50,7 @@ class MonitorRoutes {
this.router.get("/export/json", isAllowed(["admin", "superadmin"]), this.monitorController.exportMonitorsToJSON);
this.router.post("/import/json", isAllowed(["admin", "superadmin"]), this.monitorController.importMonitorsFromJSON);
this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.monitorController.sendTestEmail);
this.router.get("/games", this.monitorController.getAllGames);
// Individual monitor CRUD routes
+45 -87
View File
@@ -1,53 +1,19 @@
import { Types } from "mongoose";
import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js";
import type {
MonitorStatusResponse,
CheckErrorInfo,
Check,
ILighthouseAudit,
ChecksQueryResult,
ChecksSummary,
MonitorPayloadMap,
} from "@/types/index.js";
import type { MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js";
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "checkService";
export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>;
buildCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
getChecksByMonitor(params: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
status?: boolean;
}): Promise<ChecksQueryResult>;
getChecksByTeam(params: {
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
}): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
}
export class CheckService implements ICheckService {
class CheckService {
static SERVICE_NAME = SERVICE_NAME;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private logger: ILogger;
private logger: any;
constructor(monitorsRepository: IMonitorsRepository, logger: ILogger, checksRepository: IChecksRepository) {
this.monitorsRepository = monitorsRepository;
this.logger = logger;
@@ -62,14 +28,12 @@ export class CheckService implements ICheckService {
return this.checksRepository.createChecks(checks);
};
buildCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
const now = new Date().toISOString();
const check: Check = {
const check: Partial<Check> = {
id: new Types.ObjectId().toString(),
createdAt: now,
updatedAt: now,
createdAt: new Date().toISOString(),
metadata: {
monitorId,
teamId,
@@ -89,12 +53,13 @@ export class CheckService implements ICheckService {
message: "Failed to build check",
service: SERVICE_NAME,
method: "buildCheck",
details: "empty payload",
});
return undefined;
}
const categories = pageSpeedPayload.lighthouseResult?.categories ?? {};
const audits = pageSpeedPayload.lighthouseResult?.audits ?? {};
const mapAudit = (audit: ILighthouseAudit | undefined) => {
const mapAudit = (audit: any) => {
if (!audit || typeof audit !== "object") {
return undefined;
}
@@ -137,25 +102,7 @@ export class CheckService implements ICheckService {
return check;
};
getChecksByMonitor = async ({
monitorId,
teamId,
sortOrder,
dateRange,
filter,
page,
rowsPerPage,
status,
}: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
status?: boolean;
filter?: string;
}) => {
getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
}
@@ -163,57 +110,68 @@ export class CheckService implements ICheckService {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
}
// For verification, throws an error if monitor doesn't belong to team
// For verificaiton, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
let { sortOrder, dateRange, filter, page, rowsPerPage, status } = query;
const parsedStatus = typeof status === "undefined" ? status : ParseBoolean(status);
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, status);
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, parsedStatus);
return result;
};
getChecksByTeam = async ({
teamId,
sortOrder,
dateRange,
filter,
page,
rowsPerPage,
}: {
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
}) => {
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
getChecksByTeam = async ({ teamId, query }: { teamId: string; query: any }) => {
let { sortOrder, dateRange, filter, page, rowsPerPage } = query;
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
}
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
return checkData;
};
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => {
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksSummaryByTeamId", status: 400 });
}
const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange);
return summary;
};
deleteChecks = async ({ monitorId, teamId }: { monitorId: string; teamId: string }) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 });
}
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 });
}
// For verificaiton, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
const deletedCount = await this.checksRepository.deleteByMonitorId(monitorId);
return deletedCount;
};
deleteChecksByTeamId = async ({ teamId }: { teamId: string }) => {
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecksByTeamId", status: 400 });
}
const deletedCount = await this.checksRepository.deleteByTeamId(teamId);
return deletedCount;
};
deleteOlderThan = async (date: Date) => {
const deletedCount = await this.checksRepository.deleteOlderThan(date);
return deletedCount;
updateChecksTTL = async ({ teamId, ttl }: { teamId: string; ttl: string }) => {
throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500 });
};
}
export default CheckService;
@@ -3,19 +3,15 @@ import os from "os";
const SERVICE_NAME = "diagnosticService";
export interface IDiagnosticService {
getCPUUsage(): Promise<{ userUsageMs: number; systemUsageMs: number; usagePercentage: number }>;
getSystemStats(): Promise<{
osStats: { freeMemoryBytes: number; totalMemoryBytes: number };
memoryUsage: Record<keyof NodeJS.MemoryUsage, number>;
cpuUsage: { userUsageMs: number; systemUsageMs: number; usagePercentage: number };
v8HeapStats: { totalHeapSizeBytes: number; usedHeapSizeBytes: number; heapSizeLimitBytes: number };
eventLoopDelayMs: number;
uptimeMs: number;
}>;
interface MemoryUsageMetrics {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
arrayBuffers: number;
}
export class DiagnosticService implements IDiagnosticService {
class DiagnosticService {
static SERVICE_NAME = SERVICE_NAME;
constructor() {
@@ -107,3 +103,5 @@ export class DiagnosticService implements IDiagnosticService {
return diagnostics;
};
}
export default DiagnosticService;
@@ -16,7 +16,7 @@ export interface IGeoChecksService {
getGeoChecksByMonitor(args: { monitorId: string; query: any; teamId: string }): Promise<any>;
}
export class GeoChecksService implements IGeoChecksService {
class GeoChecksService implements IGeoChecksService {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
@@ -68,7 +68,7 @@ export class GeoChecksService implements IGeoChecksService {
}
// Step 1: Create measurement request
const measurementId = await this.globalPingService.createMeasurement(monitor.type, monitor.url, monitor.geoCheckLocations);
const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations);
if (!measurementId) {
// GlobalPing API is down, skip this check
@@ -76,6 +76,7 @@ export class GeoChecksService implements IGeoChecksService {
message: "Skipping geo check due to API unavailability",
service: SERVICE_NAME,
method: "buildGeoCheck",
details: { monitorId: monitor.id },
});
return null;
}
@@ -89,6 +90,7 @@ export class GeoChecksService implements IGeoChecksService {
message: "No successful geo check results",
service: SERVICE_NAME,
method: "buildGeoCheck",
details: { monitorId: monitor.id, measurementId },
});
return null;
}
@@ -100,16 +102,17 @@ export class GeoChecksService implements IGeoChecksService {
message: `Geo check completed for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "buildGeoCheck",
details: { monitorId: monitor.id, resultsCount: results.length },
});
return geoCheck;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: "Error executing geo check",
service: SERVICE_NAME,
method: "buildGeoCheck",
details: { monitorId: monitor.id, error: error instanceof Error ? error.message : "Unknown error" },
stack: error instanceof Error ? error.stack : undefined,
details: { monitorId: monitor.id, error: error.message },
stack: error.stack,
});
return null;
}
@@ -167,11 +170,23 @@ export class GeoChecksService implements IGeoChecksService {
});
}
const { sortOrder, dateRange, page, rowsPerPage, continent } = query;
let { sortOrder, dateRange, page, rowsPerPage, continent } = query;
const continents = continent ? (Array.isArray(continent) ? continent : [continent]) : undefined;
this.logger.debug({
message: "getGeoChecksByMonitor query params",
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
details: { continent, continents, query },
});
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continents);
return result;
};
}
export default GeoChecksService;
+29 -47
View File
@@ -2,36 +2,15 @@ const SERVICE_NAME = "incidentService";
import type { Monitor } from "@/types/monitor.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
import { getDateForRange } from "@/utils/dataUtils.js";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js";
import type { Incident, IncidentSummary, User } from "@/types/index.js";
import type { Incident } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
import type { ILogger } from "@/utils/logger.js";
export interface IIncidentService {
handleIncident(
monitor: Monitor,
code: number,
decision: MonitorActionDecision,
monitorStatusResponse?: MonitorStatusResponse
): Promise<Incident | null>;
resolveIncident(incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string): Promise<Incident>;
getIncidentsByTeam(
teamId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
status: boolean | undefined,
monitorId: string | undefined,
resolutionType: string | undefined
): Promise<{ incidents: Incident[]; count: number }>;
getIncidentSummary(teamId: string, limit?: number): Promise<IncidentSummary>;
getIncidentById(incidentId: string, teamId: string): Promise<{ incident: Incident; monitor: Monitor; user: User | null }>;
}
export class IncidentService implements IIncidentService {
class IncidentService {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
@@ -163,13 +142,13 @@ export class IncidentService implements IIncidentService {
});
return resolvedIncident;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
service: SERVICE_NAME,
method: "resolveIncident",
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
details: { id: incidentId },
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
throw error;
}
@@ -179,11 +158,11 @@ export class IncidentService implements IIncidentService {
teamId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
status: boolean | undefined,
monitorId: string | undefined,
resolutionType: string | undefined
page: string,
rowsPerPage: string,
status: string,
monitorId: string,
resolutionType: string
) => {
try {
if (!teamId) {
@@ -192,8 +171,9 @@ export class IncidentService implements IIncidentService {
const startDate = getDateForRange(dateRange);
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 20;
const parsedPage = Number.isFinite(parseInt(page)) ? parseInt(page) : 0;
const parsedRowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20;
const parsedStatus = typeof status === "undefined" ? undefined : ParseBoolean(status);
const incidents = await this.incidentsRepository.findByTeamId(
teamId,
@@ -201,43 +181,43 @@ export class IncidentService implements IIncidentService {
parsedPage,
parsedRowsPerPage,
sortOrder,
status,
parsedStatus,
monitorId,
resolutionType
);
const count = await this.incidentsRepository.countByTeamId(teamId, startDate, status, monitorId, resolutionType);
const count = await this.incidentsRepository.countByTeamId(teamId, startDate, parsedStatus, monitorId, resolutionType);
return { incidents, count };
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
service: SERVICE_NAME,
method: "getIncidentsByTeam",
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
details: { teamId },
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
throw error;
}
};
getIncidentSummary = async (teamId: string, limit?: number) => {
getIncidentSummary = async (teamId: string, limit?: string) => {
try {
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getIncidentSummary", status: 400 });
}
const parsedLimit = limit ?? 10;
const parsedLimit = limit && Number.isFinite(parseInt(limit, 10)) ? parseInt(limit, 10) : 10;
const summary = await this.incidentsRepository.findSummaryByTeamId(teamId, parsedLimit);
return summary;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
service: SERVICE_NAME,
method: "getIncidentSummary",
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
details: { teamId },
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
throw error;
}
@@ -252,15 +232,17 @@ export class IncidentService implements IIncidentService {
user = await this.usersRepository.findById(incident.resolvedBy);
}
return { incident, monitor, user };
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
service: SERVICE_NAME,
method: "getIncidentById",
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
details: { incidentId },
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
throw error;
}
};
}
export default IncidentService;
+3 -7
View File
@@ -5,13 +5,7 @@ import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "inviteService";
export interface IInviteService {
getInviteToken(params: { invite: Partial<Invite>; teamId: string; userRoles: UserRole[] }): Promise<Invite>;
sendInviteEmail(params: { invite: Partial<Invite>; firstName: string; userRoles: UserRole[] }): Promise<void>;
verifyInviteToken(params: { inviteToken: string }): Promise<Invite>;
}
export class InviteService implements IInviteService {
class InviteService {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: any;
@@ -94,3 +88,5 @@ export class InviteService implements IInviteService {
return await this.invitesRepository.findByToken(inviteToken);
};
}
export default InviteService;
@@ -1,26 +1,10 @@
import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js";
import type { MaintenanceWindow } from "@/types/index.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "maintenanceWindowService";
export interface IMaintenanceWindowService {
createMaintenanceWindow(params: { teamId: string; body: Partial<MaintenanceWindow> & { monitors: string[] } }): Promise<void>;
getMaintenanceWindowById(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
getMaintenanceWindowsByTeamId(params: {
teamId: string;
active?: boolean;
page?: number;
rowsPerPage?: number;
field?: string;
order?: string;
}): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>;
getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise<MaintenanceWindow[]>;
deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
editMaintenanceWindow(params: { id: string; teamId: string; body: Partial<MaintenanceWindow> }): Promise<MaintenanceWindow>;
}
export class MaintenanceWindowService implements IMaintenanceWindowService {
class MaintenanceWindowService {
static SERVICE_NAME = SERVICE_NAME;
private monitorsRepository: IMonitorsRepository;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
@@ -75,25 +59,14 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
return await this.maintenanceWindowsRepository.findById(id, teamId);
};
getMaintenanceWindowsByTeamId = async ({
teamId,
active,
page,
rowsPerPage,
field,
order,
}: {
teamId: string;
active?: boolean;
page?: number;
rowsPerPage?: number;
field?: string;
order?: string;
}) => {
page = page ?? 0;
rowsPerPage = rowsPerPage ?? 10;
getMaintenanceWindowsByTeamId = async ({ teamId, query }: { teamId: string; query: any }) => {
let { active, page, rowsPerPage, field, order } = query || {};
const maintenanceWindows = await this.maintenanceWindowsRepository.findByTeamId(teamId, page, rowsPerPage, field, order, active);
active = typeof active === "undefined" ? undefined : ParseBoolean(active);
page = parseInt(page) || 0;
rowsPerPage = parseInt(rowsPerPage) || 10;
const maintenanceWindows = await this.maintenanceWindowsRepository.findByTeamId(teamId, active, page, rowsPerPage, field, order);
const maintenanceWindowCount = await this.maintenanceWindowsRepository.countByTeamId(teamId, active);
return { maintenanceWindows, maintenanceWindowCount };
};
@@ -110,3 +83,5 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
return await this.maintenanceWindowsRepository.updateById(id, teamId, body);
};
}
export default MaintenanceWindowService;
+39 -17
View File
@@ -8,7 +8,6 @@ import type {
PageSpeedDetailsResult,
GamesMap,
} from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import type { GeoContinent } from "@/types/geoCheck.js";
import type {
IChecksRepository,
@@ -18,7 +17,9 @@ import type {
IMonitorStatsRepository,
IStatusPagesRepository,
} from "@/repositories/index.js";
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import fs from "fs";
import { fileURLToPath } from "url";
import path from "path";
import { AppError } from "@/utils/AppError.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
@@ -29,7 +30,7 @@ export interface IMonitorService {
readonly serviceName: string;
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitor(teamId: string, userId: string, body: Monitor): Promise<void>;
createMonitors(monitors: Array<Monitor>, userId: string, teamId: string): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
@@ -64,7 +65,7 @@ export interface IMonitorService {
getGroupsByTeamId(args: { teamId: string }): Promise<string[]>;
// update
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
editMonitor(args: { teamId: string; monitorId: string; body: Monitor }): Promise<Monitor>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
// delete
@@ -72,6 +73,7 @@ export interface IMonitorService {
deleteAllMonitors(args: { teamId: string }): Promise<number>;
// other
sendTestEmail(args: { to: string }): Promise<string>;
exportMonitorsToJSON(args: { teamId: string }): Promise<Monitor[]>;
importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: any[] }): Promise<{ imported: number; errors: string[] }>;
}
@@ -174,17 +176,23 @@ export class MonitorService implements IMonitorService {
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<any[]> => {
const demoData = demoMonitorsData;
const monitors = demoData.map((monitor) => ({
userId,
teamId,
name: monitor.name,
description: monitor.name,
type: "http" as const,
url: monitor.url,
interval: 60000,
}));
const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const demoMonitorsPath = path.resolve(__dirname, "../../utils/demoMonitors.json");
const demoData = JSON.parse(fs.readFileSync(demoMonitorsPath, "utf8"));
const monitors: Monitor[] = demoData.map((monitor: Monitor) => {
return {
userId,
teamId,
name: monitor.name,
description: monitor.name,
type: "http",
url: monitor.url,
interval: 60000,
};
});
const demoMonitors = await this.monitorsRepository.createMonitors(monitors);
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
return demoMonitors;
@@ -333,7 +341,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (!supportsGeoCheck(monitor.type) || !monitor.geoCheckEnabled) {
if (monitor.type !== "http" || !monitor.geoCheckEnabled) {
return { groupedGeoChecks: [] };
}
@@ -429,7 +437,7 @@ export class MonitorService implements IMonitorService {
return groups;
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Monitor }) => {
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
await this.jobQueue.updateJob(editedMonitor);
return editedMonitor;
@@ -508,6 +516,20 @@ export class MonitorService implements IMonitorService {
return deletedCount;
};
sendTestEmail = async ({ to }: { to: string }): Promise<string> => {
const subject = "Test email from Checkmate";
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
const messageId = await this.emailService.sendEmail(to, subject, html);
if (!messageId) {
throw new AppError({ message: "Failed to send test email.", service: SERVICE_NAME, method: "sendTestEmail", status: 500 });
}
return messageId;
};
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<any[]> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
+58 -134
View File
@@ -11,39 +11,10 @@ import { canManageRole, type UserRole } from "@/types/user.js";
import bcrypt from "bcryptjs";
import { AppError } from "@/utils/AppError.js";
import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "@/service/infrastructure/emailService.js";
import { EnvConfig, ISettingsService } from "@/service/system/settingsService.js";
import { ILogger } from "@/utils/logger.js";
import jwt from "jsonwebtoken";
import crypto from "crypto";
type CryptoType = typeof crypto;
type JwtType = typeof jwt;
const SERVICE_NAME = "userService";
export interface IUserService {
issueToken(payload: Partial<User>, appSettings: EnvConfig): string;
registerUser(user: Partial<User>, inviteToken: string, file: Express.Multer.File | null): Promise<{ user: User; token: string }>;
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null): Promise<User>;
loginUser(email: string, password: string): Promise<{ user: User; token: string }>;
editUser(
updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
): Promise<User>;
checkSuperadminExists(): Promise<boolean>;
requestRecovery(email: string): Promise<string | false | undefined>;
validateRecovery(recoveryToken: string): Promise<void>;
resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>;
deleteUser(params: { userId: string; teamId: string; roles: UserRole[] }): Promise<void>;
deleteUserById(params: { actorId: string; actorTeamId: string; actorRoles: UserRole[]; targetUserId: string }): Promise<void>;
getAllUsers(): Promise<User[]>;
getUserById(roles: UserRole[], userId: string): Promise<User>;
editUserById(userId: string, patch: Partial<User>): Promise<void>;
setPasswordByUserId(userId: string, password: string): Promise<User>;
}
export class UserService implements IUserService {
class UserService {
static SERVICE_NAME = SERVICE_NAME;
private hashPassword = (password: string): string => {
@@ -51,12 +22,12 @@ export class UserService implements IUserService {
return bcrypt.hashSync(password, salt);
};
private emailService: IEmailService;
private settingsService: ISettingsService;
private logger: ILogger;
private jwt: JwtType;
private emailService: any;
private settingsService: any;
private logger: any;
private jwt: any;
private jobQueue: ISuperSimpleQueue;
private crypto: CryptoType;
private crypto: any;
private monitorsRepository: IMonitorsRepository;
private usersRepository: IUsersRepository;
private invitesRepository: IInvitesRepository;
@@ -78,11 +49,11 @@ export class UserService implements IUserService {
settingsRepository,
teamsRepository,
}: {
crypto: CryptoType;
emailService: IEmailService;
settingsService: ISettingsService;
logger: ILogger;
jwt: JwtType;
crypto: any;
emailService: any;
settingsService: any;
logger: any;
jwt: any;
jobQueue: ISuperSimpleQueue;
monitorsRepository: IMonitorsRepository;
usersRepository: IUsersRepository;
@@ -109,14 +80,14 @@ export class UserService implements IUserService {
return UserService.SERVICE_NAME;
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
issueToken = (payload: any, appSettings: any) => {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
};
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
registerUser = async (user: Partial<User>, inviteToken: string, file: any) => {
// Create a new user
// If superAdmin exists, a token should be attached to all further register requests
const superAdminExists = await this.usersRepository.findSuperAdmin();
@@ -149,7 +120,7 @@ export class UserService implements IUserService {
message: "New user created",
service: SERVICE_NAME,
method: "registerUser",
details: { userId: newUser.id },
details: newUser.id,
});
delete newUser.profileImage;
@@ -163,30 +134,27 @@ export class UserService implements IUserService {
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
name: newUser.firstName,
});
if (!html) {
throw new Error("Failed to build welcome email HTML");
}
this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error: unknown) => {
this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error: any) => {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "registerUser",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
});
} catch (error: unknown) {
} catch (error: any) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "registerUser",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
return { user: newUser, token };
};
createUser = async (userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null) => {
createUser = async (userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: any) => {
// Validate that the creator can assign the requested roles
const targetRoles = userData.role ?? [];
for (const targetRole of targetRoles) {
@@ -213,7 +181,7 @@ export class UserService implements IUserService {
message: "New user created by superadmin",
service: SERVICE_NAME,
method: "createUser",
details: { userId: newUser.id },
details: newUser.id,
});
newUser.profileImage = undefined;
@@ -246,19 +214,14 @@ export class UserService implements IUserService {
return { user: userWithoutPassword, token };
};
editUser = async (
updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
) => {
editUser = async (updates: Partial<User & { newPassword?: string }>, file: any, currentUser: any) => {
// Change Password check
if (updates?.password && updates?.newPassword) {
// Get user's email
// Add user email to body for DB operation
updates.email = currentUserEmail;
updates.email = currentUser.email;
// Get user
const user = await this.usersRepository.findByEmail(currentUserEmail);
const user = await this.usersRepository.findByEmail(currentUser.email);
// Compare passwords
const match = await bcrypt.compare(updates?.password, user.password);
// If not a match, throw a 403
@@ -271,7 +234,7 @@ export class UserService implements IUserService {
delete updates.newPassword;
}
return await this.usersRepository.updateById(currentUserId, updates, file);
return await this.usersRepository.updateById(currentUser.id, updates, file);
};
checkSuperadminExists = async () => {
@@ -285,22 +248,14 @@ export class UserService implements IUserService {
await this.recoveryTokensRepository.deleteManyByEmail(email);
const recoveryToken = await this.recoveryTokensRepository.create(email);
const name = user.firstName;
const settings = this.settingsService.getSettings();
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
const { clientHost } = this.settingsService.getSettings();
const url = `${clientHost}/set-new-password/${recoveryToken.token}`;
const html = await this.emailService.buildEmail("passwordResetTemplate", {
name,
email,
url,
});
if (!html) {
throw new AppError({
message: "Failed to build password reset email HTML",
service: SERVICE_NAME,
method: "requestRecovery",
status: 500,
});
}
const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html);
return msgId;
};
@@ -331,7 +286,24 @@ export class UserService implements IUserService {
return { user: existingUser, token };
};
deleteUser = async ({ userId, teamId, roles }: { userId: string; teamId: string; roles: UserRole[] }) => {
deleteUser = async (user: User) => {
const email = user?.email;
if (!email) {
throw new AppError({ message: "No email in request", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
const teamId = user?.teamId;
const userId = user?.id;
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
if (!userId) {
throw new AppError({ message: "No user ID in request", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
const roles = user?.role;
if (roles.includes("demo")) {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
@@ -341,87 +313,39 @@ export class UserService implements IUserService {
if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts
if (res && res.length > 0) {
await Promise.all(
res &&
res?.length > 0 &&
(await Promise.all(
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor);
await this.jobQueue.deleteJob(monitor.id);
})
);
}
));
}
// 6. Delete the user by id
await this.usersRepository.deleteById(userId);
};
deleteUserById = async ({
actorId,
actorRoles,
actorTeamId,
targetUserId,
}: {
actorId: string;
actorTeamId: string;
actorRoles: UserRole[];
targetUserId: string;
}) => {
if (actorId === targetUserId) {
throw new AppError({ message: "Cannot delete your own account from here", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
}
const targetUser = await this.usersRepository.findById(targetUserId);
if (targetUser.teamId !== actorTeamId) {
throw new AppError({ message: "User is not on your team", service: SERVICE_NAME, method: "deleteUserById", status: 403 });
}
if (targetUser.role.includes("demo")) {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
}
const targetRoles = targetUser.role;
// Check actor can manage all of target's roles
for (const targetRole of targetRoles) {
const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
if (!canManage) {
throw new AppError({
message: "You do not have permission to remove this user",
service: SERVICE_NAME,
method: "deleteUserById",
status: 403,
});
}
}
await this.usersRepository.deleteById(targetUserId);
this.logger.info({
message: `User ${targetUserId} deleted by ${actorId}`,
service: SERVICE_NAME,
method: "deleteUserById",
});
};
getAllUsers = async () => {
return await this.usersRepository.findAll();
};
getUserById = async (roles: UserRole[], userId: string) => {
if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
getUserById = async (roles: any, userId: any) => {
if (!roles.includes("superadmin")) {
throw new AppError({ message: "User is not a superadmin", service: SERVICE_NAME, status: 403 });
}
const user = await this.usersRepository.findById(userId);
return user;
};
editUserById = async (userId: string, patch: Partial<User>) => {
editUserById = async (userId: any, patch: Partial<User>) => {
await this.usersRepository.updateById(userId, patch, null);
};
setPasswordByUserId = async (userId: string, password: string) => {
setPasswordByUserId = async (userId: any, password: string) => {
const hashedPassword = this.hashPassword(password);
const updatedUser = await this.usersRepository.updateById(userId, { password: hashedPassword }, null);
return updatedUser;
};
}
export default UserService;
+6 -26
View File
@@ -1,34 +1,14 @@
// Business services
export * from "@/service/business/checkService.js";
export * from "@/service/business/diagnosticService.js";
export * from "@/service/business/geoChecksService.js";
export * from "@/service/business/incidentService.js";
export * from "@/service/business/inviteService.js";
export * from "@/service/business/maintenanceWindowService.js";
export * from "@/service/business/monitorService.js";
export * from "@/service/business/statusPageService.js";
export * from "@/service/business/userService.js";
// Infrastructure services
export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
export * from "@/service/infrastructure/notificationMessageBuilder.js";
export * from "@/service/infrastructure/bufferService.js";
export * from "@/service/infrastructure/emailService.js";
export * from "@/service/infrastructure/globalPingService.js";
export * from "@/service/infrastructure/networkService.js";
export * from "@/service/infrastructure/notificationsService.js";
export * from "@/service/infrastructure/statusService.js";
// Notification providers
export * from "@/service/infrastructure/notificationProviders/discord.js";
export * from "@/service/infrastructure/notificationProviders/email.js";
export * from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
export * from "@/service/infrastructure/notificationProviders/matrix.js";
export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
export * from "@/service/infrastructure/notificationProviders/matrix.js";
export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
// System services
export * from "@/service/system/settingsService.js";
export * from "@/service/infrastructure/notificationsService.js";
export * from "@/service/infrastructure/statusService.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/matrix.js";
export * from "@/service/business/statusPageService.js";
@@ -2,7 +2,6 @@ import { IMonitorsRepository } from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import Scheduler from "super-simple-scheduler";
import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js";
const SERVICE_NAME = "JobQueue";
type QueueJobFailure = {
@@ -42,11 +41,11 @@ type QueueJobSummary = {
export interface ISuperSimpleQueue {
readonly serviceName: string;
init(): Promise<boolean>;
addJob(monitorId: string, monitor: Monitor): Promise<void>;
deleteJob(monitor: Monitor): Promise<void>;
pauseJob(monitor: Monitor): Promise<void>;
resumeJob(monitor: Monitor): Promise<void>;
updateJob(monitor: Monitor): Promise<void>;
addJob(monitorId: string, monitor: any): Promise<void>;
deleteJob(monitor: any): Promise<void>;
pauseJob(monitor: any): Promise<void>;
resumeJob(monitor: any): Promise<void>;
updateJob(monitor: any): Promise<void>;
shutdown(): Promise<void>;
getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>;
@@ -54,7 +53,7 @@ export interface ISuperSimpleQueue {
obliterate(): Promise<void>;
}
export class SuperSimpleQueue implements ISuperSimpleQueue {
class SuperSimpleQueue implements ISuperSimpleQueue {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
@@ -89,10 +88,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
try {
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getGeoCheckJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob());
const monitors = await this.monitorsRepository.findAll();
if (!monitors) {
return true;
@@ -105,20 +103,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
}
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true });
this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 });
return true;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`,
message: "Failed to initialize SuperSimpleQueue",
service: SERVICE_NAME,
method: "init",
details: error,
});
return false;
}
};
addJob = async (monitorId: string, monitor: Monitor) => {
addJob = async (monitorId: string, monitor: any) => {
this.scheduler.addJob({
id: monitorId,
template: "monitor-job",
@@ -127,13 +125,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
data: monitor,
});
// Return early if we don't need geo checks
if (!supportsGeoCheck(monitor.type)) {
return;
}
// Add geo check job if enabled for HTTP monitors
if (monitor.geoCheckEnabled) {
if (monitor.geoCheckEnabled && monitor.type === "http") {
this.scheduler.addJob({
id: `${monitorId}-geo`,
template: "geo-check-job",
@@ -144,12 +137,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
}
};
deleteJob = async (monitor: Monitor) => {
deleteJob = async (monitor: any) => {
this.scheduler.removeJob(monitor.id);
this.scheduler.removeJob(`${monitor.id}-geo`);
};
pauseJob = async (monitor: Monitor) => {
pauseJob = async (monitor: any) => {
const result = await this.scheduler.pauseJob(monitor.id);
if (result === false) {
throw new Error("Failed to pause monitor");
@@ -162,7 +155,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
});
};
resumeJob = async (monitor: Monitor) => {
resumeJob = async (monitor: any) => {
const result = await this.scheduler.resumeJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
@@ -177,12 +170,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
});
};
updateJob = async (monitor: Monitor) => {
updateJob = async (monitor: any) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
// Handle geo check job lifecycle
const geoJobId = `${monitor.id}-geo`;
if (monitor.geoCheckEnabled && supportsGeoCheck(monitor.type)) {
if (monitor.geoCheckEnabled && monitor.type === "http") {
// Check if geo job exists
const existingGeoJob = await this.scheduler.getJob(geoJobId);
if (existingGeoJob) {
@@ -245,8 +238,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
failingJobs: 0,
jobsWithFailures: [] as Array<{
monitorId: string | number;
monitorUrl: string;
monitorType: MonitorType;
monitorUrl: any;
monitorType: any;
failedAt: number | null;
failCount: number;
failReason: string | null;
@@ -296,3 +289,5 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
});
};
}
export default SuperSimpleQueue;
@@ -1,17 +1,10 @@
const SERVICE_NAME = "JobQueueHelper";
import type { Monitor } from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import {
ICheckService,
INetworkService,
INotificationsService,
ISettingsService,
IStatusService,
IncidentService,
type IGeoChecksService,
} from "@/service/index.js";
import { CHECK_TTL_SENTINEL, type MaintenanceWindow, type StatusChangeResult } from "@/types/index.js";
import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js";
import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js";
import IncidentService from "@/service/business/incidentService.js";
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
import {
IMaintenanceWindowsRepository,
IMonitorsRepository,
@@ -22,14 +15,13 @@ import {
IGeoChecksRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "@/service/index.js";
import { IBufferService } from "../bufferService.js";
export interface ISuperSimpleQueueHelper {
readonly serviceName: string;
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
getMonitorJob(): (monitor: Monitor) => Promise<void>;
getCleanupOrphanedJob(): () => Promise<void>;
getCleanupRetentionJob(): () => Promise<void>;
getGeoCheckJob(): (monitor: Monitor) => Promise<void>;
isInMaintenanceWindow(monitorId: string, teamId: string): Promise<boolean>;
}
@@ -47,15 +39,14 @@ export interface MonitorActionDecision {
};
}
export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
private logger: any;
private networkService: INetworkService;
private statusService: IStatusService;
private notificationsService: INotificationsService;
private checkService: ICheckService;
private settingsService: ISettingsService;
private checkService: any;
private buffer: IBufferService;
private incidentService: IncidentService;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
@@ -72,8 +63,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
networkService: INetworkService,
statusService: IStatusService,
notificationsService: INotificationsService,
checkService: ICheckService,
settingsService: ISettingsService,
checkService: any,
buffer: IBufferService,
incidentService: IncidentService,
maintenanceWindowsRepository: IMaintenanceWindowsRepository,
@@ -89,7 +79,6 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.networkService = networkService;
this.statusService = statusService;
this.checkService = checkService;
this.settingsService = settingsService;
this.buffer = buffer;
this.notificationsService = notificationsService;
this.incidentService = incidentService;
@@ -107,7 +96,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return SuperSimpleQueueHelper.SERVICE_NAME;
}
getHeartbeatJob = () => {
getMonitorJob = () => {
return async (monitor: Monitor) => {
try {
const monitorId = monitor.id;
@@ -138,7 +127,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
}
// Step 3. Build check
const check = this.checkService.buildCheck(status);
const check = await this.checkService.buildCheck(status);
if (!check) {
this.logger.warn({
message: `No check could be built for monitor ${monitorId}`,
@@ -158,31 +147,33 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
// Step 6. Handle notifications (best effort, continue even in event of failure, don't wait)
if (decision.shouldSendNotification) {
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => {
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => {
this.logger.error({
message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`,
stack: error.stack,
});
});
}
// Step 7. Handle incidents (best effort, don't wait)
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => {
this.logger.warn({
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
details: `Error handling incident for job ${monitor.id}: ${error.message}`,
stack: error.stack,
});
});
} catch (error: unknown) {
} catch (error: any) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
message: error.message,
service: error.service || SERVICE_NAME,
method: error.method || "getMonitorJob",
stack: error.stack,
});
throw error;
}
@@ -268,19 +259,19 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
});
} catch (error: unknown) {
} catch (error: any) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "getCleanupOrphanedJob",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
throw error;
}
};
};
getHeartbeatGeoJob = () => {
getGeoCheckJob = () => {
return async (monitor: Monitor) => {
try {
const monitorId = monitor.id;
@@ -288,26 +279,27 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
// Step 1: Validate monitor eligibility
if (!monitorId) {
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" });
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" });
}
if (monitor.type !== "http") {
this.logger.debug({
message: `Monitor ${monitorId} is not HTTP type, skipping geo check`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
});
return;
}
if (!monitor.geoCheckEnabled) {
return;
}
if (!supportsGeoCheck(monitor.type)) {
this.logger.debug({
message: `Monitor ${monitorId} type does not support geo checks, skipping`,
service: SERVICE_NAME,
method: "getHeartbeatGeoJob",
});
return;
}
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
this.logger.warn({
message: `No geo check locations configured for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getHeartbeatGeoJob",
method: "getGeoCheckJob",
});
return;
}
@@ -318,7 +310,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.debug({
message: `Monitor ${monitorId} is in maintenance window, skipping geo check`,
service: SERVICE_NAME,
method: "getHeartbeatGeoJob",
method: "getGeoCheckJob",
});
return;
}
@@ -329,7 +321,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.warn({
message: `No geo check could be built for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getHeartbeatGeoJob",
method: "getGeoCheckJob",
});
return;
}
@@ -340,14 +332,14 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
this.logger.debug({
message: `Geo check job executed for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getHeartbeatGeoJob",
method: "getGeoCheckJob",
});
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "getHeartbeatGeoJob",
stack: error instanceof Error ? error.stack : undefined,
method: "getGeoCheckJob",
stack: error.stack,
});
// Don't throw - geo check failures shouldn't crash the job scheduler
}
@@ -357,7 +349,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
async isInMaintenanceWindow(monitorId: string, teamId: string) {
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: boolean, window: MaintenanceWindow) => {
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: any, window: any) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
@@ -384,40 +376,6 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return maintenanceWindowIsActive;
}
getCleanupRetentionJob = () => {
return async () => {
try {
const settings = await this.settingsService.getDBSettings();
const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS
if (checkTTL === CHECK_TTL_SENTINEL) {
this.logger.info({
message: `Check TTL is set to unlimited, skipping cleanup`,
service: SERVICE_NAME,
method: "getCleanupRetentionJob",
});
return;
}
const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000;
const cutoffDate = new Date(Date.now() - checkTTLInMs);
const deleteCount = await this.checkService.deleteOlderThan(cutoffDate);
this.logger.info({
message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`,
service: SERVICE_NAME,
method: "getCleanupRetentionJob",
});
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "getCleanupRetentionJob",
stack: error instanceof Error ? error.stack : undefined,
});
}
};
};
private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision {
const { monitor, statusChanged, prevStatus } = statusChangeResult;
@@ -456,3 +414,5 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return decision;
}
}
export default SuperSimpleQueueHelper;
@@ -3,7 +3,6 @@ import type { GeoCheck } from "@/types/index.js";
import type { IGeoChecksService } from "../business/geoChecksService.js";
import type { ILogger } from "@/utils/logger.js";
import type { ISettingsService } from "@/service/system/settingsService.js";
import { ICheckService } from "../business/checkService.js";
const SERVICE_NAME = "BufferService";
export interface IBufferService {
@@ -15,18 +14,18 @@ export interface IBufferService {
flushGeoBuffer(): Promise<void>;
}
export class BufferService implements IBufferService {
class BufferService implements IBufferService {
static SERVICE_NAME = SERVICE_NAME;
private BUFFER_TIMEOUT: number;
private logger: ILogger;
private SERVICE_NAME: string;
private buffer: Check[];
private geoBuffer: GeoCheck[];
private buffer: any[];
private geoBuffer: any[];
private bufferTimer: NodeJS.Timeout | null = null;
private checksService: ICheckService;
private checksService: any;
private geoChecksService: IGeoChecksService;
constructor(logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, settingsService: ISettingsService) {
constructor(logger: ILogger, checkService: any, geoChecksService: IGeoChecksService, settingsService: ISettingsService) {
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute
this.logger = logger;
this.checksService = checkService;
@@ -49,12 +48,12 @@ export class BufferService implements IBufferService {
addToBuffer(check: Check) {
try {
this.buffer.push(check);
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: this.SERVICE_NAME,
method: "addToBuffer",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
}
@@ -62,12 +61,12 @@ export class BufferService implements IBufferService {
addGeoCheckToBuffer(geoCheck: GeoCheck) {
try {
this.geoBuffer.push(geoCheck);
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: this.SERVICE_NAME,
method: "addGeoCheckToBuffer",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
}
@@ -99,12 +98,12 @@ export class BufferService implements IBufferService {
}
return false;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
return false;
}
@@ -118,12 +117,12 @@ export class BufferService implements IBufferService {
try {
await this.flushBuffer();
await this.flushGeoBuffer();
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: `Error in flush cycle: ${error.message}`,
service: this.SERVICE_NAME,
method: "scheduleNextFlush",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
} finally {
// Schedule the next flush only after the current one completes
@@ -142,12 +141,12 @@ export class BufferService implements IBufferService {
await this.checksService.createChecks(this.buffer);
this.buffer = [];
}
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: `Error flushing checks buffer: ${error.message}`,
service: this.SERVICE_NAME,
method: "flushBuffer",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
// Clear buffer even on error to prevent infinite retry loops
this.buffer = [];
@@ -165,15 +164,17 @@ export class BufferService implements IBufferService {
await this.geoChecksService.createGeoChecks(this.geoBuffer);
this.geoBuffer = [];
}
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: `Error flushing geo checks buffer: ${error.message}`,
service: this.SERVICE_NAME,
method: "flushGeoBuffer",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
// Clear buffer even on error to prevent infinite retry loops
this.geoBuffer = [];
}
}
}
export default BufferService;
@@ -1,51 +1,27 @@
import { fileURLToPath } from "url";
import path from "path";
import { EmailTransportConfig } from "@/types/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { ILogger } from "@/utils/logger.js";
import fs from "node:fs";
import path from "node:path";
import nodemailer from "nodemailer";
import mjml2html from "mjml";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SERVICE_NAME = "EmailService";
type MjmlFn = typeof mjml2html;
type FileSystem = typeof fs;
type PathModule = typeof path;
type Mailer = typeof nodemailer;
type TemplateCompiler = (template: string) => (context: Record<string, unknown>) => string;
export interface IEmailService {
init(): void;
buildEmail(template: string, context: Record<string, unknown>): Promise<string | undefined>;
sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise<string | false | undefined>;
}
export class EmailService implements IEmailService {
class EmailService {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: ISettingsService;
private fs: FileSystem;
private path: PathModule;
private compile: TemplateCompiler;
private mjml2html: MjmlFn;
private nodemailer: Mailer;
private logger: ILogger;
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
private settingsService: any;
private fs: any;
private path: any;
private compile: any;
private mjml2html: any;
private nodemailer: any;
private logger: any;
private transporter: any;
private templateLookup: Record<string, Function>;
private loadTemplate: (templateName: string) => Function;
constructor(
settingsService: ISettingsService,
fs: FileSystem,
path: PathModule,
compile: TemplateCompiler,
mjml2html: MjmlFn,
nodemailer: Mailer,
logger: ILogger
) {
constructor(settingsService: any, fs: any, path: any, compile: any, mjml2html: any, nodemailer: any, logger: any) {
this.settingsService = settingsService;
this.fs = fs;
this.path = path;
@@ -54,7 +30,9 @@ export class EmailService implements IEmailService {
this.nodemailer = nodemailer;
this.logger = logger;
this.templateLookup = {};
this.loadTemplate = () => undefined;
this.loadTemplate = () => {
return () => {};
};
this.init();
}
@@ -68,12 +46,12 @@ export class EmailService implements IEmailService {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
return this.compile(templateContent);
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "loadTemplate",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
};
@@ -88,20 +66,17 @@ export class EmailService implements IEmailService {
};
};
buildEmail = async (template: string, context: Record<string, unknown>) => {
buildEmail = async (template: string, context: Record<string, any>) => {
try {
const mjml = this.templateLookup[template]?.(context);
if (!mjml) {
throw new Error(`Template ${template} not found`);
}
const html = await this.mjml2html(mjml);
return html.html;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "buildEmail",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
};
@@ -150,12 +125,11 @@ export class EmailService implements IEmailService {
try {
await this.transporter.verify();
} catch (error: unknown) {
} catch (error) {
this.logger.warn({
message: "Email transporter verification failed",
service: SERVICE_NAME,
method: "verifyTransporter",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
@@ -168,13 +142,15 @@ export class EmailService implements IEmailService {
html: html,
});
return info?.messageId;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "sendEmail",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
};
}
export default EmailService;
@@ -1,6 +1,4 @@
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import { MonitorType } from "@/types/index.js";
import type { ILogger } from "@/utils/logger.js";
import got from "got";
@@ -10,7 +8,7 @@ const POLL_INTERVAL_MS = 2000;
const MAX_POLL_TIMEOUT_MS = 30000;
interface GlobalPingMeasurementRequest {
type: MonitorType;
type: "http";
target: string;
locations: Array<{ continent: GeoContinent }>;
limit: number;
@@ -46,26 +44,17 @@ interface GlobalPingProbeResult {
firstByte: number;
download: number;
};
stats?: {
min: number;
max: number;
avg: number;
total: number;
loss: number;
rcv: number;
drop: number;
};
rawOutput?: string;
};
}
export interface IGlobalPingService {
readonly serviceName: string;
createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>;
createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null>;
pollForResults(measurementId: string, timeoutMs?: number): Promise<GeoCheckResult[]>;
}
export class GlobalPingService implements IGlobalPingService {
class GlobalPingService implements IGlobalPingService {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
@@ -78,16 +67,13 @@ export class GlobalPingService implements IGlobalPingService {
return GlobalPingService.SERVICE_NAME;
}
async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null> {
async createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null> {
try {
if (!supportsGeoCheck(monitorType)) {
throw new Error(`Unsupported monitor type for GlobalPing: ${monitorType}`);
}
// GlobalPing API expects target without protocol (http:// or https://)
const cleanTarget = url.replace(/^https?:\/\//, "");
const requestBody: GlobalPingMeasurementRequest = {
type: monitorType,
type: "http",
target: cleanTarget,
locations: locations.map((continent) => ({ continent })),
limit: locations.length,
@@ -102,18 +88,19 @@ export class GlobalPingService implements IGlobalPingService {
const measurementId = response.body.id;
this.logger.debug({
message: `Created GlobalPing measurement: ${measurementId} for target: ${cleanTarget}`,
message: `Created GlobalPing measurement: ${measurementId}`,
service: SERVICE_NAME,
method: "createMeasurement",
details: { measurementId, url, locations },
});
return measurementId;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: "GlobalPing API unavailable, skipping geo check",
service: SERVICE_NAME,
method: "createMeasurement",
stack: error instanceof Error ? error.stack : undefined,
details: error,
});
return null;
}
@@ -153,12 +140,12 @@ export class GlobalPingService implements IGlobalPingService {
// Still in-progress, wait and poll again
await this.sleep(POLL_INTERVAL_MS);
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: "Error polling GlobalPing API",
service: SERVICE_NAME,
method: "pollForResults",
stack: error instanceof Error ? error.stack : undefined,
details: error.message,
});
return [];
}
@@ -178,7 +165,8 @@ export class GlobalPingService implements IGlobalPingService {
const successfulResults: GeoCheckResult[] = [];
for (const probeResult of probeResults) {
if (probeResult.result.status !== "finished") {
// Skip failed or timeout results
if (probeResult.result.status !== "finished" || !probeResult.result.statusCode || !probeResult.result.timings) {
continue;
}
@@ -192,38 +180,23 @@ export class GlobalPingService implements IGlobalPingService {
latitude: probeResult.probe.latitude,
};
// HTTP results have statusCode and timings, ping results have stats
if (probeResult.result.statusCode && probeResult.result.timings) {
const timings: GeoCheckTimings = {
total: probeResult.result.timings.total,
dns: probeResult.result.timings.dns,
tcp: probeResult.result.timings.tcp,
tls: probeResult.result.timings.tls,
firstByte: probeResult.result.timings.firstByte,
download: probeResult.result.timings.download,
};
const timings: GeoCheckTimings = {
total: probeResult.result.timings.total,
dns: probeResult.result.timings.dns,
tcp: probeResult.result.timings.tcp,
tls: probeResult.result.timings.tls,
firstByte: probeResult.result.timings.firstByte,
download: probeResult.result.timings.download,
};
successfulResults.push({
location,
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
statusCode: probeResult.result.statusCode,
timings,
});
} else if (probeResult.result.stats) {
successfulResults.push({
location,
status: probeResult.result.stats.loss === 0,
statusCode: probeResult.result.stats.loss === 0 ? 200 : 5000,
timings: {
total: probeResult.result.stats.avg,
dns: 0,
tcp: 0,
tls: 0,
firstByte: 0,
download: 0,
},
});
}
const result: GeoCheckResult = {
location,
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
statusCode: probeResult.result.statusCode,
timings,
};
successfulResults.push(result);
}
return successfulResults;
@@ -233,3 +206,5 @@ export class GlobalPingService implements IGlobalPingService {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
export default GlobalPingService;
@@ -1,45 +0,0 @@
import { Monitor } from "@/types/monitor.js";
import jmespath from "jmespath";
type JmesPath = typeof jmespath;
export class AdvancedMatcher {
constructor(private jmespath: JmesPath) {}
private compare(actual: unknown, expected: string, method?: string): boolean {
if (method === "equal") return String(actual) === expected;
if (method === "include") return String(actual).includes(expected);
if (method === "regex") return new RegExp(expected).test(String(actual));
return String(actual) === expected; // Default
}
validate<T>(payload: T, monitor: Monitor): { ok: boolean; message: string; extracted?: any } {
const { useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor;
if (!useAdvancedMatching) return { ok: true, message: "Success" };
let dataToValidate = payload;
if (jsonPath) {
try {
dataToValidate = this.jmespath.search(payload, jsonPath);
} catch {
return { ok: false, message: "Error evaluating JSON path" };
}
}
if (expectedValue) {
const ok = this.compare(dataToValidate, expectedValue, matchMethod);
return {
ok,
message: ok ? "Success" : "Expected value did not match",
extracted: dataToValidate,
};
}
const isFalsey = dataToValidate === false || dataToValidate === "false" || dataToValidate === undefined || dataToValidate === null;
return {
ok: !isFalsey,
message: !isFalsey ? "Success" : "Extracted value is falsey",
extracted: dataToValidate,
};
}
}
@@ -1,160 +0,0 @@
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode";
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
type DockerodeType = typeof Dockerode;
const SERVICE_NAME = "DockerProvider";
export interface DockerError extends Error {
statusCode?: number;
reason?: string;
json?: { message?: string };
}
export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
readonly type = "docker";
private docker: Dockerode;
constructor(
private logger: ILogger,
private DockerLib: DockerodeType
) {
this.docker = new this.DockerLib();
}
supports(type: MonitorType): boolean {
return type === "docker";
}
private isDockerError(error: unknown): error is DockerError {
return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error);
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> {
const { url: containerInput } = monitor;
try {
if (!containerInput) {
throw new Error("Container name or ID is required for Docker monitor");
}
const containers = await this.docker.listContainers({ all: true });
const normalizedInput = containerInput.replace(/^\/+/, "").toLowerCase();
// Priority-based matching to avoid ambiguity:
// 1. Exact full ID match (64-char)
const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput);
// 2. Exact container name match (case-insensitive)
const exactNameMatch = containers.find((c) =>
c.Names.some((name: string) => {
const cleanName = name.replace(/^\/+/, "").toLowerCase();
return cleanName === normalizedInput;
})
);
// 3. Partial ID match (fallback for backwards compatibility)
const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput));
// Select container based on priority
const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch;
// Handle no match
if (!targetContainer) {
this.logger.warn({
message: `No container found for "${monitor.url}".`,
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url },
});
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: 404,
message: "Docker container not found",
responseTime: 0,
payload: null,
};
}
// 5. Handle Ambiguity Check
const matchTypes: string[] = [];
if (exactIdMatch) matchTypes.push("exact ID");
if (exactNameMatch) matchTypes.push("exact name");
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
if (matchTypes.length > 1) {
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
this.logger.warn({
message,
service: SERVICE_NAME,
method: "handle",
details: { url: containerInput },
});
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message,
responseTime: 0,
payload: null,
};
}
// 6. Inspect Container Status
const container = this.docker.getContainer(targetContainer.Id);
const { response, responseTime, error } = await timeRequest(() => container.inspect());
if (error) {
let message = "Failed to fetch Docker container information";
let code = NETWORK_ERROR;
if (this.isDockerError(error)) {
code = error.statusCode ?? NETWORK_ERROR;
message = error.json?.message ?? error.reason ?? error.message;
} else if (error instanceof Error) {
message = error.message;
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: code,
message: message,
responseTime,
payload: null,
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response?.State?.Status === "running",
code: 200,
message: "Docker container status fetched successfully",
responseTime,
payload: response as unknown as DockerStatusPayload,
};
} catch (err: unknown) {
throw new AppError({
message: err instanceof Error ? err.message : "Error performing Docker request",
service: SERVICE_NAME,
method: "handle",
details: { url: containerInput },
});
}
}
}
@@ -1,78 +0,0 @@
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { GameDig } from "gamedig";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js";
import { ILogger } from "@/utils/logger.js";
type GameDigType = typeof GameDig;
const SERVICE_NAME = "GameProvider";
export class GameProvider implements IStatusProvider<GameStatusPayload> {
readonly type = "game";
constructor(
private logger: ILogger,
private gameDig: GameDigType
) {}
supports(type: MonitorType): boolean {
return type === "game";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<GameStatusPayload>> {
const { url, port, gameId, id, teamId, type } = monitor;
// Clean the host in case a full URL was passed
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0] || "";
try {
const state = await this.gameDig
.query({
type: gameId ?? "unknown",
host: host,
port: port ?? 0,
})
.catch((error: unknown) => {
this.logger.warn({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: "handle",
details: { host, port, gameId },
});
return undefined;
});
if (!state) {
return {
monitorId: id,
teamId: teamId,
type: type,
status: false,
code: NETWORK_ERROR,
message: "No response from game server",
responseTime: 0,
payload: null,
};
}
return {
monitorId: id,
teamId: teamId,
type: type,
status: true,
code: 200,
message: "Success",
responseTime: state.ping ?? 0,
payload: state,
};
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
throw new AppError({
message: originalMessage || "Error performing game server check",
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url, port: monitor.port, gameId: monitor.gameId },
});
}
}
}
@@ -1,180 +0,0 @@
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/infrastructure/network/utils.js";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import path from "path";
import { fileURLToPath } from "url";
type GrpcType = typeof grpc;
type ProtoLoaderType = typeof protoLoader;
const SERVICE_NAME = "GrpcProvider";
export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
readonly type = "grpc";
constructor(
private grpc: GrpcType,
private protoLoader: ProtoLoaderType
) {}
supports(type: MonitorType): boolean {
return type === "grpc";
}
private getGrpcStatusName(code: number): string {
const statusNames: Record<number, string> = {
0: "OK",
1: "CANCELLED",
2: "UNKNOWN",
3: "INVALID_ARGUMENT",
4: "DEADLINE_EXCEEDED",
5: "NOT_FOUND",
6: "ALREADY_EXISTS",
7: "PERMISSION_DENIED",
8: "RESOURCE_EXHAUSTED",
9: "FAILED_PRECONDITION",
10: "ABORTED",
11: "OUT_OF_RANGE",
12: "UNIMPLEMENTED",
13: "INTERNAL",
14: "UNAVAILABLE",
15: "DATA_LOSS",
16: "UNAUTHENTICATED",
};
return statusNames[code] || "UNKNOWN";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<GrpcStatusPayload>> {
try {
const { id, teamId, type, url, port, ignoreTlsErrors } = monitor;
const grpcServiceName = monitor.grpcServiceName || "";
if (!url) {
throw new AppError({ message: "Monitor host is required", service: SERVICE_NAME, method: "handle" });
}
if (!port) {
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" });
}
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const target = `${host}:${port}`;
const currentFilePath = fileURLToPath(import.meta.url);
const protoPath = path.join(path.dirname(currentFilePath), "protos", "health.proto");
const packageDefinition = this.protoLoader.loadSync(protoPath, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const grpcObject = this.grpc.loadPackageDefinition(packageDefinition) as unknown as {
grpc: {
health: {
v1: {
Health: new (
target: string,
credentials: unknown
) => {
Check: (request: { service: string }, options: { deadline: Date }, callback: (err: unknown, response: unknown) => void) => void;
close: () => void;
};
};
};
};
};
const healthService = grpcObject.grpc.health.v1.Health;
let credentials;
if (ignoreTlsErrors) {
credentials = this.grpc.credentials.createSsl(null, null, null, {
checkServerIdentity: () => undefined,
});
} else {
credentials = this.grpc.credentials.createInsecure();
}
const client = new healthService(target, credentials);
const TIMEOUT_MS = 10000;
const deadline = new Date(Date.now() + TIMEOUT_MS);
const { response, responseTime, error } = await timeRequest<GrpcStatusPayload>(() => {
return new Promise<GrpcStatusPayload>((resolve, reject) => {
client.Check({ service: grpcServiceName }, { deadline }, (err: unknown, response: unknown) => {
client.close();
if (err) {
const grpcErr = err as { code?: number; details?: string; message?: string };
const payload: GrpcStatusPayload = {
grpcStatusCode: grpcErr.code ?? -1,
grpcStatusName: this.getGrpcStatusName(grpcErr.code ?? -1),
serviceName: grpcServiceName,
servingStatus: "UNKNOWN",
};
const grpcError = new AppError({
message: grpcErr.details || grpcErr.message || "gRPC error",
service: SERVICE_NAME,
method: "handle",
}) as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number };
grpcError.grpcPayload = payload;
grpcError.grpcCode = grpcErr.code;
reject(grpcError);
return;
}
const resp = response as { status?: string } | undefined;
const servingStatus = resp?.status ?? "UNKNOWN";
resolve({
grpcStatusCode: 0,
grpcStatusName: "OK",
serviceName: grpcServiceName,
servingStatus,
});
});
});
});
if (error) {
const grpcError = error as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number };
const payload = grpcError.grpcPayload;
return {
monitorId: id,
teamId: teamId,
type: type,
status: false,
code: grpcError.grpcCode ?? 5000,
message: grpcError.message ?? "gRPC health check failed",
responseTime,
payload: payload ?? null,
};
}
const grpcPayload = response as GrpcStatusPayload;
const isServing = grpcPayload.servingStatus === "SERVING";
return {
monitorId: id,
teamId: teamId,
type: type,
status: isServing,
code: isServing ? 200 : 5000,
message: isServing ? `gRPC service healthy (${grpcPayload.servingStatus})` : `gRPC service unhealthy (${grpcPayload.servingStatus})`,
responseTime,
payload: grpcPayload,
};
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
throw new AppError({
message: originalMessage || "Error performing gRPC health check",
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url, port: monitor.port, grpcServiceName: monitor.grpcServiceName },
});
}
}
}
@@ -1,29 +0,0 @@
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
import { AppError } from "@/utils/AppError.js";
export class HardwareProvider implements IStatusProvider<HardwareStatusPayload> {
readonly type = "hardware";
constructor(private httpProvider: HttpProvider) {}
supports(type: MonitorType) {
return type === "hardware";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<HardwareStatusPayload>> {
const { url } = monitor;
try {
if (!url) throw new Error("URL is required for Hardware monitor");
return await this.httpProvider.handle<HardwareStatusPayload>(monitor);
} catch (err: unknown) {
throw new AppError({
message: err instanceof Error ? err.message : "Error performing Hardware request",
service: "HardwareProvider",
method: "handle",
details: { url: monitor.url },
});
}
}
}
@@ -1,121 +0,0 @@
import { type Got, HTTPError, RequestError } from "got";
import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js";
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { HttpStatusPayload } from "@/types/network.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { Agent as HttpsAgent } from "https";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js";
import CacheableLookup from "cacheable-lookup";
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
readonly type = "http";
constructor(
private got: Got,
private advancedMatcher: AdvancedMatcher
) {
const cacheable = new CacheableLookup({ maxTtl: 300, errorTtl: 30 });
this.got = got.extend({
dnsCache: cacheable,
timeout: {
request: 30000,
},
retry: { limit: 1 },
});
}
supports(type: MonitorType) {
return type === "http";
}
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
if (error instanceof HTTPError || error instanceof RequestError) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: error.response?.statusCode ?? NETWORK_ERROR,
message: error.message,
responseTime: error.timings?.phases?.total ?? 0,
timings: error.timings,
payload: null as T,
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: error instanceof Error ? error.message : String(error),
responseTime: 0,
payload: null as T,
};
}
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
const { url, secret, jsonPath, ignoreTlsErrors } = monitor;
if (!url) {
throw new Error("URL is required for HTTP monitor");
}
const options: Record<string, unknown> = {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
options.agent = {
https: new HttpsAgent({ rejectUnauthorized: !ignoreTlsErrors }),
};
try {
const response = await this.got<string>(url, options);
const contentType = response.headers["content-type"] || "";
const isJson = contentType.includes("application/json");
if (jsonPath && !isJson) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: response.statusCode,
message: "Response is not JSON",
responseTime: response.timings.phases.total ?? 0,
timings: response.timings,
payload: response.body as unknown as T,
};
}
let payload: T;
if (isJson) {
try {
payload = JSON.parse(response.body) as T;
} catch {
payload = response.body as unknown as T;
}
} else {
payload = response.body as unknown as T;
}
const matchResult = this.advancedMatcher.validate<T>(payload, monitor);
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response.ok && matchResult.ok,
code: response.statusCode,
message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message,
responseTime: response.timings.phases.total ?? 0,
timings: response.timings,
payload,
extracted: matchResult.extracted,
};
} catch (error: unknown) {
return this.handleHttpError(error, monitor);
}
}
}
@@ -1,8 +0,0 @@
import { Monitor, MonitorType } from "@/types/monitor.js";
import { MonitorStatusResponse } from "@/types/network.js";
export interface IStatusProvider<T> {
type: string;
supports: (type: MonitorType) => boolean;
handle(monitor: Monitor): Promise<MonitorStatusResponse<T>>;
}
@@ -1,55 +0,0 @@
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
export class PageSpeedProvider implements IStatusProvider<PageSpeedStatusPayload> {
readonly type = "pagespeed";
constructor(
private httpProvider: HttpProvider,
private settingsService: ISettingsService,
private logger: ILogger
) {}
supports(type: MonitorType) {
return type === "pagespeed";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<PageSpeedStatusPayload>> {
const { url } = monitor;
try {
if (!url) throw new Error("URL is required for PageSpeed monitor");
const dbSettings = await this.settingsService.getDBSettings();
const apiKey = dbSettings?.pagespeedApiKey;
let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(
url
)}&category=seo&category=accessibility&category=best-practices&category=performance`;
if (apiKey) {
pageSpeedUrl += `&key=${apiKey}`;
} else {
this.logger.warn({
message: "PageSpeed API key not found, performance may be throttled",
service: "PageSpeedProvider",
method: "handle",
details: { url },
});
}
return await this.httpProvider.handle<PageSpeedStatusPayload>({
...monitor,
url: pageSpeedUrl,
});
} catch (err: unknown) {
throw new AppError({
message: err instanceof Error ? err.message : "Error performing PageSpeed request",
service: "PageSpeedProvider",
method: "handle",
details: { url: monitor.url },
});
}
}
}
@@ -1,66 +0,0 @@
import { PingStatusPayload } from "@/types/network.js";
import { IStatusProvider } from "./IStatusProvider.js";
import { MonitorType, Monitor } from "@/types/monitor.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import ping from "ping";
import { timeRequest } from "@/service/infrastructure/network/utils.js";
const SERVICE_NAME = "PingProvider";
type Ping = typeof ping;
export class PingProvider implements IStatusProvider<PingStatusPayload> {
readonly type = "ping";
constructor(private ping: Ping) {}
supports(type: MonitorType): boolean {
return type === "ping";
}
private sanitizeHost(url: string): string {
return url
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "")
.replace(/:.*/, "");
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<PingStatusPayload>> {
try {
if (!monitor.url) {
throw new Error("URL is required for ping monitor");
}
const sanitizedHost = this.sanitizeHost(monitor.url);
const { response, error } = await timeRequest<PingStatusPayload>(() => this.ping.promise.probe(sanitizedHost));
if (error) {
throw error;
}
if (!response) {
throw new Error(`No response from ping for host: ${sanitizedHost}`);
}
const responseTime = typeof response.time === "number" ? response.time : parseFloat(String(response.time)) || 0;
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response.alive ?? false,
code: response.alive ? 200 : 5000,
message: response.alive ? "Success" : "Ping failed",
responseTime,
payload: response,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw new AppError({
message,
service: SERVICE_NAME,
method: "handle",
});
}
}
}
@@ -1,90 +0,0 @@
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
import * as net from "net";
type NetType = typeof net;
const SERVICE_NAME = "PortProvider";
export class PortProvider implements IStatusProvider<PortStatusPayload> {
readonly type = "port";
constructor(private net: NetType) {}
supports(type: MonitorType): boolean {
return type === "port";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<PortStatusPayload>> {
try {
const { url, port } = monitor;
if (!url || !port) {
throw new Error("URL and port are required for port monitoring");
}
const { responseTime, error } = await timeRequest(async () => {
return new Promise((resolve, reject) => {
const socket = this.net.createConnection(
{
host: url,
port,
},
() => {
socket.end();
socket.destroy();
resolve({ success: true });
}
);
socket.setTimeout(5000);
socket.on("timeout", () => {
socket.destroy();
reject(new Error("Connection timeout"));
});
socket.on("error", (err: unknown) => {
socket.destroy();
reject(err);
});
});
});
if (error) {
const errorMessage = error instanceof Error ? error.message : "Port check failed";
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: errorMessage,
responseTime: responseTime,
timings: undefined,
payload: { success: false },
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: true,
code: 200,
message: "Port check successful",
responseTime: responseTime,
timings: undefined,
payload: { success: true },
};
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
throw new AppError({
message: originalMessage || "Error performing port check",
status: 500,
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url, port: monitor.port },
});
}
}
}
@@ -1,94 +0,0 @@
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
import type WebSocket from "ws";
type WebSocketConstructor = typeof WebSocket;
const SERVICE_NAME = "WebSocketProvider";
const TIMEOUT_MS = 10000;
export class WebSocketProvider implements IStatusProvider<WebSocketStatusPayload> {
readonly type = "websocket";
constructor(private WS: WebSocketConstructor) {}
supports(type: MonitorType): boolean {
return type === "websocket";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<WebSocketStatusPayload>> {
try {
const { url } = monitor;
if (!url) {
throw new Error("URL is required for WebSocket monitoring");
}
const { responseTime, error } = await timeRequest(async () => {
return new Promise<{ connected: boolean }>((resolve, reject) => {
const options: WebSocket.ClientOptions = {};
if (monitor.ignoreTlsErrors) {
options.rejectUnauthorized = false;
}
const ws = new this.WS(url, options);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("WebSocket connection timeout"));
}, TIMEOUT_MS);
ws.on("open", () => {
clearTimeout(timeout);
ws.close();
resolve({ connected: true });
});
ws.on("error", (err: unknown) => {
clearTimeout(timeout);
ws.close();
reject(err);
});
});
});
if (error) {
const errorMessage = error instanceof Error ? error.message : "WebSocket check failed";
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: errorMessage,
responseTime: responseTime,
timings: undefined,
payload: { connected: false },
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: true,
code: 200,
message: "WebSocket check successful",
responseTime: responseTime,
timings: undefined,
payload: { connected: true },
};
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
throw new AppError({
message: originalMessage || "Error performing WebSocket check",
status: 500,
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url },
});
}
}
}
@@ -1,14 +0,0 @@
export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => {
const start = process.hrtime.bigint();
try {
const response = await operation();
const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response, responseTime: elapsedMs, error: null };
} catch (error) {
const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response: null, responseTime: elapsedMs, error };
}
};
export const NETWORK_ERROR = 5000;
export const PING_ERROR = 5001;
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
import type { HardwareStatusPayload, Monitor, MonitorStatusResponse } from "@/types/index.js";
import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type {
NotificationMessage,
@@ -30,8 +30,8 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
clientHost: string
): NotificationMessage {
const type = this.determineNotificationType(decision, monitor);
const severity = this.determineSeverity(type);
const content = this.buildContent(type, monitor, monitorStatusResponse);
const severity = this.determineSeverity(type, monitor.status);
const content = this.buildContent(type, monitor, monitorStatusResponse, decision, clientHost);
return {
type,
@@ -77,7 +77,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
return "monitor_up";
}
private determineSeverity(type: NotificationType): NotificationSeverity {
private determineSeverity(type: NotificationType, monitorStatus: string): NotificationSeverity {
switch (type) {
case "monitor_down":
return "critical";
@@ -93,22 +93,28 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
}
}
private buildContent(type: NotificationType, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent {
private buildContent(
type: NotificationType,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationContent {
switch (type) {
case "monitor_down":
return this.buildMonitorDownContent(monitor, monitorStatusResponse);
return this.buildMonitorDownContent(monitor, monitorStatusResponse, clientHost);
case "monitor_up":
return this.buildMonitorUpContent(monitor);
return this.buildMonitorUpContent(monitor, clientHost);
case "threshold_breach":
return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<HardwareStatusPayload>);
return this.buildThresholdBreachContent(monitor, monitorStatusResponse, decision, clientHost);
case "threshold_resolved":
return this.buildThresholdResolvedContent(monitor);
return this.buildThresholdResolvedContent(monitor, clientHost);
default:
return this.buildDefaultContent(monitor);
return this.buildDefaultContent(monitor, clientHost);
}
}
private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent {
private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, clientHost: string): NotificationContent {
const title = `Monitor Down: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" is currently down and unreachable.`;
const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`];
@@ -131,7 +137,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
private buildMonitorUpContent(monitor: Monitor): NotificationContent {
private buildMonitorUpContent(monitor: Monitor, clientHost: string): NotificationContent {
const title = `Monitor Recovered: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" is back up and operational.`;
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
@@ -144,7 +150,12 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
private buildThresholdBreachContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<HardwareStatusPayload>): NotificationContent {
private buildThresholdBreachContent(
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationContent {
const title = `Threshold Breach: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" has breached one or more thresholds.`;
const details = [`URL: ${monitor.url}`, `Status: Breached`, `Type: ${monitor.type}`];
@@ -160,7 +171,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
private buildThresholdResolvedContent(monitor: Monitor): NotificationContent {
private buildThresholdResolvedContent(monitor: Monitor, clientHost: string): NotificationContent {
const title = `Thresholds Resolved: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" thresholds have returned to normal.`;
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
@@ -173,7 +184,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
private buildDefaultContent(monitor: Monitor): NotificationContent {
private buildDefaultContent(monitor: Monitor, clientHost: string): NotificationContent {
return {
title: `Monitor: ${monitor.name}`,
summary: `Status update for monitor "${monitor.name}".`,
@@ -182,7 +193,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<HardwareStatusPayload>): ThresholdBreach[] {
public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] {
const breaches: ThresholdBreach[] = [];
// Check if this is a hardware monitor with threshold data
@@ -191,7 +202,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
}
// Cast to HardwareStatusPayload type
const payload = monitorStatusResponse.payload;
const payload = monitorStatusResponse.payload as { data?: any };
const hardware = payload.data;
if (!hardware) {
@@ -3,5 +3,5 @@ import type { NotificationMessage } from "@/types/notificationMessage.js";
export interface INotificationProvider {
sendMessage: (notification: Notification, message: NotificationMessage) => Promise<boolean>;
sendTestAlert(notification: Partial<Notification>): Promise<boolean>;
sendTestAlert(notification: Notification): Promise<boolean>;
}
@@ -1,19 +1,18 @@
const SERVICE_NAME = "DiscordProvider";
import type { AlertDiscordPayload, DiscordEmbedField, Notification } from "@/types/index.js";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage, NotificationSeverity } from "@/types/notificationMessage.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class DiscordProvider implements INotificationProvider {
private logger: ILogger;
private logger: any;
constructor(logger: ILogger) {
constructor(logger: any) {
this.logger = logger;
}
sendTestAlert = async (notification: Partial<Notification>) => {
sendTestAlert = async (notification: Notification) => {
if (!notification.address) {
return false;
}
@@ -37,6 +36,9 @@ export class DiscordProvider implements INotificationProvider {
}
};
/**
* New unified message format implementation
*/
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
this.logger.warn({
@@ -69,17 +71,18 @@ export class DiscordProvider implements INotificationProvider {
}
}
private buildDiscordEmbed(message: NotificationMessage): AlertDiscordPayload {
const colorMap: Record<NotificationSeverity, number> = {
private buildDiscordEmbed(message: NotificationMessage): any {
// Map severity to Discord embed colors
const colorMap: Record<string, number> = {
critical: 0xdc2626, // red-600
warning: 0xf59e0b, // amber-500
info: 0x3b82f6, // blue-500
success: 0x10b981, // green-500
};
const color = colorMap[message.severity] ?? colorMap.info;
const color = colorMap[message.severity] || colorMap.info;
const fields: Array<DiscordEmbedField> = [];
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
// Add monitor details
fields.push({
@@ -1,41 +1,23 @@
const SERVICE_NAME = "EmailProvider";
import type { Notification } from "@/types/index.js";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import type { ILogger } from "@/utils/logger.js";
import { IEmailService } from "@/service/infrastructure/emailService.js";
export class EmailProvider implements INotificationProvider {
private emailService: IEmailService;
private logger: ILogger;
constructor(emailService: IEmailService, logger: ILogger) {
export class EmailProvider implements INotificationProvider {
private emailService: any;
private logger: any;
constructor(emailService: any, logger: any) {
this.emailService = emailService;
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
async sendTestAlert(notification: Notification): Promise<boolean> {
const subject = "Test notification";
const html = await buildTestEmail(this.emailService);
if (!notification.address) {
this.logger.warn({
message: "Missing address",
service: SERVICE_NAME,
method: "sendTestAlert",
});
return false;
}
if (!html) {
this.logger.warn({
message: "Failed to build test email content",
service: SERVICE_NAME,
method: "sendTestAlert",
});
return false;
}
const messageId = await this.emailService.sendEmail(notification.address, subject, html);
if (!messageId) {
this.logger.warn({
@@ -56,15 +38,6 @@ export class EmailProvider implements INotificationProvider {
const subject = this.buildSubject(message);
const html = await this.buildEmailFromMessage(message);
if (!html) {
this.logger.warn({
message: "Failed to build email content",
service: SERVICE_NAME,
method: "sendMessage",
});
return false;
}
const messageId = await this.emailService.sendEmail(notification.address, subject, html);
if (!messageId) {
this.logger.warn({
@@ -92,7 +65,7 @@ export class EmailProvider implements INotificationProvider {
}
}
private async buildEmailFromMessage(message: NotificationMessage): Promise<string | undefined> {
private async buildEmailFromMessage(message: NotificationMessage): Promise<string> {
const context = {
title: message.content.title,
summary: message.content.summary,
@@ -115,6 +88,16 @@ export class EmailProvider implements INotificationProvider {
const html = await this.emailService.buildEmail("unifiedNotificationTemplate", context);
this.logger.info({
message: "[DEBUG] Email HTML generated",
service: SERVICE_NAME,
method: "buildEmailFromMessage",
details: {
htmlLength: html?.length || 0,
htmlPreview: html?.substring(0, 200),
},
});
return html;
}
@@ -1,19 +1,18 @@
const SERVICE_NAME = "MatrixProvider";
import got from "got";
import type { INotificationProvider } from "@/service/index.js";
import type { AlertMatrixPayload, Notification } from "@/types/index.js";
import type { Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { ILogger } from "@/utils/logger.js";
export class MatrixProvider implements INotificationProvider {
private logger: ILogger;
private logger: any;
constructor(logger: ILogger) {
constructor(logger: any) {
this.logger = logger;
}
sendTestAlert = async (notification: Partial<Notification>) => {
sendTestAlert = async (notification: Notification) => {
const { homeserverUrl, accessToken, roomId } = notification;
if (!homeserverUrl || !accessToken || !roomId) {
return false;
@@ -43,6 +42,9 @@ export class MatrixProvider implements INotificationProvider {
}
};
/**
* New unified message format - builds Matrix HTML message from NotificationMessage
*/
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification;
@@ -85,7 +87,7 @@ export class MatrixProvider implements INotificationProvider {
* Returns both plain text and HTML formatted versions
* Matrix supports HTML subset for rich formatting
*/
private buildMatrixMessage(message: NotificationMessage): AlertMatrixPayload {
private buildMatrixMessage(message: NotificationMessage): { plainText: string; htmlText: string } {
const plainLines: string[] = [];
const htmlLines: string[] = [];
@@ -1,20 +1,19 @@
const SERVICE_NAME = "PagerDutyProvider";
import got from "got";
import type { Notification } from "@/types/index.js";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { INotificationProvider } from "@/service/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { ILogger } from "@/utils/logger.js";
import { AlertPagerDutyPayload } from "@/types/index.js";
export class PagerDutyProvider implements INotificationProvider {
private logger: ILogger;
private logger: any;
constructor(logger: ILogger) {
constructor(logger: any) {
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
async sendTestAlert(notification: Notification): Promise<boolean> {
try {
await got.post("https://events.pagerduty.com/v2/enqueue", {
json: {
@@ -78,7 +77,7 @@ export class PagerDutyProvider implements INotificationProvider {
}
}
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): AlertPagerDutyPayload {
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): any {
// Map our notification type to PagerDuty event_action
const eventAction = message.type === "monitor_up" || message.type === "threshold_resolved" ? "resolve" : "trigger";
@@ -107,7 +106,7 @@ export class PagerDutyProvider implements INotificationProvider {
}
// Build custom details
const customDetails: Record<string, unknown> = {
const customDetails: any = {
monitor_name: message.monitor.name,
monitor_url: message.monitor.url,
monitor_type: message.monitor.type,
@@ -1,19 +1,19 @@
const SERVICE_NAME = "SlackProvider";
import type { Notification } from "@/types/index.js";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got, { HTTPError } from "got";
import { ILogger } from "@/utils/logger.js";
export class SlackProvider implements INotificationProvider {
private logger: ILogger;
private logger: any;
constructor(logger: ILogger) {
constructor(logger: any) {
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
async sendTestAlert(notification: Notification): Promise<boolean> {
if (!notification.address) {
return false;
}
@@ -78,7 +78,7 @@ export class SlackProvider implements INotificationProvider {
* Uses Slack's rich formatting with sections, fields, and context blocks
*/
private buildSlackPayload(message: NotificationMessage): object {
const blocks: unknown[] = [];
const blocks: any[] = [];
// Determine color based on severity
const colorMap = {
@@ -1,274 +0,0 @@
const SERVICE_NAME = "TeamsProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import type { ILogger } from "@/utils/logger.js";
import got, { HTTPError } from "got";
// Types for Adaptive Card elements
type TextBlock = {
type: "TextBlock";
text: string;
weight?: "Bolder" | "Normal" | "Lighter";
size?: "Small" | "Medium" | "Large" | "ExtraLarge";
color?: "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention";
wrap?: boolean;
spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto";
isSubtle?: boolean;
};
type ColumnSet = {
type: "ColumnSet";
separator?: boolean;
spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto";
columns: unknown[];
};
type Fact = {
title: string;
value: string;
};
type FactSet = {
type: "FactSet";
facts: Fact[];
};
type Action = {
type: string;
title: string;
url?: string;
};
type AdaptiveCard = {
type: "AdaptiveCard";
$schema: "http://adaptivecards.io/schemas/adaptive-card.json";
version: "1.4";
body: (TextBlock | ColumnSet | FactSet)[];
actions?: Action[];
};
type TeamsMessage = {
type: "message";
attachments: Array<{
contentType: "application/vnd.microsoft.card.adaptive";
contentUrl: null;
content: AdaptiveCard;
}>;
};
export class TeamsProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address) {
return false;
}
try {
const payload = this.wrapAdaptiveCard({
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: [
{
type: "TextBlock",
text: getTestMessage(),
weight: "Bolder",
size: "Medium",
},
],
});
await got.post(notification.address, {
json: payload,
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as HTTPError;
this.logger.warn({
message: "Teams test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: err?.stack,
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
return false;
}
const payload = this.wrapAdaptiveCard(this.buildAdaptiveCard(message));
try {
await got.post(notification.address, {
json: payload,
headers: {
"Content-Type": "application/json",
},
});
this.logger.info({
message: "Teams notification sent via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as HTTPError;
this.logger.warn({
message: "Teams alert failed via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
/**
* Wrap an Adaptive Card in the Teams webhook envelope format
*/
private wrapAdaptiveCard(card: AdaptiveCard): TeamsMessage {
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
contentUrl: null,
content: card,
},
],
};
}
/**
* Build an Adaptive Card from NotificationMessage
*/
private buildAdaptiveCard(message: NotificationMessage): AdaptiveCard {
const colorMap: Record<string, string> = {
critical: "attention",
warning: "warning",
success: "good",
info: "accent",
};
const color = (colorMap[message.severity] || "default") as "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention" | undefined;
const body: (TextBlock | ColumnSet | FactSet)[] = [];
// Header with colored status indicator
body.push({
type: "TextBlock",
text: message.content.title,
weight: "Bolder",
size: "Large",
color,
wrap: true,
});
// Summary
body.push({
type: "TextBlock",
text: message.content.summary,
wrap: true,
spacing: "Small",
});
// Separator
body.push({
type: "ColumnSet",
separator: true,
spacing: "Medium",
columns: [],
});
// Monitor details as a FactSet
body.push({
type: "FactSet",
facts: [
{ title: "Name", value: message.monitor.name },
{ title: "Type", value: message.monitor.type },
{ title: "Status", value: message.monitor.status },
{ title: "URL", value: message.monitor.url },
],
});
// Threshold breaches
if (message.content.thresholds && message.content.thresholds.length > 0) {
body.push({
type: "TextBlock",
text: "**Threshold Breaches**",
weight: "Bolder",
spacing: "Medium",
wrap: true,
});
for (const breach of message.content.thresholds) {
body.push({
type: "TextBlock",
text: `• **${breach.metric.toUpperCase()}**: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`,
wrap: true,
spacing: "Small",
});
}
}
// Additional details
if (message.content.details && message.content.details.length > 0) {
body.push({
type: "TextBlock",
text: "**Additional Information**",
weight: "Bolder",
spacing: "Medium",
wrap: true,
});
for (const detail of message.content.details) {
body.push({
type: "TextBlock",
text: `• ${detail}`,
wrap: true,
spacing: "Small",
});
}
}
// Timestamp footer
body.push({
type: "TextBlock",
text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`,
size: "Small",
isSubtle: true,
spacing: "Medium",
wrap: true,
});
// Actions (incident link)
const actions: Action[] = [];
if (message.content.incident) {
actions.push({
type: "Action.OpenUrl",
title: "View Incident",
url: `${message.clientHost}/incidents/${message.content.incident.id}`,
});
}
return {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body,
...(actions.length > 0 ? { actions } : {}),
};
}
}
@@ -1,5 +1,6 @@
import { IEmailService } from "@/service/infrastructure/emailService.js";
export const buildTestEmail = async (emailService: IEmailService) => {
import { Monitor, HardwareStatusPayload, MonitorStatusResponse } from "@/types/index.js";
// Test notification helpers - used by all providers for test alerts
export const buildTestEmail = async (emailService: any) => {
const context = { testName: "Monitoring System" };
const html = await emailService.buildEmail("testEmailTemplate", context);
return html;
@@ -1,15 +1,15 @@
const SERVICE_NAME = "WebhookProvider";
import type { Notification } from "@/types/index.js";
import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class WebhookProvider implements INotificationProvider {
private logger: ILogger;
private logger: any;
constructor(logger: ILogger) {
constructor(logger: any) {
this.logger = logger;
}
@@ -46,6 +46,10 @@ export class WebhookProvider implements INotificationProvider {
}
};
/**
* Build webhook payload from NotificationMessage
* Format: { text: string, severity: string, monitor: object, details: object }
*/
private buildWebhookPayload(message: NotificationMessage): object {
const lines: string[] = [];
@@ -98,7 +102,7 @@ export class WebhookProvider implements INotificationProvider {
};
}
sendTestAlert = async (notification: Partial<Notification>) => {
sendTestAlert = async (notification: Notification) => {
if (!notification.address) {
return false;
}
@@ -1,4 +1,4 @@
import type { Monitor, MonitorStatusResponse, Notification } from "@/types/index.js";
import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification, MonitorStatus } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js";
import { INotificationProvider } from "./notificationProviders/INotificationProvider.js";
@@ -8,14 +8,14 @@ import { ILogger } from "@/utils/logger.js";
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
export interface INotificationsService {
createNotification: (notificationData: Partial<Notification>, userId: string, teamId: string) => Promise<Notification>;
createNotification: (notificationData: Partial<Notification>) => Promise<Notification>;
findById: (id: string, teamId: string) => Promise<Notification>;
findNotificationsByTeamId: (teamId: string) => Promise<Notification[]>;
updateById(id: string, teamId: string, updateData: Partial<Notification>): Promise<Notification>;
deleteById: (id: string, teamId: string) => Promise<Notification>;
handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise<boolean>;
sendTestNotification: (notification: Partial<Notification>) => Promise<boolean>;
sendTestNotification: (notification: Notification) => Promise<boolean>;
testAllNotifications: (notificationIds: string[]) => Promise<boolean>;
}
@@ -32,7 +32,6 @@ export class NotificationsService implements INotificationsService {
private discordProvider: INotificationProvider;
private pagerDutyProvider: INotificationProvider;
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -46,7 +45,6 @@ export class NotificationsService implements INotificationsService {
discordProvider: INotificationProvider,
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -59,7 +57,6 @@ export class NotificationsService implements INotificationsService {
this.discordProvider = discordProvider;
this.pagerDutyProvider = pagerDutyProvider;
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -95,8 +92,6 @@ export class NotificationsService implements INotificationsService {
return await this.discordProvider.sendMessage!(notification, notificationMessage);
case "email":
return await this.emailProvider.sendMessage!(notification, notificationMessage);
case "teams":
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -141,7 +136,7 @@ export class NotificationsService implements INotificationsService {
return await this.sendNotifications(monitor, monitorStatusResponse, decision);
};
sendTestNotification = async (notification: Partial<Notification>) => {
sendTestNotification = async (notification: Notification) => {
switch (notification.type) {
case "email":
return await this.emailProvider.sendTestAlert(notification);
@@ -155,8 +150,6 @@ export class NotificationsService implements INotificationsService {
return await this.matrixProvider.sendTestAlert(notification);
case "webhook":
return await this.webhookProvider.sendTestAlert(notification);
case "teams":
return await this.teamsProvider.sendTestAlert(notification);
default:
return false;
}
@@ -174,9 +167,7 @@ export class NotificationsService implements INotificationsService {
return true;
};
createNotification = async (notificationData: Partial<Notification>, userId: string, teamId: string): Promise<Notification> => {
notificationData.userId = userId;
notificationData.teamId = teamId;
createNotification = async (notificationData: Partial<Notification>): Promise<Notification> => {
return await this.notificationsRepository.create(notificationData);
};
@@ -15,15 +15,13 @@ import type {
GrpcStatusPayload,
CheckSnapshot,
MonitorStats,
CheckDiskInfo,
} from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "./bufferService.js";
const SERVICE_NAME = "StatusService";
export interface IStatusService {
updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise<boolean>;
updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: any }): Promise<boolean>;
handleIncidentForCheck(check: any, monitor: Monitor, action: any, errorContext?: string): Promise<void>;
updateMonitorStatus(
statusResponse: MonitorStatusResponse<
| PingStatusPayload
@@ -42,15 +40,15 @@ export interface IStatusService {
export class StatusService implements IStatusService {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
private buffer: IBufferService;
private logger: any;
private buffer: any;
private monitorsRepository: IMonitorsRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
constructor(
logger: ILogger,
buffer: IBufferService,
buffer: any,
monitorsRepository: IMonitorsRepository,
monitorStatsRepository: IMonitorStatsRepository,
checksRepository: IChecksRepository
@@ -66,7 +64,7 @@ export class StatusService implements IStatusService {
return StatusService.SERVICE_NAME;
}
async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) {
async updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: MonitorStatusResponse }) {
try {
const monitorId = monitor.id;
const { responseTime, status } = networkResponse;
@@ -166,17 +164,62 @@ export class StatusService implements IStatusService {
}
return true;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
service: SERVICE_NAME,
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
method: "updateRunningStats",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
return false;
}
}
handleIncidentForCheck = async (check: Check, monitor: Monitor, action: any, errorContext = "incident handling") => {
try {
let savedCheck: Check | null = null;
if (!check.id) {
try {
savedCheck = await this.checksRepository.create(check);
this.buffer.removeCheckFromBuffer(check);
} catch (checkError: any) {
this.logger.error({
service: SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to save check immediately for ${errorContext}: ${checkError.message}`,
monitorId: monitor.id,
stack: checkError.stack,
});
savedCheck = null;
}
}
if (savedCheck && savedCheck.id) {
try {
this.buffer.addIncidentToBuffer({ monitor, check: savedCheck, action });
} catch (incidentError: any) {
this.logger.error({
service: SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to add incident to buffer for ${errorContext}: ${incidentError.message}`,
monitorId: monitor.id,
action,
stack: incidentError.stack,
});
}
}
} catch (error: any) {
this.logger.error({
service: SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Error in ${errorContext}: ${error.message}`,
monitorId: monitor?.id,
stack: error.stack,
});
}
};
updateMonitorStatus = async (
statusResponse: MonitorStatusResponse<
| PingStatusPayload
@@ -196,7 +239,7 @@ export class StatusService implements IStatusService {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
// Update running stats
this.updateRunningStats(monitor, statusResponse);
this.updateRunningStats({ monitor, networkResponse: statusResponse });
monitor.statusWindow = monitor.statusWindow || [];
monitor.statusWindow.push(status);
@@ -279,8 +322,7 @@ export class StatusService implements IStatusService {
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach =
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
false;
metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false;
const temps = metrics.cpu?.temperature ?? [];
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
@@ -358,13 +400,10 @@ export class StatusService implements IStatusService {
timestamp: new Date().getTime(),
thresholdBreaches,
};
} catch (error: unknown) {
throw new AppError({
message: `Failed to update monitor status: ${error instanceof Error ? error.message : "Unknown error"}`,
details: `Error updating status for monitor with id ${check.metadata.monitorId}`,
service: SERVICE_NAME,
method: "updateMonitorStatus",
});
} catch (error: any) {
error.service = SERVICE_NAME;
error.method = "updateStatus";
throw error;
}
};
@@ -380,13 +419,13 @@ export class StatusService implements IStatusService {
}
this.buffer.addToBuffer(check);
return true;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "insertCheck",
details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` },
stack: error instanceof Error ? error.stack : undefined,
message: error.message,
service: error.service || SERVICE_NAME,
method: error.method || "insertCheck",
details: error.details || `Error inserting check for monitor: ${check?.metadata.monitorId}`,
stack: error.stack,
});
}
};
+7 -5
View File
@@ -2,12 +2,12 @@ import { ISettingsRepository } from "@/repositories/index.js";
import { Settings } from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
import { ValidatedEnv } from "@/validation/envValidation.js";
import type { StringValue } from "ms";
const SERVICE_NAME = "SettingsService";
export type EnvConfig = {
jwtSecret: string;
jwtTTL: StringValue;
jwtTTL: string;
nodeEnv: string;
logLevel: string;
clientHost: string;
@@ -21,8 +21,8 @@ export interface ISettingsService {
getDBSettings(): Promise<Settings>;
}
export class SettingsService implements ISettingsService {
static SERVICE_NAME = SERVICE_NAME;
class SettingsService implements ISettingsService {
static SERVICE_NAME = "SettingsService";
private settings: EnvConfig;
private settingsRepository: ISettingsRepository;
@@ -30,7 +30,7 @@ export class SettingsService implements ISettingsService {
this.settingsRepository = settingsRepository;
this.settings = {
jwtSecret: env.JWT_SECRET,
jwtTTL: env.TOKEN_TTL as StringValue,
jwtTTL: env.TOKEN_TTL,
nodeEnv: env.NODE_ENV,
logLevel: env.LOG_LEVEL,
clientHost: env.CLIENT_HOST,
@@ -74,3 +74,5 @@ export class SettingsService implements ISettingsService {
return settings;
};
}
export default SettingsService;
+7 -20
View File
@@ -4,29 +4,16 @@ export interface AlertWebhookPayload {
}
export interface AlertPagerDutyPayload {
routing_key?: string;
dedup_key?: string;
event_action?: "trigger" | "resolve";
payload: Record<string, unknown>;
routingKey: string;
monitorUrl?: string;
}
export interface AlertMatrixPayload {
plainText: string;
htmlText: string;
}
export interface DiscordEmbedField {
name: string;
value: string;
inline?: boolean;
}
export interface AlertDiscordPayload {
title: string;
description: string;
color: number;
fields: DiscordEmbedField[];
timestamp: string;
friendlyName?: string;
homeserverUrl: string;
accessToken: string;
roomId: string;
monitorName: string;
}
export interface Alert {
+5 -4
View File
@@ -1,8 +1,5 @@
import type { MonitorType } from "@/types/index.js";
import type { Response } from "got";
export const CHECK_TTL_SENTINEL = 366;
export type GotTimings = Response["timings"];
export interface CheckMetadata {
@@ -101,6 +98,9 @@ export interface Check {
timings?: GotTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
@@ -113,6 +113,7 @@ export interface Check {
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
}
@@ -204,4 +205,4 @@ export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
export type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">;
export type CheckSnapshot = Omit<Check, "metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt">;
+1 -1
View File
@@ -5,7 +5,7 @@ declare global {
interface Request {
file?: Multer.File;
user?: User | undefined;
resource?: unknown;
resource?: any;
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
export interface MaintenanceWindow {
id: string;
monitorId: string;
monitorId: String;
teamId: string;
active: boolean;
name: string;
+1 -5
View File
@@ -6,14 +6,10 @@ export type { GeoContinent } from "@/types/geoCheck.js";
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "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): boolean => GeoCheckSupportedTypes.includes(type);
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
export type MonitorStatus = (typeof MonitorStatuses)[number];
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export interface Monitor {
id: string;
+4 -9
View File
@@ -14,8 +14,6 @@ import type {
MonitorStatus,
} from "@/types/index.js";
import type { QueryResult } from "gamedig";
export interface MonitorStatusResponse<
T =
| HttpStatusPayload
@@ -24,8 +22,7 @@ export interface MonitorStatusResponse<
| HardwareStatusPayload
| DockerStatusPayload
| GameStatusPayload
| GrpcStatusPayload
| WebSocketStatusPayload,
| GrpcStatusPayload,
> {
monitorId: string;
teamId: string;
@@ -52,7 +49,7 @@ export interface PingStatusPayload {
host: string;
numeric_host?: string;
alive: boolean;
time: number | unknown;
time: number;
times?: number[];
output?: string;
min?: string;
@@ -103,7 +100,7 @@ export interface PortStatusPayload {
success: boolean;
}
export type GameStatusPayload = QueryResult;
export type GameStatusPayload = Record<string, unknown>;
export interface GrpcStatusPayload {
grpcStatusCode: number;
@@ -126,7 +123,7 @@ export interface MonitorPayloadMap {
game: GameStatusPayload;
grpc: GrpcStatusPayload;
websocket: WebSocketStatusPayload;
unknown: unknown;
default: unknown;
}
export type StatusChangeResult = {
@@ -142,5 +139,3 @@ export type StatusChangeResult = {
temp: boolean;
};
};
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more