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} + + + {geoChecks.map((check, index) => ( + { + e.originalEvent.stopPropagation(); + setSelectedCheck(check); + }} + > +
+ + ))} + + {selectedCheck && ( + setSelectedCheck(null)} + closeOnClick={false} + > + + + {selectedCheck.location.city}, {selectedCheck.location.country} + + + + + Status: + {" "} + {selectedCheck.status ? "Up" : "Down"} + + + + Status Code: + {" "} + {selectedCheck.statusCode} + + + + Response Time: + {" "} + {formatResponseTime(selectedCheck.timings.total)} + + + {new Date(selectedCheck.createdAt).toLocaleString()} + + + + + )} + + + + ); +}; 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 = () => { - + } /> )} @@ -939,13 +966,46 @@ export const SettingsPage = () => { bottom: 0, backgroundColor: theme.palette.background.paper, borderTop: `1px solid ${theme.palette.divider}`, - padding: theme.spacing(8), - marginLeft: theme.spacing(-8), - marginRight: theme.spacing(-8), - marginBottom: theme.spacing(-8), + padding: theme.spacing(LAYOUT.MD), + marginLeft: theme.spacing(-LAYOUT.MD), + marginRight: theme.spacing(-LAYOUT.MD), + marginBottom: theme.spacing(-LAYOUT.MD), zIndex: 1000, }} > + {/* Validation Error Display */} + {Object.keys(form.formState.errors).length > 0 && ( + + + {t("pages.settings.form.validation.errorMessage")} + + + {Object.entries(form.formState.errors).map(([field, error]) => { + const message = + typeof error === "object" && error?.message + ? error.message + : "Invalid value"; + return ( +
  • + + {field}: {message} + +
  • + ); + })} +
    +
    + )} +
    + + + ); +}; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 23f9a2196..300117bce 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -6,9 +6,12 @@ import { RadialAvgResponse, HistogramDetails, HeaderMonitorControls, + HeaderGeoTabs, + GeoChecksMap, } from "@/Components/monitors"; import { TrendingUp, AlertTriangle } from "lucide-react"; import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; +import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable"; import { MonitorStatBoxes } from "@/Components/monitors"; import { useTheme } from "@mui/material/styles"; @@ -19,9 +22,15 @@ import { useSelector } from "react-redux"; import { useGet } from "@/Hooks/UseApi"; import type { MonitorDetailsResponse } from "@/Types/Monitor"; import type { ChecksResponse } from "@/Types/Check"; +import type { + GeoChecksResult, + FlatGeoChecksResponse, + GeoContinent, +} from "@/Types/GeoCheck"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { t } from "i18next"; +import { Typography } from "@mui/material"; const certificateDateFormat = "MMM D, YYYY h A"; @@ -37,7 +46,10 @@ const UptimeDetailsPage = () => { const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(5); + const [geoPage, setGeoPage] = useState(0); + const [geoRowsPerPage, setGeoRowsPerPage] = useState(5); const [dateRange, setDateRange] = useState("recent"); + const [selectedLocation, setSelectedLocation] = useState("NA"); const monitorDetailsUrl = useMemo(() => { if (!monitorId) { @@ -56,7 +68,7 @@ const UptimeDetailsPage = () => { } = useGet( monitorDetailsUrl, {}, - { refreshInterval: 10000, keepPreviousData: true } + { refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false } ); const monitorData = monitorDetailsData?.monitorData; @@ -71,7 +83,11 @@ const UptimeDetailsPage = () => { return `/monitors/certificate/${monitorId}`; }, [monitorId, monitor?.type]); - const { data: certificateData } = useGet(certificateUrl); + const { data: certificateData } = useGet( + certificateUrl, + {}, + { revalidateOnFocus: false } + ); const certificateExpiry = useMemo(() => { if (!certificateData?.certificateDate) { @@ -102,9 +118,58 @@ const UptimeDetailsPage = () => { const { data: checksData, isLoading: checksIsLoading } = useGet( checksUrl, {}, - { keepPreviousData: true } + { keepPreviousData: true, revalidateOnFocus: false } ); + const geoChecksUrl = useMemo(() => { + if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) { + return null; + } + const params = new URLSearchParams(); + params.append("dateRange", dateRange); + params.append("continent", selectedLocation); + return `/monitors/${monitorId}/geo-checks?${params.toString()}`; + }, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]); + + const { data: geoGroupedData } = useGet( + geoChecksUrl, + {}, + { keepPreviousData: true, revalidateOnFocus: false } + ); + + const geoGroupedChecks = geoGroupedData?.groupedGeoChecks ?? []; + + // Fetch paginated geo checks for the table + const geoChecksTableUrl = useMemo(() => { + if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) { + return null; + } + const params = new URLSearchParams(); + params.append("sortOrder", "desc"); + params.append("dateRange", dateRange); + params.append("page", String(geoPage)); + params.append("rowsPerPage", String(geoRowsPerPage)); + return `/geo-checks/${monitorId}?${params.toString()}`; + }, [ + monitorId, + monitor?.type, + monitor?.geoCheckEnabled, + dateRange, + geoPage, + geoRowsPerPage, + ]); + + const { data: geoChecksTableData } = useGet( + geoChecksTableUrl, + {}, + { keepPreviousData: true, revalidateOnFocus: false } + ); + + const geoChecksForTable = geoChecksTableData?.geoChecks ?? []; + const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0; + + const geoLocations = monitor?.geoCheckLocations; + const checks = checksData?.checks ?? []; const checksCount = checksData?.checksCount ?? 0; @@ -127,6 +192,7 @@ const UptimeDetailsPage = () => { dateRange={dateRange} setDateRange={setDateRange} /> + { rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} /> + + {monitor?.geoCheckEnabled && ( + <> + Location breakdown + + + + + + )} ); }; 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",