diff --git a/.gitignore b/.gitignore
index f411b5c8b..ff828c1ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,6 @@
mongo
node_modules/
docs/architecture
-docs/reviews
\ No newline at end of file
+docs/reviews
+docs/todo
+docs/frontend
\ No newline at end of file
diff --git a/README.md b/README.md
index e0528d0cb..07c0e84bd 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
## Demo
-You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
+You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
## User's guide
diff --git a/client/package-lock.json b/client/package-lock.json
index 038814543..338de1e26 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -17,6 +17,7 @@
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
+ "@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
@@ -25,6 +26,7 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
+ "maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
@@ -33,6 +35,7 @@
"react-hook-form": "^7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
+ "react-map-gl": "^8.1.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
@@ -80,7 +83,6 @@
"node_modules/@babel/core": {
"version": "7.29.0",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -351,7 +353,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -389,7 +390,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -434,6 +434,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -450,6 +451,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -466,6 +468,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -482,6 +485,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -498,6 +502,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -514,6 +519,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -530,6 +536,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -546,6 +553,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -562,6 +570,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -578,6 +587,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -594,6 +604,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -610,6 +621,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -626,6 +638,7 @@
"cpu": [
"mips64el"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -642,6 +655,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -658,6 +672,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -674,6 +689,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -688,6 +704,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -704,6 +721,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -720,6 +738,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -736,6 +755,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -752,6 +772,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -768,6 +789,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -784,6 +806,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -800,6 +823,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -816,6 +840,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -832,6 +857,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -997,6 +1023,115 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mapbox/geojson-rewind": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
+ "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
+ "license": "ISC",
+ "dependencies": {
+ "get-stream": "^6.0.1",
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "geojson-rewind": "geojson-rewind"
+ }
+ },
+ "node_modules/@mapbox/jsonlint-lines-primitives": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
+ "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@mapbox/point-geometry": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
+ "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
+ "license": "ISC"
+ },
+ "node_modules/@mapbox/tiny-sdf": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
+ "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@mapbox/unitbezier": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
+ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@mapbox/vector-tile": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
+ "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/point-geometry": "~1.1.0",
+ "@types/geojson": "^7946.0.16",
+ "pbf": "^4.0.1"
+ }
+ },
+ "node_modules/@mapbox/whoots-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
+ "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@maplibre/geojson-vt": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
+ "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
+ "license": "ISC"
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec": {
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.5.0.tgz",
+ "integrity": "sha512-55sPMCtWAZY7r7ftU2at1SsBJfhJyIE5X16Tbl+uFcnTuiCxEuh6839iq/hgjLM8zUEjXdRu2V30bfsYCNB1Qg==",
+ "license": "ISC",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^0.0.1",
+ "json-stringify-pretty-compact": "^4.0.0",
+ "minimist": "^1.2.8",
+ "quickselect": "^3.0.0",
+ "rw": "^1.3.3",
+ "tinyqueue": "^3.0.0"
+ },
+ "bin": {
+ "gl-style-format": "dist/gl-style-format.mjs",
+ "gl-style-migrate": "dist/gl-style-migrate.mjs",
+ "gl-style-validate": "dist/gl-style-validate.mjs"
+ }
+ },
+ "node_modules/@maplibre/mlt": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.6.tgz",
+ "integrity": "sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==",
+ "license": "(MIT OR Apache-2.0)",
+ "dependencies": {
+ "@mapbox/point-geometry": "^1.1.0"
+ }
+ },
+ "node_modules/@maplibre/vt-pbf": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
+ "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/point-geometry": "^1.1.0",
+ "@mapbox/vector-tile": "^2.0.4",
+ "@maplibre/geojson-vt": "^5.0.4",
+ "@types/geojson": "^7946.0.16",
+ "@types/supercluster": "^7.1.3",
+ "pbf": "^4.0.1",
+ "supercluster": "^8.0.1"
+ }
+ },
"node_modules/@mui/core-downloads-tracker": {
"version": "7.3.7",
"license": "MIT",
@@ -1050,7 +1185,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1155,7 +1289,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -1533,6 +1666,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1546,6 +1680,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1559,6 +1694,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1572,6 +1708,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1585,6 +1722,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1598,6 +1736,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1611,6 +1750,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1624,6 +1764,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1637,6 +1778,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1650,6 +1792,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1663,6 +1806,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1676,6 +1820,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1689,6 +1834,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1702,6 +1848,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1715,6 +1862,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1728,6 +1876,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1741,6 +1890,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1752,6 +1902,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1765,6 +1916,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1778,6 +1930,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1791,6 +1944,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1804,6 +1958,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1817,6 +1972,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1830,6 +1986,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1843,6 +2000,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2175,11 +2333,25 @@
"version": "1.0.8",
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/maplibre-gl": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@types/maplibre-gl/-/maplibre-gl-1.13.2.tgz",
+ "integrity": "sha512-IC1RBMhKXpGDpiFsEwt17c/hbff0GCS/VmzqmrY6G+kyy2wfv2e7BoSQRAfqrvhBQPCoO8yc0SNCi5HkmCcVqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "24.5.2",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2194,8 +2366,8 @@
},
"node_modules/@types/react": {
"version": "18.3.27",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2216,6 +2388,15 @@
"@types/react": "*"
}
},
+ "node_modules/@types/supercluster": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
+ "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"license": "MIT"
@@ -2225,6 +2406,66 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/@vis.gl/react-mapbox": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.0.tgz",
+ "integrity": "sha512-FwvH822oxEjWYOr+pP2L8hpv+7cZB2UsQbHHHT0ryrkvvqzmTgt7qHDhamv0EobKw86e1I+B4ojENdJ5G5BkyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "mapbox-gl": ">=3.5.0",
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ },
+ "peerDependenciesMeta": {
+ "mapbox-gl": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vis.gl/react-maplibre": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.0.tgz",
+ "integrity": "sha512-PkAK/gp3mUfhCLhUuc+4gc3PN9zCtVGxTF2hB6R5R5yYUw+hdg84OZ770U5MU4tPMTCG6fbduExuIW6RRKN6qQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@maplibre/maplibre-gl-style-spec": "^19.2.1"
+ },
+ "peerDependencies": {
+ "maplibre-gl": ">=4.0.0",
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ },
+ "peerDependenciesMeta": {
+ "maplibre-gl": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": {
+ "version": "19.3.3",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz",
+ "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==",
+ "license": "ISC",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^0.0.1",
+ "json-stringify-pretty-compact": "^3.0.0",
+ "minimist": "^1.2.8",
+ "rw": "^1.3.3",
+ "sort-object": "^3.0.3"
+ },
+ "bin": {
+ "gl-style-format": "dist/gl-style-format.mjs",
+ "gl-style-migrate": "dist/gl-style-migrate.mjs",
+ "gl-style-validate": "dist/gl-style-validate.mjs"
+ }
+ },
+ "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
+ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==",
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"dev": true,
@@ -2248,7 +2489,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2321,6 +2561,15 @@
"version": "2.0.1",
"license": "Python-2.0"
},
+ "node_modules/arr-union": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
+ "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"dev": true,
@@ -2445,6 +2694,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/async-function": {
"version": "1.0.0",
"dev": true,
@@ -2553,7 +2811,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2568,6 +2825,25 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/bytewise": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz",
+ "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bytewise-core": "^1.2.2",
+ "typewise": "^1.0.3"
+ }
+ },
+ "node_modules/bytewise-core": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz",
+ "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==",
+ "license": "MIT",
+ "dependencies": {
+ "typewise-core": "^1.2"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.8",
"dev": true,
@@ -2966,8 +3242,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3085,6 +3360,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/earcut": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
+ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
+ "license": "ISC"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.283",
"license": "ISC"
@@ -3285,6 +3566,7 @@
},
"node_modules/esbuild": {
"version": "0.25.12",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -3343,7 +3625,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3559,6 +3840,18 @@
"version": "4.0.7",
"license": "MIT"
},
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"dev": true,
@@ -3591,6 +3884,7 @@
},
"node_modules/fdir": {
"version": "6.5.0",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -3724,6 +4018,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3829,6 +4124,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-symbol-description": {
"version": "1.1.0",
"dev": true,
@@ -3845,6 +4152,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/gl-matrix": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
+ "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "7.2.3",
"dev": true,
@@ -4049,7 +4371,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -4256,6 +4577,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
@@ -4378,6 +4708,18 @@
"node": ">=8"
}
},
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "license": "MIT",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"dev": true,
@@ -4515,6 +4857,15 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"dev": true,
@@ -4585,6 +4936,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stringify-pretty-compact": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
+ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
+ "license": "MIT"
+ },
"node_modules/json5": {
"version": "2.2.3",
"license": "MIT",
@@ -4609,6 +4966,12 @@
"node": ">=4.0"
}
},
+ "node_modules/kdbush": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
+ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
+ "license": "ISC"
+ },
"node_modules/keyv": {
"version": "4.5.4",
"dev": true,
@@ -4786,6 +5149,43 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/maplibre-gl": {
+ "version": "5.19.0",
+ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
+ "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/geojson-rewind": "^0.5.2",
+ "@mapbox/jsonlint-lines-primitives": "^2.0.2",
+ "@mapbox/point-geometry": "^1.1.0",
+ "@mapbox/tiny-sdf": "^2.0.7",
+ "@mapbox/unitbezier": "^0.0.1",
+ "@mapbox/vector-tile": "^2.0.4",
+ "@mapbox/whoots-js": "^3.1.0",
+ "@maplibre/geojson-vt": "^5.0.4",
+ "@maplibre/maplibre-gl-style-spec": "^24.4.1",
+ "@maplibre/mlt": "^1.1.6",
+ "@maplibre/vt-pbf": "^4.2.1",
+ "@types/geojson": "^7946.0.16",
+ "@types/supercluster": "^7.1.3",
+ "earcut": "^3.0.2",
+ "gl-matrix": "^3.4.4",
+ "kdbush": "^4.0.2",
+ "murmurhash-js": "^1.0.0",
+ "pbf": "^4.0.1",
+ "potpack": "^2.1.0",
+ "quickselect": "^3.0.0",
+ "supercluster": "^8.0.1",
+ "tinyqueue": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.14.0",
+ "npm": ">=8.1.0"
+ },
+ "funding": {
+ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
@@ -4861,6 +5261,15 @@
"node": "*"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
@@ -4887,6 +5296,12 @@
}
}
},
+ "node_modules/murmurhash-js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
+ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
+ "license": "MIT"
+ },
"node_modules/nano-spawn": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
@@ -4902,6 +5317,7 @@
},
"node_modules/nanoid": {
"version": "3.3.11",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -5185,6 +5601,18 @@
"node": ">=8"
}
},
+ "node_modules/pbf": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
+ "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
@@ -5222,6 +5650,7 @@
},
"node_modules/postcss": {
"version": "8.5.6",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -5246,6 +5675,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/potpack": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
+ "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
+ "license": "ISC"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"dev": true,
@@ -5306,6 +5741,12 @@
"version": "16.13.1",
"license": "MIT"
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
+ "license": "MIT"
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"license": "MIT"
@@ -5337,6 +5778,12 @@
],
"license": "MIT"
},
+ "node_modules/quickselect": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
+ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
+ "license": "ISC"
+ },
"node_modules/raf-schd": {
"version": "4.0.3",
"license": "MIT"
@@ -5354,7 +5801,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5366,7 +5812,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -5413,6 +5858,30 @@
"version": "19.2.4",
"license": "MIT"
},
+ "node_modules/react-map-gl": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz",
+ "integrity": "sha512-vDx/QXR3Tb+8/ap/z6gdMjJQ8ZEyaZf6+uMSPz7jhWF5VZeIsKsGfPvwHVPPwGF43Ryn+YR4bd09uEFNR5OPdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vis.gl/react-mapbox": "8.1.0",
+ "@vis.gl/react-maplibre": "8.1.0"
+ },
+ "peerDependencies": {
+ "mapbox-gl": ">=1.13.0",
+ "maplibre-gl": ">=1.13.0",
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ },
+ "peerDependenciesMeta": {
+ "mapbox-gl": {
+ "optional": true
+ },
+ "maplibre-gl": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-redux": {
"version": "9.2.0",
"license": "MIT",
@@ -5542,8 +6011,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -5628,6 +6096,15 @@
"node": ">=4"
}
},
+ "node_modules/resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -5681,8 +6158,8 @@
},
"node_modules/rollup": {
"version": "4.57.1",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -5744,6 +6221,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/safe-array-concat": {
"version": "1.1.3",
"dev": true,
@@ -5850,6 +6333,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "license": "MIT",
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"dev": true,
@@ -5988,6 +6486,41 @@
"tslib": "^2.0.3"
}
},
+ "node_modules/sort-asc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz",
+ "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-desc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz",
+ "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-object": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz",
+ "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bytewise": "^1.1.0",
+ "get-value": "^2.0.2",
+ "is-extendable": "^0.1.1",
+ "sort-asc": "^0.2.0",
+ "sort-desc": "^0.2.0",
+ "union-value": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map": {
"version": "0.5.7",
"license": "BSD-3-Clause",
@@ -5997,11 +6530,49 @@
},
"node_modules/source-map-js": {
"version": "1.2.1",
+ "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
+ "node_modules/split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "license": "MIT",
+ "dependencies": {
+ "extend-shallow": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split-string/node_modules/extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split-string/node_modules/is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-object": "^2.0.4"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"dev": true,
@@ -6184,6 +6755,15 @@
"version": "4.2.0",
"license": "MIT"
},
+ "node_modules/supercluster": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
+ "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
+ "license": "ISC",
+ "dependencies": {
+ "kdbush": "^4.0.2"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"dev": true,
@@ -6238,6 +6818,7 @@
},
"node_modules/tinyglobby": {
"version": "0.2.15",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -6250,6 +6831,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyqueue": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
+ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
+ "license": "ISC"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6361,9 +6948,8 @@
},
"node_modules/typescript": {
"version": "5.9.3",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6372,6 +6958,21 @@
"node": ">=14.17"
}
},
+ "node_modules/typewise": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz",
+ "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "typewise-core": "^1.2.0"
+ }
+ },
+ "node_modules/typewise-core": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz",
+ "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==",
+ "license": "MIT"
+ },
"node_modules/unbox-primitive": {
"version": "1.1.0",
"dev": true,
@@ -6391,9 +6992,24 @@
},
"node_modules/undici-types": {
"version": "7.12.0",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
+ "node_modules/union-value": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
+ "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
+ "license": "MIT",
+ "dependencies": {
+ "arr-union": "^3.1.0",
+ "get-value": "^2.0.6",
+ "is-extendable": "^0.1.1",
+ "set-value": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"funding": [
@@ -6466,8 +7082,8 @@
},
"node_modules/vite": {
"version": "6.3.6",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -6750,7 +7366,7 @@
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
diff --git a/client/package.json b/client/package.json
index f0b34dbbc..dd585636b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -25,6 +25,7 @@
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
+ "@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
@@ -33,6 +34,7 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
+ "maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
@@ -41,6 +43,7 @@
"react-hook-form": "^7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
+ "react-map-gl": "^8.1.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 127426c85..15c94dbb5 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -4,39 +4,52 @@ import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react";
import { CssBaseline, GlobalStyles } from "@mui/material";
+import { SWRConfig } from "swr";
import { Routes } from "./Routes";
import AppLayout from "@/Components/layout/AppLayout";
import type { RootState } from "@/Types/state";
import { lightTheme, darkTheme } from "@/Utils/Theme/Theme";
+const swrConfig = {
+ dedupingInterval: 5000,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ shouldRetryOnError: false,
+ errorRetryCount: 2,
+ errorRetryInterval: 1000,
+ focusThrottleInterval: 5000,
+};
+
function App() {
const mode = useSelector((state: RootState) => state.ui.mode);
const theme = mode === "light" ? lightTheme : darkTheme;
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/client/src/Components/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx
index 569652b20..de87395eb 100644
--- a/client/src/Components/design-elements/BaseChart.tsx
+++ b/client/src/Components/design-elements/BaseChart.tsx
@@ -2,6 +2,7 @@ import { BaseBox } from ".";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
+import { LAYOUT } from "@/Utils/Theme/constants";
import type { ResponsiveStyleValue } from "@mui/system";
import { useTheme } from "@mui/material/styles";
@@ -26,7 +27,7 @@ export const BaseChart = ({
return (
{icon && (
@@ -220,7 +221,7 @@ export const BaseAuthPage = ({
return (
{title}
{subtitle}
{
lg: "50%",
xl: "40%",
}}
- padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
+ padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
@@ -55,7 +56,7 @@ export const ErrorFallback = ({
{title}
@@ -81,7 +82,7 @@ export const EmptyFallback = ({
return (
@@ -89,7 +90,7 @@ export const EmptyFallback = ({
@@ -147,7 +148,7 @@ export const EmptyMonitorFallback = ({
{right}
@@ -60,7 +61,7 @@ export const ConfigBox = ({
return (
+
+
{data.map((row) => {
const key = row.id || row._id || Math.random();
keys.push(key);
return (
(onRowClick ? onRowClick(row) : null)}
- spacing={theme.spacing(4)}
+ spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
borderWidth: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
- padding: theme.spacing(4),
+ padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default",
}}
key={key}
@@ -155,7 +156,7 @@ export function DataTable<
{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridAutoRows: "auto",
- rowGap: theme.spacing(1),
+ rowGap: theme.spacing(SPACING.SM),
alignItems: "center",
justifyItems: "center",
paddingLeft: 0,
@@ -556,8 +557,8 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
alignItems={"center"}
justifyContent={"center"}
sx={{
- py: theme.spacing(12),
- px: theme.spacing(4),
+ py: theme.spacing(LAYOUT.XL),
+ px: theme.spacing(LAYOUT.XS),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
@@ -574,7 +575,7 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
display: "flex",
alignItems: "center",
justifyContent: "center",
- mb: theme.spacing(2),
+ mb: theme.spacing(SPACING.LG),
}}
>
= {
+ http: "HTTP",
+ ping: "Ping",
+ port: "Port",
+ docker: "Docker",
+ game: "Game",
+ grpc: "gRPC",
+};
const statuses = ["up", "down"];
const states = ["active", "paused"];
@@ -30,6 +39,7 @@ export const ControlsFilter = ({
onClearFilters: () => void;
}) => {
const theme = useTheme();
+ const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
@@ -50,7 +60,7 @@ export const ControlsFilter = ({
key={type}
value={type}
>
- {type}
+ {typeDisplayNames[type] ?? type}
))}
@@ -88,7 +98,7 @@ export const ControlsFilter = ({
variant="contained"
onClick={onClearFilters}
>
- Clear Filters
+ {t("common.buttons.clearFilters")}
)}
diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx
new file mode 100644
index 000000000..3f9429853
--- /dev/null
+++ b/client/src/Components/monitors/GeoChecksMap.tsx
@@ -0,0 +1,203 @@
+import { useRef, useEffect, useState } from "react";
+import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
+import Map, { Marker, Popup } from "react-map-gl/maplibre";
+import type { MapRef } from "react-map-gl/maplibre";
+import type { FlatGeoCheck } from "@/Types/GeoCheck";
+import "maplibre-gl/dist/maplibre-gl.css";
+
+interface GeoChecksMapProps {
+ geoChecks: FlatGeoCheck[];
+}
+
+export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
+ const mapRef = useRef(null);
+ const [selectedCheck, setSelectedCheck] = useState(null);
+ const theme = useTheme();
+ const isDarkMode = theme.palette.mode === "dark";
+
+ const mapPopupStyles = (
+
+ );
+
+ useEffect(() => {
+ if (geoChecks.length === 0 || !mapRef.current) return;
+
+ const bounds = geoChecks.reduce(
+ (acc, check) => {
+ return {
+ minLng: Math.min(acc.minLng, check.location.longitude),
+ maxLng: Math.max(acc.maxLng, check.location.longitude),
+ minLat: Math.min(acc.minLat, check.location.latitude),
+ maxLat: Math.max(acc.maxLat, check.location.latitude),
+ };
+ },
+ {
+ minLng: Infinity,
+ maxLng: -Infinity,
+ minLat: Infinity,
+ maxLat: -Infinity,
+ }
+ );
+
+ if (bounds.minLng !== Infinity) {
+ mapRef.current.fitBounds(
+ [
+ [bounds.minLng, bounds.minLat],
+ [bounds.maxLng, bounds.maxLat],
+ ],
+ { padding: 50, duration: 1000 }
+ );
+ }
+ }, [geoChecks]);
+
+ const getMarkerColor = (status: boolean): string => {
+ return status ? theme.palette.success.main : theme.palette.error.main;
+ };
+
+ const formatResponseTime = (timing: number): string => {
+ return `${timing.toFixed(0)}ms`;
+ };
+
+ return (
+ <>
+ {mapPopupStyles}
+
+
+
+ >
+ );
+};
diff --git a/client/src/Components/monitors/HeaderGeoTabs.tsx b/client/src/Components/monitors/HeaderGeoTabs.tsx
new file mode 100644
index 000000000..7351078d4
--- /dev/null
+++ b/client/src/Components/monitors/HeaderGeoTabs.tsx
@@ -0,0 +1,54 @@
+import Stack from "@mui/material/Stack";
+import { Tabs, Tab } from "@/Components/design-elements";
+import { useTheme } from "@mui/material/styles";
+import { useTranslation } from "react-i18next";
+import type { GeoContinent } from "@/Types/GeoCheck";
+
+interface HeaderGeoTabsProps {
+ geoCheckEnabled: boolean;
+ locations: GeoContinent[] | undefined;
+ selectedLocation: GeoContinent;
+ onLocationChange: (location: GeoContinent) => void;
+}
+
+export const HeaderGeoTabs = ({
+ geoCheckEnabled,
+ locations,
+ selectedLocation,
+ onLocationChange,
+}: HeaderGeoTabsProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ if (!geoCheckEnabled || !locations || locations.length === 0) {
+ return null;
+ }
+
+ const handleChange = (_event: React.SyntheticEvent, newValue: GeoContinent) => {
+ onLocationChange(newValue);
+ };
+
+ return (
+
+
+ {locations.map((location) => (
+
+ ))}
+
+
+ );
+};
diff --git a/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx b/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx
index 8df491197..f89994437 100644
--- a/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx
+++ b/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx
@@ -12,7 +12,7 @@ import {
import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
-import type { CheckSnapshot } from "@/Types/Check";
+import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { Palette } from "@mui/material/styles";
type PaletteColorKey = Extract<
keyof Palette,
@@ -52,7 +52,7 @@ export const HistogramPageSpeedDetails = ({
checks,
range,
}: {
- checks: CheckSnapshot[];
+ checks: PageSpeedGroupedCheck[];
range: string;
}) => {
const theme = useTheme();
@@ -73,7 +73,7 @@ export const HistogramPageSpeedDetails = ({
>
(
});
const menuItemSx = {
- gap: theme.spacing(2),
+ gap: theme.spacing(LAYOUT.MD),
borderRadius: theme.shape.borderRadius,
- pl: theme.spacing(4),
+ pl: theme.spacing(LAYOUT.XS),
"& svg": { stroke: theme.palette.text.secondary },
};
@@ -74,9 +75,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
@@ -132,10 +133,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
disableScrollLock
anchorOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
- paper: { sx: { mt: theme.spacing(-4), ml: collapsed ? theme.spacing(2) : 0 } },
+ paper: {
+ sx: {
+ mt: theme.spacing(-LAYOUT.XS),
+ ml: collapsed ? theme.spacing(SPACING.LG) : 0,
+ },
+ },
}}
MenuListProps={{ sx: { p: 2, "& li": { m: 0 } } }}
- sx={{ ml: theme.spacing(4) }}
+ sx={{ ml: theme.spacing(LAYOUT.XS) }}
>
{collapsed && (
{t("components.sidebar.starPrompt.title")}
@@ -78,7 +76,7 @@ export const StarPrompt = ({
{t("components.sidebar.starPrompt.description")}
@@ -96,7 +94,7 @@ export const StarPrompt = ({
"&:hover": {
opacity: 0.8,
},
- pl: theme.spacing(4),
+ pl: theme.spacing(LAYOUT.XS),
filter: mode === "dark" ? "invert(1)" : "none",
}}
/>
diff --git a/client/src/Components/sidebar/index.tsx b/client/src/Components/sidebar/index.tsx
index dcf02e45f..f4c6527eb 100644
--- a/client/src/Components/sidebar/index.tsx
+++ b/client/src/Components/sidebar/index.tsx
@@ -3,6 +3,7 @@ import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
+import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -65,9 +66,9 @@ export const Sidebar = () => {
left={0}
minHeight={"100vh"}
maxHeight={"100vh"}
- paddingTop={theme.spacing(6)}
- paddingBottom={theme.spacing(6)}
- gap={theme.spacing(6)}
+ paddingTop={theme.spacing(LAYOUT.SM)}
+ paddingBottom={theme.spacing(LAYOUT.SM)}
+ gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
sx={{
@@ -81,13 +82,13 @@ export const Sidebar = () => {
component="nav"
disablePadding
sx={{
- px: theme.spacing(6),
+ px: theme.spacing(LAYOUT.SM),
flex: 1,
}}
>
{menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
@@ -105,7 +106,7 @@ export const Sidebar = () => {
{bottomMenu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts
index e1faa2b29..84fd1b547 100644
--- a/client/src/Hooks/UseApi.ts
+++ b/client/src/Hooks/UseApi.ts
@@ -4,6 +4,7 @@ import type { AxiosRequestConfig } from "axios";
import { get, patch, post, put, deleteOp } from "@/Utils/ApiClient";
import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
+import { logger } from "@/Utils/logger";
interface ApiResponse {
success: boolean;
@@ -17,7 +18,7 @@ const fetcher = async (url: string, config?: AxiosRequestConfig) => {
};
export const useGet = (
- url: string | null,
+ url: string | null | undefined,
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration
) => {
@@ -61,7 +62,7 @@ export const usePost = () => {
return res.data;
} catch (err: any) {
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
- console.error(err);
+ logger.error("POST request failed", err, { endpoint, body });
toastError(errMsg);
setError(errMsg);
return null;
@@ -96,7 +97,7 @@ export const usePatch = () => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
- console.error(err);
+ logger.error("PATCH request failed", err, { endpoint, body });
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -132,7 +133,7 @@ export const usePut = () => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
- console.error(err);
+ logger.error("PUT request failed", err, { endpoint, body });
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -167,7 +168,7 @@ export const useDelete = () => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
- console.error(err);
+ logger.error("DELETE request failed", err, { endpoint });
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -201,7 +202,7 @@ export const useLazyGet = () => {
});
return res.data;
} catch (err: any) {
- console.error(err);
+ logger.error("GET request failed", err, { endpoint });
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts
index 633fcb5c9..8b5252879 100644
--- a/client/src/Hooks/useMonitorForm.ts
+++ b/client/src/Hooks/useMonitorForm.ts
@@ -14,6 +14,9 @@ const getBaseDefaults = (data?: Monitor | null) => ({
notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
+ geoCheckEnabled: data?.geoCheckEnabled ?? false,
+ geoCheckLocations: data?.geoCheckLocations || [],
+ geoCheckInterval: data?.geoCheckInterval || 300000,
});
export const useMonitorForm = ({
@@ -70,6 +73,16 @@ export const useMonitorForm = ({
gameId: data?.gameId || "",
};
break;
+ case "grpc":
+ defaults = {
+ ...base,
+ type: "grpc",
+ url: data?.url || "",
+ port: data?.port || 50051,
+ grpcServiceName: data?.grpcServiceName || "",
+ ignoreTlsErrors: data?.ignoreTlsErrors || false,
+ };
+ break;
case "pagespeed":
defaults = {
...base,
diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts
index ce04a81e2..3af8c229e 100644
--- a/client/src/Hooks/useSettingsForm.ts
+++ b/client/src/Hooks/useSettingsForm.ts
@@ -22,10 +22,22 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
globalThresholds: {
- cpu: data?.globalThresholds?.cpu ?? 0,
- memory: data?.globalThresholds?.memory ?? 0,
- disk: data?.globalThresholds?.disk ?? 0,
- temperature: data?.globalThresholds?.temperature ?? 0,
+ cpu:
+ data?.globalThresholds?.cpu && data.globalThresholds.cpu >= 1
+ ? data.globalThresholds.cpu
+ : 80,
+ memory:
+ data?.globalThresholds?.memory && data.globalThresholds.memory >= 1
+ ? data.globalThresholds.memory
+ : 80,
+ disk:
+ data?.globalThresholds?.disk && data.globalThresholds.disk >= 1
+ ? data.globalThresholds.disk
+ : 80,
+ temperature:
+ data?.globalThresholds?.temperature && data.globalThresholds.temperature >= 1
+ ? data.globalThresholds.temperature
+ : 80,
},
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx
index 4a40d993b..a7f230599 100644
--- a/client/src/Pages/Checks/Components/ChecksTable.tsx
+++ b/client/src/Pages/Checks/Components/ChecksTable.tsx
@@ -5,7 +5,6 @@ import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
-import { useNavigate } from "react-router";
import type { Check } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
@@ -29,7 +28,6 @@ export const ChecksTable = ({
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
- const navigate = useNavigate();
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header[] = [
@@ -109,9 +107,6 @@ export const ChecksTable = ({
{
- navigate(`/checks/${row.id}`);
- }}
emptyViewText={t("pages.checks.table.empty")}
/>
{
const watchedType = watch("type") as MonitorType;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
+ const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
useEffect(() => {
clearErrors();
@@ -224,7 +255,7 @@ const CreateMonitorPage = () => {
};
const onError = (errors: unknown) => {
- console.log(errors);
+ logger.debug("Monitor creation validation errors", errors);
};
return (
@@ -251,7 +282,7 @@ const CreateMonitorPage = () => {
{
"pages.createMonitor.form.type.optionGameDescription"
)}
/>
+
)}
@@ -300,7 +338,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
rightContent={
-
+
{/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && (
{
/>
)}
+ {/* gRPC Service Name field - only for grpc type */}
+ {generalSettingsConfig.showGrpcServiceName && (
+ (
+
+ )}
+ />
+ )}
+
{/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && (
{
title={t("pages.createMonitor.form.thresholds.title")}
subtitle={t("pages.createMonitor.form.thresholds.description")}
rightContent={
-
+
{
title={t("pages.createMonitor.form.incidents.title")}
subtitle={t("pages.createMonitor.form.incidents.description")}
rightContent={
-
+
{
(field.value ?? []).includes(n.id)
);
return (
-
+
{
}
/>
- {watchedType === "http" && (
+ {(watchedType === "http" || watchedType === "grpc") && (
{
{
title={t("pages.createMonitor.form.advanced.title")}
subtitle={t("pages.createMonitor.form.advanced.description")}
rightContent={
-
+
{
{
)}
/>
{watchedUseAdvancedMatching && (
-
+
{
/>
)}
+ {watchedType === "http" && (
+
+ (
+
+ field.onChange(e.target.checked)}
+ />
+
+ {t("pages.createMonitor.form.geoChecks.option.enabled.label")}
+
+
+ )}
+ />
+ {watchGeoCheckEnabled && (
+
+ {
+ // Map continents to have 'name' property for Autocomplete
+ const locationOptions = GeoContinents.map((continent) => ({
+ id: continent,
+ name: t(
+ `pages.createMonitor.form.geoChecks.option.locations.options.${continent}`
+ ),
+ }));
+ const selectedLocations = locationOptions.filter((loc) =>
+ (field.value ?? []).includes(loc.id)
+ );
+ return (
+
+ option.name}
+ onChange={(_: unknown, newValue: typeof locationOptions) => {
+ field.onChange(newValue.map((loc) => loc.id));
+ }}
+ isOptionEqualToValue={(option, value) =>
+ option.id === value.id
+ }
+ fieldLabel={t(
+ "pages.createMonitor.form.geoChecks.option.locations.label"
+ )}
+ />
+ {selectedLocations.length > 0 && (
+
+ {selectedLocations.map((location, index) => (
+
+ {location.name}
+ {
+ field.onChange(
+ (field.value ?? []).filter(
+ (id: string) => id !== location.id
+ )
+ );
+ }}
+ aria-label="Remove location"
+ >
+
+
+ {index < selectedLocations.length - 1 && }
+
+ ))}
+
+ )}
+
+ );
+ }}
+ />
+ (
+
+ )}
+ />
+
+ )}
+
+ }
+ />
+ )}
+
{
}
return (
{t("pages.incidents.dialog.details.title")}
-
-
+
+
{t("pages.incidents.dialog.details.overview")}
@@ -44,7 +45,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
{t("pages.incidents.dialog.details.status")}
@@ -75,15 +76,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
-
-
+
+
{t("pages.incidents.dialog.details.analysis")}
{t("pages.incidents.dialog.details.timeline")}
@@ -127,15 +128,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
{!incident.status && (
-
-
+
+
{t("pages.incidents.dialog.details.resolutionDetails")}
diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx
index 101ac11af..467e2f3a7 100644
--- a/client/src/Pages/Logs/index.tsx
+++ b/client/src/Pages/Logs/index.tsx
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
- const [activeTab, setActiveTab] = useState(2);
+ const [activeTab, setActiveTab] = useState(1);
return (
{
const isLoading = isPosting || isPatching;
const onError = (errors: any) => {
- console.error("Form submission errors:", errors);
+ logger.error("Maintenance form submission failed", undefined, { errors });
};
return (
@@ -120,7 +122,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
-
+
{
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
-
+
{
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
-
+
{
{
.filter((m): m is Monitor => m !== undefined);
return (
-
+
{
key={monitor.id}
direction="row"
alignItems="center"
- spacing={theme.spacing(4)}
- padding={theme.spacing(4)}
- marginTop={theme.spacing(2)}
+ padding={theme.spacing(LAYOUT.XS)}
+ marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
@@ -350,7 +351,7 @@ const CreateMaintenanceWindowPage = () => {
);
};
diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
index 87db4246d..d8951b6c5 100644
--- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
+++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
@@ -181,6 +181,7 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
+
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
@@ -211,6 +212,7 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "type")}
sx={{ cursor: "pointer" }}
>
+
{t("common.table.headers.type")}
{renderSortIcon(sortField === "type")}
diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx
index 5c419738b..80ee8e2d9 100644
--- a/client/src/Pages/Uptime/Monitors/index.tsx
+++ b/client/src/Pages/Uptime/Monitors/index.tsx
@@ -42,11 +42,11 @@ const UptimeMonitorsPage = () => {
const debouncedSearch = useDebounce(search, 300);
// Convert filter selections to API filter values
- // Status: "up" -> true, "down" -> false
+ // Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
- if (selectedStatus === "up") return "true";
- if (selectedStatus === "down") return "false";
+ if (selectedStatus === "up") return "up";
+ if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
@@ -68,7 +68,9 @@ const UptimeMonitorsPage = () => {
// Default to all types when none selected
const effectiveTypes =
- selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port", "game"];
+ selectedTypes.length > 0
+ ? selectedTypes
+ : ["http", "ping", "docker", "port", "game", "grpc"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts
index f204ab2ca..c69fe7972 100644
--- a/client/src/Types/Check.ts
+++ b/client/src/Types/Check.ts
@@ -151,6 +151,15 @@ export interface GroupedCheck {
totalChecks: number;
}
+export interface PageSpeedGroupedCheck {
+ bucketDate: string;
+ performance: number;
+ accessibility: number;
+ bestPractices: number;
+ seo: number;
+ totalChecks: number;
+}
+
export interface LatestCheck {
status: boolean;
responseTime: number;
diff --git a/client/src/Types/GeoCheck.ts b/client/src/Types/GeoCheck.ts
new file mode 100644
index 000000000..ec46eeac1
--- /dev/null
+++ b/client/src/Types/GeoCheck.ts
@@ -0,0 +1,80 @@
+export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
+export type GeoContinent = (typeof GeoContinents)[number];
+
+export interface GeoCheckMetadata {
+ monitorId: string;
+ teamId: string;
+ type: string;
+}
+
+export interface GeoCheckTimings {
+ total: number;
+ dns: number;
+ tcp: number;
+ tls: number;
+ firstByte: number;
+ download: number;
+}
+
+export interface GeoCheckLocation {
+ continent: GeoContinent;
+ region: string;
+ country: string;
+ state: string;
+ city: string;
+ longitude: number;
+ latitude: number;
+}
+
+export interface GeoCheckResult {
+ location: GeoCheckLocation;
+ status: boolean;
+ statusCode: number;
+ timings: GeoCheckTimings;
+}
+
+export interface GeoCheck {
+ id: string;
+ metadata: GeoCheckMetadata;
+ results: GeoCheckResult[];
+ expiry: string;
+ __v: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FlatGeoCheck {
+ id: string;
+ monitorId: string;
+ teamId: string;
+ type: string;
+ location: GeoCheckLocation;
+ status: boolean;
+ statusCode: number;
+ timings: GeoCheckTimings;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface GroupedGeoCheck {
+ bucketDate: string;
+ continent: GeoContinent;
+ avgResponseTime: number;
+ totalChecks: number;
+ uptimePercentage: number;
+}
+
+export interface GeoChecksResult {
+ monitorType: string;
+ groupedGeoChecks: GroupedGeoCheck[];
+}
+
+export interface GeoChecksResponse {
+ geoChecks: GeoCheck[];
+ geoChecksCount: number;
+}
+
+export interface FlatGeoChecksResponse {
+ geoChecks: FlatGeoCheck[];
+ geoChecksCount: number;
+}
diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts
index dab6dd734..01cae62e9 100644
--- a/client/src/Types/Monitor.ts
+++ b/client/src/Types/Monitor.ts
@@ -1,4 +1,7 @@
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
+import type { PageSpeedGroupedCheck } from "@/Types/Check";
+import type { GeoContinent } from "@/Types/GeoCheck";
+export type { GeoContinent } from "@/Types/GeoCheck";
export const MonitorTypes = [
"http",
@@ -8,6 +11,7 @@ export const MonitorTypes = [
"docker",
"port",
"game",
+ "grpc",
"unknown",
] as const;
export type MonitorType = (typeof MonitorTypes)[number];
@@ -57,7 +61,11 @@ export interface Monitor {
tempAlertCounter: number;
selectedDisks: string[];
gameId?: string;
+ grpcServiceName?: string;
group: string | null;
+ geoCheckEnabled?: boolean;
+ geoCheckLocations?: GeoContinent[];
+ geoCheckInterval?: number;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
@@ -112,7 +120,10 @@ export interface MonitorDetailsResponse {
}
export interface PageSpeedDetailsResponse {
- monitor: MonitorWithChecks;
+ monitorData: {
+ monitor: Monitor;
+ groupedChecks: PageSpeedGroupedCheck[];
+ };
monitorStats: MonitorStats | null;
}
diff --git a/client/src/Types/env.d.ts b/client/src/Types/env.d.ts
index cd714db75..af8f983fb 100644
--- a/client/src/Types/env.d.ts
+++ b/client/src/Types/env.d.ts
@@ -4,6 +4,7 @@ declare const __APP_VERSION__: string;
interface ImportMetaEnv {
readonly VITE_APP_API_V2_BASE_URL?: string;
+ readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
}
interface ImportMeta {
@@ -17,3 +18,8 @@ declare module "*.svg?react" {
>;
export default ReactComponent;
}
+
+declare module "*.css" {
+ const content: string;
+ export default content;
+}
diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts
index a19316c62..ad744ba6d 100644
--- a/client/src/Utils/MonitorUtils.ts
+++ b/client/src/Utils/MonitorUtils.ts
@@ -8,6 +8,7 @@ export const getMonitorPath = (type: MonitorType): string => {
port: "uptime",
ping: "uptime",
game: "uptime",
+ grpc: "uptime",
unknown: "uptime",
docker: "uptime",
hardware: "infrastructure",
diff --git a/client/src/Utils/Theme/constants.ts b/client/src/Utils/Theme/constants.ts
new file mode 100644
index 000000000..8b045f9f6
--- /dev/null
+++ b/client/src/Utils/Theme/constants.ts
@@ -0,0 +1,17 @@
+export const SPACING = {
+ XS: 0.5,
+ SM: 1,
+ MD: 1.5,
+ LG: 2,
+ XL: 2.5,
+ XXL: 3,
+} as const;
+
+export const LAYOUT = {
+ XS: 4,
+ SM: 6,
+ MD: 8,
+ LG: 10,
+ XL: 12,
+ XXL: 16,
+} as const;
diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts
new file mode 100644
index 000000000..4a97dbecb
--- /dev/null
+++ b/client/src/Utils/logger.ts
@@ -0,0 +1,137 @@
+import type { LogLevel } from "@/Types/Log";
+
+interface LogContext {
+ [key: string]: any;
+}
+
+const LOG_LEVEL_PRIORITY: Record = {
+ debug: 0,
+ info: 1,
+ warn: 2,
+ error: 3,
+};
+
+interface ErrorLogData {
+ timestamp: string;
+ level: LogLevel;
+ message: string;
+ error?: {
+ message: string;
+ name: string;
+ stack?: string;
+ };
+ context?: LogContext;
+ url: string;
+ userAgent: string;
+}
+
+const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
+const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
+
+const shouldLog = (level: LogLevel): boolean => {
+ return LOG_LEVEL_PRIORITY[level] >= configuredPriority;
+};
+
+const formatError = (error?: Error) => {
+ if (!error) return undefined;
+
+ return {
+ message: error.message,
+ name: error.name,
+ stack: error.stack,
+ };
+};
+
+const createLogData = (
+ level: LogLevel,
+ message: string,
+ error?: Error,
+ context?: LogContext
+): ErrorLogData => {
+ return {
+ timestamp: new Date().toISOString(),
+ level,
+ message,
+ error: formatError(error),
+ context,
+ url: window.location.href,
+ userAgent: navigator.userAgent,
+ };
+};
+
+const error = (message: string, error?: Error, context?: LogContext): void => {
+ if (!shouldLog("error")) return;
+
+ const logData = createLogData("error", message, error, context);
+
+ if (configuredLevel === "debug") {
+ console.group(`ERROR: ${message}`);
+ if (error) {
+ console.error("Error:", error);
+ }
+ if (context && Object.keys(context).length > 0) {
+ console.log("Context:", context);
+ }
+ console.log("URL:", logData.url);
+ console.log("Timestamp:", logData.timestamp);
+ console.groupEnd();
+ } else {
+ console.error(JSON.stringify(logData));
+ }
+};
+
+const warn = (message: string, context?: LogContext): void => {
+ if (!shouldLog("warn")) return;
+
+ const logData = createLogData("warn", message, undefined, context);
+
+ if (configuredLevel === "debug") {
+ console.group(`WARN: ${message}`);
+ if (context && Object.keys(context).length > 0) {
+ console.log("Context:", context);
+ }
+ console.groupEnd();
+ } else {
+ console.warn(JSON.stringify(logData));
+ }
+};
+
+const debug = (message: string, data?: any): void => {
+ if (!shouldLog("debug")) return;
+
+ console.group(`DEBUG: ${message}`);
+ if (data !== undefined) {
+ console.log(data);
+ }
+ console.groupEnd();
+};
+
+const info = (message: string, data?: any): void => {
+ if (!shouldLog("info")) return;
+
+ if (configuredLevel === "debug") {
+ console.log(`INFO: ${message}`, data);
+ } else {
+ const logData = {
+ timestamp: new Date().toISOString(),
+ level: "info" as LogLevel,
+ message,
+ data,
+ };
+ console.log(JSON.stringify(logData));
+ }
+};
+
+export interface ILogger {
+ error(message: string, error?: Error, context?: LogContext): void;
+ warn(message: string, context?: LogContext): void;
+ debug(message: string, data?: any): void;
+ info(message: string, data?: any): void;
+}
+
+export const logger: ILogger = {
+ error,
+ warn,
+ debug,
+ info,
+};
diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts
index 68a15fbec..439af977f 100644
--- a/client/src/Validation/monitor.ts
+++ b/client/src/Validation/monitor.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { GeoContinents } from "@/Types/GeoCheck";
// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -20,6 +21,12 @@ const baseSchema = z.object({
.number({ message: "Threshold percentage is required" })
.min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100"),
+ geoCheckEnabled: z.boolean().optional(),
+ geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
+ geoCheckInterval: z
+ .number()
+ .min(300000, "Interval must be at least 5 minutes")
+ .optional(),
});
// HTTP monitor schema
@@ -66,6 +73,18 @@ const gameSchema = baseSchema.extend({
gameId: z.string().min(1, "Game type is required"),
});
+// gRPC monitor schema
+const grpcSchema = baseSchema.extend({
+ type: z.literal("grpc"),
+ url: z.string().min(1, "Host is required"),
+ port: z
+ .number()
+ .min(1, "Port must be at least 1")
+ .max(65535, "Port must be at most 65535"),
+ grpcServiceName: z.string().optional(),
+ ignoreTlsErrors: z.boolean(),
+});
+
// PageSpeed monitor schema
const pagespeedSchema = baseSchema.extend({
type: z.literal("pagespeed"),
@@ -103,6 +122,7 @@ export const monitorSchema = z.discriminatedUnion("type", [
portSchema,
dockerSchema,
gameSchema,
+ grpcSchema,
pagespeedSchema,
hardwareSchema,
]);
@@ -116,6 +136,7 @@ export {
portSchema,
dockerSchema,
gameSchema,
+ grpcSchema,
pagespeedSchema,
hardwareSchema,
};
diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts
index 4c89a4c2c..7eddaafe2 100644
--- a/client/src/Validation/settings.ts
+++ b/client/src/Validation/settings.ts
@@ -40,34 +40,12 @@ export const settingsSchema = z.object({
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
- globalThresholds: z
- .object({
- cpu: z
- .number()
- .int()
- .min(1, "Please enter a valid value")
- .max(100, "Maximum value is 100")
- .optional(),
- memory: z
- .number()
- .int()
- .min(1, "Please enter a valid value")
- .max(100, "Maximum value is 100")
- .optional(),
- disk: z
- .number()
- .int()
- .min(1, "Please enter a valid value")
- .max(100, "Maximum value is 100")
- .optional(),
- temperature: z
- .number()
- .int()
- .min(1, "Please enter a valid value")
- .max(150, "Maximum value is 150")
- .optional(),
- })
- .optional(),
+ globalThresholds: z.object({
+ cpu: z.number().int().min(1).max(100),
+ memory: z.number().int().min(1).max(100),
+ disk: z.number().int().min(1).max(100),
+ temperature: z.number().int().min(1).max(150),
+ }),
});
export type SettingsFormData = z.infer;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index e47ab64cc..c6bf41478 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -44,7 +44,9 @@
"addDemo": "Add demo monitors",
"removeMonitors": "Remove monitors",
"sendTestEmail": "Send test email",
- "exportToJSON": "Export to JSON"
+ "exportToJSON": "Export to JSON",
+ "importFromJSON": "Import from JSON",
+ "clearFilters": "Clear filters"
},
"charts": {
"labels": {
@@ -114,7 +116,8 @@
"type": "Type",
"url": "Url",
"interval": "Interval",
- "active": "Active"
+ "active": "Active",
+ "responseTime": "Response time"
}
}
},
@@ -371,8 +374,8 @@
"table": {
"empty": "No down checks in this time range",
"headers": {
- "dateTime": "Date & time",
- "statusCode": "Status code"
+ "statusCode": "Status code",
+ "location": "Location"
}
}
},
@@ -480,6 +483,10 @@
"port": {
"label": "Port to monitor",
"placeholder": 80
+ },
+ "grpcServiceName": {
+ "label": "Service name",
+ "placeholder": "e.g. my.service.v1 (leave empty for overall health)"
}
},
"title": "General settings",
@@ -490,6 +497,7 @@
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
+ "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
}
},
@@ -524,6 +532,8 @@
"optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionGame": "Game",
"optionGameDescription": "Monitor if a specific game server is online.",
+ "optionGrpc": "gRPC",
+ "optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionHttp": "HTTP(S)",
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
"optionPing": "Ping",
@@ -550,6 +560,36 @@
}
}
},
+ "geoChecks": {
+ "title": "Geo-Distributed Checks",
+ "description": "Run checks from multiple geographic locations to monitor global availability and performance.",
+ "option": {
+ "enabled": {
+ "label": "Enable geo-distributed checks"
+ },
+ "locations": {
+ "label": "Locations",
+ "placeholder": "Select locations",
+ "options": {
+ "EU": "Europe",
+ "NA": "North America",
+ "AS": "Asia",
+ "SA": "South America",
+ "AF": "Africa",
+ "OC": "Oceania"
+ }
+ },
+ "interval": {
+ "label": "Check interval",
+ "value": {
+ "fiveMinutes": "5 minutes",
+ "tenMinutes": "10 minutes",
+ "fifteenMinutes": "15 minutes",
+ "thirtyMinutes": "30 minutes"
+ }
+ }
+ }
+ },
"url": {
"title": "Monitor IP/URL on Status Page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
@@ -619,7 +659,6 @@
},
"filters": {
"allMonitors": "All monitors",
- "clearFilters": "Clear filters",
"monitor": "Monitor",
"resolutionType": "Resolution type",
"resolutionTypes": {
@@ -1086,12 +1125,18 @@
}
},
"exportMonitors": {
- "title": "Export monitors",
- "description": "Export your monitors data as a JSON file for backup or transfer."
+ "title": "Export monitors"
+ },
+ "importExportMonitors": {
+ "title": "Import / Export monitors",
+ "description": "Import or export your monitors data as a JSON file for backup or transfer."
},
"about": {
"title": "About",
"developedBy": "Developed by Bluewave Labs"
+ },
+ "validation": {
+ "errorMessage": "Please fix the following validation errors:"
}
}
},
diff --git a/client/vite.config.ts b/client/vite.config.ts
index ffa4f544d..7408636dd 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig(({}) => {
- let version = "3.4.0";
+ let version = "3.5.0";
return {
base: "/",
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 000000000..1a05a6ff7
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,26 @@
+# ==============================================
+# Checkmate Server Configuration
+# ==============================================
+# Copy this file to .env and fill in your values
+
+# Server Configuration
+# --------------------
+NODE_ENV=development
+LOG_LEVEL=debug
+
+# Database
+# --------
+DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
+
+# JWT Authentication
+# ------------------
+JWT_SECRET=my_secret
+TOKEN_TTL=99d
+
+# Client Configuration
+# --------------------
+CLIENT_HOST=http://localhost:5173
+
+# Optional Configuration
+# ----------------------
+ORIGIN=localhost
diff --git a/server/eslint.config.js b/server/eslint.config.js
index 13f1fc038..c854029a0 100755
--- a/server/eslint.config.js
+++ b/server/eslint.config.js
@@ -1,6 +1,7 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import mochaPlugin from "eslint-plugin-mocha";
+import tseslint from "typescript-eslint";
/*
Please do not forget to look at the latest eslint configurations and rules.
@@ -10,6 +11,9 @@ ESlint v9 configuration is different than v8.
/** @type {import('eslint').Linter.Config[]} */
export default [
+ {
+ ignores: ["dist/**", "node_modules/**", "coverage/**", "test/**"],
+ },
{
languageOptions: {
globals: {
@@ -21,9 +25,19 @@ export default [
},
},
pluginJs.configs.recommended, // Core JS rules
+ ...tseslint.configs.recommended, // TypeScript recommended rules
mochaPlugin.configs.flat.recommended, // Mocha rules
{
+ files: ["**/*.ts", "**/*.tsx"],
+ languageOptions: {
+ parser: tseslint.parser,
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
rules: {
+ "@typescript-eslint/no-explicit-any": "warn", // Warn on 'any' types
"mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing
},
},
diff --git a/server/openapi.json b/server/openapi.json
index d56c619bf..1a6bbbfbf 100644
--- a/server/openapi.json
+++ b/server/openapi.json
@@ -432,46 +432,6 @@
}
},
"/monitors": {
- "get": {
- "tags": ["monitors"],
- "summary": "Get all monitors",
- "description": "Retrieve all monitors for the authenticated user",
- "responses": {
- "200": {
- "description": "Monitors retrieved successfully",
- "content": {
- "application/json": {
- "schema": {
- "allOf": [
- {
- "$ref": "#/components/schemas/SuccessResponse"
- },
- {
- "type": "object",
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/Monitor"
- }
- }
- }
- }
- ]
- }
- }
- }
- },
- "500": {
- "$ref": "#/components/responses/InternalServerError"
- }
- },
- "security": [
- {
- "bearerAuth": []
- }
- ]
- },
"post": {
"tags": ["monitors"],
"summary": "Create monitor",
diff --git a/server/package-lock.json b/server/package-lock.json
index d89613ecf..ced3a5d0e 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -9,6 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "@grpc/grpc-js": "^1.14.3",
+ "@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
@@ -28,7 +30,6 @@
"ioredis": "^5.4.2",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
- "joi": "^17.13.1",
"jsdom": "^26.1.0",
"jsonwebtoken": "9.0.2",
"mailersend": "^2.2.0",
@@ -41,7 +42,8 @@
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
- "winston": "^3.13.0"
+ "winston": "^3.13.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -61,6 +63,8 @@
"@types/papaparse": "^5.5.2",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
+ "@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
@@ -74,7 +78,8 @@
"ts-node": "^10.9.2",
"tsc-alias": "1.8.16",
"tsx": "4.20.5",
- "typescript": "5.9.2"
+ "typescript": "5.9.2",
+ "typescript-eslint": "^8.56.1"
},
"engines": {
"node": ">=20"
@@ -2041,9 +2046,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
- "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2198,9 +2203,9 @@
}
},
"node_modules/@grpc/grpc-js": {
- "version": "1.14.0",
- "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz",
- "integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==",
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
+ "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
@@ -2210,7 +2215,7 @@
"node": ">=12.10.0"
}
},
- "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
+ "node_modules/@grpc/proto-loader": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
@@ -2228,39 +2233,6 @@
"node": ">=6"
}
},
- "node_modules/@grpc/proto-loader": {
- "version": "0.7.15",
- "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
- "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "lodash.camelcase": "^4.3.0",
- "long": "^5.0.0",
- "protobufjs": "^7.2.5",
- "yargs": "^17.7.2"
- },
- "bin": {
- "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/@hapi/hoek": {
- "version": "9.3.0",
- "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
- "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@hapi/topo": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
- "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@hapi/hoek": "^9.0.0"
- }
- },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3475,27 +3447,6 @@
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
- "node_modules/@sideway/address": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
- "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@hapi/hoek": "^9.0.0"
- }
- },
- "node_modules/@sideway/formula": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
- "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@sideway/pinpoint": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
- "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
- "license": "BSD-3-Clause"
- },
"node_modules/@sinclair/typebox": {
"version": "0.34.47",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz",
@@ -4223,16 +4174,6 @@
"node": ">=14.16"
}
},
- "node_modules/@trysound/sax": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
- "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
- "license": "ISC",
- "optional": true,
- "engines": {
- "node": ">=10.13.0"
- }
- },
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -4749,6 +4690,360 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
+ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/type-utils": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.56.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
+ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
+ "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.56.1",
+ "@typescript-eslint/types": "^8.56.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
+ "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
+ "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
+ "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
+ "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
+ "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.56.1",
+ "@typescript-eslint/tsconfig-utils": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
+ "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
+ "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.56.1",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -6948,6 +7243,24 @@
"node": ">= 8.0"
}
},
+ "node_modules/dockerode/node_modules/@grpc/proto-loader": {
+ "version": "0.7.15",
+ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
+ "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lodash.camelcase": "^4.3.0",
+ "long": "^5.0.0",
+ "protobufjs": "^7.2.5",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/dockerode/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -9938,19 +10251,6 @@
"node": ">= 0.6.0"
}
},
- "node_modules/joi": {
- "version": "17.13.3",
- "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
- "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@hapi/hoek": "^9.3.0",
- "@hapi/topo": "^5.1.0",
- "@sideway/address": "^4.1.5",
- "@sideway/formula": "^3.0.1",
- "@sideway/pinpoint": "^2.0.0"
- }
- },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -14173,63 +14473,6 @@
"node": ">=8"
}
},
- "node_modules/svgo": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
- "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@trysound/sax": "0.2.0",
- "commander": "^7.2.0",
- "css-select": "^5.1.0",
- "css-tree": "^2.3.1",
- "css-what": "^6.1.0",
- "csso": "^5.0.5",
- "picocolors": "^1.0.0"
- },
- "bin": {
- "svgo": "bin/svgo"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/svgo"
- }
- },
- "node_modules/svgo/node_modules/commander": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
- "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/svgo/node_modules/css-tree": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
- "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "mdn-data": "2.0.30",
- "source-map-js": "^1.0.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
- "node_modules/svgo/node_modules/mdn-data": {
- "version": "2.0.30",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
- "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
- "license": "CC0-1.0",
- "optional": true
- },
"node_modules/swagger-ui-dist": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.0.tgz",
@@ -14365,6 +14608,55 @@
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
"license": "MIT"
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
@@ -14454,6 +14746,19 @@
"node": ">= 14.0.0"
}
},
+ "node_modules/ts-api-utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
"node_modules/ts-jest": {
"version": "29.4.6",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz",
@@ -14685,6 +14990,30 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-eslint": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
+ "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.56.1",
+ "@typescript-eslint/parser": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -15481,6 +15810,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "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"
+ }
}
}
}
diff --git a/server/package.json b/server/package.json
index c1ef2b8ec..b94a1e852 100755
--- a/server/package.json
+++ b/server/package.json
@@ -24,6 +24,8 @@
"node": ">=20"
},
"dependencies": {
+ "@grpc/grpc-js": "^1.14.3",
+ "@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
@@ -43,7 +45,6 @@
"ioredis": "^5.4.2",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
- "joi": "^17.13.1",
"jsdom": "^26.1.0",
"jsonwebtoken": "9.0.2",
"mailersend": "^2.2.0",
@@ -56,7 +57,8 @@
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
- "winston": "^3.13.0"
+ "winston": "^3.13.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -76,6 +78,8 @@
"@types/papaparse": "^5.5.2",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
+ "@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
@@ -89,6 +93,7 @@
"ts-node": "^10.9.2",
"tsc-alias": "1.8.16",
"tsx": "4.20.5",
- "typescript": "5.9.2"
+ "typescript": "5.9.2",
+ "typescript-eslint": "^8.56.1"
}
}
diff --git a/server/scripts/generate-checks.js b/server/scripts/generate-checks.js
index 544770bb6..2e336274c 100644
--- a/server/scripts/generate-checks.js
+++ b/server/scripts/generate-checks.js
@@ -3,8 +3,6 @@ import { MonitorModel } from "../dist/db/models/Monitor.js";
import { CheckModel } from "../dist/db/models/Check.js";
const DEFAULT_MONITOR_ID = "000000000000000000000001";
-const DEFAULT_TEAM_ID = "0000000000000000000000aa";
-const DEFAULT_USER_ID = "0000000000000000000000bb";
const DEFAULT_MONITOR_TYPE = "http";
const DEFAULT_TOTAL = 1_000_000;
const DEFAULT_BATCH_SIZE = 5_000;
@@ -12,7 +10,7 @@ const DEFAULT_BATCH_SIZE = 5_000;
const parseObjectId = (value, fallback) => {
try {
return new mongoose.Types.ObjectId(value || fallback);
- } catch (error) {
+ } catch {
console.warn(`Invalid ObjectId '${value}', falling back to '${fallback}'.`);
return new mongoose.Types.ObjectId(fallback);
}
diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts
index 8a83a0f63..98e6ded8e 100644
--- a/server/src/config/controllers.ts
+++ b/server/src/config/controllers.ts
@@ -2,6 +2,7 @@ import MonitorController from "../controllers/monitorController.js";
import AuthController from "../controllers/authController.js";
import SettingsController from "../controllers/settingsController.js";
import CheckController from "../controllers/checkController.js";
+import GeoCheckController from "../controllers/geoCheckController.js";
import InviteController from "../controllers/inviteController.js";
import MaintenanceWindowController from "../controllers/maintenanceWindowController.js";
import QueueController from "../controllers/queueController.js";
@@ -17,6 +18,7 @@ export interface InitializedControllers {
monitorController: MonitorController;
settingsController: SettingsController;
checkController: CheckController;
+ geoCheckController: GeoCheckController;
inviteController: InviteController;
maintenanceWindowController: MaintenanceWindowController;
queueController: QueueController;
@@ -32,6 +34,7 @@ export const initializeControllers = (services: InitializedServices): Initialize
monitorController: new MonitorController(services.monitorService),
settingsController: new SettingsController(services.settingsService, services.emailService),
checkController: new CheckController(services.checkService),
+ geoCheckController: new GeoCheckController(services.geoChecksService),
inviteController: new InviteController(services.inviteService),
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
queueController: new QueueController(services.jobQueue),
diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts
index 8707009a8..672866dc1 100644
--- a/server/src/config/routes.ts
+++ b/server/src/config/routes.ts
@@ -6,6 +6,7 @@ import AuthRoutes from "../routes/authRoute.js";
import InviteRoutes from "../routes/inviteRoute.js";
import MonitorRoutes from "../routes/monitorRoute.js";
import CheckRoutes from "../routes/checkRoute.js";
+import GeoCheckRoutes from "../routes/geoCheckRoutes.js";
import SettingsRoutes from "../routes/settingsRoute.js";
import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js";
import StatusPageRoutes from "../routes/statusPageRoute.js";
@@ -22,6 +23,7 @@ export const setupRoutes = (app: any, controllers: Record, services
const monitorRoutes = new MonitorRoutes(controllers.monitorController);
const settingsRoutes = new SettingsRoutes(controllers.settingsController);
const checkRoutes = new CheckRoutes(controllers.checkController);
+ const geoCheckRoutes = new GeoCheckRoutes(controllers.geoCheckController);
const inviteRoutes = new InviteRoutes(controllers.inviteController, verifyJWT);
const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController);
const queueRoutes = new QueueRoutes(controllers.queueController);
@@ -36,6 +38,7 @@ export const setupRoutes = (app: any, controllers: Record, services
app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter());
app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter());
app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter());
+ app.use("/api/v1/geo-checks", verifyJWT, geoCheckRoutes.getRouter());
app.use("/api/v1/invite", inviteRoutes.getRouter());
app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter());
app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter());
diff --git a/server/src/config/services.ts b/server/src/config/services.ts
index ecb49ab40..56aef2f19 100644
--- a/server/src/config/services.ts
+++ b/server/src/config/services.ts
@@ -17,6 +17,8 @@ import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/S
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";
@@ -27,7 +29,6 @@ import { NotificationMessageBuilder, INotificationMessageBuilder } from "../serv
import axios from "axios";
import got from "got";
import ping from "ping";
-import http from "http";
import https from "https";
import Docker from "dockerode";
import net from "net";
@@ -41,11 +42,14 @@ import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
+import * as grpc from "@grpc/grpc-js";
+import * as protoLoader from "@grpc/proto-loader";
// repositories
import {
MongoMonitorsRepository,
MongoChecksRepository,
+ MongoGeoChecksRepository,
MongoMonitorStatsRepository,
MongoStatusPagesRepository,
MongoUsersRepository,
@@ -57,6 +61,7 @@ import {
MongoMaintenanceWindowsRepository,
IMonitorsRepository,
IChecksRepository,
+ IGeoChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
@@ -81,12 +86,13 @@ export type InitializedServices = {
jobQueue: any;
userService: any;
checkService: any;
+ geoChecksService: any;
diagnosticService: any;
inviteService: any;
maintenanceWindowService: any;
monitorService: any;
incidentService: any;
- logger: any;
+ logger: ILogger;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
notificationMessageBuilder: INotificationMessageBuilder;
@@ -94,6 +100,7 @@ export type InitializedServices = {
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
+ geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -126,6 +133,7 @@ export const initializeServices = async ({
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
+ const geoChecksRepository = new MongoGeoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const usersRepository = new MongoUsersRepository();
@@ -136,38 +144,40 @@ export const initializeServices = async ({
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
- const networkService = new NetworkService({
+ const networkService = new NetworkService(
axios,
got,
https,
jmespath,
- GameDig,
+ GameDig as unknown as {
+ query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }>;
+ },
ping,
logger,
- http,
Docker,
net,
settingsService,
- });
+ grpc,
+ protoLoader
+ );
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const notificationMessageBuilder = new NotificationMessageBuilder();
- const incidentService = new IncidentService({
+ const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
+
+ const checkService = new CheckService(monitorsRepository, logger, checksRepository);
+
+ const globalPingService = new GlobalPingService(logger);
+
+ const geoChecksService = new GeoChecksService({
logger,
- incidentsRepository,
+ geoChecksRepository,
+ globalPingService,
monitorsRepository,
- usersRepository,
- notificationMessageBuilder,
});
- const checkService = new CheckService({
- monitorsRepository,
- logger,
- checksRepository,
- });
-
- const bufferService = new BufferService({ logger, checkService, settingsService });
+ const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
@@ -192,13 +202,13 @@ export const initializeServices = async ({
notificationMessageBuilder
);
- const superSimpleQueueHelper = new SuperSimpleQueueHelper({
+ const superSimpleQueueHelper = new SuperSimpleQueueHelper(
logger,
networkService,
statusService,
notificationsService,
checkService,
- buffer: bufferService,
+ bufferService,
incidentService,
maintenanceWindowsRepository,
monitorsRepository,
@@ -206,13 +216,11 @@ export const initializeServices = async ({
monitorStatsRepository,
checksRepository,
incidentsRepository,
- });
+ geoChecksService,
+ geoChecksRepository
+ );
- const superSimpleQueue = await SuperSimpleQueue.create({
- logger,
- helper: superSimpleQueueHelper,
- monitorsRepository,
- });
+ const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository);
// Business services
const userService = new UserService({
@@ -247,6 +255,7 @@ export const initializeServices = async ({
games,
monitorsRepository,
checksRepository,
+ geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -265,6 +274,7 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
userService,
checkService,
+ geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
@@ -278,6 +288,7 @@ export const initializeServices = async ({
// Repositories
monitorsRepository,
checksRepository,
+ geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts
index 3fa551808..8add39087 100755
--- a/server/src/controllers/authController.ts
+++ b/server/src/controllers/authController.ts
@@ -6,17 +6,20 @@ import type { UserRole } from "@/types/user.js";
import {
registrationBodyValidation,
loginValidation,
- editUserBodyValidation,
- createUserBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
+} from "@/validation/authValidation.js";
+
+import {
+ editUserBodyValidation,
+ createUserBodyValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
-} from "@/validation/joi.js";
+} from "@/validation/userValidation.js";
const SERVICE_NAME = "authController";
@@ -40,7 +43,7 @@ class AuthController {
if (newUser?.email) {
newUser.email = newUser.email.toLowerCase();
}
- await registrationBodyValidation.validateAsync(newUser);
+ registrationBodyValidation.parse(newUser);
const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file);
res.status(200).json({
@@ -59,7 +62,7 @@ class AuthController {
if (userData?.email) {
userData.email = userData.email.toLowerCase();
}
- await createUserBodyValidation.validateAsync(userData);
+ createUserBodyValidation.parse(userData);
const teamId = requireTeamId(req.user?.teamId);
const actorRoles = requireUserRoles(req.user?.role) as UserRole[];
@@ -79,7 +82,7 @@ class AuthController {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
- await loginValidation.validateAsync(req.body);
+ loginValidation.parse(req.body);
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
return res.status(200).json({
@@ -97,7 +100,7 @@ class AuthController {
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
- await editUserBodyValidation.validateAsync(req.body);
+ editUserBodyValidation.parse(req.body);
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
res.status(200).json({
@@ -125,7 +128,7 @@ class AuthController {
requestRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
- await recoveryValidation.validateAsync(req.body);
+ recoveryValidation.parse(req.body);
const email = req?.body?.email;
const msgId = await this.userService.requestRecovery(email);
return res.status(200).json({
@@ -140,7 +143,7 @@ class AuthController {
validateRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
- await recoveryTokenBodyValidation.validateAsync(req.body);
+ recoveryTokenBodyValidation.parse(req.body);
await this.userService.validateRecovery(req.body.recoveryToken);
return res.status(200).json({
success: true,
@@ -153,7 +156,7 @@ class AuthController {
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
- await newPasswordValidation.validateAsync(req.body);
+ newPasswordValidation.parse(req.body);
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
return res.status(200).json({
success: true,
@@ -192,7 +195,7 @@ class AuthController {
getUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getUserByIdParamValidation.validateAsync(req.params);
+ getUserByIdParamValidation.parse(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
@@ -223,12 +226,12 @@ class AuthController {
const userId = req.params.userId as string;
const user = { ...req.body };
- await editUserByIdParamValidation.validateAsync(req.params);
+ editUserByIdParamValidation.parse(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user?.id) {
- await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
+ editSuperadminUserByIdBodyValidation.parse(req.body);
} else {
- await editUserByIdBodyValidation.validateAsync(req.body);
+ editUserByIdBodyValidation.parse(req.body);
}
await this.userService.editUserById(userId, user);
@@ -246,8 +249,8 @@ class AuthController {
}
const userId = req.params.userId as string;
- await editUserByIdParamValidation.validateAsync(req.params);
- await editUserPasswordByIdBodyValidation.validateAsync(req.body);
+ 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" });
diff --git a/server/src/controllers/checkController.ts b/server/src/controllers/checkController.ts
index 0c085f6d3..ec6722bd3 100755
--- a/server/src/controllers/checkController.ts
+++ b/server/src/controllers/checkController.ts
@@ -7,10 +7,7 @@ import {
deleteChecksParamValidation,
deleteChecksByTeamIdParamValidation,
updateChecksTTLBodyValidation,
- ackCheckBodyValidation,
- ackAllChecksParamValidation,
- ackAllChecksBodyValidation,
-} from "@/validation/joi.js";
+} from "@/validation/checkValidation.js";
const SERVICE_NAME = "checkController";
@@ -28,8 +25,8 @@ class CheckController {
getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getChecksParamValidation.validateAsync(req.params);
- await getChecksQueryValidation.validateAsync(req.query);
+ getChecksParamValidation.parse(req.params);
+ getChecksQueryValidation.parse(req.query);
const result = await this.checkService.getChecksByMonitor({
monitorId: req?.params?.monitorId,
@@ -49,7 +46,7 @@ class CheckController {
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getTeamChecksQueryValidation.validateAsync(req.query);
+ getTeamChecksQueryValidation.parse(req.query);
const checkData = await this.checkService.getChecksByTeam({
teamId: req?.user?.teamId,
query: req?.query,
@@ -80,7 +77,7 @@ class CheckController {
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
try {
- await deleteChecksParamValidation.validateAsync(req.params);
+ deleteChecksParamValidation.parse(req.params);
const deletedCount = await this.checkService.deleteChecks({
monitorId: req.params.monitorId as string,
@@ -99,7 +96,7 @@ class CheckController {
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
- await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
+ deleteChecksByTeamIdParamValidation.parse(req.params);
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId });
@@ -115,7 +112,7 @@ class CheckController {
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
try {
- await updateChecksTTLBodyValidation.validateAsync(req.body);
+ updateChecksTTLBodyValidation.parse(req.body);
await this.checkService.updateChecksTTL({
teamId: req?.user?.teamId,
diff --git a/server/src/controllers/geoCheckController.ts b/server/src/controllers/geoCheckController.ts
new file mode 100644
index 000000000..cfa7fe056
--- /dev/null
+++ b/server/src/controllers/geoCheckController.ts
@@ -0,0 +1,41 @@
+import { Request, Response, NextFunction } from "express";
+import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
+import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
+
+const SERVICE_NAME = "geoCheckController";
+
+class GeoCheckController {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private geoChecksService: IGeoChecksService;
+ constructor(geoChecksService: IGeoChecksService) {
+ this.geoChecksService = geoChecksService;
+ }
+
+ get serviceName() {
+ return GeoCheckController.SERVICE_NAME;
+ }
+
+ getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ getChecksParamValidation.parse(req.params);
+ getChecksQueryValidation.parse(req.query);
+
+ const result = await this.geoChecksService.getGeoChecksByMonitor({
+ monitorId: req?.params?.monitorId as string,
+ query: req?.query,
+ teamId: req?.user?.teamId as string,
+ });
+
+ return res.status(200).json({
+ success: true,
+ msg: "Geo checks retrieved successfully",
+ data: result,
+ });
+ } catch (error) {
+ next(error);
+ }
+ };
+}
+
+export default GeoCheckController;
diff --git a/server/src/controllers/inviteController.ts b/server/src/controllers/inviteController.ts
index bba04fbbd..b17d32e98 100755
--- a/server/src/controllers/inviteController.ts
+++ b/server/src/controllers/inviteController.ts
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
-import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/joi.js";
+import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "inviteController";
@@ -20,7 +20,7 @@ class InviteController {
const userRoles = requireUserRoles(req.user?.role);
const invite = req.body;
invite.teamId = teamId;
- await inviteBodyValidation.validateAsync(invite);
+ inviteBodyValidation.parse(invite);
const inviteToken = await this.inviteService.getInviteToken({ invite, teamId, userRoles });
return res.status(200).json({
success: true,
@@ -39,7 +39,7 @@ class InviteController {
const inviteRequest = req.body;
inviteRequest.teamId = teamId;
- await inviteBodyValidation.validateAsync(inviteRequest);
+ inviteBodyValidation.parse(inviteRequest);
const inviteToken = await this.inviteService.sendInviteEmail({
invite: inviteRequest,
@@ -58,7 +58,7 @@ class InviteController {
verifyInviteToken = async (req: Request, res: Response, next: NextFunction) => {
try {
- await inviteVerificationBodyValidation.validateAsync(req.body);
+ inviteVerificationBodyValidation.parse(req.body);
const invite = await this.inviteService.verifyInviteToken({ inviteToken: req?.body?.token });
return res.status(200).json({
success: true,
diff --git a/server/src/controllers/maintenanceWindowController.ts b/server/src/controllers/maintenanceWindowController.ts
index 187e479cc..ed16911bf 100644
--- a/server/src/controllers/maintenanceWindowController.ts
+++ b/server/src/controllers/maintenanceWindowController.ts
@@ -7,7 +7,7 @@ import {
getMaintenanceWindowsByMonitorIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
deleteMaintenanceWindowByIdParamValidation,
-} from "@/validation/joi.js";
+} from "@/validation/maintenanceWindowValidation.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "maintenanceWindowController";
@@ -25,7 +25,7 @@ class MaintenanceWindowController {
createMaintenanceWindows = async (req: Request, res: Response, next: NextFunction) => {
try {
- await createMaintenanceWindowBodyValidation.validateAsync(req.body);
+ createMaintenanceWindowBodyValidation.parse(req.body);
const teamId = requireTeamId(req?.user?.teamId);
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body });
@@ -40,7 +40,7 @@ class MaintenanceWindowController {
};
getMaintenanceWindowById = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMaintenanceWindowByIdParamValidation.validateAsync(req.params);
+ getMaintenanceWindowByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
@@ -58,7 +58,7 @@ class MaintenanceWindowController {
getMaintenanceWindowsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query);
+ getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req?.user?.teamId);
@@ -76,7 +76,7 @@ class MaintenanceWindowController {
getMaintenanceWindowsByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params);
+ getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
@@ -96,7 +96,7 @@ class MaintenanceWindowController {
};
deleteMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
- await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params);
+ deleteMaintenanceWindowByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
@@ -113,8 +113,8 @@ class MaintenanceWindowController {
editMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
- await editMaintenanceWindowByIdParamValidation.validateAsync(req.params);
- await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body);
+ editMaintenanceWindowByIdParamValidation.parse(req.params);
+ editMaintenanceByIdWindowBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts
index df1945fcc..1d222e6e2 100644
--- a/server/src/controllers/monitorController.ts
+++ b/server/src/controllers/monitorController.ts
@@ -12,7 +12,7 @@ import {
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
-} from "@/validation/joi.js";
+} from "@/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import {
fetchMonitorCertificate,
@@ -23,9 +23,11 @@ import {
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 {
@@ -43,7 +45,7 @@ class MonitorController {
getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getCertificateParamValidation.validateAsync(req.params);
+ 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 });
@@ -86,8 +88,8 @@ class MonitorController {
getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
- await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
+ getHardwareDetailsByIdParamValidation.parse(req.params);
+ getHardwareDetailsByIdQueryValidation.parse(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent";
@@ -110,8 +112,8 @@ class MonitorController {
};
getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
- await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
+ getHardwareDetailsByIdParamValidation.parse(req.params);
+ getHardwareDetailsByIdQueryValidation.parse(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
@@ -133,10 +135,42 @@ class MonitorController {
}
};
+ getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ getMonitorByIdParamValidation.parse(req.params);
+ getMonitorByIdQueryValidation.parse(req.query);
+
+ 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,
+ monitorId,
+ dateRange,
+ continents,
+ });
+
+ return res.status(200).json({
+ success: true,
+ msg: "Geo checks retrieved successfully",
+ data,
+ });
+ } catch (error) {
+ next(error);
+ }
+ };
+
getMonitorById = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMonitorByIdParamValidation.validateAsync(req.params);
- await getMonitorByIdQueryValidation.validateAsync(req.query);
+ getMonitorByIdParamValidation.parse(req.params);
+ getMonitorByIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
@@ -155,7 +189,7 @@ class MonitorController {
createMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
- await createMonitorBodyValidation.validateAsync(req.body);
+ createMonitorBodyValidation.parse(req.body);
const userId = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -172,22 +206,23 @@ class MonitorController {
}
};
- createBulkMonitors = async (req: Request, res: Response, next: NextFunction) => {
+ importMonitorsFromJSON = async (req: Request, res: Response, next: NextFunction) => {
try {
- const userId = req.user?.id;
- if (!userId) {
- throw new AppError({ message: "User ID is required", status: 400 });
+ 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 teamId = requireTeamId(req?.user?.teamId);
-
- const data = req.body;
- const monitors = await this.monitorService.createBulkMonitors(data, userId, teamId);
+ const result = await this.monitorService.importMonitorsFromJSON({ teamId, userId, monitors });
return res.status(200).json({
success: true,
- msg: "Bulk monitors created successfully",
- data: monitors,
+ msg: `Successfully imported ${result.imported} monitor(s)`,
+ data: result,
});
} catch (error) {
next(error);
@@ -196,7 +231,7 @@ class MonitorController {
deleteMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMonitorByIdParamValidation.validateAsync(req.params);
+ getMonitorByIdParamValidation.parse(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -229,8 +264,8 @@ class MonitorController {
editMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMonitorByIdParamValidation.validateAsync(req.params);
- await editMonitorBodyValidation.validateAsync(req.body);
+ getMonitorByIdParamValidation.parse(req.params);
+ editMonitorBodyValidation.parse(req.body);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -248,7 +283,7 @@ class MonitorController {
pauseMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
- await pauseMonitorParamValidation.validateAsync(req.params);
+ pauseMonitorParamValidation.parse(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -301,8 +336,8 @@ class MonitorController {
getMonitorsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
- await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
+ getMonitorsByTeamIdParamValidation.parse(req.params);
+ getMonitorsByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const type = parseMonitorTypeFilter(req.query?.type);
@@ -322,8 +357,8 @@ class MonitorController {
getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
- await getMonitorsWithChecksQueryValidation.validateAsync(req.query);
+ 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");
diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts
index f32f80ddc..0cee3726e 100644
--- a/server/src/controllers/notificationController.ts
+++ b/server/src/controllers/notificationController.ts
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { Notification } from "@/types/index.js";
-import { createNotificationBodyValidation } from "@/validation/joi.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";
@@ -42,9 +42,7 @@ class NotificationController {
createNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
- await createNotificationBodyValidation.validateAsync(req.body, {
- abortEarly: false,
- });
+ createNotificationBodyValidation.parse(req.body);
const body = req.body;
@@ -134,9 +132,7 @@ class NotificationController {
editNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
- await createNotificationBodyValidation.validateAsync(req.body, {
- abortEarly: false,
- });
+ createNotificationBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const notificationId = req.params.id as string;
diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts
index 3cbfd3f6d..d3caa0d8f 100644
--- a/server/src/controllers/settingsController.ts
+++ b/server/src/controllers/settingsController.ts
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
-import { sendTestEmailBodyValidation, updateAppSettingsBodyValidation } from "@/validation/joi.js";
+import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
+import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "SettingsController";
@@ -55,20 +56,24 @@ class SettingsController {
};
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => {
- await updateAppSettingsBodyValidation.validateAsync(req.body);
+ try {
+ updateAppSettingsBodyValidation.parse(req.body);
- const updatedSettings = await this.settingsService.updateDbSettings(req.body);
- const returnSettings = this.buildAppSettings(updatedSettings);
- return res.status(200).json({
- success: true,
- msg: "App settings updated successfully",
- data: returnSettings,
- });
+ const updatedSettings = await this.settingsService.updateDbSettings(req.body);
+ const returnSettings = this.buildAppSettings(updatedSettings);
+ return res.status(200).json({
+ success: true,
+ msg: "App settings updated successfully",
+ data: returnSettings,
+ });
+ } catch (error) {
+ next(error);
+ }
};
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
- await sendTestEmailBodyValidation.validateAsync(req.body);
+ sendTestEmailBodyValidation.parse(req.body);
const {
to,
diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts
index b936ff5ad..8593cd53f 100644
--- a/server/src/controllers/statusPageController.ts
+++ b/server/src/controllers/statusPageController.ts
@@ -1,12 +1,16 @@
import { Request, Response, NextFunction } from "express";
-import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation } from "@/validation/joi.js";
+import {
+ createStatusPageBodyValidation,
+ getStatusPageParamValidation,
+ getStatusPageQueryValidation,
+ imageValidation,
+} from "@/validation/statusPageValidation.js";
import { AppError } from "@/utils/AppError.js";
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";
@@ -28,8 +32,10 @@ class StatusPageController {
createStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
- await createStatusPageBodyValidation.validateAsync(req.body);
- await imageValidation.validateAsync(req.file);
+ createStatusPageBodyValidation.parse(req.body);
+ if (req.file) {
+ imageValidation.parse(req.file);
+ }
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req?.user?.id);
@@ -47,8 +53,10 @@ class StatusPageController {
updateStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
- await createStatusPageBodyValidation.validateAsync(req.body);
- await imageValidation.validateAsync(req.file);
+ createStatusPageBodyValidation.parse(req.body);
+ if (req.file) {
+ imageValidation.parse(req.file);
+ }
const teamId = requireTeamId(req?.user?.teamId);
const statusPageId = req.params.id as string;
if (!statusPageId) {
@@ -70,8 +78,8 @@ class StatusPageController {
getStatusPageByUrl = async (req: Request, res: Response, next: NextFunction) => {
try {
- await getStatusPageParamValidation.validateAsync(req.params);
- await getStatusPageQueryValidation.validateAsync(req.query);
+ getStatusPageParamValidation.parse(req.params);
+ getStatusPageQueryValidation.parse(req.query);
if (!req.params.url) {
throw new AppError({ message: "Status page URL is required", status: 400 });
@@ -101,6 +109,7 @@ 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 };
}
diff --git a/server/src/db/models/GeoCheck.ts b/server/src/db/models/GeoCheck.ts
new file mode 100644
index 000000000..8c0749f00
--- /dev/null
+++ b/server/src/db/models/GeoCheck.ts
@@ -0,0 +1,116 @@
+import { Schema, model, Types } from "mongoose";
+import { MonitorTypes, type MonitorType } from "@/types/monitor.js";
+import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/types/geoCheck.js";
+
+type GeoCheckMetadataDocument = Omit & {
+ monitorId: Types.ObjectId;
+ teamId: Types.ObjectId;
+ type: MonitorType;
+};
+
+type GeoCheckDocumentBase = Omit & {
+ metadata: GeoCheckMetadataDocument;
+ results: GeoCheckResult[];
+ expiry: Date;
+ createdAt: Date;
+ updatedAt: Date;
+ __v: number;
+};
+
+export interface GeoCheckDocument extends GeoCheckDocumentBase {
+ _id: Types.ObjectId;
+}
+
+const geoCheckMetadataSchema = new Schema(
+ {
+ monitorId: { type: Schema.Types.ObjectId, required: true, index: true },
+ teamId: { type: Schema.Types.ObjectId, required: true, index: true },
+ type: { type: String, required: true, enum: MonitorTypes },
+ },
+ { _id: false }
+);
+
+const geoCheckTimingsSchema = new Schema(
+ {
+ total: { type: Number, default: 0 },
+ dns: { type: Number, default: 0 },
+ tcp: { type: Number, default: 0 },
+ tls: { type: Number, default: 0 },
+ firstByte: { type: Number, default: 0 },
+ download: { type: Number, default: 0 },
+ },
+ { _id: false }
+);
+
+const geoCheckLocationSchema = new Schema(
+ {
+ continent: { type: String, required: true },
+ region: { type: String, default: "" },
+ country: { type: String, default: "" },
+ state: { type: String, default: "" },
+ city: { type: String, default: "" },
+ longitude: { type: Number, default: 0 },
+ latitude: { type: Number, default: 0 },
+ },
+ { _id: false }
+);
+
+const geoCheckResultSchema = new Schema(
+ {
+ location: {
+ type: geoCheckLocationSchema,
+ required: true,
+ },
+ status: {
+ type: Boolean,
+ required: true,
+ },
+ statusCode: {
+ type: Number,
+ required: true,
+ },
+ timings: {
+ type: geoCheckTimingsSchema,
+ required: true,
+ },
+ },
+ { _id: false }
+);
+
+const GeoCheckSchema = new Schema(
+ {
+ metadata: {
+ type: geoCheckMetadataSchema,
+ required: true,
+ },
+ results: {
+ type: [geoCheckResultSchema],
+ required: true,
+ default: [],
+ },
+ expiry: {
+ type: Date,
+ default: Date.now,
+ },
+ },
+ {
+ timestamps: true,
+ strict: false,
+ timeseries: {
+ timeField: "createdAt",
+ metaField: "metadata",
+ granularity: "seconds",
+ },
+ }
+);
+
+GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: -1 });
+GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: 1 });
+GeoCheckSchema.index({ "metadata.teamId": 1, createdAt: -1 });
+GeoCheckSchema.index({ createdAt: 1 });
+
+const GeoCheckModel = model("GeoCheck", GeoCheckSchema);
+
+export type { GeoCheckMetadataDocument };
+export { GeoCheckModel };
+export default GeoCheckModel;
diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts
index a459cdf3e..2def7701c 100644
--- a/server/src/db/models/Monitor.ts
+++ b/server/src/db/models/Monitor.ts
@@ -160,6 +160,10 @@ const MonitorSchema = new Schema(
gameId: {
type: String,
},
+ grpcServiceName: {
+ type: String,
+ default: "",
+ },
group: {
type: String,
trim: true,
@@ -169,6 +173,18 @@ const MonitorSchema = new Schema(
return value && value.trim() ? value.trim() : null;
},
},
+ geoCheckEnabled: {
+ type: Boolean,
+ default: false,
+ },
+ geoCheckLocations: {
+ type: [String],
+ default: [],
+ },
+ geoCheckInterval: {
+ type: Number,
+ default: 300000,
+ },
recentChecks: {
type: [checkSnapshotSchema],
default: [],
diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts
index fee369e2b..1024b2b93 100644
--- a/server/src/db/models/index.ts
+++ b/server/src/db/models/index.ts
@@ -33,3 +33,6 @@ export { default as TeamModel } from "@/db/models/Team.js";
export * from "@/db/models/MaintenanceWindow.js";
export { default as MaintenanceWindowModel } from "@/db/models/MaintenanceWindow.js";
+
+export * from "@/db/models/GeoCheck.js";
+export { default as GeoCheckModel } from "@/db/models/GeoCheck.js";
diff --git a/server/src/index.ts b/server/src/index.ts
index ca6cea2ba..e5b679beb 100755
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -2,6 +2,7 @@ import { initializeServices } from "./config/services.js";
import { initializeControllers } from "./config/controllers.js";
import { createApp } from "./app.js";
import { initShutdownListener } from "./shutdown.js";
+import { validateEnv } from "./validation/envValidation.js";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs";
@@ -15,6 +16,9 @@ const SERVICE_NAME = "Server";
let logger: ILogger;
const startApp = async () => {
+ // Validate environment variables first
+ const env = validateEnv();
+
// FE path
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -23,7 +27,7 @@ const startApp = async () => {
// Create services
const settingsRepository = new MongoSettingsRepository();
- const settingsService = new SettingsService(settingsRepository);
+ const settingsService = new SettingsService(settingsRepository, env);
const envSettings = settingsService.loadSettings();
@@ -46,7 +50,7 @@ const startApp = async () => {
openApiSpec,
});
- const port = envSettings.port || 52345;
+ const port = 52345;
const server = app.listen(port, () => {
logger.info({ message: `Server started on port:${port}` });
});
diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts
index bc25420b9..66e7f43f9 100644
--- a/server/src/repositories/checks/MongoChecksRepistory.ts
+++ b/server/src/repositories/checks/MongoChecksRepistory.ts
@@ -15,18 +15,10 @@ import type {
} from "@/types/index.js";
import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
+import { getDateForRange } from "@/utils/dataUtils.js";
const SERVICE_NAME = "StatusService";
-const dateRangeLookup: Record = {
- recent: new Date(new Date().setHours(new Date().getHours() - 2)),
- hour: new Date(new Date().setHours(new Date().getHours() - 1)),
- day: new Date(new Date().setDate(new Date().getDate() - 1)),
- week: new Date(new Date().setDate(new Date().getDate() - 7)),
- month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
- all: undefined,
-};
-
export type LatestChecksMap = Record;
type DateRange = { start: Date; end: Date };
type HardwareUpChecks = { totalChecks: number };
@@ -248,9 +240,9 @@ class MongoChecksRepository implements IChecksRepository {
const matchStage: Record = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(typeof status !== "undefined" && { status }),
- ...(dateRangeLookup[dateRange] && {
+ ...(getDateForRange(dateRange) && {
createdAt: {
- $gte: dateRangeLookup[dateRange],
+ $gte: getDateForRange(dateRange),
},
}),
};
@@ -299,9 +291,9 @@ class MongoChecksRepository implements IChecksRepository {
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
const matchStage: Record = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
- ...(dateRangeLookup[dateRange] && {
+ ...(getDateForRange(dateRange) && {
createdAt: {
- $gte: dateRangeLookup[dateRange],
+ $gte: getDateForRange(dateRange),
},
}),
};
@@ -378,7 +370,7 @@ class MongoChecksRepository implements IChecksRepository {
return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
}
if (options?.type === "pagespeed") {
- return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate);
+ return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
}
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString);
};
@@ -386,9 +378,9 @@ class MongoChecksRepository implements IChecksRepository {
findSummaryByTeamId = async (teamId: string, dateRange: string) => {
const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
- ...(dateRangeLookup[dateRange] && {
+ ...(getDateForRange(dateRange) && {
createdAt: {
- $gte: dateRangeLookup[dateRange],
+ $gte: getDateForRange(dateRange),
},
}),
};
@@ -572,16 +564,50 @@ class MongoChecksRepository implements IChecksRepository {
};
};
- private findPageSpeedDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date) => {
+ private findPageSpeedDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => {
const matchStage = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
};
- const checks = await CheckModel.find(matchStage).sort({ createdAt: -1 }).limit(25).lean();
+ const [result] = await CheckModel.aggregate([
+ { $match: matchStage },
+ { $sort: { createdAt: 1 } },
+ {
+ $facet: {
+ groupedChecks: [
+ {
+ $group: {
+ _id: {
+ $dateToString: { format: dateString, date: "$createdAt" },
+ },
+ avgPerformance: { $avg: "$performance" },
+ avgAccessibility: { $avg: "$accessibility" },
+ avgBestPractices: { $avg: "$bestPractices" },
+ avgSeo: { $avg: "$seo" },
+ totalChecks: { $sum: 1 },
+ },
+ },
+ { $sort: { _id: 1 } },
+ {
+ $project: {
+ bucketDate: "$_id",
+ performance: "$avgPerformance",
+ accessibility: "$avgAccessibility",
+ bestPractices: "$avgBestPractices",
+ seo: "$avgSeo",
+ totalChecks: 1,
+ _id: 0,
+ },
+ },
+ ],
+ },
+ },
+ ]);
+
return {
monitorType: "pagespeed" as const,
- checks: this.mapDocuments(checks),
+ groupedChecks: result?.groupedChecks ?? [],
};
};
diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts
new file mode 100644
index 000000000..fe4beb796
--- /dev/null
+++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts
@@ -0,0 +1,35 @@
+import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js";
+import type { GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js";
+
+export interface GeoChecksQueryResult {
+ geoChecksCount: number;
+ geoChecks: GeoCheck[];
+}
+
+export interface FlatGeoChecksQueryResult {
+ geoChecksCount: number;
+ geoChecks: FlatGeoCheck[];
+}
+
+export interface IGeoChecksRepository {
+ createGeoChecks(geoChecks: Omit[]): Promise;
+ findByMonitorId(
+ monitorId: string,
+ sortOrder: string,
+ dateRange: string,
+ page: number,
+ rowsPerPage: number,
+ continents?: GeoContinent[]
+ ): Promise;
+ findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise;
+ findGroupedByMonitorIdAndDateRange(
+ monitorId: string,
+ startDate: Date,
+ endDate: Date,
+ dateFormat: string,
+ continents?: GeoContinent[]
+ ): Promise;
+ deleteByMonitorId(monitorId: string): Promise;
+ deleteByTeamId(teamId: string): Promise;
+ deleteByMonitorIdsNotIn(monitorIds: string[]): Promise;
+}
diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts
new file mode 100644
index 000000000..7633fcf7c
--- /dev/null
+++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts
@@ -0,0 +1,386 @@
+import { IGeoChecksRepository } from "./IGeoChecksRepository.js";
+import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.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";
+
+const SERVICE_NAME = "GeoChecksRepository";
+
+class MongoGeoChecksRepository implements IGeoChecksRepository {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private logger: any;
+ constructor(logger: any) {
+ this.logger = logger;
+ }
+
+ private toEntity = (doc: GeoCheckDocument): GeoCheck => {
+ const toStringId = (value: mongoose.Types.ObjectId | string | undefined | null): string => {
+ if (!value) {
+ return "";
+ }
+ return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
+ };
+
+ const toDateString = (value?: Date | string | null): string => {
+ if (!value) {
+ return new Date(0).toISOString();
+ }
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
+ };
+
+ const mapMetadata = (metadata: any): GeoCheckMetadata => ({
+ monitorId: toStringId(metadata.monitorId),
+ teamId: toStringId(metadata.teamId),
+ type: metadata.type,
+ });
+
+ const mapResults = (results: any[]): GeoCheckResult[] => {
+ if (!results || !Array.isArray(results)) {
+ return [];
+ }
+ return results.map((result) => ({
+ location: {
+ continent: result.location?.continent ?? "",
+ region: result.location?.region ?? "",
+ country: result.location?.country ?? "",
+ state: result.location?.state ?? "",
+ city: result.location?.city ?? "",
+ longitude: result.location?.longitude ?? 0,
+ latitude: result.location?.latitude ?? 0,
+ },
+ status: result.status ?? false,
+ statusCode: result.statusCode ?? 0,
+ timings: {
+ total: result.timings?.total ?? 0,
+ dns: result.timings?.dns ?? 0,
+ tcp: result.timings?.tcp ?? 0,
+ tls: result.timings?.tls ?? 0,
+ firstByte: result.timings?.firstByte ?? 0,
+ download: result.timings?.download ?? 0,
+ },
+ }));
+ };
+
+ return {
+ id: toStringId(doc._id),
+ metadata: mapMetadata(doc.metadata),
+ results: mapResults(doc.results),
+ expiry: toDateString(doc.expiry),
+ __v: doc.__v ?? 0,
+ createdAt: toDateString(doc.createdAt),
+ updatedAt: toDateString(doc.updatedAt),
+ };
+ };
+
+ createGeoChecks = async (geoChecks: Omit[]): Promise => {
+ try {
+ const docs = await GeoCheckModel.insertMany(
+ geoChecks.map((geoCheck) => ({
+ metadata: {
+ monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
+ teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
+ type: geoCheck.metadata.type,
+ },
+ results: geoCheck.results,
+ expiry: new Date(geoCheck.expiry),
+ }))
+ );
+ return docs.map((doc) => this.toEntity(doc));
+ } catch (error: any) {
+ this.logger.error({
+ message: `Failed to createGeoChecks: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "createGeoChecks",
+ stack: error.stack,
+ });
+ throw error;
+ }
+ };
+
+ findByMonitorId = async (
+ monitorId: string,
+ sortOrder: string,
+ dateRange: string,
+ page: number,
+ rowsPerPage: number,
+ continents?: GeoContinent[]
+ ): Promise => {
+ try {
+ const matchStage: Record = {
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ ...(getDateForRange(dateRange) && {
+ createdAt: {
+ $gte: getDateForRange(dateRange),
+ },
+ }),
+ };
+
+ const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
+
+ let skip = 0;
+ if (page && rowsPerPage) {
+ skip = page * rowsPerPage;
+ }
+
+ if (continents && continents.length > 0) {
+ const pipeline: any[] = [
+ { $match: matchStage },
+ { $unwind: "$results" },
+ {
+ $match: {
+ "results.location.continent": { $in: continents },
+ },
+ },
+ ];
+ } else {
+ const pipeline: any[] = [{ $match: matchStage }, { $unwind: "$results" }];
+ }
+
+ // Common pipeline stages for both paths
+ const pipeline: any[] = [
+ { $match: matchStage },
+ { $unwind: "$results" },
+ // Filter by continent if specified
+ ...(continents && continents.length > 0
+ ? [
+ {
+ $match: {
+ "results.location.continent": { $in: continents },
+ },
+ },
+ ]
+ : []),
+ // Project to flat structure
+ {
+ $project: {
+ _id: 0,
+ monitorId: "$metadata.monitorId",
+ teamId: "$metadata.teamId",
+ type: "$metadata.type",
+ location: "$results.location",
+ status: "$results.status",
+ statusCode: "$results.statusCode",
+ timings: "$results.timings",
+ createdAt: 1,
+ updatedAt: 1,
+ },
+ },
+ { $sort: { createdAt: convertedSortOrder } },
+ { $skip: skip },
+ { $limit: rowsPerPage },
+ ];
+
+ // Count pipeline
+ const countPipeline: any[] = [
+ { $match: matchStage },
+ { $unwind: "$results" },
+ ...(continents && continents.length > 0
+ ? [
+ {
+ $match: {
+ "results.location.continent": { $in: continents },
+ },
+ },
+ ]
+ : []),
+ { $count: "count" },
+ ];
+
+ const [countResult, dataResults] = await Promise.all([GeoCheckModel.aggregate(countPipeline), GeoCheckModel.aggregate(pipeline)]);
+
+ const geoChecksCount = countResult[0]?.count || 0;
+ const geoChecks: FlatGeoCheck[] = dataResults.map((doc) => ({
+ id: `${doc.monitorId.toString()}-${new Date(doc.createdAt).getTime()}-${doc.location.continent}-${doc.location.city}-${Math.random().toString(36).substring(2, 15)}`,
+ monitorId: doc.monitorId.toString(),
+ teamId: doc.teamId.toString(),
+ type: doc.type,
+ location: doc.location,
+ status: doc.status,
+ statusCode: doc.statusCode,
+ timings: doc.timings,
+ createdAt: new Date(doc.createdAt).toISOString(),
+ updatedAt: new Date(doc.updatedAt).toISOString(),
+ }));
+
+ return { geoChecksCount, geoChecks };
+ } catch (error: any) {
+ this.logger.error({
+ message: `Error finding geo checks by monitor ID: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "findByMonitorId",
+ stack: error.stack,
+ });
+ throw error;
+ }
+ };
+
+ findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise => {
+ try {
+ const docs = await GeoCheckModel.find({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ createdAt: {
+ $gte: startDate,
+ $lte: endDate,
+ },
+ }).sort({ createdAt: -1 });
+ return docs.map(this.toEntity);
+ } catch (error: any) {
+ this.logger.error({
+ message: `Error finding geo checks by monitor ID and date range: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "findByMonitorIdAndDateRange",
+ });
+ throw error;
+ }
+ };
+
+ findGroupedByMonitorIdAndDateRange = async (
+ monitorId: string,
+ startDate: Date,
+ endDate: Date,
+ dateFormat: string,
+ continents?: GeoContinent[]
+ ): Promise => {
+ try {
+ const pipeline: any[] = [
+ // Match geo checks for this monitor in date range
+ {
+ $match: {
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ createdAt: {
+ $gte: startDate,
+ $lte: endDate,
+ },
+ },
+ },
+ // Unwind the results array to process each location separately
+ {
+ $unwind: "$results",
+ },
+ // Filter by continent if specified
+ ...(continents && continents.length > 0
+ ? [
+ {
+ $match: {
+ "results.location.continent": { $in: continents },
+ },
+ },
+ ]
+ : []),
+ // Group by date bucket and continent
+ {
+ $group: {
+ _id: {
+ bucketDate: {
+ $dateToString: {
+ format: dateFormat,
+ date: "$createdAt",
+ },
+ },
+ continent: "$results.location.continent",
+ },
+ avgResponseTime: { $avg: "$results.timings.total" },
+ totalChecks: { $sum: 1 },
+ upChecks: {
+ $sum: {
+ $cond: ["$results.status", 1, 0],
+ },
+ },
+ },
+ },
+ // Calculate uptime percentage
+ {
+ $project: {
+ _id: 0,
+ bucketDate: "$_id.bucketDate",
+ continent: "$_id.continent",
+ avgResponseTime: { $round: ["$avgResponseTime", 2] },
+ totalChecks: 1,
+ uptimePercentage: {
+ $round: [
+ {
+ $multiply: [
+ {
+ $divide: ["$upChecks", "$totalChecks"],
+ },
+ 100,
+ ],
+ },
+ 2,
+ ],
+ },
+ },
+ },
+ // Sort by date and continent
+ {
+ $sort: {
+ bucketDate: 1,
+ continent: 1,
+ },
+ },
+ ];
+
+ const results = await GeoCheckModel.aggregate(pipeline);
+ return results as GroupedGeoCheck[];
+ } catch (error: any) {
+ this.logger.error({
+ message: `Error finding grouped geo checks: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "findGroupedByMonitorIdAndDateRange",
+ });
+ throw error;
+ }
+ };
+
+ deleteByMonitorId = async (monitorId: string): Promise => {
+ try {
+ const result = await GeoCheckModel.deleteMany({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ });
+ return result.deletedCount || 0;
+ } catch (error: any) {
+ this.logger.error({
+ message: `Error deleting geo checks by monitor ID: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "deleteByMonitorId",
+ });
+ throw error;
+ }
+ };
+
+ deleteByTeamId = async (teamId: string): Promise => {
+ try {
+ const result = await GeoCheckModel.deleteMany({
+ "metadata.teamId": new mongoose.Types.ObjectId(teamId),
+ });
+ return result.deletedCount || 0;
+ } catch (error: any) {
+ this.logger.error({
+ message: `Error deleting geo checks by team ID: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "deleteByTeamId",
+ });
+ throw error;
+ }
+ };
+
+ deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => {
+ try {
+ const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
+ const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
+ return result.deletedCount || 0;
+ } catch (error: any) {
+ this.logger.error({
+ message: `Error deleting orphaned geo checks: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "deleteByMonitorIdsNotIn",
+ });
+ throw error;
+ }
+ };
+}
+
+export { MongoGeoChecksRepository };
+export default MongoGeoChecksRepository;
diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts
index da31a8c75..c1d8c2ecf 100644
--- a/server/src/repositories/index.ts
+++ b/server/src/repositories/index.ts
@@ -33,3 +33,6 @@ export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeams
export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js";
+
+export * from "@/repositories/geo-checks/IGeoChecksRepository.js";
+export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
diff --git a/server/src/repositories/invites/MongoInviteRepository.ts b/server/src/repositories/invites/MongoInviteRepository.ts
index e9243e96e..a8187d09a 100644
--- a/server/src/repositories/invites/MongoInviteRepository.ts
+++ b/server/src/repositories/invites/MongoInviteRepository.ts
@@ -36,7 +36,7 @@ class MongoInvitesRepository implements IInvitesRepository {
create = async (invite: Partial) => {
await InviteModel.deleteMany({ email: invite.email });
invite.token = crypto.randomBytes(32).toString("hex");
- let inviteToken = await InviteModel.create(invite);
+ const inviteToken = await InviteModel.create(invite);
return this.toEntity(inviteToken);
};
diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts
index a8910d0df..4aadf9cc2 100644
--- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts
+++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts
@@ -92,7 +92,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
}
// Sorting
- let sort: Record = {};
+ const sort: Record = {};
if (field !== undefined && order !== undefined) {
sort[field] = order === "asc" ? 1 : -1;
}
diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts
index 63940f591..9d82d9987 100644
--- a/server/src/repositories/monitors/IMonitorsRepository.ts
+++ b/server/src/repositories/monitors/IMonitorsRepository.ts
@@ -17,7 +17,7 @@ export interface SummaryConfig {
export interface IMonitorsRepository {
// create
create(monitor: Monitor, teamId: string, userId: string): Promise;
- createBulkMonitors(monitors: Monitor[]): Promise;
+ createMonitors(monitors: Monitor[]): Promise;
// single fetch
findById(monitorId: string, teamId: string): Promise;
diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts
index 728d2081f..a3b40f459 100644
--- a/server/src/repositories/monitors/MongoMonitorsRepository.ts
+++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts
@@ -12,13 +12,23 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.toEntity(saved);
};
- createBulkMonitors = async (monitors: Monitor[]): Promise => {
+ createMonitors = async (monitors: Monitor[]): Promise => {
if (!monitors.length) {
return [];
}
const payload = monitors.map((monitor) => ({ ...monitor, notifications: undefined }));
- const inserted = await MonitorModel.insertMany(payload, { ordered: false });
- return this.mapDocuments(inserted);
+ try {
+ const inserted = await MonitorModel.insertMany(payload, { ordered: false });
+ return this.mapDocuments(inserted);
+ } catch (error: any) {
+ if (error.name === "MongoBulkWriteError" || error.name === "BulkWriteError") {
+ const insertedDocs = error.insertedDocs || [];
+ if (insertedDocs.length > 0) {
+ return this.mapDocuments(insertedDocs);
+ }
+ }
+ throw error;
+ }
};
findById = async (monitorId: string, teamId: string): Promise => {
@@ -342,8 +352,12 @@ class MongoMonitorsRepository implements IMonitorsRepository {
tempAlertCounter: doc.tempAlertCounter,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
+ grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
+ geoCheckEnabled: doc.geoCheckEnabled ?? false,
+ geoCheckLocations: doc.geoCheckLocations ?? [],
+ geoCheckInterval: doc.geoCheckInterval ?? 300000,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -397,8 +411,12 @@ class MongoMonitorsRepository implements IMonitorsRepository {
tempAlertCounter: doc.tempAlertCounter,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
+ grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
+ geoCheckEnabled: doc.geoCheckEnabled ?? false,
+ geoCheckLocations: doc.geoCheckLocations ?? [],
+ geoCheckInterval: doc.geoCheckInterval ?? 300000,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts
index 730072d8c..3c4225b50 100644
--- a/server/src/repositories/notifications/MongoNotificationsRepository.ts
+++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts
@@ -3,7 +3,6 @@ import { NotificationModel, type NotificationDocument } from "@/db/models/index.
import { INotificationsRepository } from "@/repositories/index.js";
import type { Notification } from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
-import { not } from "joi";
class MongoNotificationsRepository implements INotificationsRepository {
private mapDocuments = (documents: NotificationDocument[]): Notification[] => {
diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts
index 91a087423..f803ae6a9 100644
--- a/server/src/repositories/settings/MongoSettingsRepository.ts
+++ b/server/src/repositories/settings/MongoSettingsRepository.ts
@@ -52,7 +52,7 @@ class MongoSettingsRepository implements ISettingsRepository {
};
findSingleton = async () => {
- let settings = await AppSettingsModel.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean();
+ const settings = await AppSettingsModel.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean();
if (!settings) {
return null;
}
diff --git a/server/src/routes/geoCheckRoutes.ts b/server/src/routes/geoCheckRoutes.ts
new file mode 100644
index 000000000..4c34df56e
--- /dev/null
+++ b/server/src/routes/geoCheckRoutes.ts
@@ -0,0 +1,22 @@
+import { Router } from "express";
+
+class GeoCheckRoutes {
+ private router: Router;
+ private geoCheckController: any;
+
+ constructor(geoCheckController: any) {
+ this.router = Router();
+ this.geoCheckController = geoCheckController;
+ this.initRoutes();
+ }
+
+ initRoutes() {
+ this.router.get("/:monitorId", this.geoCheckController.getGeoChecksByMonitor);
+ }
+
+ getRouter() {
+ return this.router;
+ }
+}
+
+export default GeoCheckRoutes;
diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts
index e958f3664..476e421ce 100755
--- a/server/src/routes/monitorRoute.ts
+++ b/server/src/routes/monitorRoute.ts
@@ -26,9 +26,13 @@ class MonitorRoutes {
// Hardware routes
this.router.get("/hardware/details/:monitorId", this.monitorController.getHardwareDetailsById);
+
// PageSpeed routes
this.router.get("/pagespeed/details/:monitorId", this.monitorController.getPageSpeedDetailsById);
+ // Geo checks routes
+ this.router.get("/:monitorId/geo-checks", this.monitorController.getGeoChecksByMonitorId);
+
// General monitor routes
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
@@ -44,7 +48,8 @@ class MonitorRoutes {
// Other static routes
this.router.post("/demo", isAllowed(["admin", "superadmin"]), this.monitorController.addDemoMonitors);
this.router.get("/export/json", isAllowed(["admin", "superadmin"]), this.monitorController.exportMonitorsToJSON);
- this.router.post("/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors);
+ 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);
diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts
index 5564d1d7f..2d2bf0e01 100644
--- a/server/src/service/business/checkService.ts
+++ b/server/src/service/business/checkService.ts
@@ -1,28 +1,31 @@
-import { requireTeamId } from "@/controllers/controllerUtils.js";
import { Types } from "mongoose";
import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js";
-import type { MonitorType, MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js";
+import type { MonitorStatusResponse, CheckErrorInfo, Check, ILighthouseAudit, ChecksQueryResult, ChecksSummary } 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";
-class CheckService {
+export interface ICheckService {
+ createChecks(checks: Check[]): Promise;
+ buildCheck(statusResponse: MonitorStatusResponse): Partial | undefined;
+ getChecksByMonitor(params: { monitorId: string; query: Record; teamId: string }): Promise;
+ getChecksByTeam(params: { teamId: string; query: Record }): Promise;
+ getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise;
+ deleteChecks(params: { monitorId: string; teamId: string }): Promise;
+ deleteChecksByTeamId(params: { teamId: string }): Promise;
+ updateChecksTTL(params: { teamId: string; ttl: string }): Promise;
+}
+
+class CheckService implements ICheckService {
static SERVICE_NAME = SERVICE_NAME;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
- private logger: any;
- constructor({
- monitorsRepository,
- logger,
- checksRepository,
- }: {
- monitorsRepository: IMonitorsRepository;
- logger: any;
- checksRepository: IChecksRepository;
- }) {
+ private logger: ILogger;
+ constructor(monitorsRepository: IMonitorsRepository, logger: ILogger, checksRepository: IChecksRepository) {
this.monitorsRepository = monitorsRepository;
this.logger = logger;
this.checksRepository = checksRepository;
@@ -61,13 +64,12 @@ class CheckService {
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: any) => {
+ const mapAudit = (audit: ILighthouseAudit | undefined) => {
if (!audit || typeof audit !== "object") {
return undefined;
}
@@ -110,7 +112,7 @@ class CheckService {
return check;
};
- getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => {
+ getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: Record; teamId: string }) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
}
@@ -118,28 +120,34 @@ class CheckService {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
}
- // For verificaiton, throws an error if monitor doesn't belong to team
+ // For verification, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
- let { sortOrder, dateRange, filter, page, rowsPerPage, status } = query;
+ const { sortOrder, dateRange, filter, page, rowsPerPage, status } = query;
+ if (!sortOrder || !dateRange || !filter || !page || !rowsPerPage) {
+ throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
+ }
const parsedStatus = typeof status === "undefined" ? status : ParseBoolean(status);
- const parsedPage = page ? parseInt(page) : page;
- const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
+ const parsedPage = page ? parseInt(page) : 0;
+ const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, parsedStatus);
return result;
};
- getChecksByTeam = async ({ teamId, query }: { teamId: string; query: any }) => {
- let { sortOrder, dateRange, filter, page, rowsPerPage } = query;
+ getChecksByTeam = async ({ teamId, query }: { teamId: string; query: Record }) => {
+ const { sortOrder, dateRange, filter, page, rowsPerPage } = query;
+ if (!sortOrder || !dateRange || !filter || !page || !rowsPerPage) {
+ throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
+ }
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 parsedPage = page ? parseInt(page) : 0;
+ const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
return checkData;
@@ -162,7 +170,7 @@ class CheckService {
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
+ // For verification, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
const deletedCount = await this.checksRepository.deleteByMonitorId(monitorId);
@@ -178,7 +186,7 @@ class CheckService {
};
updateChecksTTL = async ({ teamId, ttl }: { teamId: string; ttl: string }) => {
- throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500 });
+ throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500, details: { teamId, ttl } });
};
}
diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts
new file mode 100644
index 000000000..1e5720656
--- /dev/null
+++ b/server/src/service/business/geoChecksService.ts
@@ -0,0 +1,192 @@
+import type { Monitor, GeoCheck } from "@/types/index.js";
+import type { GeoCheckResult } from "@/types/geoCheck.js";
+import { Types } from "mongoose";
+import type { IGeoChecksRepository } from "@/repositories/index.js";
+import type { IMonitorsRepository } from "@/repositories/index.js";
+import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js";
+import type { ILogger } from "@/utils/logger.js";
+import { AppError } from "@/utils/AppError.js";
+
+const SERVICE_NAME = "GeoChecksService";
+
+export interface IGeoChecksService {
+ readonly serviceName: string;
+ buildGeoCheck(monitor: Monitor): Promise;
+ createGeoChecks(geoChecks: GeoCheck[]): Promise;
+ getGeoChecksByMonitor(args: { monitorId: string; query: any; teamId: string }): Promise;
+}
+
+class GeoChecksService implements IGeoChecksService {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private logger: ILogger;
+ private geoChecksRepository: IGeoChecksRepository;
+ private globalPingService: IGlobalPingService;
+ private monitorsRepository: IMonitorsRepository;
+
+ constructor({
+ logger,
+ geoChecksRepository,
+ globalPingService,
+ monitorsRepository,
+ }: {
+ logger: ILogger;
+ geoChecksRepository: IGeoChecksRepository;
+ globalPingService: IGlobalPingService;
+ monitorsRepository: IMonitorsRepository;
+ }) {
+ this.logger = logger;
+ this.geoChecksRepository = geoChecksRepository;
+ this.globalPingService = globalPingService;
+ this.monitorsRepository = monitorsRepository;
+ }
+
+ get serviceName() {
+ return GeoChecksService.SERVICE_NAME;
+ }
+
+ async buildGeoCheck(monitor: Monitor): Promise {
+ try {
+ if (!monitor.url) {
+ this.logger.warn({
+ message: "Monitor missing URL for geo check",
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id },
+ });
+ return null;
+ }
+
+ if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
+ this.logger.warn({
+ message: "Monitor missing geo check locations",
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id },
+ });
+ return null;
+ }
+
+ // Step 1: Create measurement request
+ const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations);
+
+ if (!measurementId) {
+ // GlobalPing API is down, skip this check
+ this.logger.debug({
+ message: "Skipping geo check due to API unavailability",
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id },
+ });
+ return null;
+ }
+
+ // Step 2: Poll for results
+ const results = await this.globalPingService.pollForResults(measurementId);
+
+ if (results.length === 0) {
+ // No successful results (all locations timed out or failed)
+ this.logger.debug({
+ message: "No successful geo check results",
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id, measurementId },
+ });
+ return null;
+ }
+
+ // Step 3: Build GeoCheck document
+ const geoCheck = this.createGeoCheckDocument(monitor, results);
+
+ this.logger.debug({
+ message: `Geo check completed for monitor ${monitor.id}`,
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id, resultsCount: results.length },
+ });
+
+ return geoCheck;
+ } catch (error: any) {
+ this.logger.error({
+ message: "Error executing geo check",
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id, error: error.message },
+ stack: error.stack,
+ });
+ return null;
+ }
+ }
+
+ private createGeoCheckDocument(monitor: Monitor, results: GeoCheckResult[]): GeoCheck {
+ const now = new Date();
+ const ttl = 90 * 24 * 60 * 60 * 1000; // 90 days in ms
+ const expiryDate = new Date(now.getTime() + ttl);
+
+ return {
+ id: new Types.ObjectId().toString(),
+ metadata: {
+ monitorId: monitor.id,
+ teamId: monitor.teamId,
+ type: monitor.type,
+ },
+ results,
+ expiry: expiryDate.toISOString(),
+ __v: 0,
+ createdAt: now.toISOString(),
+ updatedAt: now.toISOString(),
+ };
+ }
+
+ createGeoChecks = async (geoChecks: GeoCheck[]) => {
+ return this.geoChecksRepository.createGeoChecks(geoChecks);
+ };
+
+ getGeoChecksByMonitor = 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: "getGeoChecksByMonitor",
+ status: 400,
+ });
+ }
+ if (!teamId) {
+ throw new AppError({
+ message: "No team ID in request",
+ service: SERVICE_NAME,
+ method: "getGeoChecksByMonitor",
+ status: 400,
+ });
+ }
+
+ const monitor = await this.monitorsRepository.findById(monitorId, teamId);
+ if (!monitor) {
+ throw new AppError({
+ message: `Monitor with ID ${monitorId} not found.`,
+ service: SERVICE_NAME,
+ method: "getGeoChecksByMonitor",
+ status: 404,
+ });
+ }
+
+ const { 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;
diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts
index 9b3344f11..db44bdbb2 100644
--- a/server/src/service/business/incidentService.ts
+++ b/server/src/service/business/incidentService.ts
@@ -3,42 +3,29 @@ 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 } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
-
-const dateRangeLookup: Record = {
- recent: new Date(new Date().setHours(new Date().getHours() - 2)),
- hour: new Date(new Date().setHours(new Date().getHours() - 1)),
- day: new Date(new Date().setDate(new Date().getDate() - 1)),
- week: new Date(new Date().setDate(new Date().getDate() - 7)),
- month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
- all: undefined,
-};
+import type { ILogger } from "@/utils/logger.js";
class IncidentService {
static SERVICE_NAME = SERVICE_NAME;
- private logger: any;
+ private logger: ILogger;
private incidentsRepository: IIncidentsRepository;
private monitorsRepository: IMonitorsRepository;
private usersRepository: IUsersRepository;
private notificationMessageBuilder: INotificationMessageBuilder;
- constructor({
- logger,
- incidentsRepository,
- monitorsRepository,
- usersRepository,
- notificationMessageBuilder,
- }: {
- logger: any;
- incidentsRepository: IIncidentsRepository;
- monitorsRepository: IMonitorsRepository;
- usersRepository: IUsersRepository;
- notificationMessageBuilder: INotificationMessageBuilder;
- }) {
+ constructor(
+ logger: ILogger,
+ incidentsRepository: IIncidentsRepository,
+ monitorsRepository: IMonitorsRepository,
+ usersRepository: IUsersRepository,
+ notificationMessageBuilder: INotificationMessageBuilder
+ ) {
this.logger = logger;
this.incidentsRepository = incidentsRepository;
this.monitorsRepository = monitorsRepository;
@@ -151,7 +138,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "resolveIncidentManually",
message: `Incident manually resolved by user`,
- details: resolvedIncident.id,
+ details: { incidentId: resolvedIncident.id },
});
return resolvedIncident;
@@ -160,7 +147,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "resolveIncident",
message: error.message,
- details: incidentId,
+ details: { id: incidentId },
stack: error.stack,
});
throw error;
@@ -182,7 +169,7 @@ class IncidentService {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getIncidentsByTeam", status: 400 });
}
- const startDate = dateRangeLookup[dateRange];
+ const startDate = getDateForRange(dateRange);
const parsedPage = Number.isFinite(parseInt(page)) ? parseInt(page) : 0;
const parsedRowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20;
@@ -207,7 +194,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "getIncidentsByTeam",
message: error.message,
- details: teamId,
+ details: { teamId },
stack: error.stack,
});
throw error;
@@ -229,7 +216,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "getIncidentSummary",
message: error.message,
- details: teamId,
+ details: { teamId },
stack: error.stack,
});
throw error;
@@ -250,7 +237,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "getIncidentById",
message: error.message,
- details: incidentId,
+ details: { incidentId },
stack: error.stack,
});
throw error;
diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts
index 8ea83c509..b94d99c90 100644
--- a/server/src/service/business/monitorService.ts
+++ b/server/src/service/business/monitorService.ts
@@ -8,8 +8,10 @@ import type {
PageSpeedDetailsResult,
GamesMap,
} from "@/types/monitor.js";
+import type { GeoContinent } from "@/types/geoCheck.js";
import type {
IChecksRepository,
+ IGeoChecksRepository,
IIncidentsRepository,
IMonitorsRepository,
IMonitorStatsRepository,
@@ -29,13 +31,14 @@ export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Monitor): Promise;
- createBulkMonitors(monitors: Array, userId: string, teamId: string): Promise;
+ createMonitors(monitors: Array, userId: string, teamId: string): Promise;
addDemoMonitors(args: { userId: string; teamId: string }): Promise;
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise;
+ getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continents?: GeoContinent[] }): Promise;
getMonitorById(args: { teamId: string; monitorId: string }): Promise;
getMonitorsByTeamId(args: {
teamId: string;
@@ -72,6 +75,7 @@ export interface IMonitorService {
// other
sendTestEmail(args: { to: string }): Promise;
exportMonitorsToJSON(args: { teamId: string }): Promise;
+ importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: any[] }): Promise<{ imported: number; errors: string[] }>;
}
export class MonitorService implements IMonitorService {
@@ -83,6 +87,7 @@ export class MonitorService implements IMonitorService {
private games: any;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
+ private geoChecksRepository: IGeoChecksRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
@@ -94,6 +99,7 @@ export class MonitorService implements IMonitorService {
games,
monitorsRepository,
checksRepository,
+ geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -104,6 +110,7 @@ export class MonitorService implements IMonitorService {
games: any;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
+ geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
@@ -114,6 +121,7 @@ export class MonitorService implements IMonitorService {
this.games = games;
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
+ this.geoChecksRepository = geoChecksRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
@@ -157,10 +165,10 @@ export class MonitorService implements IMonitorService {
this.jobQueue.addJob(monitor.id, monitor);
};
- createBulkMonitors = async (monitors: Array, userId: string, teamId: string): Promise => {
- const createdMonitors = await this.monitorsRepository.createBulkMonitors(monitors);
+ createMonitors = async (monitors: Array, userId: string, teamId: string): Promise => {
+ const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) {
- throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createBulkMonitors" });
+ throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
}
await Promise.all(createdMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
@@ -184,7 +192,7 @@ export class MonitorService implements IMonitorService {
interval: 60000,
};
});
- const demoMonitors = await this.monitorsRepository.createBulkMonitors(monitors);
+ const demoMonitors = await this.monitorsRepository.createMonitors(monitors);
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
return demoMonitors;
@@ -217,7 +225,8 @@ export class MonitorService implements IMonitorService {
checksData.monitorType !== "ping" &&
checksData.monitorType !== "docker" &&
checksData.monitorType !== "port" &&
- checksData.monitorType !== "game"
+ checksData.monitorType !== "game" &&
+ checksData.monitorType !== "grpc"
) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
@@ -307,13 +316,47 @@ export class MonitorService implements IMonitorService {
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
return {
- monitor: {
- ...monitor,
- checks: checksData.checks,
+ monitorData: {
+ monitor,
+ groupedChecks: checksData.groupedChecks,
},
monitorStats,
};
};
+
+ getGeoChecksByMonitorId = async ({
+ teamId,
+ monitorId,
+ dateRange,
+ continents,
+ }: {
+ teamId: string;
+ monitorId: string;
+ dateRange: string;
+ continents?: GeoContinent[];
+ }): Promise => {
+ const monitor = await this.monitorsRepository.findById(monitorId, teamId);
+ if (!monitor) {
+ throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
+ }
+
+ if (monitor.type !== "http" || !monitor.geoCheckEnabled) {
+ return { groupedGeoChecks: [] };
+ }
+
+ const rangeKey = (dateRange as DateRangeKey) ?? "recent";
+ const { start, end } = this.getDateRange(rangeKey);
+ const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
+ monitor.id,
+ start,
+ end,
+ this.getDateFormat(rangeKey),
+ continents
+ );
+
+ return { groupedGeoChecks };
+ };
+
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
@@ -437,6 +480,14 @@ export class MonitorService implements IMonitorService {
});
});
+ await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
+ this.logger.warn({
+ message: `Error deleting geo checks for monitor ${monitor.id} with name ${monitor.name}`,
+ service: SERVICE_NAME,
+ stack: err.stack,
+ });
+ });
+
await this.jobQueue.deleteJob(monitor);
return monitor;
};
@@ -448,6 +499,7 @@ export class MonitorService implements IMonitorService {
try {
await this.jobQueue.deleteJob(monitor);
await this.checksRepository.deleteByMonitorId(monitor.id);
+ await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id);
} catch (error: any) {
@@ -486,4 +538,41 @@ export class MonitorService implements IMonitorService {
return monitors;
};
+
+ importMonitorsFromJSON = async ({
+ teamId,
+ userId,
+ monitors,
+ }: {
+ teamId: string;
+ userId: string;
+ monitors: any[];
+ }): Promise<{ imported: number; errors: string[] }> => {
+ const errors: string[] = [];
+
+ const cleanedMonitors = monitors.map((monitor) => {
+ const cleanData = { ...monitor };
+ delete cleanData.id;
+ delete cleanData._id;
+ delete cleanData.createdAt;
+ delete cleanData.updatedAt;
+ delete cleanData.recentChecks;
+ // Monitors must belong to current team
+ cleanData.teamId = teamId;
+ return cleanData;
+ });
+
+ const createdMonitors = await this.createMonitors(cleanedMonitors, userId, teamId);
+
+ if (!createdMonitors || createdMonitors.length === 0) {
+ throw new AppError({
+ message: "Failed to import any monitors. Please check the file format and try again.",
+ service: SERVICE_NAME,
+ method: "importMonitorsFromJSON",
+ status: 400,
+ });
+ }
+
+ return { imported: createdMonitors.length, errors };
+ };
}
diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts
index 1947549c9..0eb645cb7 100644
--- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts
+++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts
@@ -1,5 +1,7 @@
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";
const SERVICE_NAME = "JobQueue";
type QueueJobFailure = {
@@ -54,22 +56,12 @@ export interface ISuperSimpleQueue {
class SuperSimpleQueue implements ISuperSimpleQueue {
static SERVICE_NAME = SERVICE_NAME;
- private logger: any;
- private helper: any;
+ private logger: ILogger;
+ private helper: ISuperSimpleQueueHelper;
private monitorsRepository: IMonitorsRepository;
private readonly scheduler: Scheduler;
- constructor({
- logger,
- helper,
- monitorsRepository,
- scheduler,
- }: {
- logger: any;
- helper: any;
- monitorsRepository: IMonitorsRepository;
- scheduler: Scheduler;
- }) {
+ constructor(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) {
this.logger = logger;
this.helper = helper;
this.monitorsRepository = monitorsRepository;
@@ -80,14 +72,14 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
return SuperSimpleQueue.SERVICE_NAME;
}
- static async create({ logger, helper, monitorsRepository }: { logger: any; helper: any; monitorsRepository: IMonitorsRepository }) {
+ static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
const scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
// dbUri: envSettings.dbConnectionString,
});
- const instance = new SuperSimpleQueue({ logger, helper, monitorsRepository, scheduler });
+ const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
await instance.init();
return instance;
}
@@ -97,6 +89,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
this.scheduler.start();
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());
const monitors = await this.monitorsRepository.findAll();
if (!monitors) {
@@ -112,7 +105,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true });
return true;
- } catch (error) {
+ } catch (error: any) {
this.logger.error({
message: "Failed to initialize SuperSimpleQueue",
service: SERVICE_NAME,
@@ -131,17 +124,30 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
active: monitor.isActive,
data: monitor,
});
+
+ // Add geo check job if enabled for HTTP monitors
+ if (monitor.geoCheckEnabled && monitor.type === "http") {
+ this.scheduler.addJob({
+ id: `${monitorId}-geo`,
+ template: "geo-check-job",
+ repeat: monitor.geoCheckInterval,
+ active: monitor.isActive,
+ data: monitor,
+ });
+ }
};
deleteJob = async (monitor: any) => {
this.scheduler.removeJob(monitor.id);
+ this.scheduler.removeJob(`${monitor.id}-geo`);
};
pauseJob = async (monitor: any) => {
const result = await this.scheduler.pauseJob(monitor.id);
if (result === false) {
- throw new Error("Failed to resume monitor");
+ throw new Error("Failed to pause monitor");
}
+ await this.scheduler.pauseJob(`${monitor.id}-geo`);
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -154,6 +160,9 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to resume monitor");
}
+
+ await this.scheduler.resumeJob(`${monitor.id}-geo`);
+
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -163,6 +172,29 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
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 && monitor.type === "http") {
+ // Check if geo job exists
+ const existingGeoJob = await this.scheduler.getJob(geoJobId);
+ if (existingGeoJob) {
+ // Update existing geo job
+ this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
+ } else {
+ // Create new geo job
+ this.scheduler.addJob({
+ id: geoJobId,
+ template: "geo-check-job",
+ repeat: monitor.geoCheckInterval,
+ active: monitor.isActive,
+ data: monitor,
+ });
+ }
+ } else {
+ // Remove geo job if disabled or monitor type changed
+ this.scheduler.removeJob(geoJobId);
+ }
};
shutdown = async () => {
@@ -250,7 +282,11 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
};
obliterate = async () => {
- console.log("obliterate not implemented");
+ this.logger.warn({
+ message: "obliterate method not implemented",
+ service: SERVICE_NAME,
+ method: "obliterate",
+ });
};
}
diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts
index 4d9876f2f..3946f620a 100644
--- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts
+++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts
@@ -2,8 +2,9 @@ const SERVICE_NAME = "JobQueueHelper";
import type { Monitor } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js";
-import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js";
+import type { StatusChangeResult } from "@/types/index.js";
import IncidentService from "@/service/business/incidentService.js";
+import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
import {
IMaintenanceWindowsRepository,
IMonitorsRepository,
@@ -11,7 +12,18 @@ import {
IMonitorStatsRepository,
IChecksRepository,
IIncidentsRepository,
+ IGeoChecksRepository,
} from "@/repositories/index.js";
+import { ILogger } from "@/utils/logger.js";
+import { IBufferService } from "../bufferService.js";
+
+export interface ISuperSimpleQueueHelper {
+ readonly serviceName: string;
+ getMonitorJob(): (monitor: Monitor) => Promise;
+ getCleanupOrphanedJob(): () => Promise;
+ getGeoCheckJob(): (monitor: Monitor) => Promise;
+ isInMaintenanceWindow(monitorId: string, teamId: string): Promise;
+}
export interface MonitorActionDecision {
shouldCreateIncident: boolean;
@@ -27,7 +39,7 @@ export interface MonitorActionDecision {
};
}
-class SuperSimpleQueueHelper {
+class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
@@ -35,7 +47,7 @@ class SuperSimpleQueueHelper {
private statusService: IStatusService;
private notificationsService: INotificationsService;
private checkService: any;
- private buffer: any;
+ private buffer: IBufferService;
private incidentService: IncidentService;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
private monitorsRepository: IMonitorsRepository;
@@ -43,36 +55,26 @@ class SuperSimpleQueueHelper {
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
private incidentsRepository: IIncidentsRepository;
+ private geoChecksService: IGeoChecksService;
+ private geoChecksRepository: IGeoChecksRepository;
- constructor({
- logger,
- networkService,
- statusService,
- notificationsService,
- checkService,
- buffer,
- incidentService,
- maintenanceWindowsRepository,
- monitorsRepository,
- teamsRepository,
- monitorStatsRepository,
- checksRepository,
- incidentsRepository,
- }: {
- logger: any;
- networkService: INetworkService;
- statusService: IStatusService;
- notificationsService: INotificationsService;
- checkService: any;
- buffer: any;
- incidentService: IncidentService;
- maintenanceWindowsRepository: IMaintenanceWindowsRepository;
- monitorsRepository: IMonitorsRepository;
- teamsRepository: ITeamsRepository;
- monitorStatsRepository: IMonitorStatsRepository;
- checksRepository: IChecksRepository;
- incidentsRepository: IIncidentsRepository;
- }) {
+ constructor(
+ logger: ILogger,
+ networkService: INetworkService,
+ statusService: IStatusService,
+ notificationsService: INotificationsService,
+ checkService: any,
+ buffer: IBufferService,
+ incidentService: IncidentService,
+ maintenanceWindowsRepository: IMaintenanceWindowsRepository,
+ monitorsRepository: IMonitorsRepository,
+ teamsRepository: ITeamsRepository,
+ monitorStatsRepository: IMonitorStatsRepository,
+ checksRepository: IChecksRepository,
+ incidentsRepository: IIncidentsRepository,
+ geoChecksService: IGeoChecksService,
+ geoChecksRepository: IGeoChecksRepository
+ ) {
this.logger = logger;
this.networkService = networkService;
this.statusService = statusService;
@@ -86,6 +88,8 @@ class SuperSimpleQueueHelper {
this.monitorStatsRepository = monitorStatsRepository;
this.checksRepository = checksRepository;
this.incidentsRepository = incidentsRepository;
+ this.geoChecksService = geoChecksService;
+ this.geoChecksRepository = geoChecksRepository;
}
get serviceName() {
@@ -240,6 +244,16 @@ class SuperSimpleQueueHelper {
});
}
+ // Remove orphaned geo checks
+ const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds);
+ if (deletedGeoChecksCount > 0) {
+ this.logger.info({
+ message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`,
+ service: SERVICE_NAME,
+ method: "getCleanupOrphanedJob",
+ });
+ }
+
this.logger.info({
message: "Cleanup of orphaned data completed",
service: SERVICE_NAME,
@@ -257,6 +271,81 @@ class SuperSimpleQueueHelper {
};
};
+ getGeoCheckJob = () => {
+ return async (monitor: Monitor) => {
+ try {
+ const monitorId = monitor.id;
+ const teamId = monitor.teamId;
+
+ // Step 1: Validate monitor eligibility
+ if (!monitorId) {
+ 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 (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
+ this.logger.warn({
+ message: `No geo check locations configured for monitor ${monitorId}`,
+ service: SERVICE_NAME,
+ method: "getGeoCheckJob",
+ });
+ return;
+ }
+
+ // Step 2: Check for maintenance window
+ const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId);
+ if (maintenanceWindowActive) {
+ this.logger.debug({
+ message: `Monitor ${monitorId} is in maintenance window, skipping geo check`,
+ service: SERVICE_NAME,
+ method: "getGeoCheckJob",
+ });
+ return;
+ }
+
+ // Step 3: Build geo check (handles API calls and polling)
+ const geoCheck = await this.geoChecksService.buildGeoCheck(monitor);
+ if (!geoCheck) {
+ this.logger.warn({
+ message: `No geo check could be built for monitor ${monitorId}`,
+ service: SERVICE_NAME,
+ method: "getGeoCheckJob",
+ });
+ return;
+ }
+
+ // Step 4: Add geo check to buffer
+ this.buffer.addGeoCheckToBuffer(geoCheck);
+
+ this.logger.debug({
+ message: `Geo check job executed for monitor ${monitorId}`,
+ service: SERVICE_NAME,
+ method: "getGeoCheckJob",
+ });
+ } catch (error: any) {
+ this.logger.error({
+ message: error.message,
+ service: SERVICE_NAME,
+ method: "getGeoCheckJob",
+ stack: error.stack,
+ });
+ // Don't throw - geo check failures shouldn't crash the job scheduler
+ }
+ };
+ };
+
async isInMaintenanceWindow(monitorId: string, teamId: string) {
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
// Check for active maintenance window:
diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts
index c38fd52a4..318fd35cd 100755
--- a/server/src/service/infrastructure/bufferService.ts
+++ b/server/src/service/infrastructure/bufferService.ts
@@ -1,29 +1,39 @@
import type { Check } from "@/types/index.js";
-
+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 {
addToBuffer(check: Check): void;
+ addGeoCheckToBuffer(geoCheck: GeoCheck): void;
removeCheckFromBuffer(check: Check): boolean;
scheduleNextFlush(): void;
flushBuffer(): Promise;
+ flushGeoBuffer(): Promise;
}
class BufferService implements IBufferService {
static SERVICE_NAME = SERVICE_NAME;
private BUFFER_TIMEOUT: number;
- private logger: any;
+ private logger: ILogger;
private SERVICE_NAME: string;
- private buffer: any[];
+ private buffer: Check[];
+ private geoBuffer: GeoCheck[];
private bufferTimer: NodeJS.Timeout | null = null;
- private checksService: any;
+ private checksService: ICheckService;
+ private geoChecksService: IGeoChecksService;
- constructor({ logger, checkService, settingsService }: { logger: any; checkService: any; settingsService: any }) {
+ constructor(logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, settingsService: ISettingsService) {
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute
this.logger = logger;
this.checksService = checkService;
+ this.geoChecksService = geoChecksService;
this.SERVICE_NAME = SERVICE_NAME;
this.buffer = [];
+ this.geoBuffer = [];
this.scheduleNextFlush();
this.logger.info({
message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`,
@@ -39,12 +49,25 @@ class BufferService implements IBufferService {
addToBuffer(check: Check) {
try {
this.buffer.push(check);
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
- message: error.message,
+ message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "addToBuffer",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ }
+ }
+
+ addGeoCheckToBuffer(geoCheck: GeoCheck) {
+ try {
+ this.geoBuffer.push(geoCheck);
+ } catch (error: unknown) {
+ this.logger.error({
+ message: error instanceof Error ? error.message : "Unknown error",
+ service: this.SERVICE_NAME,
+ method: "addGeoCheckToBuffer",
+ stack: error instanceof Error ? error.stack : undefined,
});
}
}
@@ -76,12 +99,12 @@ class BufferService implements IBufferService {
}
return false;
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
- message: error.message,
+ message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
@@ -94,12 +117,13 @@ class BufferService implements IBufferService {
this.bufferTimer = setTimeout(async () => {
try {
await this.flushBuffer();
- } catch (error: any) {
+ await this.flushGeoBuffer();
+ } catch (error: unknown) {
this.logger.error({
- message: `Error in flush cycle: ${error.message}`,
+ message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "scheduleNextFlush",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
} finally {
// Schedule the next flush only after the current one completes
@@ -110,18 +134,47 @@ class BufferService implements IBufferService {
async flushBuffer() {
try {
if (this.buffer.length > 0) {
+ this.logger.debug({
+ message: `Flushing ${this.buffer.length} checks to database`,
+ service: this.SERVICE_NAME,
+ method: "flushBuffer",
+ });
await this.checksService.createChecks(this.buffer);
+ this.buffer = [];
}
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
- message: error.message,
+ message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "flushBuffer",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
+ // Clear buffer even on error to prevent infinite retry loops
+ this.buffer = [];
}
+ }
- this.buffer = [];
+ async flushGeoBuffer() {
+ try {
+ if (this.geoBuffer.length > 0) {
+ this.logger.debug({
+ message: `Flushing ${this.geoBuffer.length} geo checks to database`,
+ service: this.SERVICE_NAME,
+ method: "flushGeoBuffer",
+ });
+ await this.geoChecksService.createGeoChecks(this.geoBuffer);
+ this.geoBuffer = [];
+ }
+ } catch (error: unknown) {
+ this.logger.error({
+ message: error instanceof Error ? error.message : "Unknown error",
+ service: this.SERVICE_NAME,
+ method: "flushGeoBuffer",
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ // Clear buffer even on error to prevent infinite retry loops
+ this.geoBuffer = [];
+ }
}
}
diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts
index 26e3e3e6d..0840d5a4c 100755
--- a/server/src/service/infrastructure/emailService.ts
+++ b/server/src/service/infrastructure/emailService.ts
@@ -1,27 +1,51 @@
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;
-class EmailService {
+export interface IEmailService {
+ init(): void;
+ buildEmail(template: string, context: Record): Promise;
+ sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise;
+}
+
+class EmailService implements IEmailService {
static SERVICE_NAME = SERVICE_NAME;
- 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;
- private loadTemplate: (templateName: string) => Function;
+ private settingsService: ISettingsService;
+ private fs: FileSystem;
+ private path: PathModule;
+ private compile: TemplateCompiler;
+ private mjml2html: MjmlFn;
+ private nodemailer: Mailer;
+ private logger: ILogger;
+ private transporter: ReturnType | null = null;
+ private templateLookup: Record) => string) | undefined>;
+ private loadTemplate: (templateName: string) => ((context: Record) => string) | undefined;
- constructor(settingsService: any, fs: any, path: any, compile: any, mjml2html: any, nodemailer: any, logger: any) {
+ constructor(
+ settingsService: ISettingsService,
+ fs: FileSystem,
+ path: PathModule,
+ compile: TemplateCompiler,
+ mjml2html: MjmlFn,
+ nodemailer: Mailer,
+ logger: ILogger
+ ) {
this.settingsService = settingsService;
this.fs = fs;
this.path = path;
@@ -30,9 +54,7 @@ class EmailService {
this.nodemailer = nodemailer;
this.logger = logger;
this.templateLookup = {};
- this.loadTemplate = () => {
- return () => {};
- };
+ this.loadTemplate = () => undefined;
this.init();
}
@@ -46,12 +68,12 @@ class EmailService {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
return this.compile(templateContent);
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
- message: error.message,
+ message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "loadTemplate",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
}
};
@@ -66,17 +88,20 @@ class EmailService {
};
};
- buildEmail = async (template: string, context: Record) => {
+ buildEmail = async (template: string, context: Record) => {
try {
const mjml = this.templateLookup[template]?.(context);
- const html = await this.mjml2html(mjml);
+ if (!mjml) {
+ throw new Error(`Template ${template} not found`);
+ }
+ const html = this.mjml2html(mjml);
return html.html;
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
- message: error.message,
+ message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "buildEmail",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
}
};
@@ -125,11 +150,12 @@ class EmailService {
try {
await this.transporter.verify();
- } catch (error) {
+ } catch (error: unknown) {
this.logger.warn({
message: "Email transporter verification failed",
service: SERVICE_NAME,
method: "verifyTransporter",
+ stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
@@ -142,12 +168,12 @@ class EmailService {
html: html,
});
return info?.messageId;
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
- message: error.message,
+ message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "sendEmail",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
}
};
diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts
new file mode 100644
index 000000000..883b9f76b
--- /dev/null
+++ b/server/src/service/infrastructure/globalPingService.ts
@@ -0,0 +1,210 @@
+import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js";
+import type { ILogger } from "@/utils/logger.js";
+import got from "got";
+
+const SERVICE_NAME = "GlobalPingService";
+const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1";
+const POLL_INTERVAL_MS = 2000;
+const MAX_POLL_TIMEOUT_MS = 30000;
+
+interface GlobalPingMeasurementRequest {
+ type: "http";
+ target: string;
+ locations: Array<{ continent: GeoContinent }>;
+ limit: number;
+}
+
+interface GlobalPingMeasurementResponse {
+ id: string;
+ type: string;
+ status: "in-progress" | "finished" | "failed";
+ probesCount: number;
+ results?: GlobalPingProbeResult[];
+}
+
+interface GlobalPingProbeResult {
+ probe: {
+ continent: GeoContinent;
+ region: string;
+ country: string;
+ state: string | null;
+ city: string;
+ longitude: number;
+ latitude: number;
+ };
+ result: {
+ status: "finished" | "failed" | "timeout";
+ statusCode?: number;
+ statusCodeName?: string;
+ timings?: {
+ total: number;
+ dns: number;
+ tcp: number;
+ tls: number;
+ firstByte: number;
+ download: number;
+ };
+ rawOutput?: string;
+ };
+}
+
+export interface IGlobalPingService {
+ readonly serviceName: string;
+ createMeasurement(url: string, locations: GeoContinent[]): Promise;
+ pollForResults(measurementId: string, timeoutMs?: number): Promise;
+}
+
+class GlobalPingService implements IGlobalPingService {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private logger: ILogger;
+
+ constructor(logger: ILogger) {
+ this.logger = logger;
+ }
+
+ get serviceName() {
+ return GlobalPingService.SERVICE_NAME;
+ }
+
+ async createMeasurement(url: string, locations: GeoContinent[]): Promise {
+ try {
+ // GlobalPing API expects target without protocol (http:// or https://)
+ const cleanTarget = url.replace(/^https?:\/\//, "");
+
+ const requestBody: GlobalPingMeasurementRequest = {
+ type: "http",
+ target: cleanTarget,
+ locations: locations.map((continent) => ({ continent })),
+ limit: locations.length,
+ };
+
+ const response = await got.post(`${GLOBAL_PING_API_BASE}/measurements`, {
+ json: requestBody,
+ responseType: "json",
+ timeout: { request: 10000 },
+ });
+
+ const measurementId = response.body.id;
+
+ this.logger.debug({
+ message: `Created GlobalPing measurement: ${measurementId}`,
+ service: SERVICE_NAME,
+ method: "createMeasurement",
+ details: { measurementId, url, locations },
+ });
+
+ return measurementId;
+ } catch (error: unknown) {
+ this.logger.error({
+ message: "GlobalPing API unavailable, skipping geo check",
+ service: SERVICE_NAME,
+ method: "createMeasurement",
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ return null;
+ }
+ }
+
+ async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS): Promise {
+ const startTime = Date.now();
+
+ while (Date.now() - startTime < timeoutMs) {
+ try {
+ const response = await got.get(`${GLOBAL_PING_API_BASE}/measurements/${measurementId}`, {
+ responseType: "json",
+ timeout: { request: 5000 },
+ });
+
+ const measurement = response.body;
+
+ if (measurement.status === "finished") {
+ const results = this.transformResults(measurement.results || []);
+ this.logger.debug({
+ message: `GlobalPing measurement completed: ${measurementId}`,
+ service: SERVICE_NAME,
+ method: "pollForResults",
+ details: { measurementId, resultsCount: results.length },
+ });
+ return results;
+ }
+
+ if (measurement.status === "failed") {
+ this.logger.warn({
+ message: `GlobalPing measurement failed: ${measurementId}`,
+ service: SERVICE_NAME,
+ method: "pollForResults",
+ });
+ return [];
+ }
+
+ // Still in-progress, wait and poll again
+ await this.sleep(POLL_INTERVAL_MS);
+ } catch (error: unknown) {
+ this.logger.error({
+ message: "Error polling GlobalPing API",
+ service: SERVICE_NAME,
+ method: "pollForResults",
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ return [];
+ }
+ }
+
+ // Timeout reached
+ this.logger.warn({
+ message: `GlobalPing measurement polling timeout: ${measurementId}`,
+ service: SERVICE_NAME,
+ method: "pollForResults",
+ details: { measurementId, timeoutMs },
+ });
+ return [];
+ }
+
+ private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] {
+ const successfulResults: GeoCheckResult[] = [];
+
+ for (const probeResult of probeResults) {
+ // Skip failed or timeout results
+ if (probeResult.result.status !== "finished" || !probeResult.result.statusCode || !probeResult.result.timings) {
+ continue;
+ }
+
+ const location: GeoCheckLocation = {
+ continent: probeResult.probe.continent,
+ region: probeResult.probe.region,
+ country: probeResult.probe.country,
+ state: probeResult.probe.state || "",
+ city: probeResult.probe.city,
+ longitude: probeResult.probe.longitude,
+ latitude: probeResult.probe.latitude,
+ };
+
+ 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 result: GeoCheckResult = {
+ location,
+ status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
+ statusCode: probeResult.result.statusCode,
+ timings,
+ };
+
+ successfulResults.push(result);
+ }
+
+ return successfulResults;
+ }
+
+ private sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+}
+
+export default GlobalPingService;
diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts
index 92d269808..702affed2 100644
--- a/server/src/service/infrastructure/networkService.ts
+++ b/server/src/service/infrastructure/networkService.ts
@@ -1,8 +1,25 @@
import { HTTPError, RequestError } from "got";
import type { Got, Response } from "got";
-import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
+import type {
+ Monitor,
+ MonitorStatusResponse,
+ GrpcStatusPayload,
+ PageSpeedStatusPayload,
+ HttpStatusPayload,
+ HardwareStatusPayload,
+ PingStatusPayload,
+ DockerStatusPayload,
+ PortStatusPayload,
+ GameStatusPayload,
+} from "@/types/index.js";
+import type { AxiosStatic } from "axios";
+import { AppError } from "@/utils/AppError.js";
+import path from "path";
+import { fileURLToPath } from "url";
import CacheableLookup from "cacheable-lookup";
+import { ISettingsService } from "../system/settingsService.js";
+import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "NetworkService";
type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>;
@@ -21,8 +38,25 @@ interface BuildStatusResponseArgs {
export interface INetworkService {
readonly serviceName: string;
- requestStatus(monitor: Monitor): Promise;
- requestWebhook(type: string, url: string, body: any): Promise<{ type: string; status: boolean; code: number; message: string; payload?: unknown }>;
+ requestStatus(
+ monitor: Monitor
+ ): Promise<
+ MonitorStatusResponse<
+ | PingStatusPayload
+ | HttpStatusPayload
+ | PageSpeedStatusPayload
+ | HardwareStatusPayload
+ | DockerStatusPayload
+ | PortStatusPayload
+ | GameStatusPayload
+ | GrpcStatusPayload
+ >
+ >;
+ requestWebhook(
+ type: string,
+ url: string,
+ body: unknown
+ ): Promise<{ type: string; status: boolean; code: number; message: string; payload?: unknown }>;
requestPagerDuty(args: { message: string; routingKey: string; monitorUrl: string }): Promise;
requestMatrix(args: { homeserverUrl: string; accessToken: string; roomId: string; message: string }): Promise<{
status: boolean;
@@ -42,20 +76,23 @@ class NetworkService implements INetworkService {
private TYPE_DOCKER: string;
private TYPE_PORT: string;
private TYPE_GAME: string;
+ private TYPE_GRPC: string;
private SERVICE_NAME: string;
private NETWORK_ERROR: number;
private PING_ERROR: number;
- private axios: any;
+ private axios: AxiosStatic;
private got: Got;
- private https: any;
- private jmespath: any;
- private GameDig: any;
- private ping: any;
- private logger: any;
- private Docker: any;
- private net: any;
- private settingsService: any;
+ private https: typeof import("https");
+ private jmespath: { search: (data: unknown, expression: string) => unknown };
+ private GameDig: { query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }> };
+ private ping: typeof import("ping");
+ private logger: ILogger;
+ private Docker: typeof import("dockerode");
+ private net: typeof import("net");
+ private settingsService: ISettingsService;
+ private grpc: typeof import("@grpc/grpc-js");
+ private protoLoader: typeof import("@grpc/proto-loader");
private buildStatusResponse = ({
monitor,
@@ -90,7 +127,7 @@ class NetworkService implements INetworkService {
statusResponse.responseTime = error.timings?.phases?.total ?? 0;
statusResponse.timings = error.timings;
}
- return { ...statusResponse, ...(overrides ?? {}) };
+ return { ...statusResponse, ...(overrides ?? {}) } as MonitorStatusResponse;
}
return {
@@ -108,34 +145,23 @@ class NetworkService implements INetworkService {
expectedValue,
extracted,
...(overrides ?? {}),
- };
+ } as MonitorStatusResponse;
};
- constructor({
- axios,
- got,
- https,
- jmespath,
- GameDig,
- ping,
- logger,
- http,
- Docker,
- net,
- settingsService,
- }: {
- axios: any;
- got: Got;
- https: any;
- jmespath: any;
- GameDig: any;
- ping: any;
- logger: any;
- http: any;
- Docker: any;
- net: any;
- settingsService: any;
- }) {
+ constructor(
+ axios: AxiosStatic,
+ got: Got,
+ https: typeof import("https"),
+ jmespath: { search: (data: unknown, expression: string) => unknown },
+ GameDig: { query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }> },
+ ping: typeof import("ping"),
+ logger: ILogger,
+ Docker: typeof import("dockerode"),
+ net: typeof import("net"),
+ settingsService: ISettingsService,
+ grpc: typeof import("@grpc/grpc-js"),
+ protoLoader: typeof import("@grpc/proto-loader")
+ ) {
this.TYPE_PING = "ping";
this.TYPE_HTTP = "http";
this.TYPE_PAGESPEED = "pagespeed";
@@ -143,6 +169,7 @@ class NetworkService implements INetworkService {
this.TYPE_DOCKER = "docker";
this.TYPE_PORT = "port";
this.TYPE_GAME = "game";
+ this.TYPE_GRPC = "grpc";
this.SERVICE_NAME = SERVICE_NAME;
this.NETWORK_ERROR = 5000;
this.PING_ERROR = 5001;
@@ -155,6 +182,8 @@ class NetworkService implements INetworkService {
this.Docker = Docker;
this.net = net;
this.settingsService = settingsService;
+ this.grpc = grpc;
+ this.protoLoader = protoLoader;
const cacheable = new CacheableLookup();
@@ -185,13 +214,26 @@ class NetworkService implements INetworkService {
}
// Main entry point
- async requestStatus(monitor: Monitor): Promise {
+ async requestStatus(
+ monitor: Monitor
+ ): Promise<
+ MonitorStatusResponse<
+ | PingStatusPayload
+ | HttpStatusPayload
+ | PageSpeedStatusPayload
+ | HardwareStatusPayload
+ | DockerStatusPayload
+ | PortStatusPayload
+ | GameStatusPayload
+ | GrpcStatusPayload
+ >
+ > {
const type = monitor?.type || "unknown";
switch (type) {
case this.TYPE_PING:
return await this.requestPing(monitor);
case this.TYPE_HTTP:
- return await this.requestHttp(monitor);
+ return await this.requestHttp(monitor);
case this.TYPE_PAGESPEED:
return await this.requestPageSpeed(monitor);
case this.TYPE_HARDWARE:
@@ -202,12 +244,14 @@ class NetworkService implements INetworkService {
return await this.requestPort(monitor);
case this.TYPE_GAME:
return await this.requestGame(monitor);
+ case this.TYPE_GRPC:
+ return await this.requestGrpc(monitor);
default:
return this.handleUnsupportedType(type);
}
}
- private async requestPing(monitor: Monitor): Promise {
+ private async requestPing(monitor: Monitor): Promise> {
try {
if (!monitor?.url) {
throw new Error("Monitor URL is required");
@@ -227,36 +271,33 @@ class NetworkService implements INetworkService {
throw new Error("Ping failed - no result returned");
}
- const pingResponse = this.buildStatusResponse({
+ const pingResponse = this.buildStatusResponse({
monitor,
- payload: response,
+ payload: response as PingStatusPayload,
overrides: {
status: (response as { alive?: boolean })?.alive ?? false,
code: 200,
message: "Success",
responseTime: (response as { time?: number })?.time ?? 0,
- payload: response,
- },
+ payload: response as PingStatusPayload,
+ } as MonitorStatusResponseOverrides,
});
- if (error) {
- pingResponse.status = false;
- pingResponse.code = this.PING_ERROR;
- pingResponse.message = "Ping failed";
- return pingResponse;
- }
-
return pingResponse;
- } catch (err: any) {
- err.service = this.SERVICE_NAME;
- err.method = "requestPing";
- throw err;
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing ping",
+ service: this.SERVICE_NAME,
+ method: "requestPing",
+ details: { url: monitor.url },
+ });
}
}
- private async requestHttp(monitor: Monitor): Promise {
- const { url, secret, id, teamId, type, ignoreTlsErrors, useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor;
- const httpResponse = this.buildStatusResponse({
+ private async requestHttp(monitor: Monitor): Promise> {
+ const { url, secret, ignoreTlsErrors, useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor;
+ const httpResponse = this.buildStatusResponse({
monitor,
overrides: {
status: false,
@@ -269,7 +310,7 @@ class NetworkService implements INetworkService {
if (!url) {
throw new Error("Monitor URL is required");
}
- const config: Record = {
+ const config: Record = {
headers: secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
@@ -283,7 +324,7 @@ class NetworkService implements INetworkService {
const response = await this.got(url, config);
- let payload: any;
+ let payload: T | string | undefined;
const contentType = response.headers["content-type"];
if (contentType && contentType.includes("application/json")) {
@@ -371,23 +412,27 @@ class NetworkService implements INetworkService {
}
}
return httpResponse;
- } catch (err: any) {
- if (err.name === "HTTPError" || err.name === "RequestError") {
- httpResponse.code = err?.response?.statusCode || this.NETWORK_ERROR;
+ } catch (err: unknown) {
+ if (err instanceof HTTPError || err instanceof RequestError) {
+ httpResponse.code = err.response?.statusCode || this.NETWORK_ERROR;
httpResponse.status = false;
- httpResponse.message = err?.response?.statusCode || err.message;
- httpResponse.responseTime = err?.timings?.phases?.total || 0;
+ httpResponse.message = String(err.response?.statusCode) || err.message;
+ httpResponse.responseTime = err.timings?.phases?.total || 0;
httpResponse.payload = null;
- httpResponse.timings = err?.timings || {};
+ httpResponse.timings = err.timings || undefined;
return httpResponse;
}
- err.service = this.SERVICE_NAME;
- err.method = "requestHttp";
- throw err;
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing HTTP request",
+ service: this.SERVICE_NAME,
+ method: "requestHttp",
+ details: { url: monitor.url },
+ });
}
}
- private async requestPageSpeed(monitor: Monitor): Promise {
+ private async requestPageSpeed(monitor: Monitor): Promise> {
try {
const url = monitor.url;
if (!url) {
@@ -405,28 +450,36 @@ class NetworkService implements INetworkService {
details: { url },
});
}
- return await this.requestHttp({
+ return await this.requestHttp({
...monitor,
url: pageSpeedUrl,
});
- } catch (err: any) {
- err.service = this.SERVICE_NAME;
- err.method = "requestPageSpeed";
- throw err;
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing PageSpeed request",
+ service: this.SERVICE_NAME,
+ method: "requestPageSpeed",
+ details: { url: monitor.url },
+ });
}
}
- private async requestHardware(monitor: Monitor): Promise {
+ private async requestHardware(monitor: Monitor): Promise> {
try {
- return await this.requestHttp(monitor);
- } catch (err: any) {
- err.service = this.SERVICE_NAME;
- err.method = "requestHardware";
- throw err;
+ return await this.requestHttp(monitor);
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing hardware request",
+ service: this.SERVICE_NAME,
+ method: "requestHardware",
+ details: { url: monitor.url },
+ });
}
}
- private async requestDocker(monitor: Monitor): Promise {
+ private async requestDocker(monitor: Monitor): Promise> {
try {
if (!monitor.url) {
throw new Error("Monitor URL is required");
@@ -434,10 +487,9 @@ class NetworkService implements INetworkService {
const docker = new this.Docker({
socketPath: "/var/run/docker.sock",
- handleError: true, // Enable error handling
});
- const dockerResponse = this.buildStatusResponse({
+ const dockerResponse = this.buildStatusResponse({
monitor,
overrides: {
status: false,
@@ -453,10 +505,10 @@ class NetworkService implements INetworkService {
// Priority-based matching to avoid ambiguity:
// 1. Exact full ID match (64-char)
- let exactIdMatch = containers.find((c: any) => c.Id.toLowerCase() === normalizedInput);
+ const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput);
// 2. Exact container name match (case-insensitive)
- let exactNameMatch = containers.find((c: any) =>
+ const exactNameMatch = containers.find((c) =>
c.Names.some((name: string) => {
const cleanName = name.replace(/^\/+/, "").toLowerCase();
return cleanName === normalizedInput;
@@ -464,10 +516,10 @@ class NetworkService implements INetworkService {
);
// 3. Partial ID match (fallback for backwards compatibility)
- let partialIdMatch = containers.find((c: any) => c.Id.toLowerCase().startsWith(normalizedInput));
+ const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput));
// Select container based on priority
- let targetContainer = exactIdMatch || exactNameMatch || partialIdMatch;
+ const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch;
// Return negative response if no container
if (!targetContainer) {
@@ -503,9 +555,8 @@ class NetworkService implements INetworkService {
}
const container = docker.getContainer(targetContainer.Id);
- const { response, responseTime, error }: { response?: any; responseTime?: number; error?: any } = await this.timeRequest(() =>
- container.inspect()
- );
+ const { response, responseTime, error }: { response?: { State: { Status: string } } | null; responseTime: number; error: unknown } =
+ await this.timeRequest(() => container.inspect());
dockerResponse.responseTime = responseTime;
dockerResponse.status = response?.State?.Status === "running" ? true : false;
@@ -514,22 +565,34 @@ class NetworkService implements INetworkService {
if (error) {
dockerResponse.status = false;
- dockerResponse.code = error.statusCode || this.NETWORK_ERROR;
- dockerResponse.message = error.reason || "Failed to fetch Docker container information";
+ dockerResponse.code =
+ (error && typeof error === "object" && "statusCode" in error ? (error as { statusCode?: number }).statusCode : undefined) ||
+ this.NETWORK_ERROR;
+ dockerResponse.message =
+ (error && typeof error === "object" && "reason" in error ? (error as { reason?: string }).reason : undefined) ||
+ "Failed to fetch Docker container information";
return dockerResponse;
}
return dockerResponse;
- } catch (err: any) {
- err.service = this.SERVICE_NAME;
- err.method = "requestDocker";
- throw err;
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing Docker request",
+ service: this.SERVICE_NAME,
+ method: "requestDocker",
+ details: { url: monitor.url },
+ });
}
}
- private async requestPort(monitor: Monitor): Promise {
+ private async requestPort(monitor: Monitor): Promise> {
try {
const { url, port } = monitor;
+ if (!port) {
+ throw new Error("Port is required for port monitor");
+ }
+
const { response, responseTime, error } = await this.timeRequest(async () => {
return new Promise((resolve, reject) => {
const socket = this.net.createConnection(
@@ -550,14 +613,14 @@ class NetworkService implements INetworkService {
reject(new Error("Connection timeout"));
});
- socket.on("error", (err: any) => {
+ socket.on("error", (err: unknown) => {
socket.destroy();
reject(err);
});
});
});
- const portResponse = this.buildStatusResponse({
+ const portResponse = this.buildStatusResponse({
monitor,
overrides: {
code: 200,
@@ -575,18 +638,22 @@ class NetworkService implements INetworkService {
}
return portResponse;
- } catch (error: any) {
- error.service = this.SERVICE_NAME;
- error.method = "requestTCP";
- throw error;
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing port check",
+ service: this.SERVICE_NAME,
+ method: "requestPort",
+ details: { url: monitor.url, port: monitor.port },
+ });
}
}
- private async requestGame(monitor: Monitor): Promise {
+ private async requestGame(monitor: Monitor): Promise> {
try {
const { url, port, gameId } = monitor;
- const gameResponse = this.buildStatusResponse({
+ const gameResponse = this.buildStatusResponse({
monitor,
overrides: {
code: 200,
@@ -596,16 +663,17 @@ class NetworkService implements INetworkService {
});
const state = await this.GameDig.query({
- type: gameId,
+ type: gameId ?? "unknown",
host: url,
- port: port,
- }).catch((error: any) => {
+ port: port ?? 0,
+ }).catch((error: unknown): undefined => {
this.logger.warn({
- message: error.message,
+ message: error instanceof Error ? error.message : String(error),
service: this.SERVICE_NAME,
method: "requestGame",
details: { url, port, gameId },
});
+ return undefined;
});
if (!state) {
@@ -615,15 +683,176 @@ class NetworkService implements INetworkService {
return gameResponse;
}
- gameResponse.responseTime = state.ping;
- gameResponse.payload = state;
+ gameResponse.responseTime = state.ping ?? 0;
+ gameResponse.payload = state as GameStatusPayload;
return gameResponse;
- } catch (error: any) {
- error.service = this.SERVICE_NAME;
- error.method = "requestPing";
- throw error;
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing game server check",
+ service: this.SERVICE_NAME,
+ method: "requestGame",
+ details: { url: monitor.url, port: monitor.port, gameId: monitor.gameId },
+ });
}
}
+ private async requestGrpc(monitor: Monitor): Promise> {
+ try {
+ const { url, port, ignoreTlsErrors } = monitor;
+ const grpcServiceName = monitor.grpcServiceName || "";
+
+ if (!url) {
+ throw new AppError({ message: "Monitor host is required", service: this.SERVICE_NAME, method: "requestGrpc" });
+ }
+ if (!port) {
+ throw new AppError({ message: "Monitor port is required", service: this.SERVICE_NAME, method: "requestGrpc" });
+ }
+
+ const target = `${url}:${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 grpcResponse = this.buildStatusResponse({
+ monitor,
+ overrides: {
+ status: false,
+ code: this.NETWORK_ERROR,
+ message: "gRPC health check not executed",
+ },
+ });
+
+ const { response, responseTime, error } = await this.timeRequest(() => {
+ return new Promise((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: this.SERVICE_NAME,
+ method: "requestGrpc",
+ }) 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;
+ grpcResponse.status = false;
+ grpcResponse.code = grpcError.grpcCode ?? this.NETWORK_ERROR;
+ grpcResponse.message = grpcError.message ?? "gRPC health check failed";
+ grpcResponse.responseTime = responseTime;
+ grpcResponse.payload = payload ?? null;
+ return grpcResponse;
+ }
+
+ const grpcPayload = response as GrpcStatusPayload;
+ const isServing = grpcPayload.servingStatus === "SERVING";
+
+ grpcResponse.status = isServing;
+ grpcResponse.code = isServing ? 200 : this.NETWORK_ERROR;
+ grpcResponse.message = isServing
+ ? `gRPC service healthy (${grpcPayload.servingStatus})`
+ : `gRPC service unhealthy (${grpcPayload.servingStatus})`;
+ grpcResponse.responseTime = responseTime;
+ grpcResponse.payload = grpcPayload;
+
+ return grpcResponse;
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+ throw new AppError({
+ message: originalMessage || "Error performing gRPC health check",
+ service: this.SERVICE_NAME,
+ method: "requestGrpc",
+ details: { url: monitor.url, port: monitor.port, grpcServiceName: monitor.grpcServiceName },
+ });
+ }
+ }
+
+ private getGrpcStatusName(code: number): string {
+ const statusNames: Record = {
+ 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";
+ }
+
private async handleUnsupportedType(type: string): Promise {
return {
monitorId: "unknown",
@@ -636,7 +865,7 @@ class NetworkService implements INetworkService {
}
// Other network requests unrelated to monitoring:
- async requestWebhook(type: string, url: string, body: any) {
+ async requestWebhook(type: string, url: string, body: unknown) {
try {
const response = await this.axios.post(url, body, {
headers: {
@@ -651,19 +880,29 @@ class NetworkService implements INetworkService {
message: `Successfully sent ${type} notification`,
payload: response.data,
};
- } catch (error: any) {
+ } catch (err: unknown) {
this.logger.warn({
- message: error.message,
+ message: err instanceof Error ? err.message : String(err),
service: this.SERVICE_NAME,
method: "requestWebhook",
});
+ if (err && typeof err === "object" && "response" in err) {
+ const axiosError = err as { response?: { status?: number; data?: unknown } };
+ return {
+ type: "webhook",
+ status: false,
+ code: axiosError.response?.status ?? this.NETWORK_ERROR,
+ message: `Failed to send ${type} notification`,
+ payload: axiosError.response?.data,
+ };
+ }
+
return {
type: "webhook",
status: false,
- code: error.response?.status || this.NETWORK_ERROR,
+ code: this.NETWORK_ERROR,
message: `Failed to send ${type} notification`,
- payload: error.response?.data,
};
}
}
@@ -683,11 +922,17 @@ class NetworkService implements INetworkService {
if (response?.data?.status !== "success") return false;
return true;
- } catch (error: any) {
- error.details = error.response?.data;
- error.service = this.SERVICE_NAME;
- error.method = "requestPagerDuty";
- throw error;
+ } catch (err: unknown) {
+ const originalMessage = err instanceof Error ? err.message : String(err);
+
+ throw new AppError({
+ message: originalMessage || "Error sending PagerDuty notification",
+ service: this.SERVICE_NAME,
+ method: "requestPagerDuty",
+ details: {
+ responseData: err && typeof err === "object" && "response" in err ? (err as { response?: { data?: unknown } }).response?.data : undefined,
+ },
+ });
}
}
@@ -721,18 +966,35 @@ class NetworkService implements INetworkService {
code: response.status,
message: "Successfully sent Matrix notification",
};
- } catch (error: any) {
+ } catch (err: unknown) {
+ if (err instanceof Error) {
+ this.logger.warn({
+ message: err.message,
+ service: this.SERVICE_NAME,
+ method: "requestMatrix",
+ });
+
+ if (err && typeof err === "object" && "response" in err) {
+ const axiosError = err as { response?: { status?: number; data?: unknown } };
+ return {
+ status: false,
+ code: axiosError.response?.status || this.NETWORK_ERROR,
+ message: "Failed to send Matrix notification",
+ payload: axiosError.response?.data,
+ };
+ }
+ }
+
this.logger.warn({
- message: error.message,
+ message: String(err),
service: this.SERVICE_NAME,
method: "requestMatrix",
});
return {
status: false,
- code: error.response?.status || this.NETWORK_ERROR,
+ code: this.NETWORK_ERROR,
message: "Failed to send Matrix notification",
- payload: error.response?.data,
};
}
}
diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts
index d8235e263..3d16acf19 100644
--- a/server/src/service/infrastructure/notificationMessageBuilder.ts
+++ b/server/src/service/infrastructure/notificationMessageBuilder.ts
@@ -1,4 +1,4 @@
-import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
+import type { HardwareStatusPayload, 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, monitor.status);
- const content = this.buildContent(type, monitor, monitorStatusResponse, decision, clientHost);
+ const severity = this.determineSeverity(type);
+ const content = this.buildContent(type, monitor, monitorStatusResponse);
return {
type,
@@ -77,7 +77,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
return "monitor_up";
}
- private determineSeverity(type: NotificationType, monitorStatus: string): NotificationSeverity {
+ private determineSeverity(type: NotificationType): NotificationSeverity {
switch (type) {
case "monitor_down":
return "critical";
@@ -93,28 +93,22 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
}
}
- private buildContent(
- type: NotificationType,
- monitor: Monitor,
- monitorStatusResponse: MonitorStatusResponse,
- decision: MonitorActionDecision,
- clientHost: string
- ): NotificationContent {
+ private buildContent(type: NotificationType, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent {
switch (type) {
case "monitor_down":
- return this.buildMonitorDownContent(monitor, monitorStatusResponse, clientHost);
+ return this.buildMonitorDownContent(monitor, monitorStatusResponse);
case "monitor_up":
- return this.buildMonitorUpContent(monitor, clientHost);
+ return this.buildMonitorUpContent(monitor);
case "threshold_breach":
- return this.buildThresholdBreachContent(monitor, monitorStatusResponse, decision, clientHost);
+ return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse);
case "threshold_resolved":
- return this.buildThresholdResolvedContent(monitor, clientHost);
+ return this.buildThresholdResolvedContent(monitor);
default:
- return this.buildDefaultContent(monitor, clientHost);
+ return this.buildDefaultContent(monitor);
}
}
- private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, clientHost: string): NotificationContent {
+ private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): 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}`];
@@ -137,7 +131,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
- private buildMonitorUpContent(monitor: Monitor, clientHost: string): NotificationContent {
+ private buildMonitorUpContent(monitor: Monitor): 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}`];
@@ -150,12 +144,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
- private buildThresholdBreachContent(
- monitor: Monitor,
- monitorStatusResponse: MonitorStatusResponse,
- decision: MonitorActionDecision,
- clientHost: string
- ): NotificationContent {
+ private buildThresholdBreachContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): 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}`];
@@ -171,7 +160,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
- private buildThresholdResolvedContent(monitor: Monitor, clientHost: string): NotificationContent {
+ private buildThresholdResolvedContent(monitor: Monitor): 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}`];
@@ -184,7 +173,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
- private buildDefaultContent(monitor: Monitor, clientHost: string): NotificationContent {
+ private buildDefaultContent(monitor: Monitor): NotificationContent {
return {
title: `Monitor: ${monitor.name}`,
summary: `Status update for monitor "${monitor.name}".`,
@@ -193,7 +182,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
};
}
- public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] {
+ public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] {
const breaches: ThresholdBreach[] = [];
// Check if this is a hardware monitor with threshold data
@@ -202,7 +191,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
}
// Cast to HardwareStatusPayload type
- const payload = monitorStatusResponse.payload as { data?: any };
+ const payload = monitorStatusResponse.payload;
const hardware = payload.data;
if (!hardware) {
diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts
index 2f0b07fe8..e630e4e86 100644
--- a/server/src/service/infrastructure/notificationProviders/discord.ts
+++ b/server/src/service/infrastructure/notificationProviders/discord.ts
@@ -1,14 +1,15 @@
const SERVICE_NAME = "DiscordProvider";
-import type { Notification } from "@/types/index.js";
+import type { AlertDiscordPayload, DiscordEmbedField, Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
-import type { NotificationMessage } from "@/types/notificationMessage.js";
+import type { NotificationMessage, NotificationSeverity } from "@/types/notificationMessage.js";
import got from "got";
+import { ILogger } from "@/utils/logger.js";
export class DiscordProvider implements INotificationProvider {
- private logger: any;
+ private logger: ILogger;
- constructor(logger: any) {
+ constructor(logger: ILogger) {
this.logger = logger;
}
@@ -36,9 +37,6 @@ export class DiscordProvider implements INotificationProvider {
}
};
- /**
- * New unified message format implementation
- */
async sendMessage(notification: Notification, message: NotificationMessage): Promise {
if (!notification.address) {
this.logger.warn({
@@ -71,18 +69,17 @@ export class DiscordProvider implements INotificationProvider {
}
}
- private buildDiscordEmbed(message: NotificationMessage): any {
- // Map severity to Discord embed colors
- const colorMap: Record = {
+ private buildDiscordEmbed(message: NotificationMessage): AlertDiscordPayload {
+ const colorMap: Record = {
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<{ name: string; value: string; inline?: boolean }> = [];
+ const fields: Array = [];
// Add monitor details
fields.push({
diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts
index effd94946..c14d59a85 100644
--- a/server/src/service/infrastructure/notificationProviders/email.ts
+++ b/server/src/service/infrastructure/notificationProviders/email.ts
@@ -1,15 +1,15 @@
const SERVICE_NAME = "EmailProvider";
-import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
+import type { Notification } 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: any;
- private logger: any;
+ private emailService: IEmailService;
+ private logger: ILogger;
- constructor(emailService: any, logger: any) {
+ constructor(emailService: IEmailService, logger: ILogger) {
this.emailService = emailService;
this.logger = logger;
}
@@ -18,6 +18,24 @@ export class EmailProvider implements INotificationProvider {
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({
@@ -38,6 +56,15 @@ 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({
@@ -65,7 +92,7 @@ export class EmailProvider implements INotificationProvider {
}
}
- private async buildEmailFromMessage(message: NotificationMessage): Promise {
+ private async buildEmailFromMessage(message: NotificationMessage): Promise {
const context = {
title: message.content.title,
summary: message.content.summary,
@@ -88,16 +115,6 @@ 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;
}
diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts
index 8d58e6ead..ce53215bd 100644
--- a/server/src/service/infrastructure/notificationProviders/matrix.ts
+++ b/server/src/service/infrastructure/notificationProviders/matrix.ts
@@ -1,14 +1,15 @@
const SERVICE_NAME = "MatrixProvider";
import got from "got";
import type { INotificationProvider } from "@/service/index.js";
-import type { Notification } from "@/types/index.js";
+import type { AlertMatrixPayload, 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: any;
+ private logger: ILogger;
- constructor(logger: any) {
+ constructor(logger: ILogger) {
this.logger = logger;
}
@@ -42,9 +43,6 @@ export class MatrixProvider implements INotificationProvider {
}
};
- /**
- * New unified message format - builds Matrix HTML message from NotificationMessage
- */
sendMessage = async (notification: Notification, message: NotificationMessage): Promise => {
const { homeserverUrl, accessToken, roomId } = notification;
@@ -87,7 +85,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): { plainText: string; htmlText: string } {
+ private buildMatrixMessage(message: NotificationMessage): AlertMatrixPayload {
const plainLines: string[] = [];
const htmlLines: string[] = [];
diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts
index f96ea6cc5..b15717878 100644
--- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts
+++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts
@@ -1,15 +1,16 @@
const SERVICE_NAME = "PagerDutyProvider";
import got from "got";
-import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
-import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
+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 { ILogger } from "@/utils/logger.js";
+import { AlertPagerDutyPayload } from "@/types/index.js";
export class PagerDutyProvider implements INotificationProvider {
- private logger: any;
+ private logger: ILogger;
- constructor(logger: any) {
+ constructor(logger: ILogger) {
this.logger = logger;
}
@@ -77,7 +78,7 @@ export class PagerDutyProvider implements INotificationProvider {
}
}
- private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): any {
+ private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): AlertPagerDutyPayload {
// Map our notification type to PagerDuty event_action
const eventAction = message.type === "monitor_up" || message.type === "threshold_resolved" ? "resolve" : "trigger";
@@ -106,7 +107,7 @@ export class PagerDutyProvider implements INotificationProvider {
}
// Build custom details
- const customDetails: any = {
+ const customDetails: Record = {
monitor_name: message.monitor.name,
monitor_url: message.monitor.url,
monitor_type: message.monitor.type,
diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts
index a4cf0ed26..e13362669 100644
--- a/server/src/service/infrastructure/notificationProviders/slack.ts
+++ b/server/src/service/infrastructure/notificationProviders/slack.ts
@@ -1,15 +1,15 @@
const SERVICE_NAME = "SlackProvider";
-import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
+import type { Notification } 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: any;
+ private logger: ILogger;
- constructor(logger: any) {
+ constructor(logger: ILogger) {
this.logger = logger;
}
@@ -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: any[] = [];
+ const blocks: unknown[] = [];
// Determine color based on severity
const colorMap = {
diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts
index 874152413..454df1963 100644
--- a/server/src/service/infrastructure/notificationProviders/utils.ts
+++ b/server/src/service/infrastructure/notificationProviders/utils.ts
@@ -1,6 +1,5 @@
-import { Monitor, HardwareStatusPayload, MonitorStatusResponse } from "@/types/index.js";
-// Test notification helpers - used by all providers for test alerts
-export const buildTestEmail = async (emailService: any) => {
+import { IEmailService } from "@/service/infrastructure/emailService.js";
+export const buildTestEmail = async (emailService: IEmailService) => {
const context = { testName: "Monitoring System" };
const html = await emailService.buildEmail("testEmailTemplate", context);
return html;
diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts
index 532714827..6a00f3f1e 100644
--- a/server/src/service/infrastructure/notificationProviders/webhook.ts
+++ b/server/src/service/infrastructure/notificationProviders/webhook.ts
@@ -1,15 +1,15 @@
const SERVICE_NAME = "WebhookProvider";
-import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/types/index.js";
+import type { Notification } 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: any;
+ private logger: ILogger;
- constructor(logger: any) {
+ constructor(logger: ILogger) {
this.logger = logger;
}
@@ -46,10 +46,6 @@ 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[] = [];
diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts
index 4331c5ff0..1da308678 100644
--- a/server/src/service/infrastructure/notificationsService.ts
+++ b/server/src/service/infrastructure/notificationsService.ts
@@ -1,4 +1,4 @@
-import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification, MonitorStatus } from "@/types/index.js";
+import type { Monitor, MonitorStatusResponse, Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js";
import { INotificationProvider } from "./notificationProviders/INotificationProvider.js";
diff --git a/server/src/service/infrastructure/protos/health.proto b/server/src/service/infrastructure/protos/health.proto
new file mode 100644
index 000000000..791354e64
--- /dev/null
+++ b/server/src/service/infrastructure/protos/health.proto
@@ -0,0 +1,21 @@
+syntax = "proto3";
+
+package grpc.health.v1;
+
+message HealthCheckRequest {
+ string service = 1;
+}
+
+message HealthCheckResponse {
+ enum ServingStatus {
+ UNKNOWN = 0;
+ SERVING = 1;
+ NOT_SERVING = 2;
+ SERVICE_UNKNOWN = 3;
+ }
+ ServingStatus status = 1;
+}
+
+service Health {
+ rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
+}
diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts
index 37657cb79..badcf78ec 100755
--- a/server/src/service/infrastructure/statusService.ts
+++ b/server/src/service/infrastructure/statusService.ts
@@ -7,32 +7,50 @@ import type {
Check,
HardwareStatusPayload,
PageSpeedStatusPayload,
+ PingStatusPayload,
+ HttpStatusPayload,
+ DockerStatusPayload,
+ PortStatusPayload,
+ GameStatusPayload,
+ 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, networkResponse }: { monitor: Monitor; networkResponse: any }): Promise;
- handleIncidentForCheck(check: any, monitor: Monitor, action: any, errorContext?: string): Promise;
+ updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise;
updateMonitorStatus(
- statusResponse: MonitorStatusResponse,
+ statusResponse: MonitorStatusResponse<
+ | PingStatusPayload
+ | HttpStatusPayload
+ | PageSpeedStatusPayload
+ | HardwareStatusPayload
+ | DockerStatusPayload
+ | PortStatusPayload
+ | GameStatusPayload
+ | GrpcStatusPayload
+ | undefined
+ >,
check: Check
): Promise;
}
export class StatusService implements IStatusService {
static SERVICE_NAME = SERVICE_NAME;
- private logger: any;
- private buffer: any;
+ private logger: ILogger;
+ private buffer: IBufferService;
private monitorsRepository: IMonitorsRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
constructor(
logger: ILogger,
- buffer: any,
+ buffer: IBufferService,
monitorsRepository: IMonitorsRepository,
monitorStatsRepository: IMonitorStatsRepository,
checksRepository: IChecksRepository
@@ -48,7 +66,7 @@ export class StatusService implements IStatusService {
return StatusService.SERVICE_NAME;
}
- async updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: MonitorStatusResponse }) {
+ async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) {
try {
const monitorId = monitor.id;
const { responseTime, status } = networkResponse;
@@ -148,64 +166,29 @@ export class StatusService implements IStatusService {
}
return true;
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
service: SERVICE_NAME,
- message: error.message,
+ message: error instanceof Error ? error.message : "Unknown error",
method: "updateRunningStats",
- stack: error.stack,
+ stack: error instanceof Error ? error.stack : undefined,
});
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,
+ statusResponse: MonitorStatusResponse<
+ | PingStatusPayload
+ | HttpStatusPayload
+ | PageSpeedStatusPayload
+ | HardwareStatusPayload
+ | DockerStatusPayload
+ | PortStatusPayload
+ | GameStatusPayload
+ | GrpcStatusPayload
+ | undefined
+ >,
check: Check
): Promise => {
try {
@@ -213,7 +196,7 @@ export class StatusService implements IStatusService {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
// Update running stats
- this.updateRunningStats({ monitor, networkResponse: statusResponse });
+ this.updateRunningStats(monitor, statusResponse);
monitor.statusWindow = monitor.statusWindow || [];
monitor.statusWindow.push(status);
@@ -296,7 +279,8 @@ export class StatusService implements IStatusService {
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach =
- metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false;
+ metrics.disk?.some((d: CheckDiskInfo) => 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);
@@ -374,10 +358,13 @@ export class StatusService implements IStatusService {
timestamp: new Date().getTime(),
thresholdBreaches,
};
- } catch (error: any) {
- error.service = SERVICE_NAME;
- error.method = "updateStatus";
- throw error;
+ } 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",
+ });
}
};
@@ -393,13 +380,13 @@ export class StatusService implements IStatusService {
}
this.buffer.addToBuffer(check);
return true;
- } catch (error: any) {
+ } catch (error: unknown) {
this.logger.error({
- 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,
+ 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,
});
}
};
diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts
index a0db6a140..58c896dc6 100755
--- a/server/src/service/system/settingsService.ts
+++ b/server/src/service/system/settingsService.ts
@@ -1,29 +1,17 @@
import { ISettingsRepository } from "@/repositories/index.js";
import { Settings } from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
+import { ValidatedEnv } from "@/validation/envValidation.js";
const SERVICE_NAME = "SettingsService";
export type EnvConfig = {
- jwtSecret: string | undefined;
- jwtTTL: string | undefined;
- systemEmailHost: string | undefined;
- nodeEnv: string | undefined;
- logLevel: string | undefined;
- clientHost: string | undefined;
- dbConnectionString: string | undefined;
- port: string | undefined;
-};
-
-const envConfig: EnvConfig = {
- jwtSecret: process.env.JWT_SECRET,
- jwtTTL: process.env.TOKEN_TTL,
- systemEmailHost: process.env.SYSTEM_EMAIL_HOST,
- nodeEnv: process.env.NODE_ENV,
- logLevel: process.env.LOG_LEVEL,
- clientHost: process.env.CLIENT_HOST,
- dbConnectionString: process.env.DB_CONNECTION_STRING,
- port: process.env.PORT,
+ jwtSecret: string;
+ jwtTTL: string;
+ nodeEnv: string;
+ logLevel: string;
+ clientHost: string;
+ dbConnectionString: string;
};
export interface ISettingsService {
@@ -34,13 +22,20 @@ export interface ISettingsService {
}
class SettingsService implements ISettingsService {
- static SERVICE_NAME = "SettingsService";
+ static SERVICE_NAME = SERVICE_NAME;
private settings: EnvConfig;
private settingsRepository: ISettingsRepository;
- constructor(settingsRepository: ISettingsRepository) {
+ constructor(settingsRepository: ISettingsRepository, env: ValidatedEnv) {
this.settingsRepository = settingsRepository;
- this.settings = { ...envConfig };
+ this.settings = {
+ jwtSecret: env.JWT_SECRET,
+ jwtTTL: env.TOKEN_TTL,
+ nodeEnv: env.NODE_ENV,
+ logLevel: env.LOG_LEVEL,
+ clientHost: env.CLIENT_HOST,
+ dbConnectionString: env.DB_CONNECTION_STRING,
+ };
}
get serviceName() {
diff --git a/server/src/types/alert.ts b/server/src/types/alert.ts
index 4725ebee8..3cb9d7734 100644
--- a/server/src/types/alert.ts
+++ b/server/src/types/alert.ts
@@ -4,16 +4,29 @@ export interface AlertWebhookPayload {
}
export interface AlertPagerDutyPayload {
- routingKey: string;
- monitorUrl?: string;
+ routing_key?: string;
+ dedup_key?: string;
+ event_action?: "trigger" | "resolve";
+ payload: Record;
}
export interface AlertMatrixPayload {
- friendlyName?: string;
- homeserverUrl: string;
- accessToken: string;
- roomId: string;
- monitorName: string;
+ 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;
}
export interface Alert {
diff --git a/server/src/types/check.ts b/server/src/types/check.ts
index c02b03099..6c4c26445 100644
--- a/server/src/types/check.ts
+++ b/server/src/types/check.ts
@@ -124,7 +124,7 @@ export interface ChecksQueryResult {
export interface PageSpeedChecksResult {
monitorType: "pagespeed";
- checks: Check[];
+ groupedChecks: PageSpeedGroupedCheck[];
}
export interface HardwareChecksResult {
@@ -170,6 +170,15 @@ export interface GroupedCheck {
totalChecks: number;
}
+export interface PageSpeedGroupedCheck {
+ bucketDate: string;
+ performance: number;
+ accessibility: number;
+ bestPractices: number;
+ seo: number;
+ totalChecks: number;
+}
+
export interface UptimeChecksResult {
monitorType: Exclude;
groupedChecks: GroupedCheck[];
diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts
index 66755659c..973d2d5b9 100644
--- a/server/src/types/express.d.ts
+++ b/server/src/types/express.d.ts
@@ -5,7 +5,7 @@ declare global {
interface Request {
file?: Multer.File;
user?: User | undefined;
- resource?: any;
+ resource?: unknown;
}
}
}
diff --git a/server/src/types/geoCheck.ts b/server/src/types/geoCheck.ts
new file mode 100644
index 000000000..548c4c65f
--- /dev/null
+++ b/server/src/types/geoCheck.ts
@@ -0,0 +1,72 @@
+import type { MonitorType } from "@/types/index.js";
+
+export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
+export type GeoContinent = (typeof GeoContinents)[number];
+
+export interface GeoCheckMetadata {
+ monitorId: string;
+ teamId: string;
+ type: MonitorType;
+}
+
+export interface GeoCheckTimings {
+ total: number;
+ dns: number;
+ tcp: number;
+ tls: number;
+ firstByte: number;
+ download: number;
+}
+
+export interface GeoCheckLocation {
+ continent: GeoContinent;
+ region: string;
+ country: string;
+ state: string;
+ city: string;
+ longitude: number;
+ latitude: number;
+}
+
+export interface GeoCheckResult {
+ location: GeoCheckLocation;
+ status: boolean;
+ statusCode: number;
+ timings: GeoCheckTimings;
+}
+
+export interface GeoCheck {
+ id: string;
+ metadata: GeoCheckMetadata;
+ results: GeoCheckResult[];
+ expiry: string;
+ __v: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FlatGeoCheck {
+ id: string;
+ monitorId: string;
+ teamId: string;
+ type: string;
+ location: GeoCheckLocation;
+ status: boolean;
+ statusCode: number;
+ timings: GeoCheckTimings;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface GroupedGeoCheck {
+ bucketDate: string;
+ continent: GeoContinent;
+ avgResponseTime: number;
+ totalChecks: number;
+ uptimePercentage: number;
+}
+
+export interface GeoChecksResult {
+ monitorType: Exclude;
+ groupedGeoChecks: GroupedGeoCheck[];
+}
diff --git a/server/src/types/index.ts b/server/src/types/index.ts
index ad92ea1fe..e5b7bf59d 100644
--- a/server/src/types/index.ts
+++ b/server/src/types/index.ts
@@ -1,4 +1,5 @@
export * from "@/types/check.js";
+export * from "@/types/geoCheck.js";
export * from "@/types/monitor.js";
export * from "@/types/monitorStats.js";
export * from "@/types/statusPage.js";
diff --git a/server/src/types/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts
index 9e4b47959..bcbd075a1 100644
--- a/server/src/types/maintenanceWindow.ts
+++ b/server/src/types/maintenanceWindow.ts
@@ -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;
diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts
index a196aaae6..a6ecc4ef4 100644
--- a/server/src/types/monitor.ts
+++ b/server/src/types/monitor.ts
@@ -1,7 +1,9 @@
import type { CheckSnapshot } from "@/types/check.js";
export type { CheckSnapshot } from "@/types/check.js";
+import type { GeoContinent } from "@/types/geoCheck.js";
+export type { GeoContinent } from "@/types/geoCheck.js";
-export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const;
+export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "unknown"] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
@@ -42,7 +44,11 @@ export interface Monitor {
tempAlertCounter: number;
selectedDisks: string[];
gameId?: string;
+ grpcServiceName?: string;
group: string | null;
+ geoCheckEnabled?: boolean;
+ geoCheckLocations?: GeoContinent[];
+ geoCheckInterval?: number;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
@@ -125,8 +131,9 @@ export interface HardwareDetailsResult {
}
export interface PageSpeedDetailsResult {
- monitor: Monitor & {
- checks: import("./check.js").Check[];
+ monitorData: {
+ monitor: Monitor;
+ groupedChecks: import("./check.js").PageSpeedGroupedCheck[];
};
monitorStats: import("./monitorStats.js").MonitorStats | null;
}
diff --git a/server/src/types/network.ts b/server/src/types/network.ts
index d87f1ea4f..2018523cd 100644
--- a/server/src/types/network.ts
+++ b/server/src/types/network.ts
@@ -14,7 +14,16 @@ import type {
MonitorStatus,
} from "@/types/index.js";
-export interface MonitorStatusResponse {
+export interface MonitorStatusResponse<
+ T =
+ | HttpStatusPayload
+ | PingStatusPayload
+ | PageSpeedStatusPayload
+ | HardwareStatusPayload
+ | DockerStatusPayload
+ | GameStatusPayload
+ | GrpcStatusPayload,
+> {
monitorId: string;
teamId: string;
type: MonitorType;
@@ -93,6 +102,13 @@ export interface PortStatusPayload {
export type GameStatusPayload = Record;
+export interface GrpcStatusPayload {
+ grpcStatusCode: number;
+ grpcStatusName: string;
+ serviceName: string;
+ servingStatus: string;
+}
+
export interface MonitorPayloadMap {
ping: PingStatusPayload;
http: HttpStatusPayload;
@@ -101,6 +117,7 @@ export interface MonitorPayloadMap {
docker: DockerStatusPayload;
port: PortStatusPayload;
game: GameStatusPayload;
+ grpc: GrpcStatusPayload;
default: unknown;
}
diff --git a/server/src/utils/AppError.ts b/server/src/utils/AppError.ts
index 93baeb78e..c19f726f2 100644
--- a/server/src/utils/AppError.ts
+++ b/server/src/utils/AppError.ts
@@ -3,14 +3,14 @@ export interface AppErrorConfig {
status?: number;
service?: string | null;
method?: string | null;
- details?: any;
+ details?: unknown;
}
export class AppError extends Error {
private status: number;
private service: string | null;
private method: string | null;
- private details: any;
+ private details: unknown;
constructor({ message, status = 500, service = null, method = null, details = null }: AppErrorConfig) {
super(message);
diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts
index b981365e9..3c86c911e 100755
--- a/server/src/utils/dataUtils.ts
+++ b/server/src/utils/dataUtils.ts
@@ -1,5 +1,25 @@
import type { GroupedCheck, NormalizedCheck, NormalizedUptimeCheck, HasResponseTime } from "@/types/index.js";
+export const getDateForRange = (dateRange: string): Date | undefined => {
+ const now = Date.now();
+ switch (dateRange) {
+ case "recent":
+ return new Date(now - 2 * 60 * 60 * 1000); // 2 hours
+ case "hour":
+ return new Date(now - 60 * 60 * 1000); // 1 hour
+ case "day":
+ return new Date(now - 24 * 60 * 60 * 1000); // 1 day
+ case "week":
+ return new Date(now - 7 * 24 * 60 * 60 * 1000); // 7 days
+ case "month":
+ return new Date(now - 30 * 24 * 60 * 60 * 1000); // 30 days
+ case "all":
+ return undefined;
+ default:
+ return undefined;
+ }
+};
+
const calculatePercentile = (arr: T[], percentile: number): number => {
const sorted = arr.slice().sort((a, b) => a.responseTime - b.responseTime);
const index = (percentile / 100) * (sorted.length - 1);
diff --git a/server/src/utils/imageProcessing.ts b/server/src/utils/imageProcessing.ts
index 442f0d6da..02d10534c 100755
--- a/server/src/utils/imageProcessing.ts
+++ b/server/src/utils/imageProcessing.ts
@@ -1,22 +1,18 @@
import sharp from "sharp";
const GenerateAvatarImage = async (file: Express.Multer.File) => {
- try {
- // Resize to target 64 * 64
- let resizedImageBuffer = await sharp(file.buffer)
- .resize({
- width: 64,
- height: 64,
- fit: "cover",
- })
- .toBuffer();
+ // Resize to target 64 * 64
+ const resizedImageBuffer = await sharp(file.buffer)
+ .resize({
+ width: 64,
+ height: 64,
+ fit: "cover",
+ })
+ .toBuffer();
- //Get b64 string
- const base64Image = resizedImageBuffer.toString("base64");
- return base64Image;
- } catch (error) {
- throw error;
- }
+ //Get b64 string
+ const base64Image = resizedImageBuffer.toString("base64");
+ return base64Image;
};
export { GenerateAvatarImage };
diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts
index 4053f4a82..e3c0db169 100755
--- a/server/src/utils/logger.ts
+++ b/server/src/utils/logger.ts
@@ -44,7 +44,7 @@ class Logger implements ILogger {
this.maxCacheSize = 1000;
const consoleFormat = format.printf((info: Logform.TransformableInfo) => {
const { level, service, method, details, timestamp, stack } = info;
- let message = info.message as string;
+ const message = info.message as string;
let formattedMessage: string = message;
let formattedDetails: string | undefined;
@@ -57,10 +57,10 @@ class Logger implements ILogger {
}
let msg = `${timestamp} ${level}:`;
- service && (msg += ` [${service}]`);
- method && (msg += `(${method})`);
- formattedMessage && (msg += ` ${formattedMessage}`);
- formattedDetails && (msg += ` (details: ${formattedDetails})`);
+ if (service) msg += ` [${service}]`;
+ if (method) msg += `(${method})`;
+ if (formattedMessage) msg += ` ${formattedMessage}`;
+ if (formattedDetails) msg += ` (details: ${formattedDetails})`;
if (typeof stack === "string") {
const stackTrace = stack
diff --git a/server/src/validation/authValidation.ts b/server/src/validation/authValidation.ts
new file mode 100644
index 000000000..045868c40
--- /dev/null
+++ b/server/src/validation/authValidation.ts
@@ -0,0 +1,50 @@
+import { z } from "zod";
+import { passwordPattern, nameValidation, lowercaseEmailValidation } from "./shared.js";
+
+//****************************************
+// Auth Validations
+//****************************************
+
+export const loginValidation = z.object({
+ email: z.email("Must be a valid email address").transform((val) => val.toLowerCase()),
+ password: z.string().min(1, "Password is required"),
+});
+
+export const registrationBodyValidation = z.object({
+ firstName: nameValidation,
+ lastName: nameValidation,
+ email: lowercaseEmailValidation,
+ password: z
+ .string()
+ .min(8, "Password must be at least 8 characters")
+ .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"),
+ profileImage: z.any().optional(),
+ inviteToken: z.string().optional().default(""),
+});
+
+export const recoveryValidation = z.object({
+ email: z.email("Must be a valid email address"),
+});
+
+export const recoveryTokenBodyValidation = z.object({
+ recoveryToken: z.string().min(1, "Recovery token is required"),
+});
+
+export const newPasswordValidation = z.object({
+ recoveryToken: z.string().min(1, "Recovery token is required"),
+ password: z
+ .string()
+ .min(8, "Password must be at least 8 characters")
+ .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"),
+ confirm: z.string().optional(),
+});
+
+export const inviteBodyValidation = z.object({
+ email: z.email("Must be a valid email address"),
+ role: z.array(z.string()).min(1, "At least one role is required"),
+ teamId: z.string().min(1, "Team ID is required"),
+});
+
+export const inviteVerificationBodyValidation = z.object({
+ token: z.string().min(1, "Token is required"),
+});
diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts
new file mode 100644
index 000000000..547281d68
--- /dev/null
+++ b/server/src/validation/checkValidation.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+import { booleanCoercion } from "./shared.js";
+import { GeoContinents } from "@/types/geoCheck.js";
+
+//****************************************
+// Check Validations
+//****************************************
+
+export const getChecksParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
+export const getChecksQueryValidation = z.object({
+ type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"]).optional(),
+ sortOrder: z.enum(["asc", "desc"]).optional(),
+ limit: z.coerce.number().optional(),
+ dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
+ filter: z.enum(["all", "up", "down", "resolve"]).optional(),
+ ack: booleanCoercion.optional(),
+ page: z.coerce.number().optional(),
+ rowsPerPage: z.coerce.number().optional(),
+ status: booleanCoercion.optional(),
+ continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(),
+});
+
+export const getTeamChecksQueryValidation = z.object({
+ sortOrder: z.enum(["asc", "desc"]).optional(),
+ limit: z.coerce.number().optional(),
+ dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
+ filter: z.enum(["all", "up", "down", "resolve"]).optional(),
+ ack: booleanCoercion.optional(),
+ page: z.coerce.number().optional(),
+ rowsPerPage: z.coerce.number().optional(),
+});
+
+export const deleteChecksParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
+export const deleteChecksByTeamIdParamValidation = z.object({});
+
+export const updateChecksTTLBodyValidation = z.object({
+ ttl: z.number().min(1, "TTL is required"),
+});
diff --git a/server/src/validation/envValidation.ts b/server/src/validation/envValidation.ts
new file mode 100644
index 000000000..6dffdd0cd
--- /dev/null
+++ b/server/src/validation/envValidation.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+
+const envSchema = z.object({
+ // Server Configuration
+ NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
+ LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("debug"),
+
+ // Database
+ DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
+
+ // JWT Authentication
+ JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
+ TOKEN_TTL: z.string().default("99d"),
+
+ // Client Configuration
+ CLIENT_HOST: z.string().url("CLIENT_HOST must be a valid URL"),
+
+ // Optional
+ ORIGIN: z.string().optional(),
+});
+
+export type ValidatedEnv = z.infer;
+
+export const validateEnv = (): ValidatedEnv => {
+ const result = envSchema.safeParse(process.env);
+
+ if (!result.success) {
+ console.error("Environment validation failed:");
+ console.error("");
+
+ const errors = result.error.format();
+ for (const [key, value] of Object.entries(errors)) {
+ if (key !== "_errors" && value && typeof value === "object" && "_errors" in value) {
+ console.error(` ${key}: ${value._errors.join(", ")}`);
+ }
+ }
+
+ console.error("Please check your .env file and ensure all required variables are set.");
+ process.exit(1);
+ }
+
+ console.log("Environment variables validated successfully");
+ return result.data;
+};
diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts
new file mode 100644
index 000000000..7439f043d
--- /dev/null
+++ b/server/src/validation/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Central validation exports
+ *
+ * This file re-exports all validation schemas from their respective modules.
+ * Import from here for convenience: import { loginValidation } from "@/validation";
+ */
+
+// Shared utilities
+export * from "./shared.js";
+
+// Environment validation
+export * from "./envValidation.js";
+
+// Domain-specific validations
+export * from "./authValidation.js";
+export * from "./monitorValidation.js";
+export * from "./checkValidation.js";
+export * from "./maintenanceWindowValidation.js";
+export * from "./settingsValidation.js";
+export * from "./statusPageValidation.js";
+export * from "./notificationValidation.js";
+export * from "./userValidation.js";
diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts
deleted file mode 100755
index 912767587..000000000
--- a/server/src/validation/joi.ts
+++ /dev/null
@@ -1,805 +0,0 @@
-import joi, { type CustomHelpers } from "joi";
-import { type UserRole, UserRoles } from "@/types/user.js";
-
-//****************************************
-// Custom Validators
-//****************************************
-
-const roleValidatior = (role: UserRole[]) => (value: UserRole[], helpers: CustomHelpers) => {
- const hasRole = role.some((role: UserRole) => value.includes(role));
- if (!hasRole) {
- return helpers.error("any.invalid", { message: `You do not have the required authorization. Required roles: ${role.join(", ")}` });
- }
- return value;
-};
-
-//****************************************
-// Auth
-//****************************************
-
-const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/;
-
-const loginValidation = joi.object({
- email: joi.string().email().required().lowercase(),
- password: joi.string().required(),
-});
-const nameValidation = joi
- .string()
- .trim()
- .max(50)
- .pattern(/^(?=.*[\p{L}\p{Sc}])[\p{L}\p{Sc}\s'\-().]+$/u)
- .messages({
- "string.empty": "Name is required",
- "string.max": "Name must be less than 50 characters",
- "string.pattern.base":
- "Names must contain at least 1 letter and may only include letters, currency symbols, spaces, apostrophes, hyphens (-), periods (.), and parentheses ().",
- });
-
-const registrationBodyValidation = joi.object({
- firstName: nameValidation.required(),
- lastName: nameValidation.required(),
- email: joi
- .string()
- .email()
- .required()
- .custom((value, helpers) => {
- const lowercasedValue = value.toLowerCase();
- if (value !== lowercasedValue) {
- return helpers.message({ custom: "Email must be in lowercase" });
- }
- return lowercasedValue;
- }),
- password: joi.string().min(8).required().pattern(passwordPattern),
- profileImage: joi.any(),
- inviteToken: joi.string().allow("").optional(),
-});
-
-const editUserBodyValidation = joi.object({
- firstName: nameValidation.optional(),
- lastName: nameValidation.optional(),
- profileImage: joi.any(),
- newPassword: joi.string().min(8).pattern(passwordPattern),
- password: joi.string().min(8).pattern(passwordPattern),
- deleteProfileImage: joi.alternatives().try(joi.boolean(), joi.string().valid("true", "false")),
-});
-
-const recoveryValidation = joi.object({
- email: joi
- .string()
- .email({ tlds: { allow: false } })
- .required(),
-});
-
-const recoveryTokenBodyValidation = joi.object({
- recoveryToken: joi.string().required(),
-});
-
-const newPasswordValidation = joi.object({
- recoveryToken: joi.string().required(),
- password: joi.string().min(8).required().pattern(passwordPattern),
- confirm: joi.string(),
-});
-
-const deleteUserParamValidation = joi.object({
- email: joi.string().email().required(),
-});
-
-const inviteBodyValidation = joi.object({
- email: joi.string().trim().email().required().messages({
- "string.empty": "Email is required",
- "string.email": "Must be a valid email address",
- }),
- role: joi.array().required(),
- teamId: joi.string().required(),
-});
-
-const inviteVerificationBodyValidation = joi.object({
- token: joi.string().required(),
-});
-
-//****************************************
-// Monitors
-//****************************************
-
-const getMonitorByIdParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const getMonitorByIdQueryValidation = joi.object({
- status: joi.boolean(),
- sortOrder: joi.string().valid("asc", "desc"),
- limit: joi.number(),
- dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
- numToDisplay: joi.number(),
- normalize: joi.boolean(),
-});
-
-const getMonitorsByTeamIdParamValidation = joi.object({});
-
-const getMonitorsByTeamIdQueryValidation = joi.object({
- type: joi
- .alternatives()
- .try(
- joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"),
- joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"))
- ),
- filter: joi.string().allow("", null),
-});
-
-const getMonitorsWithChecksQueryValidation = joi.object({
- limit: joi.number().integer().min(1).max(100).optional(),
- page: joi.number().integer().min(0).optional(),
- rowsPerPage: joi.number().integer().min(1).max(100).optional(),
- filter: joi.string().allow("", null).optional(),
- field: joi.string().optional(),
- order: joi.string().valid("asc", "desc").optional(),
- type: joi
- .alternatives()
- .try(
- joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"),
- joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"))
- )
- .optional(),
- explain: joi.boolean().optional(),
-});
-
-const getCertificateParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const createMonitorBodyValidation = joi.object({
- _id: joi.string(),
- name: joi.string().required(),
- description: joi.string().allow(null, ""),
- type: joi.string().required(),
- statusWindowSize: joi.number().min(1).max(20).default(5),
- statusWindowThreshold: joi.number().min(1).max(100).default(60),
- url: joi.string().required(),
- ignoreTlsErrors: joi.boolean().default(false),
- useAdvancedMatching: joi.boolean().default(false),
- port: joi.number(),
- isActive: joi.boolean(),
- interval: joi.number(),
- cpuAlertThreshold: joi.number(),
- memoryAlertThreshold: joi.number(),
- diskAlertThreshold: joi.number(),
- tempAlertThreshold: joi.number(),
- notifications: joi.array().items(joi.string()),
- secret: joi.string(),
- jsonPath: joi.string().allow(""),
- expectedValue: joi.string().allow(""),
- matchMethod: joi.string().allow(null, ""),
- gameId: joi.string().allow(""),
- selectedDisks: joi.array().items(joi.string()).optional(),
- group: joi.string().max(50).trim().allow(null, "").optional(),
-});
-
-const createMonitorsBodyValidation = joi.array().items(
- createMonitorBodyValidation.keys({
- userId: joi.string().required(),
- teamId: joi.string().required(),
- })
-);
-
-const editMonitorBodyValidation = joi
- .object({
- name: joi.string(),
- statusWindowSize: joi.number().min(1).max(20).default(5),
- statusWindowThreshold: joi.number().min(1).max(100).default(60),
- description: joi.string().allow(null, ""),
- interval: joi.number(),
- notifications: joi.array().items(joi.string()),
- secret: joi.string(),
- ignoreTlsErrors: joi.boolean(),
- useAdvancedMatching: joi.boolean(),
- jsonPath: joi.string().allow(""),
- expectedValue: joi.string().allow(""),
- matchMethod: joi.string().allow(null, ""),
- port: joi.number().min(1).max(65535),
- cpuAlertThreshold: joi.number(),
- memoryAlertThreshold: joi.number(),
- diskAlertThreshold: joi.number(),
- tempAlertThreshold: joi.number(),
- gameId: joi.string().allow(""),
- selectedDisks: joi.array().items(joi.string()).optional(),
- group: joi.string().max(50).trim().allow(null, "").optional(),
- })
- .options({ stripUnknown: true });
-
-const pauseMonitorParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const getMonitorURLByQueryValidation = joi.object({
- monitorURL: joi.string().uri().required(),
-});
-
-const getHardwareDetailsByIdParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const getHardwareDetailsByIdQueryValidation = joi.object({
- dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
-});
-
-//****************************************
-// Alerts
-//****************************************
-
-const createAlertParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const createAlertBodyValidation = joi.object({
- checkId: joi.string().required(),
- monitorId: joi.string().required(),
- userId: joi.string().required(),
- status: joi.boolean(),
- message: joi.string(),
- notifiedStatus: joi.boolean(),
- acknowledgeStatus: joi.boolean(),
-});
-
-const getAlertsByUserIdParamValidation = joi.object({
- userId: joi.string().required(),
-});
-
-const getAlertsByMonitorIdParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const getAlertByIdParamValidation = joi.object({
- alertId: joi.string().required(),
-});
-
-const editAlertParamValidation = joi.object({
- alertId: joi.string().required(),
-});
-
-const editAlertBodyValidation = joi.object({
- status: joi.boolean(),
- message: joi.string(),
- notifiedStatus: joi.boolean(),
- acknowledgeStatus: joi.boolean(),
-});
-
-const deleteAlertParamValidation = joi.object({
- alertId: joi.string().required(),
-});
-
-//****************************************
-// Checks
-//****************************************
-
-const createCheckParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const createCheckBodyValidation = joi.object({
- monitorId: joi.string().required(),
- status: joi.boolean().required(),
- responseTime: joi.number().required(),
- statusCode: joi.number().required(),
- message: joi.string().required(),
-});
-
-const ackCheckBodyValidation = joi.object({
- ack: joi.boolean(),
-});
-
-const ackAllChecksParamValidation = joi.object({
- monitorId: joi.string().optional(),
- path: joi.string().valid("monitor", "team").required(),
-});
-
-const ackAllChecksBodyValidation = joi.object({
- ack: joi.boolean(),
-});
-
-const getChecksParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const getChecksQueryValidation = joi.object({
- type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "game"),
- sortOrder: joi.string().valid("asc", "desc"),
- limit: joi.number(),
- dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
- filter: joi.string().valid("all", "up", "down", "resolve"),
- ack: joi.boolean(),
- page: joi.number(),
- rowsPerPage: joi.number(),
- status: joi.boolean(),
-});
-
-const getTeamChecksQueryValidation = joi.object({
- sortOrder: joi.string().valid("asc", "desc"),
- limit: joi.number(),
- dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),
- filter: joi.string().valid("all", "up", "down", "resolve"),
- ack: joi.boolean(),
- page: joi.number(),
- rowsPerPage: joi.number(),
-});
-
-const deleteChecksParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const deleteChecksByTeamIdParamValidation = joi.object({});
-
-const updateChecksTTLBodyValidation = joi.object({
- ttl: joi.number().required(),
-});
-
-//****************************************
-// PageSpeedCheckValidation
-//****************************************
-
-const getPageSpeedCheckParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-//Validation schema for the monitorId parameter
-const createPageSpeedCheckParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-//Validation schema for the monitorId body
-const createPageSpeedCheckBodyValidation = joi.object({
- url: joi.string().required(),
-});
-
-const deletePageSpeedCheckParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-//****************************************
-// MaintenanceWindowValidation
-//****************************************
-
-const createMaintenanceWindowBodyValidation = joi.object({
- monitors: joi.array().items(joi.string()).required(),
- name: joi.string().required(),
- active: joi.boolean(),
- duration: joi.number().required(),
- durationUnit: joi.string().valid("seconds", "minutes", "hours", "days").required(),
- start: joi.date().required(),
- end: joi.date().required(),
- repeat: joi.number().required(),
- expiry: joi.date(),
-});
-
-const getMaintenanceWindowByIdParamValidation = joi.object({
- id: joi.string().required(),
-});
-
-const getMaintenanceWindowsByTeamIdQueryValidation = joi.object({
- active: joi.boolean(),
- page: joi.number(),
- rowsPerPage: joi.number(),
- field: joi.string(),
- order: joi.string().valid("asc", "desc"),
-});
-
-const getMaintenanceWindowsByMonitorIdParamValidation = joi.object({
- monitorId: joi.string().required(),
-});
-
-const deleteMaintenanceWindowByIdParamValidation = joi.object({
- id: joi.string().required(),
-});
-
-const editMaintenanceWindowByIdParamValidation = joi.object({
- id: joi.string().required(),
-});
-
-const editMaintenanceByIdWindowBodyValidation = joi.object({
- active: joi.boolean(),
- name: joi.string(),
- repeat: joi.number(),
- start: joi.date(),
- end: joi.date(),
- expiry: joi.date(),
- monitors: joi.array(),
- duration: joi.number(),
- durationUnit: joi.string().valid("seconds", "minutes", "hours", "days"),
-});
-
-//****************************************
-// SettingsValidation
-//****************************************
-const updateAppSettingsBodyValidation = joi.object({
- checkTTL: joi.number().allow(""),
- pagespeedApiKey: joi.string().allow(""),
- language: joi.string().allow(""),
- timezone: joi.string().allow(""),
- showURL: joi.bool().optional(),
- systemEmailHost: joi.string().allow(""),
- systemEmailPort: joi.number().allow(""),
- systemEmailAddress: joi.string().allow(""),
- systemEmailPassword: joi.string().allow(""),
- systemEmailUser: joi.string().allow(""),
- systemEmailConnectionHost: joi.string().allow(""),
- systemEmailTLSServername: joi.string().allow(""),
- systemEmailSecure: joi.boolean(),
- systemEmailPool: joi.boolean(),
- systemEmailIgnoreTLS: joi.boolean(),
- systemEmailRequireTLS: joi.boolean(),
- systemEmailRejectUnauthorized: joi.boolean(),
-
- globalThresholds: joi
- .object({
- cpu: joi.number().min(1).max(100).allow(""),
- memory: joi.number().min(1).max(100).allow(""),
- disk: joi.number().min(1).max(100).allow(""),
- temperature: joi.number().min(1).max(150).allow(""),
- })
- .optional(),
-});
-
-//****************************************
-// Status Page Validation
-//****************************************
-
-const getStatusPageParamValidation = joi.object({
- url: joi.string().required(),
-});
-
-const getStatusPageQueryValidation = joi.object({
- type: joi.string().valid("uptime").required(),
- timeFrame: joi.number().optional(),
-});
-
-const createStatusPageBodyValidation = joi.object({
- type: joi.string().valid("uptime").required(),
- companyName: joi.string().required(),
- url: joi
- .string()
- .pattern(/^[a-zA-Z0-9_-]+$/) // Only allow alphanumeric, underscore, and hyphen
- .required()
- .messages({
- "string.pattern.base": "URL can only contain letters, numbers, underscores, and hyphens",
- }),
- timezone: joi.string().optional(),
- color: joi.string().optional(),
- monitors: joi
- .array()
- .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/))
- .required()
- .messages({
- "string.pattern.base": "Must be a valid monitor ID",
- "array.base": "Monitors must be an array",
- "array.empty": "At least one monitor is required",
- "any.required": "Monitors are required",
- }),
- subMonitors: joi
- .array()
- .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/))
- .optional(),
- deleteSubmonitors: joi.boolean().optional(),
- isPublished: joi.boolean(),
- showCharts: joi.boolean().optional(),
- showUptimePercentage: joi.boolean(),
- showAdminLoginLink: joi.boolean().optional(),
- removeLogo: joi.string().valid("true", "false").optional(),
-});
-
-const imageValidation = joi
- .object({
- fieldname: joi.string().required(),
- originalname: joi.string().required(),
- encoding: joi.string().required(),
- mimetype: joi.string().valid("image/jpeg", "image/png", "image/jpg").required().messages({
- "string.valid": "File must be a valid image (jpeg, jpg, or png)",
- }),
- size: joi.number().max(3145728).required().messages({
- "number.max": "File size must be less than 3MB",
- }),
- buffer: joi.binary().required(),
- destination: joi.string(),
- filename: joi.string(),
- path: joi.string(),
- })
- .messages({
- "any.required": "Image file is required",
- });
-
-const webhookConfigValidation = joi
- .object({
- webhookUrl: joi
- .string()
- .uri()
- .when("$platform", {
- switch: [
- {
- is: "telegram",
- then: joi.optional(),
- },
- {
- is: "discord",
- then: joi.required().messages({
- "string.empty": "Discord webhook URL is required",
- "string.uri": "Discord webhook URL must be a valid URL",
- "any.required": "Discord webhook URL is required",
- }),
- },
- {
- is: "slack",
- then: joi.required().messages({
- "string.empty": "Slack webhook URL is required",
- "string.uri": "Slack webhook URL must be a valid URL",
- "any.required": "Slack webhook URL is required",
- }),
- },
- ],
- }),
- botToken: joi.string().when("$platform", {
- is: "telegram",
- then: joi.required().messages({
- "string.empty": "Telegram bot token is required",
- "any.required": "Telegram bot token is required",
- }),
- otherwise: joi.optional(),
- }),
- chatId: joi.string().when("$platform", {
- is: "telegram",
- then: joi.required().messages({
- "string.empty": "Telegram chat ID is required",
- "any.required": "Telegram chat ID is required",
- }),
- otherwise: joi.optional(),
- }),
- })
- .required();
-
-const triggerNotificationBodyValidation = joi.object({
- monitorId: joi.string().required().messages({
- "string.empty": "Monitor ID is required",
- "any.required": "Monitor ID is required",
- }),
- type: joi.string().valid("webhook").required().messages({
- "string.empty": "Notification type is required",
- "any.required": "Notification type is required",
- "any.only": "Notification type must be webhook",
- }),
- platform: joi.string().valid("telegram", "discord", "slack").required().messages({
- "string.empty": "Platform type is required",
- "any.required": "Platform type is required",
- "any.only": "Platform must be telegram, discord, or slack",
- }),
- config: webhookConfigValidation.required().messages({
- "any.required": "Webhook configuration is required",
- }),
-});
-
-const createNotificationBodyValidation = joi.object({
- notificationName: joi.string().required().messages({
- "string.empty": "Notification name is required",
- "any.required": "Notification name is required",
- }),
-
- type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty", "matrix").required().messages({
- "string.empty": "Notification type is required",
- "any.required": "Notification type is required",
- "any.only": "Notification type must be email, webhook, slack, discord, pager_duty, or matrix",
- }),
-
- address: joi.when("type", {
- switch: [
- {
- is: "email",
- then: joi.string().email().required().messages({
- "string.empty": "E-mail address cannot be empty",
- "any.required": "E-mail address is required",
- "string.email": "Please enter a valid e-mail address",
- }),
- },
- {
- is: "pager_duty",
- then: joi.string().required().messages({
- "string.empty": "PagerDuty integration key cannot be empty",
- "any.required": "PagerDuty integration key is required",
- }),
- },
- {
- is: joi.string().valid("webhook", "slack", "discord"),
- then: joi.string().uri().required().messages({
- "string.empty": "Webhook URL cannot be empty",
- "any.required": "Webhook URL is required",
- "string.uri": "Please enter a valid Webhook URL",
- }),
- },
- {
- is: "matrix",
- then: joi.string().allow("").optional(),
- },
- ],
- }),
-
- homeserverUrl: joi.when("type", {
- is: "matrix",
- then: joi.string().uri().required().messages({
- "string.empty": "Homeserver URL cannot be empty",
- "any.required": "Homeserver URL is required",
- "string.uri": "Please enter a valid Homeserver URL",
- }),
- otherwise: joi.string().allow("").optional(),
- }),
-
- roomId: joi.when("type", {
- is: "matrix",
- then: joi.string().required().messages({
- "string.empty": "Room ID cannot be empty",
- "any.required": "Room ID is required",
- }),
- otherwise: joi.string().allow("").optional(),
- }),
-
- accessToken: joi.when("type", {
- is: "matrix",
- then: joi.string().required().messages({
- "string.empty": "Access Token cannot be empty",
- "any.required": "Access Token is required",
- }),
- otherwise: joi.string().allow("").optional(),
- }),
-});
-
-//****************************************
-// Announcetment Page Validation
-//****************************************
-
-const createAnnouncementValidation = joi.object({
- title: joi.string().required().messages({
- "string.empty": "Title cannot be empty",
- "any.required": "Title is required",
- }),
- message: joi.string().required().messages({
- "string.empty": "Message cannot be empty",
- "any.required": "Message is required",
- }),
- userId: joi.string().required(),
-});
-
-const sendTestEmailBodyValidation = joi.object({
- to: joi.string().required(),
- systemEmailHost: joi.string(),
- systemEmailPort: joi.number(),
- systemEmailSecure: joi.boolean(),
- systemEmailPool: joi.boolean(),
- systemEmailAddress: joi.string(),
- systemEmailPassword: joi.string(),
- systemEmailUser: joi.string(),
- systemEmailConnectionHost: joi.string().allow("").optional(),
- systemEmailIgnoreTLS: joi.boolean(),
- systemEmailRequireTLS: joi.boolean(),
- systemEmailRejectUnauthorized: joi.boolean(),
- systemEmailTLSServername: joi.string().allow("").optional(),
-});
-
-const getUserByIdParamValidation = joi.object({
- userId: joi.string().required(),
-});
-
-const editUserByIdParamValidation = joi.object({
- userId: joi.string().required(),
-});
-
-const editUserByIdBodyValidation = joi.object({
- firstName: nameValidation.required(),
- lastName: nameValidation.required(),
- role: joi
- .array()
- .items(joi.string().valid(...UserRoles))
- .min(1)
- .required(),
-});
-
-const editSuperadminUserByIdBodyValidation = joi.object({
- firstName: nameValidation.required(),
- lastName: nameValidation.required(),
- role: joi
- .array()
- .items(joi.string().valid(...UserRoles))
- .min(1)
- .required(),
-});
-
-const editUserPasswordByIdBodyValidation = joi.object({
- password: joi.string().min(8).required().pattern(passwordPattern),
-});
-
-const createUserBodyValidation = joi.object({
- firstName: nameValidation.required(),
- lastName: nameValidation.required(),
- email: joi
- .string()
- .email()
- .required()
- .custom((value, helpers) => {
- const lowercasedValue = value.toLowerCase();
- if (value !== lowercasedValue) {
- return helpers.message({ custom: "Email must be in lowercase" });
- }
- return lowercasedValue;
- }),
- password: joi.string().min(8).required().pattern(passwordPattern),
- role: joi
- .array()
- .items(joi.string().valid(...UserRoles))
- .min(1)
- .required(),
-});
-
-export {
- roleValidatior,
- loginValidation,
- registrationBodyValidation,
- recoveryValidation,
- recoveryTokenBodyValidation,
- newPasswordValidation,
- inviteBodyValidation,
- inviteVerificationBodyValidation,
- createMonitorBodyValidation,
- createMonitorsBodyValidation,
- getMonitorByIdParamValidation,
- getMonitorByIdQueryValidation,
- getMonitorsByTeamIdParamValidation,
- getMonitorsByTeamIdQueryValidation,
- getMonitorsWithChecksQueryValidation,
- getHardwareDetailsByIdParamValidation,
- getHardwareDetailsByIdQueryValidation,
- getCertificateParamValidation,
- editMonitorBodyValidation,
- pauseMonitorParamValidation,
- getMonitorURLByQueryValidation,
- editUserBodyValidation,
- createAlertParamValidation,
- createAlertBodyValidation,
- getAlertsByUserIdParamValidation,
- getAlertsByMonitorIdParamValidation,
- getAlertByIdParamValidation,
- editAlertParamValidation,
- editAlertBodyValidation,
- deleteAlertParamValidation,
- createCheckParamValidation,
- createCheckBodyValidation,
- getChecksParamValidation,
- getChecksQueryValidation,
- getTeamChecksQueryValidation,
- ackCheckBodyValidation,
- ackAllChecksParamValidation,
- ackAllChecksBodyValidation,
- deleteChecksParamValidation,
- deleteChecksByTeamIdParamValidation,
- updateChecksTTLBodyValidation,
- deleteUserParamValidation,
- getPageSpeedCheckParamValidation,
- createPageSpeedCheckParamValidation,
- deletePageSpeedCheckParamValidation,
- createPageSpeedCheckBodyValidation,
- createMaintenanceWindowBodyValidation,
- getMaintenanceWindowByIdParamValidation,
- getMaintenanceWindowsByTeamIdQueryValidation,
- getMaintenanceWindowsByMonitorIdParamValidation,
- deleteMaintenanceWindowByIdParamValidation,
- editMaintenanceWindowByIdParamValidation,
- editMaintenanceByIdWindowBodyValidation,
- updateAppSettingsBodyValidation,
- createStatusPageBodyValidation,
- getStatusPageParamValidation,
- getStatusPageQueryValidation,
- imageValidation,
- triggerNotificationBodyValidation,
- createNotificationBodyValidation,
- webhookConfigValidation,
- createAnnouncementValidation,
- sendTestEmailBodyValidation,
- getUserByIdParamValidation,
- editUserByIdParamValidation,
- editUserByIdBodyValidation,
- editSuperadminUserByIdBodyValidation,
- editUserPasswordByIdBodyValidation,
- createUserBodyValidation,
-};
diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts
new file mode 100644
index 000000000..e09ac2989
--- /dev/null
+++ b/server/src/validation/maintenanceWindowValidation.ts
@@ -0,0 +1,54 @@
+import { z } from "zod";
+import { booleanCoercion } from "./shared.js";
+
+//****************************************
+// Maintenance Window Validations
+//****************************************
+
+export const createMaintenanceWindowBodyValidation = z.object({
+ monitors: z.array(z.string()).min(1, "At least one monitor is required"),
+ name: z.string().min(1, "Name is required"),
+ active: z.boolean().optional(),
+ duration: z.number().min(1, "Duration is required"),
+ durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
+ start: z.coerce.date(),
+ end: z.coerce.date(),
+ repeat: z.number().min(0, "Repeat must be a non-negative number"),
+ expiry: z.coerce.date().optional(),
+});
+
+export const getMaintenanceWindowByIdParamValidation = z.object({
+ id: z.string().min(1, "ID is required"),
+});
+
+export const getMaintenanceWindowsByTeamIdQueryValidation = z.object({
+ active: booleanCoercion.optional(),
+ page: z.coerce.number().optional(),
+ rowsPerPage: z.coerce.number().optional(),
+ field: z.string().optional(),
+ order: z.enum(["asc", "desc"]).optional(),
+});
+
+export const getMaintenanceWindowsByMonitorIdParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
+export const deleteMaintenanceWindowByIdParamValidation = z.object({
+ id: z.string().min(1, "ID is required"),
+});
+
+export const editMaintenanceWindowByIdParamValidation = z.object({
+ id: z.string().min(1, "ID is required"),
+});
+
+export const editMaintenanceByIdWindowBodyValidation = z.object({
+ active: z.boolean().optional(),
+ name: z.string().optional(),
+ repeat: z.number().optional(),
+ start: z.coerce.date().optional(),
+ end: z.coerce.date().optional(),
+ expiry: z.coerce.date().optional(),
+ monitors: z.array(z.unknown()).optional(),
+ duration: z.number().optional(),
+ durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
+});
diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts
new file mode 100644
index 000000000..4694143ac
--- /dev/null
+++ b/server/src/validation/monitorValidation.ts
@@ -0,0 +1,121 @@
+import { z } from "zod";
+import { booleanCoercion } from "./shared.js";
+import { GeoContinents } from "@/types/geoCheck.js";
+
+export const getMonitorByIdParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
+export const getMonitorByIdQueryValidation = z.object({
+ status: booleanCoercion.optional(),
+ sortOrder: z.enum(["asc", "desc"]).optional(),
+ limit: z.coerce.number().optional(),
+ dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
+ numToDisplay: z.coerce.number().optional(),
+ normalize: booleanCoercion.optional(),
+ continent: z.enum(GeoContinents).optional(),
+});
+
+export const getMonitorsByTeamIdParamValidation = z.object({});
+
+export const getMonitorsByTeamIdQueryValidation = z.object({
+ type: z
+ .union([
+ z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]),
+ z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])),
+ ])
+ .optional(),
+ filter: z.union([z.string(), z.literal(""), z.null()]).optional(),
+});
+
+export const getMonitorsWithChecksQueryValidation = z.object({
+ limit: z.coerce.number().int().min(1).max(100).optional(),
+ page: z.coerce.number().int().min(0).optional(),
+ rowsPerPage: z.coerce.number().int().min(1).max(100).optional(),
+ filter: z.union([z.string(), z.literal(""), z.null()]).optional(),
+ field: z.string().optional(),
+ order: z.enum(["asc", "desc"]).optional(),
+ type: z
+ .union([
+ z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]),
+ z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])),
+ ])
+ .optional(),
+ explain: booleanCoercion.optional(),
+});
+
+export const getCertificateParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
+export const createMonitorBodyValidation = z.object({
+ _id: z.string().optional(),
+ name: z.string().min(1, "Name is required"),
+ description: z.union([z.string(), z.null(), z.literal("")]).optional(),
+ type: z.string().min(1, "Type is required"),
+ statusWindowSize: z.number().min(1).max(20).default(5),
+ statusWindowThreshold: z.number().min(1).max(100).default(60),
+ url: z.string().min(1, "URL is required"),
+ ignoreTlsErrors: z.boolean().default(false),
+ useAdvancedMatching: z.boolean().default(false),
+ port: z.number().optional(),
+ isActive: z.boolean().optional(),
+ interval: z.number().optional(),
+ cpuAlertThreshold: z.number().optional(),
+ memoryAlertThreshold: z.number().optional(),
+ diskAlertThreshold: z.number().optional(),
+ tempAlertThreshold: z.number().optional(),
+ notifications: z.array(z.string()).optional(),
+ secret: z.string().optional(),
+ jsonPath: z.union([z.string(), z.literal("")]).optional(),
+ expectedValue: z.union([z.string(), z.literal("")]).optional(),
+ matchMethod: z.union([z.string(), z.null(), z.literal("")]).optional(),
+ gameId: z.union([z.string(), z.literal("")]).optional(),
+ grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
+ selectedDisks: z.array(z.string()).optional(),
+ group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
+ geoCheckEnabled: z.boolean().optional(),
+ geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
+ geoCheckInterval: z.number().min(300000).optional(),
+});
+
+export const editMonitorBodyValidation = z.object({
+ name: z.string().optional(),
+ type: z.string().optional(),
+ url: z.string().optional(),
+ statusWindowSize: z.number().min(1).max(20).default(5),
+ statusWindowThreshold: z.number().min(1).max(100).default(60),
+ description: z.union([z.string(), z.null(), z.literal("")]).optional(),
+ interval: z.number().optional(),
+ notifications: z.array(z.string()).optional(),
+ secret: z.string().optional(),
+ ignoreTlsErrors: z.boolean().optional(),
+ useAdvancedMatching: z.boolean().optional(),
+ jsonPath: z.union([z.string(), z.literal("")]).optional(),
+ expectedValue: z.union([z.string(), z.literal("")]).optional(),
+ matchMethod: z.union([z.string(), z.null(), z.literal("")]).optional(),
+ port: z.number().min(1).max(65535).optional(),
+ cpuAlertThreshold: z.number().optional(),
+ memoryAlertThreshold: z.number().optional(),
+ diskAlertThreshold: z.number().optional(),
+ tempAlertThreshold: z.number().optional(),
+ gameId: z.union([z.string(), z.literal("")]).optional(),
+ grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
+ selectedDisks: z.array(z.string()).optional(),
+ group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
+ geoCheckEnabled: z.boolean().optional(),
+ geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
+ geoCheckInterval: z.number().min(300000).optional(),
+});
+
+export const pauseMonitorParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
+export const getHardwareDetailsByIdParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+});
+
+export const getHardwareDetailsByIdQueryValidation = z.object({
+ dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
+});
diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts
new file mode 100644
index 000000000..d974d873f
--- /dev/null
+++ b/server/src/validation/notificationValidation.ts
@@ -0,0 +1,78 @@
+import { z } from "zod";
+
+//****************************************
+// Notification Validations
+//****************************************
+
+export const createNotificationBodyValidation = z.discriminatedUnion("type", [
+ // Email notification
+ z.object({
+ notificationName: z.string().min(1, "Notification name is required"),
+ type: z.literal("email"),
+ address: z.email("Please enter a valid e-mail address"),
+ homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
+ roomId: z.union([z.string(), z.literal("")]).optional(),
+ accessToken: z.union([z.string(), z.literal("")]).optional(),
+ }),
+ // Webhook notification
+ z.object({
+ notificationName: z.string().min(1, "Notification name is required"),
+ type: z.literal("webhook"),
+ address: z.string().url("Please enter a valid Webhook URL"),
+ homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
+ roomId: z.union([z.string(), z.literal("")]).optional(),
+ accessToken: z.union([z.string(), z.literal("")]).optional(),
+ }),
+ // Slack notification
+ z.object({
+ notificationName: z.string().min(1, "Notification name is required"),
+ type: z.literal("slack"),
+ address: z.string().url("Please enter a valid Webhook URL"),
+ homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
+ roomId: z.union([z.string(), z.literal("")]).optional(),
+ accessToken: z.union([z.string(), z.literal("")]).optional(),
+ }),
+ // Discord notification
+ z.object({
+ notificationName: z.string().min(1, "Notification name is required"),
+ type: z.literal("discord"),
+ address: z.string().url("Please enter a valid Webhook URL"),
+ homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
+ roomId: z.union([z.string(), z.literal("")]).optional(),
+ accessToken: z.union([z.string(), z.literal("")]).optional(),
+ }),
+ // PagerDuty notification
+ z.object({
+ notificationName: z.string().min(1, "Notification name is required"),
+ type: z.literal("pager_duty"),
+ address: z.string().min(1, "PagerDuty integration key is required"),
+ homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
+ roomId: z.union([z.string(), z.literal("")]).optional(),
+ accessToken: z.union([z.string(), z.literal("")]).optional(),
+ }),
+ // Matrix notification
+ z.object({
+ notificationName: z.string().min(1, "Notification name is required"),
+ type: z.literal("matrix"),
+ address: z.union([z.string(), z.literal("")]).optional(),
+ homeserverUrl: z.string().url("Please enter a valid Homeserver URL"),
+ roomId: z.string().min(1, "Room ID is required"),
+ accessToken: z.string().min(1, "Access Token is required"),
+ }),
+]);
+
+export const sendTestEmailBodyValidation = z.object({
+ to: z.string().min(1, "To field is required"),
+ systemEmailHost: z.string().optional(),
+ systemEmailPort: z.number().optional(),
+ systemEmailSecure: z.boolean().optional(),
+ systemEmailPool: z.boolean().optional(),
+ systemEmailAddress: z.string().optional(),
+ systemEmailPassword: z.string().optional(),
+ systemEmailUser: z.string().optional(),
+ systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(),
+ systemEmailIgnoreTLS: z.boolean().optional(),
+ systemEmailRequireTLS: z.boolean().optional(),
+ systemEmailRejectUnauthorized: z.boolean().optional(),
+ systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(),
+});
diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts
new file mode 100644
index 000000000..ee2a2ee87
--- /dev/null
+++ b/server/src/validation/settingsValidation.ts
@@ -0,0 +1,38 @@
+import { z } from "zod";
+
+//****************************************
+// Settings Validations
+//****************************************
+
+export const updateAppSettingsBodyValidation = z
+ .object({
+ checkTTL: z.union([z.number(), z.literal("")]).optional(),
+ systemEmailPort: z.union([z.number(), z.literal("")]).optional(),
+
+ pagespeedApiKey: z.union([z.string(), z.literal("")]).optional(),
+ language: z.union([z.string(), z.literal("")]).optional(),
+ timezone: z.union([z.string(), z.literal("")]).optional(),
+ systemEmailHost: z.union([z.string(), z.literal("")]).optional(),
+ systemEmailAddress: z.union([z.string(), z.literal("")]).optional(),
+ systemEmailPassword: z.union([z.string(), z.literal("")]).optional(),
+ systemEmailUser: z.union([z.string(), z.literal("")]).optional(),
+ systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(),
+ systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(),
+
+ showURL: z.boolean().optional(),
+ systemEmailSecure: z.boolean().optional(),
+ systemEmailPool: z.boolean().optional(),
+ systemEmailIgnoreTLS: z.boolean().optional(),
+ systemEmailRequireTLS: z.boolean().optional(),
+ systemEmailRejectUnauthorized: z.boolean().optional(),
+
+ globalThresholds: z
+ .object({
+ cpu: z.union([z.number().min(1).max(100), z.literal("")]).optional(),
+ memory: z.union([z.number().min(1).max(100), z.literal("")]).optional(),
+ disk: z.union([z.number().min(1).max(100), z.literal("")]).optional(),
+ temperature: z.union([z.number().min(1).max(150), z.literal("")]).optional(),
+ })
+ .optional(),
+ })
+ .strip();
diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts
new file mode 100644
index 000000000..28fd13c82
--- /dev/null
+++ b/server/src/validation/shared.ts
@@ -0,0 +1,28 @@
+import { z } from "zod";
+import { type UserRole } from "@/types/user.js";
+
+export const passwordPattern =
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/;
+
+export const nameValidation = z
+ .string()
+ .trim()
+ .max(50, "Name must be less than 50 characters")
+ .regex(
+ /^(?=.*[\p{L}\p{Sc}])[\p{L}\p{Sc}\s'\-().]+$/u,
+ "Names must contain at least 1 letter and may only include letters, currency symbols, spaces, apostrophes, hyphens (-), periods (.), and parentheses ()."
+ );
+
+export const lowercaseEmailValidation = z.email().transform((val) => val.toLowerCase());
+
+export const booleanCoercion = z.preprocess((val) => {
+ if (val === "true" || val === true) return true;
+ if (val === "false" || val === false) return false;
+ return val; // Let Zod validation handle invalid values
+}, z.boolean());
+
+export const roleValidator = (allowedRoles: UserRole[]) => {
+ return z.array(z.custom()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), {
+ message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`,
+ });
+};
diff --git a/server/src/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts
new file mode 100644
index 000000000..b4b5e270c
--- /dev/null
+++ b/server/src/validation/statusPageValidation.ts
@@ -0,0 +1,53 @@
+import { z } from "zod";
+import { booleanCoercion } from "./shared.js";
+
+//****************************************
+// Status Page Validations
+//****************************************
+
+export const getStatusPageParamValidation = z.object({
+ url: z.string().min(1, "URL is required"),
+});
+
+export const getStatusPageQueryValidation = z.object({
+ type: z.literal("uptime"),
+ timeFrame: z.coerce.number().optional(),
+});
+
+export const createStatusPageBodyValidation = z
+ .object({
+ type: z.literal("uptime"),
+ companyName: z.string().min(1, "Company name is required"),
+ url: z.string().regex(/^[a-zA-Z0-9_-]+$/, {
+ message: "URL can only contain letters, numbers, underscores, and hyphens",
+ }),
+ timezone: z.string().optional(),
+ color: z.string().optional(),
+ monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"),
+ subMonitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/)).optional(),
+ deleteSubmonitors: z.boolean().optional(),
+ isPublished: booleanCoercion,
+ showCharts: booleanCoercion.optional(),
+ showUptimePercentage: booleanCoercion,
+ showAdminLoginLink: booleanCoercion.optional(),
+ removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
+ })
+ .strip();
+
+export const imageValidation = z
+ .object({
+ fieldname: z.string().min(1, "Field name is required"),
+ originalname: z.string().min(1, "Original name is required"),
+ encoding: z.string().min(1, "Encoding is required"),
+ mimetype: z.enum(["image/jpeg", "image/png", "image/jpg"], {
+ message: "File must be a valid image (jpeg, jpg, or png)",
+ }),
+ size: z.number().max(3145728, "File size must be less than 3MB"),
+ buffer: z.instanceof(Buffer, { message: "Buffer is required" }),
+ destination: z.string().optional(),
+ filename: z.string().optional(),
+ path: z.string().optional(),
+ })
+ .refine((data) => data.buffer, {
+ message: "Image file is required",
+ });
diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts
new file mode 100644
index 000000000..9e11c8c57
--- /dev/null
+++ b/server/src/validation/userValidation.ts
@@ -0,0 +1,52 @@
+import { z } from "zod";
+import { UserRoles } from "@/types/user.js";
+import { nameValidation, lowercaseEmailValidation, passwordPattern } from "./shared.js";
+
+//****************************************
+// User Validations
+//****************************************
+
+export const getUserByIdParamValidation = z.object({
+ userId: z.string().min(1, "User ID is required"),
+});
+
+export const editUserByIdParamValidation = z.object({
+ userId: z.string().min(1, "User ID is required"),
+});
+
+export const editUserByIdBodyValidation = z.object({
+ firstName: nameValidation,
+ lastName: nameValidation,
+ role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"),
+});
+
+export const editSuperadminUserByIdBodyValidation = z.object({
+ firstName: nameValidation,
+ lastName: nameValidation,
+ role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"),
+});
+
+export const editUserPasswordByIdBodyValidation = z.object({
+ password: z
+ .string()
+ .min(8, "Password must be at least 8 characters")
+ .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"),
+});
+
+export const createUserBodyValidation = z.object({
+ firstName: nameValidation,
+ lastName: nameValidation,
+ email: lowercaseEmailValidation,
+ password: z
+ .string()
+ .min(8, "Password must be at least 8 characters")
+ .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"),
+ role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"),
+});
+
+export const editUserBodyValidation = z.object({
+ firstName: nameValidation.optional(),
+ lastName: nameValidation.optional(),
+ email: lowercaseEmailValidation.optional(),
+ profilePicture: z.string().optional(),
+});
diff --git a/server/tsconfig.json b/server/tsconfig.json
index bdb3a7b0e..34deaf8de 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -8,6 +8,7 @@
},
"outDir": "./dist",
"strict": true,
+ "noImplicitAny": true,
"esModuleInterop": true,
"module": "nodenext",
"moduleResolution": "nodenext",