diff --git a/.gitignore b/.gitignore
index a0420fec9..63420e072 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
.vscode
*.sh
!uptime.sh
-*.idea
\ No newline at end of file
+*.idea
+*.notes
\ No newline at end of file
diff --git a/Client/.env.production b/Client/.env.production
index a5bce84e1..2ae6d13cd 100644
--- a/Client/.env.production
+++ b/Client/.env.production
@@ -1 +1,2 @@
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
+VITE_STATUS_PAGE_SUBDOMAIN_PREFIX=UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX
diff --git a/Client/package-lock.json b/Client/package-lock.json
index f92a90496..51a0c8964 100644
--- a/Client/package-lock.json
+++ b/Client/package-lock.json
@@ -11,6 +11,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource/roboto": "^5.0.13",
+ "@hello-pangea/dnd": "^17.0.0",
"@mui/icons-material": "6.4.3",
"@mui/lab": "6.0.0-beta.26",
"@mui/material": "6.4.3",
@@ -20,9 +21,15 @@
"@reduxjs/toolkit": "2.5.1",
"axios": "^1.7.4",
"dayjs": "1.11.13",
+ "flag-icons": "7.3.2",
+ "immutability-helper": "^3.1.1",
"joi": "17.13.3",
"jwt-decode": "^4.0.0",
+ "maplibre-gl": "5.1.0",
+ "mui-color-input": "^5.0.1",
"react": "^18.2.0",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
@@ -332,6 +339,14 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@ctrl/tinycolor": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz",
+ "integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -984,6 +999,24 @@
"@hapi/hoek": "^9.0.0"
}
},
+ "node_modules/@hello-pangea/dnd": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-17.0.0.tgz",
+ "integrity": "sha512-LDDPOix/5N0j5QZxubiW9T0M0+1PR0rTDWeZF5pu1Tz91UQnuVK4qQ/EjY83Qm2QeX0eM8qDXANfDh3VVqtR4Q==",
+ "dependencies": {
+ "@babel/runtime": "^7.25.6",
+ "css-box-model": "^1.2.1",
+ "memoize-one": "^6.0.0",
+ "raf-schd": "^4.0.3",
+ "react-redux": "^9.1.2",
+ "redux": "^5.0.1",
+ "use-memo-one": "^1.1.3"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1070,6 +1103,83 @@
"@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": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
+ "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
+ "license": "ISC"
+ },
+ "node_modules/@mapbox/tiny-sdf": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
+ "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==",
+ "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": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
+ "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/point-geometry": "~0.1.0"
+ }
+ },
+ "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/maplibre-gl-style-spec": {
+ "version": "23.1.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.1.0.tgz",
+ "integrity": "sha512-R6/ihEuC5KRexmKIYkWqUv84Gm+/QwsOUgHyt1yy2XqCdGdLvlBWVWIIeTZWN4NGdwmY6xDzdSGU2R9oBLNg2w==",
+ "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/@mui/base": {
"version": "5.0.0-beta.69",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.69.tgz",
@@ -1618,6 +1728,21 @@
"url": "https://opencollective.com/popperjs"
}
},
+ "node_modules/@react-dnd/asap": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
+ "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
+ },
+ "node_modules/@react-dnd/invariant": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
+ "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
+ },
+ "node_modules/@react-dnd/shallowequal": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
+ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
+ },
"node_modules/@react-spring/animated": {
"version": "9.7.5",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
@@ -2359,12 +2484,50 @@
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"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/geojson-vt": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
+ "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/mapbox__point-geometry": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
+ "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/mapbox__vector-tile": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
+ "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*",
+ "@types/mapbox__point-geometry": "*",
+ "@types/pbf": "*"
+ }
+ },
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
+ "node_modules/@types/pbf": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
+ "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
+ "license": "MIT"
+ },
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -2400,6 +2563,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",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -2932,6 +3104,14 @@
"node": ">= 8"
}
},
+ "node_modules/css-box-model": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
+ "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
+ "dependencies": {
+ "tiny-invariant": "^1.0.6"
+ }
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3215,6 +3395,24 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dnd-core": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
+ "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
+ "dependencies": {
+ "@react-dnd/asap": "^5.0.1",
+ "@react-dnd/invariant": "^4.0.1",
+ "redux": "^4.2.0"
+ }
+ },
+ "node_modules/dnd-core/node_modules/redux": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
+ "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
+ "dependencies": {
+ "@babel/runtime": "^7.9.2"
+ }
+ },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -3263,6 +3461,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/earcut": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz",
+ "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
+ "license": "ISC"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.65",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.65.tgz",
@@ -3785,7 +3989,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
@@ -3857,6 +4060,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/flag-icons": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.3.2.tgz",
+ "integrity": "sha512-QkaZ6Zvai8LIjx+UNAHUJ5Dhz9OLZpBDwCRWxF6YErxIcR16jTkIFm3bFu54EkvKQy4+wicW+Gm7/0631wVQyQ==",
+ "license": "MIT"
+ },
"node_modules/flat-cache": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
@@ -3993,6 +4202,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/geojson-vt": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
+ "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
+ "license": "ISC"
+ },
"node_modules/get-intrinsic": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
@@ -4018,6 +4233,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@@ -4036,6 +4263,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gl-matrix": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
+ "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -4071,6 +4304,44 @@
"node": ">=10.13.0"
}
},
+ "node_modules/global-prefix": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
+ "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^4.1.3",
+ "kind-of": "^6.0.3",
+ "which": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/global-prefix/node_modules/isexe": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/global-prefix/node_modules/which": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+ "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
@@ -4222,6 +4493,26 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4242,6 +4533,11 @@
"url": "https://opencollective.com/immer"
}
},
+ "node_modules/immutability-helper": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz",
+ "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ=="
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -4287,6 +4583,15 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/ini": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
+ "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4789,6 +5094,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",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -4826,6 +5137,12 @@
"node": ">=18"
}
},
+ "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",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4836,6 +5153,15 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -4915,6 +5241,47 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/maplibre-gl": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.1.0.tgz",
+ "integrity": "sha512-6lbf7qAnqAVm1T/vJBMmRtP+g8G/O/Z52IBtWX31SbFj7sEdlrk4YugxJen8IdV/pFjLFnDOw7HiHZl5nYdVjg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/geojson-rewind": "^0.5.2",
+ "@mapbox/jsonlint-lines-primitives": "^2.0.2",
+ "@mapbox/point-geometry": "^0.1.0",
+ "@mapbox/tiny-sdf": "^2.0.6",
+ "@mapbox/unitbezier": "^0.0.1",
+ "@mapbox/vector-tile": "^1.3.1",
+ "@mapbox/whoots-js": "^3.1.0",
+ "@maplibre/maplibre-gl-style-spec": "^23.1.0",
+ "@types/geojson": "^7946.0.16",
+ "@types/geojson-vt": "3.2.5",
+ "@types/mapbox__point-geometry": "^0.1.4",
+ "@types/mapbox__vector-tile": "^1.3.4",
+ "@types/pbf": "^3.0.5",
+ "@types/supercluster": "^7.1.3",
+ "earcut": "^3.0.1",
+ "geojson-vt": "^4.0.2",
+ "gl-matrix": "^3.4.3",
+ "global-prefix": "^4.0.0",
+ "kdbush": "^4.0.2",
+ "murmurhash-js": "^1.0.0",
+ "pbf": "^3.3.0",
+ "potpack": "^2.0.0",
+ "quickselect": "^3.0.0",
+ "supercluster": "^8.0.1",
+ "tinyqueue": "^3.0.0",
+ "vt-pbf": "^3.1.3"
+ },
+ "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",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4925,6 +5292,11 @@
"node": ">= 0.4"
}
},
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -4959,12 +5331,48 @@
"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",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/mui-color-input": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/mui-color-input/-/mui-color-input-5.0.1.tgz",
+ "integrity": "sha512-50Ws4vhg4UPQSZEZDCNc7vyUBSb9x1bK+bO1o0wxJvQYgeSyg2r7mYDlavpCh+ZvisgBL/98y0GVN6M9901JWg==",
+ "dependencies": {
+ "@ctrl/tinycolor": "^4.1.0"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@mui/material": "^6.0.0",
+ "@types/react": "^18.0.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "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/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@@ -5246,6 +5654,19 @@
"node": ">=8"
}
},
+ "node_modules/pbf": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
+ "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "ieee754": "^1.1.12",
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5302,6 +5723,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/potpack": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz",
+ "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==",
+ "license": "ISC"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5345,6 +5772,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"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",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -5382,6 +5815,17 @@
],
"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",
+ "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
+ "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
+ },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -5394,6 +5838,43 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-dnd": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
+ "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
+ "dependencies": {
+ "@react-dnd/invariant": "^4.0.1",
+ "@react-dnd/shallowequal": "^4.0.1",
+ "dnd-core": "^16.0.1",
+ "fast-deep-equal": "^3.1.3",
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "peerDependencies": {
+ "@types/hoist-non-react-statics": ">= 3.3.1",
+ "@types/node": ">= 12",
+ "@types/react": ">= 16",
+ "react": ">= 16.14"
+ },
+ "peerDependenciesMeta": {
+ "@types/hoist-non-react-statics": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-dnd-html5-backend": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
+ "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
+ "dependencies": {
+ "dnd-core": "^16.0.1"
+ }
+ },
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -5658,6 +6139,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/reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -5753,6 +6243,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",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -6100,6 +6596,15 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"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",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -6144,6 +6649,12 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
+ "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/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -6313,6 +6824,14 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-memo-one": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz",
+ "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/use-sync-external-store": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
@@ -6417,6 +6936,17 @@
"vite": ">=2.6.0"
}
},
+ "node_modules/vt-pbf": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
+ "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/point-geometry": "0.1.0",
+ "@mapbox/vector-tile": "^1.3.1",
+ "pbf": "^3.2.1"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/Client/package.json b/Client/package.json
index 1b1ded008..6b006e24e 100644
--- a/Client/package.json
+++ b/Client/package.json
@@ -14,6 +14,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource/roboto": "^5.0.13",
+ "@hello-pangea/dnd": "^17.0.0",
"@mui/icons-material": "6.4.3",
"@mui/lab": "6.0.0-beta.26",
"@mui/material": "6.4.3",
@@ -23,9 +24,15 @@
"@reduxjs/toolkit": "2.5.1",
"axios": "^1.7.4",
"dayjs": "1.11.13",
+ "flag-icons": "7.3.2",
+ "immutability-helper": "^3.1.1",
"joi": "17.13.3",
"jwt-decode": "^4.0.0",
+ "maplibre-gl": "5.1.0",
+ "mui-color-input": "^5.0.1",
"react": "^18.2.0",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
diff --git a/Client/src/Pages/Uptime/Monitors/Components/ActionsMenu/index.jsx b/Client/src/Components/ActionsMenu/index.jsx
similarity index 94%
rename from Client/src/Pages/Uptime/Monitors/Components/ActionsMenu/index.jsx
rename to Client/src/Components/ActionsMenu/index.jsx
index f0561d6e6..c39c130cb 100644
--- a/Client/src/Pages/Uptime/Monitors/Components/ActionsMenu/index.jsx
+++ b/Client/src/Components/ActionsMenu/index.jsx
@@ -2,23 +2,23 @@ import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
-import { createToast } from "../../../../../Utils/toastUtils";
-import { logger } from "../../../../../Utils/Logger";
+import { createToast } from "../../Utils/toastUtils";
+import { logger } from "../../Utils/Logger";
import { IconButton, Menu, MenuItem } from "@mui/material";
import {
deleteUptimeMonitor,
pauseUptimeMonitor,
-} from "../../../../../Features/UptimeMonitors/uptimeMonitorsSlice";
-import Settings from "../../../../../assets/icons/settings-bold.svg?react";
+} from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
+import Settings from "../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
-import Dialog from "../../../../../Components/Dialog";
+import Dialog from "../../Components/Dialog";
const ActionsMenu = ({
monitor,
isAdmin,
updateRowCallback,
pauseCallback,
- setIsLoading,
+ setIsLoading = () => {},
}) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
diff --git a/Client/src/Components/Charts/ChartBox/index.jsx b/Client/src/Components/Charts/ChartBox/index.jsx
index eed3efd0d..bd27d4775 100644
--- a/Client/src/Components/Charts/ChartBox/index.jsx
+++ b/Client/src/Components/Charts/ChartBox/index.jsx
@@ -11,6 +11,7 @@ const ChartBox = ({
justifyContent = "space-between",
Legend,
borderRadiusRight = 4,
+ sx,
}) => {
const theme = useTheme();
return (
@@ -70,8 +71,8 @@ const ChartBox = ({
alignItems="center"
gap={theme.spacing(6)}
>
- {icon}
- {header}
+ {icon && {icon}}
+ {header && {header}}
{children}
@@ -84,7 +85,7 @@ export default ChartBox;
ChartBox.propTypes = {
children: PropTypes.node,
- icon: PropTypes.node.isRequired,
- header: PropTypes.string.isRequired,
+ icon: PropTypes.node,
+ header: PropTypes.string,
height: PropTypes.string,
};
diff --git a/Client/src/Components/Charts/StatusPageBarChart/index.jsx b/Client/src/Components/Charts/StatusPageBarChart/index.jsx
new file mode 100644
index 000000000..5acf4c3d6
--- /dev/null
+++ b/Client/src/Components/Charts/StatusPageBarChart/index.jsx
@@ -0,0 +1,175 @@
+import { useTheme } from "@emotion/react";
+import { Box, Stack, Tooltip, Typography } from "@mui/material";
+import { formatDateWithTz } from "../../../Utils/timeUtils";
+import { useEffect, useState } from "react";
+import { useSelector } from "react-redux";
+
+/* TODO add prop validation and jsdocs */
+const StatusPageBarChart = ({ checks = [] }) => {
+ const theme = useTheme();
+ const [animate, setAnimate] = useState(false);
+ const uiTimezone = useSelector((state) => state.ui.timezone);
+
+ useEffect(() => {
+ setAnimate(true);
+ }, []);
+
+ // set responseTime to average if there's only one check
+ if (checks.length === 1) {
+ checks[0] = { ...checks[0], responseTime: 50 };
+ }
+
+ if (checks.length !== 25) {
+ const placeholders = Array(25 - checks.length).fill("placeholder");
+ checks = [...checks, ...placeholders];
+ }
+
+ return (
+ event.stopPropagation()}
+ sx={{
+ cursor: "default",
+ }}
+ >
+ {checks.map((check, index) =>
+ check === "placeholder" ? (
+ /* TODO what is the purpose of this box? */
+ // CAIO_REVIEW the purpose of this box is to make sure there are always at least 25 bars
+ // even if there are less than 25 checks
+
+ ) : (
+
+
+ {formatDateWithTz(
+ check.createdAt,
+ "ddd, MMMM D, YYYY, HH:mm A",
+ uiTimezone
+ )}
+
+
+
+
+
+ Response Time
+
+
+ {check.originalResponseTime}
+
+ {" "}
+ ms
+
+
+
+
+ >
+ }
+ placement="top"
+ key={`check-${check?._id}`}
+ slotProps={{
+ popper: {
+ className: "bar-tooltip",
+ modifiers: [
+ {
+ name: "offset",
+ options: {
+ offset: [0, -10],
+ },
+ },
+ ],
+ sx: {
+ "& .MuiTooltip-tooltip": {
+ backgroundColor: theme.palette.secondary.main,
+ border: 1,
+ borderColor: theme.palette.primary.lowContrast,
+ borderRadius: theme.shape.borderRadius,
+ boxShadow: theme.shape.boxShadow,
+ px: theme.spacing(4),
+ py: theme.spacing(3),
+ },
+ "& .MuiTooltip-tooltip p": {
+ /* TODO Font size should point to theme */
+ fontSize: 12,
+ color: theme.palette.secondary.contrastText,
+ fontWeight: 500,
+ },
+ "& .MuiTooltip-tooltip span": {
+ /* TODO Font size should point to theme */
+ fontSize: 11,
+ color: theme.palette.secondary.contrastText,
+ fontWeight: 600,
+ },
+ },
+ },
+ }}
+ >
+
+
+
+
+ )
+ )}
+
+ );
+};
+
+export default StatusPageBarChart;
diff --git a/Client/src/Components/ConfigBox/index.jsx b/Client/src/Components/ConfigBox/index.jsx
index 8f7eb7b24..9b64712fc 100644
--- a/Client/src/Components/ConfigBox/index.jsx
+++ b/Client/src/Components/ConfigBox/index.jsx
@@ -29,10 +29,7 @@ const ConfigBox = styled(Stack)(({ theme }) => ({
},
"& h1, & h2": {
color: theme.palette.primary.contrastTextSecondary,
- },
- "& p": {
- color: theme.palette.primary.contrastTextTertiary,
- },
+ }
}));
export default ConfigBox;
diff --git a/Client/src/Components/Dialog/index.jsx b/Client/src/Components/Dialog/index.jsx
index 97d822e84..3cf7001ce 100644
--- a/Client/src/Components/Dialog/index.jsx
+++ b/Client/src/Components/Dialog/index.jsx
@@ -1,18 +1,19 @@
import PropTypes from "prop-types";
-import LoadingButton from "@mui/lab/LoadingButton";
import { Button, Stack } from "@mui/material";
import { GenericDialog } from "./genericDialog";
+import { useTheme } from "@emotion/react";
const Dialog = ({
title,
description,
open,
- theme,
onCancel,
confirmationButtonLabel,
onConfirm,
isLoading,
}) => {
+ const theme = useTheme();
+
return (
Cancel
-
{confirmationButtonLabel}
-
+
);
@@ -51,7 +52,6 @@ Dialog.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
open: PropTypes.bool.isRequired,
- theme: PropTypes.object.isRequired,
onCancel: PropTypes.func.isRequired,
confirmationButtonLabel: PropTypes.string.isRequired,
onConfirm: PropTypes.func.isRequired,
diff --git a/Client/src/Pages/Uptime/Monitors/Components/Host/index.jsx b/Client/src/Components/Host/index.jsx
similarity index 96%
rename from Client/src/Pages/Uptime/Monitors/Components/Host/index.jsx
rename to Client/src/Components/Host/index.jsx
index c2f082f3a..051e60331 100644
--- a/Client/src/Pages/Uptime/Monitors/Components/Host/index.jsx
+++ b/Client/src/Components/Host/index.jsx
@@ -1,7 +1,7 @@
import { Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
-import Dot from "../../../../../Components/Dot";
+import Dot from "../Dot";
/**
* Host component.
* This subcomponent receives a params object and displays the host details.
diff --git a/Client/src/Components/Image/index.jsx b/Client/src/Components/Image/index.jsx
new file mode 100644
index 000000000..9304a4385
--- /dev/null
+++ b/Client/src/Components/Image/index.jsx
@@ -0,0 +1,57 @@
+import { Box } from "@mui/material";
+import PropTypes from "prop-types";
+
+const isValidBase64Image = (data) => {
+ return /^[A-Za-z0-9+/=]+$/.test(data);
+};
+
+const Image = ({
+ shouldRender = true,
+ src,
+ alt,
+ width = "auto",
+ height = "auto",
+ maxWidth = "auto",
+ maxHeight = "auto",
+ base64,
+ sx,
+}) => {
+ if (shouldRender === false) {
+ return null;
+ }
+
+ if (typeof src !== "undefined" && typeof base64 !== "undefined") {
+ console.warn("base64 takes precedence over src and overwrites it");
+ }
+
+ if (typeof base64 !== "undefined" && isValidBase64Image(base64)) {
+ src = `data:image/png;base64,${base64}`;
+ }
+
+ return (
+
+ );
+};
+
+Image.propTypes = {
+ shouldRender: PropTypes.bool,
+ src: PropTypes.string,
+ alt: PropTypes.string.isRequired,
+ width: PropTypes.string,
+ height: PropTypes.string,
+ maxWidth: PropTypes.string,
+ maxHeight: PropTypes.string,
+ base64: PropTypes.string,
+ sx: PropTypes.object,
+};
+
+export default Image;
diff --git a/Client/src/Components/Inputs/Checkbox/index.jsx b/Client/src/Components/Inputs/Checkbox/index.jsx
index b3a036d57..58b83c7c7 100644
--- a/Client/src/Components/Inputs/Checkbox/index.jsx
+++ b/Client/src/Components/Inputs/Checkbox/index.jsx
@@ -75,7 +75,6 @@ const Checkbox = ({
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: sizes[size], height: sizes[size] },
- alignSelf: "flex-start",
}}
/>
}
@@ -111,7 +110,7 @@ Checkbox.propTypes = {
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
size: PropTypes.oneOf(["small", "medium", "large"]),
isChecked: PropTypes.bool.isRequired,
- value: PropTypes.string,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
onChange: PropTypes.func,
isDisabled: PropTypes.bool,
};
diff --git a/Client/src/Components/Inputs/ColorPicker/index.jsx b/Client/src/Components/Inputs/ColorPicker/index.jsx
new file mode 100644
index 000000000..e64c936fc
--- /dev/null
+++ b/Client/src/Components/Inputs/ColorPicker/index.jsx
@@ -0,0 +1,63 @@
+import PropTypes from "prop-types";
+import { Stack, Typography } from "@mui/material";
+import { useTheme } from "@emotion/react";
+import { MuiColorInput } from "mui-color-input";
+
+/**
+ *
+ * @param {*} id The ID of the component
+ * @param {*} value The color value of the component
+ * @param {*} error The error of the component
+ * @param {*} onChange The Change handler function
+ * @param {*} onBlur The Blur handler function
+ * @returns The ColorPicker component
+ * Example usage:
+ *
+ *
+ */
+const ColorPicker = ({ id, name, value, error, onChange, onBlur }) => {
+ const theme = useTheme();
+ return (
+
+ onChange({ target: { name, value: color } })}
+ onBlur={onBlur}
+ />
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+};
+
+ColorPicker.propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ error: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ onBlur: PropTypes.func,
+ name: PropTypes.string,
+};
+
+export default ColorPicker;
diff --git a/Client/src/Components/Inputs/Image/index.jsx b/Client/src/Components/Inputs/Image/index.jsx
index a955a8ad6..0a2a2ef91 100644
--- a/Client/src/Components/Inputs/Image/index.jsx
+++ b/Client/src/Components/Inputs/Image/index.jsx
@@ -11,11 +11,16 @@ import { checkImage } from "../../../Utils/fileUtils";
* @param {string} props.id - The unique identifier for the input field.
* @param {string} props.src - The URL of the image to display.
* @param {function} props.onChange - The function to handle file input change.
+ * @param {boolean} props.isRound - Whether the shape of the image to display is round.
+ * @param {string} props.maxSize - Custom message for the max uploaded file size
* @returns {JSX.Element} The rendered component.
*/
-const ImageField = ({ id, src, loading, onChange }) => {
+const ImageField = ({ id, src, loading, onChange, error, isRound = true, maxSize }) => {
const theme = useTheme();
+ const error_border_style = error ? { borderColor: theme.palette.error.main } : {};
+
+ const roundShape = isRound ? { borderRadius: "50%" } : {};
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = () => {
@@ -46,6 +51,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
+ ...error_border_style,
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -62,6 +68,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
cursor: "pointer",
maxWidth: "500px",
minHeight: "175px",
+ zIndex: 1,
},
"& fieldset": {
padding: 0,
@@ -78,7 +85,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
- zIndex: "-1",
+ zIndex: 0,
width: "100%",
}}
>
@@ -111,7 +118,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
color={theme.palette.primary.contrastTextTertiary}
sx={{ opacity: 0.6 }}
>
- (maximum size: 3MB)
+ (maximum size: {maxSize ?? "3MB"})
@@ -122,6 +129,19 @@ const ImageField = ({ id, src, loading, onChange }) => {
>
Supported formats: JPG, PNG
+ {error && (
+
+ {error}
+
+ )}
>
) : (
{
sx={{
width: "250px",
height: "250px",
- borderRadius: "50%",
overflow: "hidden",
backgroundImage: `url(${src})`,
backgroundSize: "cover",
+ ...roundShape,
}}
>
@@ -148,6 +168,8 @@ ImageField.propTypes = {
id: PropTypes.string.isRequired,
src: PropTypes.string,
onChange: PropTypes.func.isRequired,
+ isRound: PropTypes.bool,
+ maxSize: PropTypes.string,
};
export default ImageField;
diff --git a/Client/src/Components/Inputs/Search/index.jsx b/Client/src/Components/Inputs/Search/index.jsx
index 95930b0ec..662ade695 100644
--- a/Client/src/Components/Inputs/Search/index.jsx
+++ b/Client/src/Components/Inputs/Search/index.jsx
@@ -12,6 +12,7 @@ import SearchIcon from "../../../assets/icons/search.svg?react";
* @param {string} props.filteredBy - Key to access the option label from the options
* @param {string} props.value - Current input value for the Autocomplete
* @param {Function} props.handleChange - Function to call when the input changes
+ * @param {Function} Prop.onBlur - Function to call when the input is blured
* @param {Object} props.sx - Additional styles to apply to the component
* @returns {JSX.Element} The rendered Search component
*/
@@ -54,10 +55,14 @@ const Search = ({
isAdorned = true,
error,
disabled,
+ startAdornment,
+ endAdornment,
+ onBlur,
}) => {
const theme = useTheme();
return (
{
- handleChange && handleChange(newValue);
+ handleChange(newValue);
}}
fullWidth
freeSolo
@@ -74,6 +79,7 @@ const Search = ({
disableClearable
options={options}
getOptionLabel={(option) => option[filteredBy]}
+ isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier
renderInput={(params) => (
}),
+ slotProps={{
+ input: {
+ ...params.InputProps,
+ ...(isAdorned && { startAdornment: }),
+ ...(startAdornment && { startAdornment: startAdornment }),
+ ...(endAdornment && { endAdornment: endAdornment }),
+ },
}}
sx={{
"& fieldset": {
@@ -212,6 +222,9 @@ Search.propTypes = {
sx: PropTypes.object,
error: PropTypes.string,
disabled: PropTypes.bool,
+ startAdornment: PropTypes.object,
+ endAdornment: PropTypes.object,
+ onBlur: PropTypes.func,
};
export default Search;
diff --git a/Client/src/Components/Inputs/TextInput/Adornments/index.jsx b/Client/src/Components/Inputs/TextInput/Adornments/index.jsx
index 2b3717085..d6af2a89e 100644
--- a/Client/src/Components/Inputs/TextInput/Adornments/index.jsx
+++ b/Client/src/Components/Inputs/TextInput/Adornments/index.jsx
@@ -1,9 +1,11 @@
import { Stack, Typography, InputAdornment, IconButton } from "@mui/material";
import { useTheme } from "@mui/material/styles";
-import { useState } from "react";
import PropTypes from "prop-types";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
+import ReorderRoundedIcon from "@mui/icons-material/ReorderRounded";
+import DeleteIcon from "../../../../assets/icons/trash-bin.svg?react";
+
export const HttpAdornment = ({ https }) => {
const theme = useTheme();
return (
@@ -23,7 +25,7 @@ export const HttpAdornment = ({ https }) => {
color={theme.palette.primary.contrastTextSecondary}
sx={{ lineHeight: 1, opacity: 0.8 }}
>
- {https ? "https" : "http"}://
+ {https ? "https" : "http"}
);
@@ -31,6 +33,7 @@ export const HttpAdornment = ({ https }) => {
HttpAdornment.propTypes = {
https: PropTypes.bool.isRequired,
+ prefix: PropTypes.string,
};
export const PasswordEndAdornment = ({ fieldType, setFieldType }) => {
diff --git a/Client/src/Components/ProgressBars/index.jsx b/Client/src/Components/ProgressBars/index.jsx
index abb896844..4f5515d3a 100644
--- a/Client/src/Components/ProgressBars/index.jsx
+++ b/Client/src/Components/ProgressBars/index.jsx
@@ -93,6 +93,7 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) =>
{error ? error : label}
@@ -168,7 +169,7 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) =>
ProgressUpload.propTypes = {
icon: PropTypes.element, // JSX element for the icon (optional)
- label: PropTypes.string.isRequired, // Label text for the progress item
+ label: PropTypes.string, // Label text for the progress item
size: PropTypes.string.isRequired, // Size information for the progress item
progress: PropTypes.number.isRequired, // Current progress value (0-100)
onClick: PropTypes.func.isRequired, // Function to handle click events on the remove button
diff --git a/Client/src/Components/ProtectedDistributedUptimeRoute/index.jsx b/Client/src/Components/ProtectedDistributedUptimeRoute/index.jsx
new file mode 100644
index 000000000..4a59859fe
--- /dev/null
+++ b/Client/src/Components/ProtectedDistributedUptimeRoute/index.jsx
@@ -0,0 +1,30 @@
+import { Navigate } from "react-router-dom";
+import { useSelector } from "react-redux";
+import PropTypes from "prop-types";
+
+/**
+ * @param {Object} props - The props passed to the ProtectedDistributedUptimeRoute component.
+ * @param {React.ReactNode} props.children - The children to render if the user is authenticated.
+ * @returns {React.ReactElement} The children wrapped in a protected route or a redirect to the login page.
+ */
+
+const ProtectedDistributedUptimeRoute = ({ children }) => {
+ const distributedUptimeEnabled = useSelector(
+ (state) => state.ui.distributedUptimeEnabled
+ );
+
+ return distributedUptimeEnabled === true ? (
+ children
+ ) : (
+
+ );
+};
+
+ProtectedDistributedUptimeRoute.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export default ProtectedDistributedUptimeRoute;
diff --git a/Client/src/Components/Sidebar/index.jsx b/Client/src/Components/Sidebar/index.jsx
index 3821306ad..27753dfcb 100644
--- a/Client/src/Components/Sidebar/index.jsx
+++ b/Client/src/Components/Sidebar/index.jsx
@@ -15,12 +15,6 @@ import {
Tooltip,
Typography,
} from "@mui/material";
-import { useLocation, useNavigate } from "react-router";
-import { useTheme } from "@emotion/react";
-import { useDispatch, useSelector } from "react-redux";
-import { clearAuthState } from "../../Features/Auth/authSlice";
-import { toggleSidebar } from "../../Features/UI/uiSlice";
-import { clearUptimeMonitorState } from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import ThemeSwitch from "../ThemeSwitch";
import Avatar from "../Avatar";
import LockSvg from "../../assets/icons/lock.svg?react";
@@ -43,16 +37,31 @@ import DotsVertical from "../../assets/icons/dots-vertical.svg?react";
import ChangeLog from "../../assets/icons/changeLog.svg?react";
import Docs from "../../assets/icons/docs.svg?react";
import Folder from "../../assets/icons/folder.svg?react";
+import StatusPages from "../../assets/icons/status-pages.svg?react";
import ChatBubbleOutlineRoundedIcon from "@mui/icons-material/ChatBubbleOutlineRounded";
-
+import DistributedUptimeIcon from "../../assets/icons/distributed-uptime.svg?react";
import "./index.css";
+// Utils
+import { useLocation, useNavigate } from "react-router";
+import { useTheme } from "@emotion/react";
+import { useDispatch, useSelector } from "react-redux";
+import { clearAuthState } from "../../Features/Auth/authSlice";
+import { toggleSidebar } from "../../Features/UI/uiSlice";
+import { clearUptimeMonitorState } from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
+
const menu = [
{ name: "Uptime", path: "uptime", icon: },
{ name: "Pagespeed", path: "pagespeed", icon: },
{ name: "Infrastructure", path: "infrastructure", icon: },
+ {
+ name: "Distributed uptime",
+ path: "distributed-uptime",
+ icon: ,
+ },
{ name: "Incidents", path: "incidents", icon: },
- // { name: "Status pages", path: "status", icon: },
+
+ { name: "Status pages", path: "status", icon: },
{ name: "Maintenance", path: "maintenance", icon: },
// { name: "Integrations", path: "integrations", icon: },
{
@@ -97,6 +106,7 @@ const PATH_MAP = {
monitors: "Dashboard",
pagespeed: "Dashboard",
infrastructure: "Dashboard",
+ ["distributed-uptime"]: "Dashboard",
account: "Account",
settings: "Settings",
};
@@ -119,6 +129,9 @@ function Sidebar() {
const [anchorEl, setAnchorEl] = useState(null);
const [popup, setPopup] = useState();
const { user } = useSelector((state) => state.auth);
+ const distributedUptimeEnabled = useSelector(
+ (state) => state.ui.distributedUptimeEnabled
+ );
const accountMenuItem = menu.find((item) => item.name === "Account");
if (user.role?.includes("demo") && accountMenuItem) {
@@ -313,8 +326,11 @@ function Sidebar() {
/* overflow: "hidden", */
}}
>
- {menu.map((item) =>
- item.path ? (
+ {menu.map((item) => {
+ if (item.path === "distributed-uptime" && distributedUptimeEnabled === false) {
+ return null;
+ }
+ return item.path ? (
/* If item has a path */
navigate(`/${item.path}`)}
sx={{
- /*
- TODO we do not need this height
- minHeight: "37px", */
- p: theme.spacing(5),
+ height: "37px",
gap: theme.spacing(4),
borderRadius: theme.shape.borderRadius,
+ px: theme.spacing(4),
}}
>
{item.icon}
@@ -549,8 +563,8 @@ function Sidebar() {
- )
- )}
+ );
+ })}
diff --git a/Client/src/Components/StatBox/index.jsx b/Client/src/Components/StatBox/index.jsx
index 4b1fd2454..58fdb7ebf 100644
--- a/Client/src/Components/StatBox/index.jsx
+++ b/Client/src/Components/StatBox/index.jsx
@@ -1,4 +1,5 @@
-import { Box, Typography } from "@mui/material";
+import { Stack, Typography } from "@mui/material";
+import Image from "../Image";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
@@ -29,7 +30,15 @@ import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
* @returns {React.ReactElement} A styled box containing the statistic
*/
-const StatBox = ({ heading, subHeading, gradient = false, status = "", sx }) => {
+const StatBox = ({
+ img,
+ alt,
+ heading,
+ subHeading,
+ gradient = false,
+ status = "",
+ sx,
+}) => {
const theme = useTheme();
const { statusToTheme } = useUtils();
const themeColor = statusToTheme[status];
@@ -70,7 +79,8 @@ const StatBox = ({ heading, subHeading, gradient = false, status = "", sx }) =>
};
return (
-
...sx,
}}
>
- {heading}
- {subHeading}
-
+ {img && (
+
+ )}
+
+ {heading}
+ {subHeading}
+
+
);
};
diff --git a/Client/src/Features/UI/uiSlice.js b/Client/src/Features/UI/uiSlice.js
index 872691bbf..4565c6c5d 100644
--- a/Client/src/Features/UI/uiSlice.js
+++ b/Client/src/Features/UI/uiSlice.js
@@ -1,6 +1,8 @@
import { createSlice } from "@reduxjs/toolkit";
-const initialMode = window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? "dark" : "light";
+const initialMode = window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches
+ ? "dark"
+ : "light";
// Initial state for UI settings.
// Add more settings as needed (e.g., theme preferences, user settings)
@@ -20,12 +22,16 @@ const initialState = {
mode: initialMode,
greeting: { index: 0, lastUpdate: null },
timezone: "America/Toronto",
+ distributedUptimeEnabled: false,
};
const uiSlice = createSlice({
name: "ui",
initialState,
reducers: {
+ setDistributedUptimeEnabled: (state, action) => {
+ state.distributedUptimeEnabled = action.payload;
+ },
setRowsPerPage: (state, action) => {
const { table, value } = action.payload;
if (state[table]) {
@@ -49,5 +55,11 @@ const uiSlice = createSlice({
});
export default uiSlice.reducer;
-export const { setRowsPerPage, toggleSidebar, setMode, setGreeting, setTimezone } =
- uiSlice.actions;
+export const {
+ setRowsPerPage,
+ toggleSidebar,
+ setMode,
+ setGreeting,
+ setTimezone,
+ setDistributedUptimeEnabled,
+} = uiSlice.actions;
diff --git a/Client/src/Hooks/useMonitorUtils.js b/Client/src/Hooks/useMonitorUtils.js
new file mode 100644
index 000000000..1e235af68
--- /dev/null
+++ b/Client/src/Hooks/useMonitorUtils.js
@@ -0,0 +1,45 @@
+import { useCallback } from "react";
+
+const useMonitorUtils = () => {
+ const getMonitorWithPercentage = useCallback((monitor, theme) => {
+ let uptimePercentage = "";
+ let percentageColor = "";
+
+ if (monitor.uptimePercentage !== undefined) {
+ uptimePercentage =
+ monitor.uptimePercentage === 0
+ ? "0"
+ : (monitor.uptimePercentage * 100).toFixed(2);
+
+ percentageColor =
+ monitor.uptimePercentage < 0.25
+ ? theme.palette.error.main
+ : monitor.uptimePercentage < 0.5
+ ? theme.palette.warning.main
+ : monitor.uptimePercentage < 0.75
+ ? theme.palette.success.main
+ : theme.palette.success.main;
+ }
+
+ return {
+ id: monitor._id,
+ name: monitor.name,
+ url: monitor.url,
+ title: monitor.name,
+ percentage: uptimePercentage,
+ percentageColor,
+ monitor: monitor,
+ };
+ }, []);
+
+ const determineState = useCallback((monitor) => {
+ if (typeof monitor === "undefined") return "pending";
+ if (monitor.isActive === false) return "paused";
+ if (monitor?.status === undefined) return "pending";
+ return monitor?.status == true ? "up" : "down";
+ }, []);
+
+ return { getMonitorWithPercentage, determineState };
+};
+
+export { useMonitorUtils };
diff --git a/Client/src/Pages/Account/index.jsx b/Client/src/Pages/Account/index.jsx
index 9fd38b5ef..00d805f34 100644
--- a/Client/src/Pages/Account/index.jsx
+++ b/Client/src/Pages/Account/index.jsx
@@ -73,11 +73,6 @@ const Account = ({ open = "profile" }) => {
{tabList.map((label, index) => (
{
onKeyDown={handleKeyDown}
onFocus={() => handleFocus(label.toLowerCase())}
tabIndex={index}
- sx={{
- fontSize: 13,
- color: theme.palette.tertiary.contrastText,
- backgroundColor: theme.palette.tertiary.main,
- textTransform: "none",
- minWidth: "fit-content",
- paddingY: theme.spacing(6),
- fontWeight: 400,
- borderBottom: "2px solid transparent",
- borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
- "&:first-child": { borderTopLeftRadius: "8px" },
- "&:last-child": { borderTopRightRadius: "8px", borderRight: 0 },
- "&:focus-visible": {
- color: theme.palette.primary.contrastText,
- borderColor: theme.palette.tertiary.contrastText,
- borderRightColor: theme.palette.primary.lowContrast,
- },
- "&.Mui-selected": {
- backgroundColor: theme.palette.secondary.main,
- color: theme.palette.secondary.contrastText,
- borderColor: theme.palette.secondary.contrastText,
- borderRightColor: theme.palette.primary.lowContrast,
- },
- "&:hover": {
- borderColor: theme.palette.primary.lowContrast,
- },
- }}
/>
))}
diff --git a/Client/src/Pages/DistributedUptime/Create/index.jsx b/Client/src/Pages/DistributedUptime/Create/index.jsx
new file mode 100644
index 000000000..2df0a4482
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Create/index.jsx
@@ -0,0 +1,307 @@
+// Components
+import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material";
+import LoadingButton from "@mui/lab/LoadingButton";
+
+import Breadcrumbs from "../../../Components/Breadcrumbs";
+import ConfigBox from "../../../Components/ConfigBox";
+import TextInput from "../../../Components/Inputs/TextInput";
+import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
+import Radio from "../../../Components/Inputs/Radio";
+import Checkbox from "../../../Components/Inputs/Checkbox";
+import Select from "../../../Components/Inputs/Select";
+import { createToast } from "../../../Utils/toastUtils";
+
+// Utility
+import { useTheme } from "@emotion/react";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useSelector, useDispatch } from "react-redux";
+import { monitorValidation } from "../../../Validation/validation";
+import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
+
+// Constants
+const BREADCRUMBS = [
+ { name: `distributed uptime`, path: "/distributed-uptime" },
+ { name: "create", path: `/distributed-uptime/create` },
+];
+const MS_PER_MINUTE = 60000;
+const SELECT_VALUES = [
+ { _id: 1, name: "1 minute" },
+ { _id: 2, name: "2 minutes" },
+ { _id: 3, name: "3 minutes" },
+ { _id: 4, name: "4 minutes" },
+ { _id: 5, name: "5 minutes" },
+];
+
+const CreateDistributedUptime = () => {
+ // Redux state
+ const { user, authToken } = useSelector((state) => state.auth);
+ const isLoading = useSelector((state) => state.uptimeMonitors.isLoading);
+
+ // Local state
+ const [https, setHttps] = useState(true);
+ const [notifications, setNotifications] = useState([]);
+ const [monitor, setMonitor] = useState({
+ type: "distributed_http",
+ name: "",
+ url: "",
+ interval: 1,
+ });
+ const [errors, setErrors] = useState({});
+
+ //utils
+ const theme = useTheme();
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ // Handlers
+ const handleCreateMonitor = async (event) => {
+ const monitorToSubmit = { ...monitor };
+
+ // Prepend protocol to url
+ monitorToSubmit.url = `http${https ? "s" : ""}://` + monitorToSubmit.url;
+
+ const { error } = monitorValidation.validate(monitorToSubmit, {
+ abortEarly: false,
+ });
+
+ if (error) {
+ const newErrors = {};
+ error.details.forEach((err) => {
+ newErrors[err.path[0]] = err.message;
+ });
+ setErrors(newErrors);
+ createToast({ body: "Please check the form for errors." });
+ return;
+ }
+
+ // Append needed fields
+ monitorToSubmit.description = monitor.name;
+ monitorToSubmit.interval = monitor.interval * MS_PER_MINUTE;
+ monitorToSubmit.teamId = user.teamId;
+ monitorToSubmit.userId = user._id;
+ monitorToSubmit.notifications = notifications;
+
+ const action = await dispatch(
+ createUptimeMonitor({ authToken, monitor: monitorToSubmit })
+ );
+ if (action.meta.requestStatus === "fulfilled") {
+ createToast({ body: "Monitor created successfully!" });
+ navigate("/distributed-uptime");
+ } else {
+ createToast({ body: "Failed to create monitor." });
+ }
+ };
+
+ const handleChange = (event) => {
+ const { name, value } = event.target;
+ setMonitor({
+ ...monitor,
+ [name]: value,
+ });
+ const { error } = monitorValidation.validate(
+ { [name]: value },
+ { abortEarly: false }
+ );
+ console.log(name);
+ setErrors((prev) => ({
+ ...prev,
+ ...(error ? { [name]: error.details[0].message } : { [name]: undefined }),
+ }));
+ };
+
+ const handleNotifications = (event, type) => {
+ const { value } = event.target;
+ let currentNotifications = [...notifications];
+ const notificationAlreadyExists = notifications.some((notification) => {
+ if (notification.type === type && notification.address === value) {
+ return true;
+ }
+ return false;
+ });
+ if (notificationAlreadyExists) {
+ currentNotifications = currentNotifications.filter((notification) => {
+ if (notification.type === type && notification.address === value) {
+ return false;
+ }
+ return true;
+ });
+ } else {
+ currentNotifications.push({ type, address: value });
+ }
+ setNotifications(currentNotifications);
+ };
+
+ return (
+
+
+ console.log("submit")}
+ >
+
+
+ Create your{" "}
+
+
+ monitor
+
+
+
+
+ General settings
+
+ Here you can select the URL of the host, together with the type of monitor.
+
+
+
+ }
+ label="URL to monitor"
+ https={https}
+ placeholder={"www.google.com"}
+ value={monitor.url}
+ name="url"
+ onChange={handleChange}
+ error={errors["url"] ? true : false}
+ helperText={errors["url"]}
+ />
+
+
+
+
+
+ Checks to perform
+
+ You can always add or remove checks after adding your site.
+
+
+
+
+
+ {monitor.type === "http" || monitor.type === "distributed_http" ? (
+
+
+
+
+ ) : (
+ ""
+ )}
+
+
+ {errors["type"] ? (
+
+
+ {errors["type"]}
+
+
+ ) : (
+ ""
+ )}
+
+
+
+
+ Incident notifications
+
+ When there is an incident, notify users.
+
+
+
+ notification.type === "email"
+ )}
+ value={user?.email}
+ onChange={(event) => handleNotifications(event, "email")}
+ />
+
+
+
+
+ Advanced settings
+
+
+
+
+
+
+ handleCreateMonitor()}
+ disabled={!Object.values(errors).every((value) => value === undefined)}
+ loading={isLoading}
+ >
+ Create monitor
+
+
+
+
+ );
+};
+
+export default CreateDistributedUptime;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx
new file mode 100644
index 000000000..22ef5596d
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx
@@ -0,0 +1,71 @@
+import { Stack, Typography, List, ListItem } from "@mui/material";
+import { useTheme } from "@emotion/react";
+import PulseDot from "../../../../../Components/Animated/PulseDot";
+import "/node_modules/flag-icons/css/flag-icons.min.css";
+
+const BASE_BOX_PADDING_VERTICAL = 16;
+const BASE_BOX_PADDING_HORIZONTAL = 8;
+
+const DeviceTicker = ({ data, width = "100%", connectionStatus }) => {
+ const theme = useTheme();
+ const statusColor = {
+ up: theme.palette.success.main,
+ down: theme.palette.error.main,
+ };
+
+ return (
+
+
+
+
+
+ {connectionStatus === "up" ? "Connected" : "No connection"}
+
+
+
+ {data.slice(Math.max(data.length - 5, 0)).map((dataPoint) => {
+ const countryCode = dataPoint?.countryCode?.toLowerCase() ?? null;
+ const flag = countryCode ? `fi fi-${countryCode}` : null;
+ return (
+
+
+
+ {flag && }
+ {dataPoint?.city || "Unknown"}
+
+ {`Response time: ${Math.floor(dataPoint?.responseTime ?? 0)} ms`}
+ {`UPT burned: ${dataPoint.uptBurnt}`}
+ {`${dataPoint?.device?.manufacturer} ${dataPoint?.device?.model}`}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default DeviceTicker;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json
new file mode 100644
index 000000000..86ca8204d
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json
@@ -0,0 +1,169 @@
+{
+ "id": "43f36e14-e3f5-43c1-84c0-50a9c80dc5c7",
+ "name": "MapLibre",
+ "zoom": 0.861983335785597,
+ "pitch": 0,
+ "center": [17.6543171043124, 32.9541203267468],
+ "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
+ "layers": [
+ {
+ "id": "background",
+ "type": "background",
+ "paint": {
+ "background-color": "#121217"
+ },
+ "filter": ["all"],
+ "layout": {
+ "visibility": "visible"
+ },
+ "maxzoom": 24
+ },
+ {
+ "id": "coastline",
+ "type": "line",
+ "paint": {
+ "line-blur": 0.5,
+ "line-color": "#000000",
+ "line-width": {
+ "stops": [
+ [0, 2],
+ [6, 6],
+ [14, 9],
+ [22, 18]
+ ]
+ }
+ },
+ "filter": ["all"],
+ "layout": {
+ "line-cap": "round",
+ "line-join": "round",
+ "visibility": "visible"
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "minzoom": 0,
+ "source-layer": "countries"
+ },
+ {
+ "id": "countries-fill",
+ "type": "fill",
+ "paint": {
+ "fill-color": "#292929"
+ },
+ "filter": ["all"],
+ "layout": {
+ "visibility": "visible"
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "source-layer": "countries"
+ },
+ {
+ "id": "countries-boundary",
+ "type": "line",
+ "paint": {
+ "line-color": "#484848",
+ "line-width": {
+ "stops": [
+ [1, 1],
+ [6, 2],
+ [14, 6],
+ [22, 12]
+ ]
+ },
+ "line-opacity": {
+ "stops": [
+ [3, 0.5],
+ [6, 1]
+ ]
+ }
+ },
+ "layout": {
+ "line-cap": "round",
+ "line-join": "round",
+ "visibility": "visible"
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "source-layer": "countries"
+ },
+ {
+ "id": "countries-label",
+ "type": "symbol",
+ "paint": {
+ "text-color": "rgba(8, 37, 77, 1)",
+ "text-halo-blur": {
+ "stops": [
+ [2, 0.2],
+ [6, 0]
+ ]
+ },
+ "text-halo-color": "rgba(255, 255, 255, 1)",
+ "text-halo-width": {
+ "stops": [
+ [2, 1],
+ [6, 1.6]
+ ]
+ }
+ },
+ "filter": ["all"],
+ "layout": {
+ "text-font": ["Open Sans Semibold"],
+ "text-size": {
+ "stops": [
+ [2, 10],
+ [4, 12],
+ [6, 16]
+ ]
+ },
+ "text-field": {
+ "stops": [
+ [2, "{ABBREV}"],
+ [4, "{NAME}"]
+ ]
+ },
+ "visibility": "visible",
+ "text-max-width": 10,
+ "text-transform": {
+ "stops": [
+ [0, "uppercase"],
+ [2, "none"]
+ ]
+ }
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "minzoom": 2,
+ "source-layer": "centroids"
+ },
+ {
+ "id": "data-dots",
+ "type": "circle",
+ "source": "data-dots",
+ "paint": {
+ "circle-radius": 3,
+ "circle-color": ["get", "color"],
+ "circle-opacity": 0.5
+ }
+ }
+ ],
+ "bearing": 0,
+ "sources": {
+ "maplibre": {
+ "url": "https://demotiles.maplibre.org/tiles/tiles.json",
+ "type": "vector"
+ },
+ "data-dots": {
+ "type": "geojson",
+ "data": {
+ "type": "FeatureCollection",
+ "features": []
+ }
+ }
+ },
+ "version": 8,
+ "metadata": {
+ "maptiler:copyright": "This style was generated on MapTiler Cloud. Usage is governed by the license terms in https://github.com/maplibre/demotiles/blob/gh-pages/LICENSE",
+ "openmaptiles:version": "3.x"
+ }
+}
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/buildStyle.js b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/buildStyle.js
new file mode 100644
index 000000000..e4aaa49ce
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/buildStyle.js
@@ -0,0 +1,33 @@
+import baseStyle from "./DistributedUptimeMapStyle.json";
+
+const buildStyle = (theme, mode) => {
+ const style = JSON.parse(JSON.stringify(baseStyle));
+
+ if (mode === "dark") {
+ return baseStyle;
+ }
+
+ if (style.layers) {
+ const newLayers = style.layers.map((layer) => {
+ if (layer.id === "background") {
+ layer.paint["background-color"] = theme.palette.map.main;
+ }
+
+ if (layer.id === "countries-fill") {
+ layer.paint["fill-color"] = theme.palette.map.lowContrast;
+ }
+
+ if (layer.id === "coastline") {
+ layer.paint["line-color"] = theme.palette.map.highContrast;
+ }
+ if (layer.id === "countries-boundary") {
+ layer.paint["line-color"] = theme.palette.map.highContrast;
+ }
+ return layer;
+ });
+ style.layers = newLayers;
+ }
+ return style;
+};
+
+export default buildStyle;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx
new file mode 100644
index 000000000..99e591750
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx
@@ -0,0 +1,102 @@
+import "maplibre-gl/dist/maplibre-gl.css";
+import PropTypes from "prop-types";
+import { useRef, useState, useEffect } from "react";
+import { useTheme } from "@mui/material/styles";
+import maplibregl from "maplibre-gl";
+import { useSelector } from "react-redux";
+import buildStyle from "./buildStyle";
+
+const DistributedUptimeMap = ({ width = "100%", height = "100%", checks }) => {
+ const mapContainer = useRef(null);
+ const map = useRef(null);
+ const theme = useTheme();
+ const [mapLoaded, setMapLoaded] = useState(false);
+ const mode = useSelector((state) => state.ui.mode);
+ const initialTheme = useRef(theme);
+ const initialMode = useRef(mode);
+
+ const colorLookup = (avgResponseTime) => {
+ if (avgResponseTime <= 150) {
+ return "#00FF00"; // Green
+ } else if (avgResponseTime <= 250) {
+ return "#FFFF00"; // Yellow
+ } else {
+ return "#FF0000"; // Red
+ }
+ };
+
+ useEffect(() => {
+ if (mapContainer.current && !map.current) {
+ const initialStyle = buildStyle(initialTheme.current, initialMode.current);
+ map.current = new maplibregl.Map({
+ container: mapContainer.current,
+ style: initialStyle,
+ center: [0, 20],
+ zoom: 0.8,
+ attributionControl: false,
+ });
+ }
+ map.current.on("load", () => {
+ setMapLoaded(true);
+ });
+
+ return () => {
+ if (map.current) {
+ map.current.remove();
+ map.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const style = buildStyle(theme, mode);
+ if (map.current && mapLoaded) {
+ map.current.setStyle(style);
+ }
+ }, [theme, mode, mapLoaded]);
+
+ useEffect(() => {
+ if (map.current && checks?.length > 0) {
+ // Convert dots to GeoJSON
+ const geojson = {
+ type: "FeatureCollection",
+ features: checks.map((check) => {
+ return {
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [check.lng, check.lat],
+ },
+ properties: {
+ color: theme.palette.accent.main,
+ // color: colorLookup(check.avgResponseTime) || "blue", // Default to blue if no color specified
+ },
+ };
+ }),
+ };
+
+ // Update the source with new dots
+ const source = map.current.getSource("data-dots");
+ if (source) {
+ source.setData(geojson);
+ }
+ }
+ }, [checks, theme, mapLoaded]);
+ return (
+
+ );
+};
+
+DistributedUptimeMap.propTypes = {
+ checks: PropTypes.array,
+ width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+};
+
+export default DistributedUptimeMap;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx
new file mode 100644
index 000000000..7a4c4b210
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx
@@ -0,0 +1,222 @@
+import PropTypes from "prop-types";
+import {
+ AreaChart,
+ Area,
+ XAxis,
+ Tooltip,
+ CartesianGrid,
+ ResponsiveContainer,
+ Text,
+} from "recharts";
+import ChartBox from "../../../../../Components/Charts/ChartBox";
+import ResponseTimeIcon from "../../../../../assets/icons/response-time-icon.svg?react";
+import { Box, Stack, Typography } from "@mui/material";
+import { useTheme } from "@emotion/react";
+import { useState } from "react";
+import { useSelector } from "react-redux";
+import { formatDateWithTz } from "../../../../../Utils/timeUtils";
+
+const CustomToolTip = ({ active, payload, label }) => {
+ const uiTimezone = useSelector((state) => state.ui.timezone);
+ const theme = useTheme();
+ if (active && payload && payload.length) {
+ const responseTime = payload[0]?.payload?.originalAvgResponseTime
+ ? payload[0]?.payload?.originalAvgResponseTime
+ : (payload[0]?.payload?.avgResponseTime ?? 0);
+ return (
+ }
+ header="Response Times"
+ sx={{ padding: 0 }}
+ >
+
+
+ {formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
+
+
+
+
+
+ Response Time
+ {" "}
+
+ {Math.floor(responseTime)}
+
+ {" "}
+ ms
+
+
+
+
+ {/* Display original value */}
+
+
+ );
+ }
+ return null;
+};
+
+CustomToolTip.propTypes = {
+ active: PropTypes.bool,
+ payload: PropTypes.arrayOf(
+ PropTypes.shape({
+ value: PropTypes.number,
+ payload: PropTypes.shape({
+ _id: PropTypes.string,
+ avgResponseTime: PropTypes.number,
+ originalAvgResponseTime: PropTypes.number,
+ }),
+ })
+ ),
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+};
+const CustomTick = ({ x, y, payload, index }) => {
+ const theme = useTheme();
+
+ const uiTimezone = useSelector((state) => state.ui.timezone);
+ // Render nothing for the first tick
+ if (index === 0) return null;
+ return (
+
+ {formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
+
+ );
+};
+
+CustomTick.propTypes = {
+ x: PropTypes.number,
+ y: PropTypes.number,
+ payload: PropTypes.object,
+ index: PropTypes.number,
+};
+
+const DistributedUptimeResponseChart = ({ checks }) => {
+ const theme = useTheme();
+ const [isHovered, setIsHovered] = useState(false);
+
+ if (checks.length === 0) return null;
+ return (
+
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
+
+
+
+
+
+ }
+ minTickGap={0}
+ axisLine={false}
+ tickLine={false}
+ height={20}
+ />
+ }
+ wrapperStyle={{ pointerEvents: "none" }}
+ />
+
+
+
+ );
+};
+
+DistributedUptimeResponseChart.propTypes = {
+ checks: PropTypes.array,
+};
+
+export default DistributedUptimeResponseChart;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx
new file mode 100644
index 000000000..8032b6527
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx
@@ -0,0 +1,31 @@
+import { Stack, Typography, Box } from "@mui/material";
+import SolanaLogo from "../../../../../assets/icons/solana_logo.svg?react";
+import { useTheme } from "@mui/material/styles";
+const Footer = () => {
+ const theme = useTheme();
+ return (
+
+ Made with ❤️ by UpRock & Bluewave Labs
+
+ Built on
+
+ Solana
+
+
+ );
+};
+
+export default Footer;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx
new file mode 100644
index 000000000..a095b5feb
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx
@@ -0,0 +1,15 @@
+import { useState, useEffect } from "react";
+const LastUpdate = ({ suffix, lastUpdateTime, trigger }) => {
+ const [elapsedMs, setElapsedMs] = useState(lastUpdateTime);
+
+ useEffect(() => {
+ setElapsedMs(lastUpdateTime);
+ const timer = setInterval(() => {
+ setElapsedMs((prev) => prev + 1000);
+ }, 1000);
+ return () => clearInterval(timer);
+ }, [lastUpdateTime, trigger]);
+
+ return `${Math.floor(elapsedMs / 1000)} ${suffix}`;
+};
+export default LastUpdate;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/MonitorHeader/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/MonitorHeader/index.jsx
new file mode 100644
index 000000000..53f36542f
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/MonitorHeader/index.jsx
@@ -0,0 +1,23 @@
+import { Stack, Typography } from "@mui/material";
+import { useTheme } from "@mui/material/styles";
+import PropTypes from "prop-types";
+
+const MonitorHeader = ({ monitor }) => {
+ const theme = useTheme();
+ return (
+
+
+ {monitor.name}
+
+ Distributed Uptime Monitoring powered by DePIN
+
+
+
+ );
+};
+
+MonitorHeader.propTypes = {
+ monitor: PropTypes.object,
+};
+
+export default MonitorHeader;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx
new file mode 100644
index 000000000..03949a9f7
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx
@@ -0,0 +1,30 @@
+import { LinearProgress } from "@mui/material";
+import { useState, useEffect } from "react";
+
+const NextExpectedCheck = ({ lastUpdateTime, interval, trigger }) => {
+ const [elapsedMs, setElapsedMs] = useState(lastUpdateTime);
+
+ useEffect(() => {
+ setElapsedMs(lastUpdateTime);
+ const timer = setInterval(() => {
+ setElapsedMs((prev) => {
+ const newElapsedMs = prev + 100;
+ return newElapsedMs;
+ });
+ }, 100);
+ return () => clearInterval(timer);
+ }, [interval, trigger]);
+
+ return (
+
+ );
+};
+
+export default NextExpectedCheck;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/StatBoxes/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/StatBoxes/index.jsx
new file mode 100644
index 000000000..a4fd3b03d
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/StatBoxes/index.jsx
@@ -0,0 +1,62 @@
+// Components
+import { Stack } from "@mui/material";
+import StatBox from "../../../../../Components/StatBox";
+import LastUpdate from "../LastUpdate";
+import UptLogo from "../../../../../assets/icons/upt_logo.png";
+
+// Utils
+import { useTheme } from "@mui/material/styles";
+import PropTypes from "prop-types";
+
+const StatBoxes = ({ monitor, lastUpdateTrigger }) => {
+ const theme = useTheme();
+
+ return (
+
+
+
+
+ }
+ />
+
+ }
+ />
+
+
+
+ );
+};
+
+StatBoxes.propTypes = {
+ monitor: PropTypes.object,
+ lastUpdateTrigger: PropTypes.number,
+};
+
+export default StatBoxes;
diff --git a/Client/src/Pages/DistributedUptime/Details/Hooks/useSubscribeToDetails.jsx b/Client/src/Pages/DistributedUptime/Details/Hooks/useSubscribeToDetails.jsx
new file mode 100644
index 000000000..409e9cf89
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Hooks/useSubscribeToDetails.jsx
@@ -0,0 +1,119 @@
+import { useState, useEffect, useRef } from "react";
+import { networkService } from "../../../../main";
+import { useSelector } from "react-redux";
+
+const getRandomDevice = () => {
+ const manufacturers = {
+ Apple: ["iPhone 15 Pro Max", "iPhone 15", "iPhone 14 Pro", "iPhone 14", "iPhone 13"],
+ Samsung: [
+ "Galaxy S23 Ultra",
+ "Galaxy S23+",
+ "Galaxy S23",
+ "Galaxy Z Fold5",
+ "Galaxy Z Flip5",
+ ],
+ Google: ["Pixel 8 Pro", "Pixel 8", "Pixel 7a", "Pixel 7", "Pixel 6a"],
+ OnePlus: [
+ "OnePlus 11",
+ "OnePlus 10T",
+ "OnePlus Nord 3",
+ "OnePlus 10 Pro",
+ "OnePlus Nord 2T",
+ ],
+ Xiaomi: ["13 Pro", "13", "Redmi Note 12", "POCO F5", "Redmi 12"],
+ Huawei: ["P60 Pro", "Mate X3", "Nova 11", "P50 Pro", "Mate 50"],
+ Sony: ["Xperia 1 V", "Xperia 5 V", "Xperia 10 V", "Xperia Pro-I", "Xperia 1 IV"],
+ Motorola: ["Edge 40 Pro", "Edge 40", "G84", "G54", "Razr 40 Ultra"],
+ ASUS: [
+ "ROG Phone 7",
+ "Zenfone 10",
+ "ROG Phone 6",
+ "Zenfone 9",
+ "ROG Phone 7 Ultimate",
+ ],
+ };
+
+ const manufacturerNames = Object.keys(manufacturers);
+ const randomManufacturer =
+ manufacturerNames[Math.floor(Math.random() * manufacturerNames.length)];
+
+ const models = manufacturers[randomManufacturer];
+ const randomModel = models[Math.floor(Math.random() * models.length)];
+
+ return {
+ manufacturer: randomManufacturer,
+ model: randomModel,
+ };
+};
+const useSubscribeToDetails = ({ monitorId, dateRange }) => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [connectionStatus, setConnectionStatus] = useState(undefined);
+ const [retryCount, setRetryCount] = useState(0);
+ const [networkError, setNetworkError] = useState(false);
+ const [monitor, setMonitor] = useState(undefined);
+ const [lastUpdateTrigger, setLastUpdateTrigger] = useState(0);
+ const [devices, setDevices] = useState(Array.from({ length: 5 }, getRandomDevice));
+ const authToken = useSelector((state) => state.auth.token);
+
+ const prevDateRangeRef = useRef(dateRange);
+
+ useEffect(() => {
+ try {
+ const cleanup = networkService.subscribeToDistributedUptimeDetails({
+ authToken,
+ monitorId,
+ dateRange: dateRange,
+ normalize: true,
+ onUpdate: (data) => {
+ if (networkError === true) {
+ setNetworkError(false);
+ }
+ setLastUpdateTrigger(Date.now());
+ const latestChecksWithDevice = data?.monitor?.latestChecks.map((check, idx) => {
+ check.device = devices[idx];
+ return check;
+ });
+ const monitorWithDevice = {
+ ...data.monitor,
+ latestChecks: latestChecksWithDevice,
+ };
+ setMonitor(monitorWithDevice);
+ },
+ onOpen: () => {
+ setConnectionStatus("up");
+ setRetryCount(0); // Reset retry count on successful connection
+ },
+ onError: () => {
+ setNetworkError(true);
+ setConnectionStatus("down");
+ },
+ });
+ return cleanup;
+ } catch (error) {
+ setNetworkError(true);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [
+ authToken,
+ dateRange,
+ monitorId,
+ retryCount,
+ setConnectionStatus,
+ networkError,
+ devices,
+ ]);
+
+ useEffect(() => {
+ const hasDateRangeChanged = prevDateRangeRef.current !== dateRange;
+ prevDateRangeRef.current = dateRange; // Update the ref to the current dateRange
+
+ if (!hasDateRangeChanged) {
+ setDevices(Array.from({ length: 5 }, getRandomDevice));
+ }
+ }, [dateRange]);
+
+ return [isLoading, networkError, connectionStatus, monitor, lastUpdateTrigger];
+};
+
+export default useSubscribeToDetails;
diff --git a/Client/src/Pages/DistributedUptime/Details/index.jsx b/Client/src/Pages/DistributedUptime/Details/index.jsx
new file mode 100644
index 000000000..ab39ec2ec
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/index.jsx
@@ -0,0 +1,103 @@
+//Components
+import DistributedUptimeMap from "./Components/DistributedUptimeMap";
+import Breadcrumbs from "../../../Components/Breadcrumbs";
+import { Stack, Typography } from "@mui/material";
+import DeviceTicker from "./Components/DeviceTicker";
+import DistributedUptimeResponseChart from "./Components/DistributedUptimeResponseChart";
+import NextExpectedCheck from "./Components/NextExpectedCheck";
+import Footer from "./Components/Footer";
+import StatBoxes from "./Components/StatBoxes";
+import MonitorHeader from "./Components/MonitorHeader";
+import MonitorTimeFrameHeader from "../../../Components/MonitorTimeFrameHeader";
+import GenericFallback from "../../../Components/GenericFallback";
+
+//Utils
+import { useTheme } from "@mui/material/styles";
+import { useState } from "react";
+import { useParams } from "react-router-dom";
+import useSubscribeToDetails from "./Hooks/useSubscribeToDetails";
+
+const DistributedUptimeDetails = () => {
+ const { monitorId } = useParams();
+
+ // Local State
+ const [dateRange, setDateRange] = useState("day");
+
+ // Utils
+ const theme = useTheme();
+ const [isLoading, networkError, connectionStatus, monitor, lastUpdateTrigger] =
+ useSubscribeToDetails({ monitorId, dateRange });
+ // Constants
+ const BREADCRUMBS = [
+ { name: "Distributed Uptime", path: "/distributed-uptime" },
+ { name: "Details", path: `/distributed-uptime/${monitorId}` },
+ ];
+
+ if (networkError) {
+ return (
+
+
+ Network error
+
+ Please check your connection
+
+ );
+ }
+
+ if (typeof monitor === "undefined" || monitor.totalChecks === 0) {
+ return (
+
+
+
+ There is no check history for this monitor yet.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DistributedUptimeDetails;
diff --git a/Client/src/Pages/DistributedUptime/Monitors/Components/MonitorTable/index.jsx b/Client/src/Pages/DistributedUptime/Monitors/Components/MonitorTable/index.jsx
new file mode 100644
index 000000000..c0aa5b57c
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Monitors/Components/MonitorTable/index.jsx
@@ -0,0 +1,94 @@
+// Components
+import DataTable from "../../../../../Components/Table";
+import BarChart from "../../../../../Components/Charts/BarChart";
+import { Box } from "@mui/material";
+import { StatusLabel } from "../../../../../Components/Label";
+import Host from "../../../../../Components/Host";
+
+// Utils
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils";
+import PropTypes from "prop-types";
+
+// Constants
+const TYPE_MAP = {
+ distributed_http: "Distributed HTTP",
+};
+
+const MonitorTable = ({ isLoading, monitors }) => {
+ const theme = useTheme();
+ const navigate = useNavigate();
+ const { determineState } = useMonitorUtils();
+
+ const headers = [
+ {
+ id: "name",
+ content: Host,
+ render: (row) => (
+
+ ),
+ },
+ {
+ id: "status",
+ content: Status,
+ render: (row) => {
+ const status = determineState(row?.monitor);
+ return (
+
+ );
+ },
+ },
+ {
+ id: "responseTime",
+ content: "Response Time",
+ render: (row) => ,
+ },
+ {
+ id: "type",
+ content: "Type",
+ render: (row) => {TYPE_MAP[row.monitor.type]},
+ },
+ {
+ id: "actions",
+ content: "Actions",
+ render: (row) => {"TODO"},
+ },
+ ];
+
+ return (
+ {
+ navigate(`/distributed-uptime/${row.id}`);
+ },
+ }}
+ />
+ );
+};
+
+MonitorTable.propTypes = {
+ isLoading: PropTypes.bool,
+ monitors: PropTypes.array,
+};
+
+export default MonitorTable;
diff --git a/Client/src/Pages/DistributedUptime/Monitors/Hooks/useSubscribeToMonitors.jsx b/Client/src/Pages/DistributedUptime/Monitors/Hooks/useSubscribeToMonitors.jsx
new file mode 100644
index 000000000..1a1db1453
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Monitors/Hooks/useSubscribeToMonitors.jsx
@@ -0,0 +1,58 @@
+import { useEffect, useState } from "react";
+import { networkService } from "../../../../main";
+import { useSelector } from "react-redux";
+import { useTheme } from "@emotion/react";
+import { useMonitorUtils } from "../../../../Hooks/useMonitorUtils";
+import { createToast } from "../../../../Utils/toastUtils";
+
+const useSubscribeToMonitors = () => {
+ // Redux
+ const { authToken, user } = useSelector((state) => state.auth);
+
+ // Local state
+ const [isLoading, setIsLoading] = useState(true);
+ const [networkError, setNetworkError] = useState(false);
+ const [monitors, setMonitors] = useState(undefined);
+ const [monitorsSummary, setMonitorsSummary] = useState(undefined);
+ const [filteredMonitors, setFilteredMonitors] = useState(undefined);
+
+ const theme = useTheme();
+ const { getMonitorWithPercentage } = useMonitorUtils();
+
+ useEffect(() => {
+ try {
+ const cleanup = networkService.subscribeToDistributedUptimeMonitors({
+ authToken: authToken,
+ teamId: user.teamId,
+ limit: 25,
+ types: ["distributed_http"],
+ page: 0,
+ rowsPerPage: 10,
+ filter: null,
+ field: null,
+ order: null,
+ onUpdate: (data) => {
+ const res = data.monitors;
+ const { monitors, filteredMonitors, summary } = res;
+ const mappedMonitors = filteredMonitors.map((monitor) =>
+ getMonitorWithPercentage(monitor, theme)
+ );
+ setMonitors(monitors);
+ setMonitorsSummary(summary);
+ setFilteredMonitors(mappedMonitors);
+ },
+ });
+
+ return cleanup;
+ } catch (error) {
+ createToast({
+ body: error.message,
+ });
+ setNetworkError(true);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [authToken, user, getMonitorWithPercentage, theme]);
+ return [isLoading, networkError, monitors, monitorsSummary, filteredMonitors];
+};
+export { useSubscribeToMonitors };
diff --git a/Client/src/Pages/DistributedUptime/Monitors/index.jsx b/Client/src/Pages/DistributedUptime/Monitors/index.jsx
new file mode 100644
index 000000000..a3466b845
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Monitors/index.jsx
@@ -0,0 +1,71 @@
+// Components
+import { Stack, Typography } from "@mui/material";
+import Breadcrumbs from "../../../Components/Breadcrumbs";
+import CreateMonitorHeader from "../../../Components/MonitorCreateHeader";
+import MonitorTable from "./Components/MonitorTable";
+import Fallback from "../../../Components/Fallback";
+import GenericFallback from "../../../Components/GenericFallback";
+
+// Utils
+import { useTheme } from "@mui/material/styles";
+import { useIsAdmin } from "../../../Hooks/useIsAdmin";
+import { useSubscribeToMonitors } from "./Hooks/useSubscribeToMonitors";
+// Constants
+const BREADCRUMBS = [{ name: `Distributed Uptime`, path: "/distributed-uptime" }];
+
+const DistributedUptimeMonitors = () => {
+ // Local state
+ // Utils
+ const theme = useTheme();
+ const isAdmin = useIsAdmin();
+ const [isLoading, networkError, monitors, monitorsSummary, filteredMonitors] =
+ useSubscribeToMonitors();
+
+ if (networkError) {
+ return (
+
+
+ Network error
+
+ Please check your connection
+
+ );
+ }
+
+ if (typeof monitorsSummary !== "undefined" && monitorsSummary.totalMonitors === 0) {
+ return (
+
+ );
+ }
+ return (
+
+
+
+
+
+ );
+};
+
+export default DistributedUptimeMonitors;
diff --git a/Client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx b/Client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx
index f44b844a7..3f1832889 100644
--- a/Client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx
+++ b/Client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx
@@ -1,6 +1,6 @@
// Components
import DataTable from "../../../../../Components/Table";
-import Host from "../../../../Uptime/Monitors/Components/Host";
+import Host from "../../../../../Components/Host";
import { StatusLabel } from "../../../../../Components/Label";
import { Stack } from "@mui/material";
import { InfrastructureMenu } from "../MonitorsTableMenu";
@@ -12,6 +12,7 @@ import CustomGauge from "../../../../../Components/Charts/CustomGauge";
import { useTheme } from "@emotion/react";
import useUtils from "../../../../Uptime/Monitors/Hooks/useUtils";
import { useNavigate } from "react-router-dom";
+import PropTypes from "prop-types";
const MonitorsTable = ({ shouldRender, monitors, isAdmin, handleActionMenuDelete }) => {
// Utils
@@ -131,4 +132,11 @@ const MonitorsTable = ({ shouldRender, monitors, isAdmin, handleActionMenuDelete
);
};
+MonitorsTable.propTypes = {
+ shouldRender: PropTypes.bool,
+ monitors: PropTypes.array,
+ isAdmin: PropTypes.bool,
+ handleActionMenuDelete: PropTypes.func,
+};
+
export default MonitorsTable;
diff --git a/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx b/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx
index cacc0e8d5..444c0fa4b 100644
--- a/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx
+++ b/Client/src/Pages/Maintenance/CreateMaintenance/index.jsx
@@ -181,7 +181,7 @@ const CreateMaintenance = () => {
setSearch(value);
};
- const handleSelectMonitors = (monitors) => {
+ const handleSelectMonitors = (_, monitors) => {
setForm({ ...form, monitors });
const { error } = maintenanceWindowValidation.validate(
{ monitors },
diff --git a/Client/src/Pages/Settings/index.css b/Client/src/Pages/Settings/index.css
deleted file mode 100644
index 52bccab5e..000000000
--- a/Client/src/Pages/Settings/index.css
+++ /dev/null
@@ -1,21 +0,0 @@
-.settings a.MuiTypography-root,
-.settings h2.MuiTypography-root,
-.settings span.MuiTypography-root,
-.settings button,
-.settings p.MuiTypography-root {
- font-size: var(--env-var-font-size-medium);
-}
-.settings h1.MuiTypography-root {
- font-size: var(--env-var-font-size-large);
-}
-.settings h1.MuiTypography-root,
-.settings h2.MuiTypography-root {
- font-weight: 600;
-}
-.settings button {
- min-height: 34px;
-}
-.settings span.MuiTypography-root {
- opacity: 0.6;
- margin-right: 4px;
-}
diff --git a/Client/src/Pages/Settings/index.jsx b/Client/src/Pages/Settings/index.jsx
index 3cabac713..b5aec4868 100644
--- a/Client/src/Pages/Settings/index.jsx
+++ b/Client/src/Pages/Settings/index.jsx
@@ -1,10 +1,16 @@
-import { useTheme } from "@emotion/react";
-import { Box, Stack, Typography, Button } from "@mui/material";
+// Components
+import { Box, Stack, Typography, Button, Switch } from "@mui/material";
import TextInput from "../../Components/Inputs/TextInput";
import Link from "../../Components/Link";
import Select from "../../Components/Inputs/Select";
+import LoadingButton from "@mui/lab/LoadingButton";
+import { useIsAdmin } from "../../Hooks/useIsAdmin";
+import Dialog from "../../Components/Dialog";
+import ConfigBox from "../../Components/ConfigBox";
+
+//Utils
+import { useTheme } from "@emotion/react";
import { logger } from "../../Utils/Logger";
-import "./index.css";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import {
@@ -14,15 +20,17 @@ import {
} from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { update } from "../../Features/Auth/authSlice";
import PropTypes from "prop-types";
-import LoadingButton from "@mui/lab/LoadingButton";
-import { setTimezone, setMode } from "../../Features/UI/uiSlice";
+import {
+ setTimezone,
+ setMode,
+ setDistributedUptimeEnabled,
+} from "../../Features/UI/uiSlice";
import timezones from "../../Utils/timezones.json";
import { useState, useEffect } from "react";
import { networkService } from "../../main";
import { settingsValidation } from "../../Validation/validation";
-import Dialog from "../../Components/Dialog";
-import { useIsAdmin } from "../../Hooks/useIsAdmin";
-import ConfigBox from "../../Components/ConfigBox";
+
+// Constants
const SECONDS_PER_DAY = 86400;
const Settings = () => {
@@ -32,10 +40,11 @@ const Settings = () => {
const { checkTTL } = user;
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const { isLoading: authIsLoading } = useSelector((state) => state.auth);
- const { timezone } = useSelector((state) => state.ui);
+ const { timezone, distributedUptimeEnabled } = useSelector((state) => state.ui);
const { mode } = useSelector((state) => state.ui);
const [checksIsLoading, setChecksIsLoading] = useState(false);
const [form, setForm] = useState({
+ enableDistributedUptime: distributedUptimeEnabled,
ttl: checkTTL ? (checkTTL / SECONDS_PER_DAY).toString() : 0,
});
const [version, setVersion] = useState("unknown");
@@ -64,7 +73,16 @@ const Settings = () => {
}, []);
const handleChange = (event) => {
- const { value, id } = event.target;
+ const { type, checked, value, id } = event.target;
+
+ if (type === "checkbox") {
+ setForm((prev) => ({
+ ...prev,
+ [id]: checked,
+ }));
+ return;
+ }
+
const { error } = settingsValidation.validate(
{ [id]: value },
{
@@ -79,7 +97,6 @@ const Settings = () => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
- console.log(newErrors);
logger.error("Validation errors:", error.details);
}
let inputValue = value;
@@ -100,6 +117,7 @@ const Settings = () => {
});
const updatedUser = { ...user, checkTTL: form.ttl };
const action = await dispatch(update({ authToken, localData: updatedUser }));
+
if (action.payload.success) {
createToast({
body: "Settings saved successfully",
@@ -197,7 +215,7 @@ const Settings = () => {
+ {isAdmin && (
+
+
+ Distributed uptime
+
+ Enable/disable distributed uptime monitoring.
+
+
+
+ {
+ dispatch(setDistributedUptimeEnabled(e.target.checked));
+ }}
+ />
+ {distributedUptimeEnabled === true ? "Enabled" : "Disabled"}
+
+
+ )}
{isAdmin && (
diff --git a/Client/src/Pages/Status/index.css b/Client/src/Pages/Status/index.css
deleted file mode 100644
index e69de29bb..000000000
diff --git a/Client/src/Pages/Status/index.jsx b/Client/src/Pages/Status/index.jsx
deleted file mode 100644
index 7ba2c0a8d..000000000
--- a/Client/src/Pages/Status/index.jsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Box } from "@mui/material";
-import { useTheme } from "@emotion/react";
-import Fallback from "../../Components/Fallback";
-
-const Status = () => {
- const theme = useTheme();
-
- return (
- [class*="fallback__"])': {
- position: "relative",
- border: 1,
- borderColor: theme.palette.primary.lowContrast,
- borderRadius: theme.shape.borderRadius,
- borderStyle: "dashed",
- backgroundColor: theme.palette.primary.main,
- overflow: "hidden",
- },
- }}
- >
-
-
- );
-};
-
-export default Status;
diff --git a/Client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx b/Client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx
new file mode 100644
index 000000000..5484202d5
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx
@@ -0,0 +1,106 @@
+// Components
+import { Stack, Typography } from "@mui/material";
+import ReorderRoundedIcon from "@mui/icons-material/ReorderRounded";
+import DeleteIcon from "@mui/icons-material/Delete";
+
+// Utils
+import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
+import { useTheme } from "@emotion/react";
+const MonitorListItem = ({
+ monitor,
+ innerRef,
+ draggableProps,
+ dragHandleProps,
+ onDelete,
+}) => {
+ const theme = useTheme();
+ return (
+
+
+ {monitor.name}
+ {
+ onDelete(monitor);
+ }}
+ />
+
+ );
+};
+
+const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
+ const onDelete = (monitorToDelete) => {
+ const newMonitors = selectedMonitors.filter(
+ (monitor) => monitor._id !== monitorToDelete._id
+ );
+ setSelectedMonitors(newMonitors);
+ };
+ const reorder = (list, startIndex, endIndex) => {
+ const result = Array.from(list);
+ const [removed] = result.splice(startIndex, 1);
+ result.splice(endIndex, 0, removed);
+
+ return result;
+ };
+
+ const onDragEnd = (result) => {
+ // dropped outside the list
+ if (!result.destination) {
+ return;
+ }
+
+ const reorderedMonitors = reorder(
+ selectedMonitors,
+ result.source.index,
+ result.destination.index
+ );
+
+ setSelectedMonitors(reorderedMonitors);
+ };
+
+ return (
+
+
+ {(provided, snapshot) => (
+
+ {selectedMonitors?.map((monitor, index) => (
+
+ {(provided, snapshot) => (
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+ );
+};
+
+export default MonitorList;
diff --git a/Client/src/Pages/StatusPage/Create/Components/Progress/index.jsx b/Client/src/Pages/StatusPage/Create/Components/Progress/index.jsx
new file mode 100644
index 000000000..d85225551
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Components/Progress/index.jsx
@@ -0,0 +1,37 @@
+import { Button, Box } from "@mui/material";
+import ProgressUpload from "../../../../../Components/ProgressBars";
+import ImageIcon from "@mui/icons-material/Image";
+
+import { formatBytes } from "../../../../../Utils/fileUtils";
+const Progress = ({ isLoading, progressValue, logo, logoType, removeLogo, errors }) => {
+ if (isLoading) {
+ return (
+ }
+ label={logo?.name}
+ size={formatBytes(logo?.size)}
+ progress={progressValue}
+ onClick={removeLogo}
+ />
+ );
+ }
+
+ if (logo && logoType) {
+ return (
+
+
+
+ );
+ }
+};
+
+export default Progress;
diff --git a/Client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx b/Client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx
new file mode 100644
index 000000000..482c490fb
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx
@@ -0,0 +1,14 @@
+import { Stack, Skeleton } from "@mui/material";
+
+const SkeletonLayout = () => {
+ return (
+
+
+
+ );
+};
+
+export default SkeletonLayout;
diff --git a/Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx b/Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
new file mode 100644
index 000000000..8eda4fe0a
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
@@ -0,0 +1,104 @@
+// Components
+import { Stack, Typography, Button } from "@mui/material";
+import { TabPanel } from "@mui/lab";
+import ConfigBox from "../../../../../Components/ConfigBox";
+import MonitorList from "../MonitorList";
+import Search from "../../../../../Components/Inputs/Search";
+import Checkbox from "../../../../../Components/Inputs/Checkbox";
+// Utils
+import { useState } from "react";
+import { useTheme } from "@emotion/react";
+const Content = ({
+ tabValue,
+ form,
+ monitors,
+ handleFormChange,
+ errors,
+ selectedMonitors,
+ setSelectedMonitors,
+}) => {
+ // Local state
+ const [search, setSearch] = useState("");
+
+ // Handlers
+ const handleMonitorsChange = (selectedMonitors) => {
+ handleFormChange({
+ target: { name: "monitors", value: selectedMonitors.map((monitor) => monitor._id) },
+ });
+ setSelectedMonitors(selectedMonitors);
+ };
+
+ // Utils
+ const theme = useTheme();
+
+ return (
+
+
+
+
+ Status page servers
+
+ You can add any number of servers that you monitor to your status page. You
+ can also reorder them for the best viewing experience.
+
+
+
+
+
+
+
+ {errors["monitors"]}
+
+
+
+ {" "}
+
+
+ Features
+ Show more details on the status page
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Content;
diff --git a/Client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx b/Client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx
new file mode 100644
index 000000000..e4fd56ebb
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx
@@ -0,0 +1,140 @@
+// Components
+import { Stack, Typography } from "@mui/material";
+import { TabPanel } from "@mui/lab";
+import ConfigBox from "../../../../../Components/ConfigBox";
+import Checkbox from "../../../../../Components/Inputs/Checkbox";
+import TextInput from "../../../../../Components/Inputs/TextInput";
+import Select from "../../../../../Components/Inputs/Select";
+import ImageField from "../../../../../Components/Inputs/Image";
+import ColorPicker from "../../../../../Components/Inputs/ColorPicker";
+import Progress from "../Progress";
+
+// Utils
+import { useTheme } from "@emotion/react";
+import timezones from "../../../../../Utils/timezones.json";
+import PropTypes from "prop-types";
+
+const TabSettings = ({
+ tabValue,
+ form,
+ handleFormChange,
+ handleImageChange,
+ progress,
+ removeLogo,
+ errors,
+}) => {
+ // Utils
+ const theme = useTheme();
+
+ return (
+
+
+
+
+ Access
+
+ If your status page is ready, you can mark it as published.
+
+
+
+
+
+
+
+
+ Basic Information
+
+ Define company name and the subdomain that your status page points to.
+
+
+
+
+
+
+
+
+
+ Timezone
+
+ Select the timezone that your status page will be displayed in.
+
+
+
+
+
+
+
+
+ Appearance
+
+ Define the default look and feel of your public status page.
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+TabSettings.propTypes = {
+ tabValue: PropTypes.string,
+ form: PropTypes.object,
+ handleFormChange: PropTypes.func,
+ handleImageChange: PropTypes.func,
+ progress: PropTypes.object,
+ removeLogo: PropTypes.func,
+ errors: PropTypes.object,
+};
+
+export default TabSettings;
diff --git a/Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx b/Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
new file mode 100644
index 000000000..87e18ed4d
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
@@ -0,0 +1,81 @@
+// Components
+import { TabContext, TabList } from "@mui/lab";
+import { Tab } from "@mui/material";
+import Settings from "./Settings";
+import Content from "./Content";
+
+// Utils
+import PropTypes from "prop-types";
+
+const Tabs = ({
+ form,
+ errors,
+ monitors,
+ selectedMonitors,
+ setSelectedMonitors,
+ handleFormChange,
+ handleImageChange,
+ progress,
+ removeLogo,
+ tab,
+ setTab,
+ TAB_LIST,
+}) => {
+ return (
+
+ {
+ setTab(TAB_LIST.indexOf(tab));
+ }}
+ >
+ {TAB_LIST.map((tab, idx) => {
+ return (
+
+ );
+ })}
+
+ {tab === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+Tabs.propTypes = {
+ form: PropTypes.object,
+ errors: PropTypes.object,
+ monitors: PropTypes.array,
+ selectedMonitors: PropTypes.array,
+ setSelectedMonitors: PropTypes.func,
+ handleFormChange: PropTypes.func,
+ handleImageChange: PropTypes.func,
+ progress: PropTypes.object,
+ removeLogo: PropTypes.func,
+ tab: PropTypes.number,
+ setTab: PropTypes.func,
+ TAB_LIST: PropTypes.array,
+};
+
+export default Tabs;
diff --git a/Client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx b/Client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx
new file mode 100644
index 000000000..92e2acf96
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx
@@ -0,0 +1,28 @@
+import { useState } from "react";
+import { networkService } from "../../../../main";
+import { useSelector } from "react-redux";
+import { createToast } from "../../../../Utils/toastUtils";
+
+const useCreateStatusPage = (isCreate) => {
+ const { authToken, user } = useSelector((state) => state.auth);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [networkError, setNetworkError] = useState(false);
+ const createStatusPage = async ({ form }) => {
+ setIsLoading(true);
+ try {
+ await networkService.createStatusPage({ authToken, user, form, isCreate });
+ return true;
+ } catch (error) {
+ setNetworkError(true);
+ createToast({ body: error?.response?.data?.msg ?? error.message });
+ return false;
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return [createStatusPage, isLoading, networkError];
+};
+
+export { useCreateStatusPage };
diff --git a/Client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx b/Client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx
new file mode 100644
index 000000000..4813157d1
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx
@@ -0,0 +1,35 @@
+import { useEffect, useState } from "react";
+import { networkService } from "../../../../main";
+import { useSelector } from "react-redux";
+import { createToast } from "../../../../Utils/toastUtils";
+
+const useMonitorsFetch = () => {
+ const { user, authToken } = useSelector((state) => state.auth);
+
+ const [monitors, setMonitors] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(true);
+ const [networkError, setNetworkError] = useState(false);
+ useEffect(() => {
+ const fetchMonitors = async () => {
+ try {
+ const response = await networkService.getMonitorsByTeamId({
+ authToken: authToken,
+ teamId: user.teamId,
+ limit: null, // donot return any checks for the monitors
+ types: ["http"], // status page is available only for the uptime type
+ });
+ setMonitors(response.data.data.monitors);
+ } catch (error) {
+ setNetworkError(true);
+ createToast({ body: error.message });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchMonitors();
+ }, [authToken, user]);
+
+ return [monitors, isLoading, networkError];
+};
+
+export { useMonitorsFetch };
diff --git a/Client/src/Pages/StatusPage/Create/index.jsx b/Client/src/Pages/StatusPage/Create/index.jsx
new file mode 100644
index 000000000..58269e3b8
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Create/index.jsx
@@ -0,0 +1,238 @@
+// Components
+import { Stack, Button, Typography } from "@mui/material";
+import Tabs from "./Components/Tabs";
+import GenericFallback from "../../../Components/GenericFallback";
+import SkeletonLayout from "./Components/Skeleton";
+//Utils
+import { useTheme } from "@emotion/react";
+import { useState, useEffect, useRef, useCallback } from "react";
+import { statusPageValidation } from "../../../Validation/validation";
+import { buildErrors } from "../../../Validation/error";
+import { useMonitorsFetch } from "./Hooks/useMonitorsFetch";
+import { useCreateStatusPage } from "./Hooks/useCreateStatusPage";
+import { createToast } from "../../../Utils/toastUtils";
+import { useNavigate } from "react-router-dom";
+import { useLocation } from "react-router-dom";
+import { useStatusPageFetch } from "../Status/Hooks/useStatusPageFetch";
+
+//Constants
+const TAB_LIST = ["General settings", "Contents"];
+
+const ERROR_TAB_MAPPING = [
+ ["companyName", "url", "timezone", "color", "isPublished", "logo"],
+ ["monitors", "showUptimePercentage", "showCharts"],
+];
+
+const CreateStatusPage = () => {
+ //Local state
+ const [tab, setTab] = useState(0);
+ const [progress, setProgress] = useState({ value: 0, isLoading: false });
+ const [form, setForm] = useState({
+ isPublished: false,
+ companyName: "",
+ url: "/status/public",
+ logo: undefined,
+ timezone: "America/Toronto",
+ color: "#4169E1",
+ monitors: [],
+ showCharts: true,
+ showUptimePercentage: true,
+ });
+ const [errors, setErrors] = useState({});
+ const [selectedMonitors, setSelectedMonitors] = useState([]);
+ // Refs
+ const intervalRef = useRef(null);
+
+ // Setup
+ const location = useLocation();
+ const isCreate = location.pathname === "/status/create";
+
+ //Utils
+ const theme = useTheme();
+ const [monitors, isLoading, networkError] = useMonitorsFetch();
+ const [createStatusPage, createStatusIsLoading, createStatusPageNetworkError] =
+ useCreateStatusPage(isCreate);
+ const navigate = useNavigate();
+ const [statusPage, statusPageMonitors, statusPageIsLoading, statusPageNetworkError] =
+ useStatusPageFetch(isCreate);
+
+ // Handlers
+ const handleFormChange = (e) => {
+ let { type, name, value, checked } = e.target;
+ // Handle errors
+ const { error } = statusPageValidation.validate(
+ { [name]: value },
+ { abortEarly: false }
+ );
+ setErrors((prev) => {
+ return buildErrors(prev, name, error);
+ });
+
+ //Handle checkbox
+ if (type === "checkbox") {
+ setForm((prev) => ({
+ ...prev,
+ [name]: checked,
+ }));
+ return;
+ }
+
+ // Handle other inputs
+ setForm((prev) => ({
+ ...prev,
+ [name]: value,
+ }));
+ };
+
+ const handleImageChange = useCallback((event) => {
+ const img = event.target?.files?.[0];
+ const newLogo = {
+ src: URL.createObjectURL(img),
+ name: img.name,
+ type: img.type,
+ size: img.size,
+ };
+ setForm((prev) => ({
+ ...prev,
+ logo: newLogo,
+ }));
+ intervalRef.current = setInterval(() => {
+ const buffer = 12;
+ setProgress((prev) => {
+ if (prev.value + buffer >= 100) {
+ clearInterval(intervalRef.current);
+ return { value: 100, isLoading: false };
+ }
+ return { ...prev, value: prev.value + buffer };
+ });
+ }, 120);
+ }, []);
+
+ const removeLogo = () => {
+ setForm((prev) => ({
+ ...prev,
+ logo: undefined,
+ }));
+ // interrupt interval if image upload is canceled prior to completing the process
+ clearInterval(intervalRef.current);
+ setProgress({ value: 0, isLoading: false });
+ };
+
+ const handleSubmit = async () => {
+ let toSubmit = {
+ ...form,
+ logo: { type: form.logo?.type ?? null, size: form.logo?.size ?? null },
+ };
+ const { error } = statusPageValidation.validate(toSubmit, {
+ abortEarly: false,
+ });
+
+ if (typeof error === "undefined") {
+ const success = await createStatusPage({ form });
+ if (success) {
+ createToast({ body: "Status page created successfully" });
+ navigate("/status");
+ }
+ return;
+ }
+
+ const newErrors = {};
+ error?.details?.forEach((err) => {
+ newErrors[err.path[0]] = err.message;
+ });
+ setErrors((prev) => ({ ...prev, ...newErrors }));
+
+ const errorTabs = Object.keys(newErrors).map((err) => {
+ return ERROR_TAB_MAPPING.findIndex((tab) => tab.includes(err));
+ });
+
+ // If there's an error in the current tab, don't change the tab
+ if (errorTabs.some((errorTab) => errorTab === tab)) {
+ return;
+ }
+ // Otherwise go to tab with error
+ setTab(errorTabs[0]);
+ };
+
+ // If we are configuring, populate fields
+ useEffect(() => {
+ if (isCreate) return;
+ if (typeof statusPage === "undefined") {
+ return;
+ }
+
+ let newLogo = undefined;
+ if (statusPage.logo) {
+ newLogo = {
+ src: `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`,
+ name: "logo",
+ type: statusPage.logo.contentType,
+ size: null,
+ };
+ }
+
+ setForm((prev) => {
+ return {
+ ...prev,
+ companyName: statusPage?.companyName,
+ isPublished: statusPage?.isPublished,
+ timezone: statusPage?.timezone,
+ monitors: statusPageMonitors.map((monitor) => monitor._id),
+ color: statusPage?.color,
+ logo: newLogo,
+ };
+ });
+ setSelectedMonitors(statusPageMonitors);
+ }, [isCreate, statusPage, statusPageMonitors]);
+
+ if (networkError === true) {
+ return (
+
+
+ Network error
+
+ Please check your connection
+
+ );
+ }
+
+ if (isLoading) return ;
+
+ // Load fields
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default CreateStatusPage;
diff --git a/Client/src/Pages/StatusPage/Status/Components/AdminLink/index.jsx b/Client/src/Pages/StatusPage/Status/Components/AdminLink/index.jsx
new file mode 100644
index 000000000..5f5941fe7
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/Components/AdminLink/index.jsx
@@ -0,0 +1,34 @@
+// Components
+import { Box, Typography } from "@mui/material";
+
+// Utils
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+
+const AdminLink = () => {
+ const theme = useTheme();
+ const navigate = useNavigate();
+
+ return (
+
+
+ Administrator?
+
+ navigate("/login")}
+ >
+ Login here
+
+
+ );
+};
+
+export default AdminLink;
diff --git a/Client/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx b/Client/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx
new file mode 100644
index 000000000..11733fc61
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx
@@ -0,0 +1,104 @@
+// Components
+import { Box, Stack, Typography, Button } from "@mui/material";
+import Image from "../../../../../Components/Image";
+import SettingsIcon from "../../../../../assets/icons/settings-bold.svg?react";
+
+//Utils
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+import { useLocation } from "react-router-dom";
+import PropTypes from "prop-types";
+
+const Controls = ({ isDeleteOpen, setIsDeleteOpen, isDeleting }) => {
+ const theme = useTheme();
+ const location = useLocation();
+ const currentPath = location.pathname;
+ const navigate = useNavigate();
+ if (currentPath === "/status/public") {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+Controls.propTypes = {
+ isDeleting: PropTypes.bool,
+ isDeleteOpen: PropTypes.bool.isRequired,
+ setIsDeleteOpen: PropTypes.func.isRequired,
+};
+
+const ControlsHeader = ({ statusPage, isDeleting, isDeleteOpen, setIsDeleteOpen }) => {
+ const theme = useTheme();
+
+ return (
+
+
+
+ {statusPage?.companyName}
+
+
+
+ );
+};
+
+ControlsHeader.propTypes = {
+ statusPage: PropTypes.object,
+ isDeleting: PropTypes.bool,
+ isDeleteOpen: PropTypes.bool.isRequired,
+ setIsDeleteOpen: PropTypes.func.isRequired,
+};
+
+export default ControlsHeader;
diff --git a/Client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx b/Client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx
new file mode 100644
index 000000000..ff7a18f97
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx
@@ -0,0 +1,58 @@
+// Components
+import { Stack, Box } from "@mui/material";
+import Host from "../../../../../Components/Host";
+import StatusPageBarChart from "../../../../../Components/Charts/StatusPageBarChart";
+import { StatusLabel } from "../../../../../Components/Label";
+
+//Utils
+import { useTheme } from "@mui/material/styles";
+import useUtils from "../../../../Uptime/Monitors/Hooks/useUtils";
+import PropTypes from "prop-types";
+const MonitorsList = ({ monitors = [] }) => {
+ const theme = useTheme();
+ const { determineState } = useUtils();
+ return (
+ <>
+ {monitors?.map((monitor) => {
+ const status = determineState(monitor);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+MonitorsList.propTypes = {
+ monitors: PropTypes.array.isRequired,
+};
+
+export default MonitorsList;
diff --git a/Client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx b/Client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx
new file mode 100644
index 000000000..482c490fb
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx
@@ -0,0 +1,14 @@
+import { Stack, Skeleton } from "@mui/material";
+
+const SkeletonLayout = () => {
+ return (
+
+
+
+ );
+};
+
+export default SkeletonLayout;
diff --git a/Client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx b/Client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx
new file mode 100644
index 000000000..1898ff3d7
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx
@@ -0,0 +1,74 @@
+// Components
+import { Stack, Typography } from "@mui/material";
+import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
+
+// Utils
+import { useTheme } from "@mui/material/styles";
+import PropTypes from "prop-types";
+
+const getMonitorStatus = (monitors, theme) => {
+ const monitorsStatus = {
+ icon: (
+
+ ),
+ };
+ if (monitors.every((monitor) => monitor.status === true)) {
+ monitorsStatus.msg = "All systems operational";
+ monitorsStatus.color = theme.palette.success.lowContrast;
+ monitorsStatus.icon = (
+
+ );
+ }
+
+ if (monitors.every((monitor) => monitor.status === false)) {
+ monitorsStatus.msg = "All systems down";
+ monitorsStatus.color = theme.palette.error.lowContrast;
+ }
+
+ if (monitors.some((monitor) => monitor.status === false)) {
+ monitorsStatus.msg = "Degraded performance";
+ monitorsStatus.color = theme.palette.warning.lowContrast;
+ }
+
+ // Paused or unknown
+ if (monitors.some((monitor) => typeof monitor.status === "undefined")) {
+ monitorsStatus.msg = "Unknown status";
+ monitorsStatus.color = theme.palette.warning.lowContrast;
+ }
+ return monitorsStatus;
+};
+
+const StatusBar = ({ monitors }) => {
+ const theme = useTheme();
+
+ if (typeof monitors === "undefined") return;
+
+ const monitorsStatus = getMonitorStatus(monitors, theme);
+ return (
+
+ {monitorsStatus.icon}
+ {/* CAIO_REVIEW */}
+ {monitorsStatus.msg}
+
+ );
+};
+
+export default StatusBar;
+
+StatusBar.propTypes = {
+ status: PropTypes.object,
+};
diff --git a/Client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx b/Client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx
new file mode 100644
index 000000000..42138d373
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx
@@ -0,0 +1,31 @@
+import { useSelector } from "react-redux";
+import { useState } from "react";
+import { networkService } from "../../../../main";
+import { createToast } from "../../../../Utils/toastUtils";
+import { useNavigate } from "react-router-dom";
+
+const useStatusPageDelete = (fetchStatusPage) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const navigate = useNavigate();
+ const { authToken } = useSelector((state) => state.auth);
+
+ const deleteStatusPage = async () => {
+ try {
+ setIsLoading(true);
+ await networkService.deleteStatusPage({ authToken });
+ fetchStatusPage?.();
+ return true;
+ } catch (error) {
+ createToast({
+ body: error.message,
+ });
+ return false;
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return [deleteStatusPage, isLoading];
+};
+
+export { useStatusPageDelete };
diff --git a/Client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx b/Client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx
new file mode 100644
index 000000000..d2b2eae2a
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx
@@ -0,0 +1,50 @@
+import { useEffect, useState, useCallback } from "react";
+import { networkService } from "../../../../main";
+import { useSelector } from "react-redux";
+import { createToast } from "../../../../Utils/toastUtils";
+import { useTheme } from "@emotion/react";
+import { useMonitorUtils } from "../../../../Hooks/useMonitorUtils";
+
+const useStatusPageFetch = (isCreate = false) => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [networkError, setNetworkError] = useState(false);
+ const [statusPage, setStatusPage] = useState(undefined);
+ const [monitors, setMonitors] = useState(undefined);
+ const { authToken } = useSelector((state) => state.auth);
+ const theme = useTheme();
+ const { getMonitorWithPercentage } = useMonitorUtils();
+ const fetchStatusPage = useCallback(async () => {
+ try {
+ const response = await networkService.getStatusPage({ authToken });
+ if (!response?.data?.data) return;
+ const { statusPage, monitors } = response.data.data;
+ setStatusPage(statusPage);
+
+ const monitorsWithPercentage = monitors.map((monitor) =>
+ getMonitorWithPercentage(monitor, theme)
+ );
+ setMonitors(monitorsWithPercentage);
+ } catch (error) {
+ // If there is a 404, status page is not found
+ if (error?.response?.status === 404) {
+ setStatusPage(undefined);
+ return;
+ }
+ createToast({ body: error.message });
+ setNetworkError(true);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [authToken, theme]);
+
+ useEffect(() => {
+ if (isCreate === true) {
+ return;
+ }
+ fetchStatusPage();
+ }, [isCreate, fetchStatusPage]);
+
+ return [statusPage, monitors, isLoading, networkError, fetchStatusPage];
+};
+
+export { useStatusPageFetch };
diff --git a/Client/src/Pages/StatusPage/Status/index.jsx b/Client/src/Pages/StatusPage/Status/index.jsx
new file mode 100644
index 000000000..e27bc8184
--- /dev/null
+++ b/Client/src/Pages/StatusPage/Status/index.jsx
@@ -0,0 +1,160 @@
+// Components
+import { Typography, Stack } from "@mui/material";
+import GenericFallback from "../../../Components/GenericFallback";
+import Fallback from "../../../Components/Fallback";
+import AdminLink from "./Components/AdminLink";
+import ControlsHeader from "./Components/ControlsHeader";
+import SkeletonLayout from "./Components/Skeleton";
+import StatusBar from "./Components/StatusBar";
+import MonitorsList from "./Components/MonitorsList";
+import Dialog from "../../../Components/Dialog";
+
+// Utils
+import { useStatusPageFetch } from "./Hooks/useStatusPageFetch";
+import { useTheme } from "@emotion/react";
+import { useIsAdmin } from "../../../Hooks/useIsAdmin";
+import { useLocation } from "react-router-dom";
+import { useStatusPageDelete } from "./Hooks/useStatusPageDelete";
+import { useState } from "react";
+
+const PublicStatus = () => {
+ // Local state
+ const [isDeleteOpen, setIsDeleteOpen] = useState(false);
+ // Utils
+ const theme = useTheme();
+ const isAdmin = useIsAdmin();
+ const [statusPage, monitors, isLoading, networkError, fetchStatusPage] =
+ useStatusPageFetch();
+ const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage);
+ const location = useLocation();
+
+ // Setup
+ const currentPath = location.pathname;
+ let sx = { paddingLeft: theme.spacing(20), paddingRight: theme.spacing(20) };
+ let link = undefined;
+ // Public status page
+ if (currentPath === "/status/public") {
+ sx = {
+ paddingTop: theme.spacing(20),
+ paddingLeft: "20vw",
+ paddingRight: "20vw",
+ };
+ link = ;
+ }
+
+ // Loading
+ if (isLoading) {
+ return ;
+ }
+
+ // Error fetching data
+ if (networkError === true) {
+ return (
+
+
+ Network error
+
+ Please check your connection
+
+ );
+ }
+
+ // Public status page fallback
+ if (
+ !isLoading &&
+ typeof statusPage === "undefined" &&
+ currentPath === "/status/public"
+ ) {
+ return (
+
+
+
+ A public status page is not set up.
+
+ Please contact to your administrator
+
+
+ );
+ }
+
+ // Finished loading, but status page is not public
+ if (
+ !isLoading &&
+ currentPath === "/status/public" &&
+ statusPage.isPublished === false
+ ) {
+ return (
+
+
+
+ This status page is not public.
+
+ Please contact to your administrator
+
+
+ );
+ }
+
+ // Status page doesn't exist
+ if (!isLoading && typeof statusPage === "undefined") {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Service status
+
+
+ {link}
+
+ );
+};
+
+export default PublicStatus;
diff --git a/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx b/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx
index 2824f80d6..0b39e141b 100644
--- a/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx
+++ b/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx
@@ -3,10 +3,10 @@ import { Box, Stack } from "@mui/material";
import DataTable from "../../../../../Components/Table";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
-import Host from "../Host";
+import Host from "../../../../../Components/Host";
import { StatusLabel } from "../../../../../Components/Label";
import BarChart from "../../../../../Components/Charts/BarChart";
-import ActionsMenu from "../ActionsMenu";
+import ActionsMenu from "../../../../../Components/ActionsMenu";
import LoadingSpinner from "../LoadingSpinner";
import TableSkeleton from "../../../../../Components/Table/skeleton";
@@ -49,7 +49,6 @@ import PropTypes from "prop-types";
const UptimeDataTable = ({
isAdmin,
isSearching,
- setIsLoading,
filteredMonitors,
sort,
setSort,
@@ -167,7 +166,6 @@ const UptimeDataTable = ({
monitor={row.monitor}
isAdmin={isAdmin}
updateRowCallback={triggerUpdate}
- setIsLoading={setIsLoading}
pauseCallback={triggerUpdate}
/>
),
@@ -206,12 +204,10 @@ UptimeDataTable.propTypes = {
isSearching: PropTypes.bool,
setSort: PropTypes.func,
setSearch: PropTypes.func,
- setIsLoading: PropTypes.func,
triggerUpdate: PropTypes.func,
debouncedSearch: PropTypes.string,
onSearchChange: PropTypes.func,
isAdmin: PropTypes.bool,
- isLoading: PropTypes.bool,
monitors: PropTypes.array,
filteredMonitors: PropTypes.array,
monitorCount: PropTypes.number,
diff --git a/Client/src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx b/Client/src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx
index ec768e9f8..b65de3525 100644
--- a/Client/src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx
+++ b/Client/src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx
@@ -2,34 +2,7 @@ import { useEffect, useState } from "react";
import { networkService } from "../../../../main";
import { createToast } from "../../../../Utils/toastUtils";
import { useTheme } from "@emotion/react";
-const getMonitorWithPercentage = (monitor, theme) => {
- let uptimePercentage = "";
- let percentageColor = "";
-
- if (monitor.uptimePercentage !== undefined) {
- uptimePercentage =
- monitor.uptimePercentage === 0 ? "0" : (monitor.uptimePercentage * 100).toFixed(2);
-
- percentageColor =
- monitor.uptimePercentage < 0.25
- ? theme.palette.error.main
- : monitor.uptimePercentage < 0.5
- ? theme.palette.warning.main
- : monitor.uptimePercentage < 0.75
- ? theme.palette.success.main
- : theme.palette.success.main;
- }
-
- return {
- id: monitor._id,
- name: monitor.name,
- url: monitor.url,
- title: monitor.name,
- percentage: uptimePercentage,
- percentageColor,
- monitor: monitor,
- };
-};
+import { useMonitorUtils } from "../../../../Hooks/useMonitorUtils";
export const useMonitorFetch = ({
authToken,
@@ -49,7 +22,7 @@ export const useMonitorFetch = ({
const [networkError, setNetworkError] = useState(false);
const theme = useTheme();
-
+ const { getMonitorWithPercentage } = useMonitorUtils();
useEffect(() => {
const fetchMonitors = async () => {
try {
diff --git a/Client/src/Pages/Uptime/Monitors/index.jsx b/Client/src/Pages/Uptime/Monitors/index.jsx
index 0784b46a8..572f3fc62 100644
--- a/Client/src/Pages/Uptime/Monitors/index.jsx
+++ b/Client/src/Pages/Uptime/Monitors/index.jsx
@@ -62,7 +62,6 @@ const UptimeMonitors = () => {
const [page, setPage] = useState(undefined);
const [sort, setSort] = useState(undefined);
const [isSearching, setIsSearching] = useState(false);
- const [isLoading, setIsLoading] = useState(false);
const [monitorUpdateTrigger, setMonitorUpdateTrigger] = useState(false);
// Utils
@@ -107,8 +106,7 @@ const UptimeMonitors = () => {
order: sort?.order,
triggerUpdate: monitorUpdateTrigger,
});
- const totalMonitors = monitorsSummary?.totalMonitors ?? 0;
-
+ const totalMonitors = monitorsSummary?.totalMonitors;
if (networkError) {
return (
@@ -123,18 +121,26 @@ const UptimeMonitors = () => {
);
}
- if (!isLoading && !monitorsAreLoading && totalMonitors === 0) {
+ if (
+ !monitorsAreLoading &&
+ (totalMonitors === 0 || typeof totalMonitors === "undefined")
+ ) {
+
return (
);
}
-
return (
{
@@ -166,8 +172,7 @@ const UptimeMonitors = () => {
{
path="/uptime/configure/:monitorId/"
element={}
/>
+
+ {" "}
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
}
@@ -112,6 +147,16 @@ const Routes = () => {
path="status"
element={}
/>
+
+ }
+ />
+ }
+ />
+
}
@@ -174,6 +219,10 @@ const Routes = () => {
path="/new-password-confirmed"
element={}
/>
+ }
+ />
{
+ params.append("type", type);
+ });
+ }
+ if (page) params.append("page", page);
+ if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
+ if (filter) params.append("filter", filter);
+ if (field) params.append("field", field);
+ if (order) params.append("order", order);
+
+ if (this.eventSource) {
+ this.eventSource.close();
+ }
+
+ const url = `${this.axiosInstance.defaults.baseURL}/distributed-uptime/monitors/${teamId}?${params.toString()}`;
+ this.eventSource = new EventSource(url, {
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ this.eventSource.onopen = () => {
+ onOpen?.();
+ };
+
+ this.eventSource.addEventListener("open", (e) => {});
+
+ this.eventSource.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ onUpdate(data);
+ };
+
+ this.eventSource.onerror = (error) => {
+ console.error("Monitor stream error:", error);
+ onError?.();
+ this.eventSource.close();
+ };
+
+ // Returns a cleanup function
+ return () => {
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+ return () => {
+ console.log("Nothing to cleanup");
+ };
+ };
+ }
+
+ subscribeToDistributedUptimeDetails(config) {
+ const params = new URLSearchParams();
+ const { authToken, monitorId, onUpdate, onOpen, onError, dateRange, normalize } =
+ config;
+ if (dateRange) params.append("dateRange", dateRange);
+ if (normalize) params.append("normalize", normalize);
+
+ const url = `${this.axiosInstance.defaults.baseURL}/distributed-uptime/monitors/details/${monitorId}?${params.toString()}`;
+ this.eventSource = new EventSource(url, {
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ this.eventSource.onopen = (e) => {
+ onOpen?.();
+ };
+
+ this.eventSource.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ onUpdate(data);
+ };
+
+ this.eventSource.onerror = (error) => {
+ console.error("Monitor stream error:", error);
+ onError?.();
+ this.eventSource.close();
+ };
+ return () => {
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+ };
+ }
+
+ async getStatusPage(config) {
+ const { authToken } = config;
+ return this.axiosInstance.get(`/status-page`, {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ });
+ }
+
+ async createStatusPage(config) {
+ const { authToken, user, form, isCreate } = config;
+ const fd = new FormData();
+ fd.append("isPublished", form.isPublished);
+ fd.append("companyName", form.companyName);
+ fd.append("url", form.url);
+ fd.append("timezone", form.timezone);
+ fd.append("color", form.color);
+ fd.append("showCharts", form.showCharts);
+ fd.append("showUptimePercentage", form.showUptimePercentage);
+ form.monitors.forEach((monitorId) => {
+ fd.append("monitors[]", monitorId);
+ });
+ if (form?.logo?.src && form?.logo?.src !== "") {
+ const imageResult = await axios.get(form.logo.src, {
+ responseType: "blob",
+ });
+ fd.append("logo", imageResult.data);
+ // Cleanup blob
+ if (form.logo.src.startsWith("blob:")) {
+ URL.revokeObjectURL(form.logo.src);
+ }
+ }
+ if (isCreate) {
+ return this.axiosInstance.post(`/status-page`, fd, {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+ }
+
+ return this.axiosInstance.put(`/status-page`, fd, {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+ }
+
+ async deleteStatusPage(config) {
+ const { authToken } = config;
+ return this.axiosInstance.delete(`/status-page`, {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+ }
}
export default NetworkService;
diff --git a/Client/src/Utils/Theme/constants.js b/Client/src/Utils/Theme/constants.js
index 11357e06d..3d1a14703 100644
--- a/Client/src/Utils/Theme/constants.js
+++ b/Client/src/Utils/Theme/constants.js
@@ -152,6 +152,11 @@ const newSemanticColors = {
light: newColors.blueGray600,
dark: newColors.gray200,
},
+ // CAIO_REVIEW, need a brighter color for dark bg
+ contrastTextSecondaryDarkBg: {
+ light: newColors.gray200,
+ dark: newColors.gray200,
+ },
contrastTextTertiary: {
light: newColors.blueGray500,
dark: newColors.gray500,
@@ -246,6 +251,21 @@ const newSemanticColors = {
dark: paletteColors.gray850,
},
},
+
+ map: {
+ main: {
+ light: newColors.offWhite,
+ dark: undefined,
+ },
+ lowContrast: {
+ light: newColors.gray200,
+ dark: undefined,
+ },
+ highContrast: {
+ light: newColors.gray500,
+ dark: undefined,
+ },
+ },
};
export { typographyLevels, semanticColors as colors, newSemanticColors };
diff --git a/Client/src/Utils/Theme/globalTheme.js b/Client/src/Utils/Theme/globalTheme.js
index 12c377b3e..f5ca9e1ff 100644
--- a/Client/src/Utils/Theme/globalTheme.js
+++ b/Client/src/Utils/Theme/globalTheme.js
@@ -21,6 +21,12 @@ const baseTheme = (palette) => ({
color: palette.primary.contrastTextSecondary,
fontWeight: 400,
},
+ // CAIO_REVIEW, need a brighter color for dark bg
+ h2DarkBg: {
+ fontSize: typographyLevels.l,
+ color: palette.primary.contrastTextSecondaryDarkBg,
+ fontWeight: 400,
+ },
body1: {
fontSize: typographyLevels.m,
color: palette.primary.contrastTextTertiary,
@@ -53,6 +59,18 @@ const baseTheme = (palette) => ({
},
},
},
+ {
+ props: (props) => props.color === "error",
+ style: {
+ "&.Mui-disabled": {
+ backgroundColor: theme.palette.error.lowContrast,
+ },
+ "& .MuiButton-loadingIndicator": {
+ // styles for error variant loading indicator
+ color: theme.palette.error.contrastText,
+ },
+ },
+ },
{
props: (props) => props.variant === "group",
style: {
@@ -302,6 +320,62 @@ const baseTheme = (palette) => ({
},
},
},
+ MuiTab: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ fontSize: 13,
+ color: theme.palette.tertiary.contrastText,
+ backgroundColor: theme.palette.tertiary.main,
+ textTransform: "none",
+ minWidth: "fit-content",
+ paddingY: theme.spacing(6),
+ fontWeight: 400,
+ borderBottom: "2px solid transparent",
+ borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
+ "&:first-of-type": { borderTopLeftRadius: "8px" },
+ "&:last-child": { borderTopRightRadius: "8px", borderRight: 0 },
+ "&:focus-visible": {
+ color: theme.palette.primary.contrastText,
+ borderColor: theme.palette.tertiary.contrastText,
+ borderRightColor: theme.palette.primary.lowContrast,
+ },
+ "&.Mui-selected": {
+ backgroundColor: theme.palette.secondary.main,
+ color: theme.palette.secondary.contrastText,
+ borderColor: theme.palette.secondary.contrastText,
+ borderRightColor: theme.palette.primary.lowContrast,
+ },
+ "&:hover": {
+ borderColor: theme.palette.primary.lowContrast,
+ },
+ }),
+ },
+ },
+ MuiSvgIcon: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ color: theme.palette.primary.contrastTextTertiary,
+ }),
+ },
+ },
+ MuiTabs: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ "& .MuiTabs-indicator": {
+ backgroundColor: theme.palette.tertiary.contrastText,
+ },
+ }),
+ },
+ },
+ MuiSwitch: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ "& .MuiSwitch-track": {
+ backgroundColor: theme.palette.primary.contrastText,
+ },
+ }),
+ },
+ },
},
shape: {
borderRadius: 2,
diff --git a/Client/src/Utils/stringUtils.js b/Client/src/Utils/stringUtils.js
index 43ae5ed54..0bf7aaf11 100644
--- a/Client/src/Utils/stringUtils.js
+++ b/Client/src/Utils/stringUtils.js
@@ -15,3 +15,22 @@ export const capitalizeFirstLetter = (str) => {
}
return str.charAt(0).toUpperCase() + str.slice(1);
};
+
+/**
+ * Helper function to get first letter as a lower case string
+ * @param {string} str String whose first letter is to be lower cased
+ * @returns A string with first letter lower cased
+ */
+
+export const toLowerCaseFirstLetter = (str) => {
+ if (str === null || str === undefined) {
+ return "";
+ }
+ if (typeof str !== "string") {
+ throw new TypeError("Input must be a string");
+ }
+ if (str.length === 0) {
+ return "";
+ }
+ return str.charAt(0).toLowerCase() + str.slice(1);
+ };
\ No newline at end of file
diff --git a/Client/src/Validation/error.js b/Client/src/Validation/error.js
index 017fa9a46..8f043f997 100644
--- a/Client/src/Validation/error.js
+++ b/Client/src/Validation/error.js
@@ -1,6 +1,16 @@
+/**
+ * Update errors if passed id matches the error.details[0].path, otherwise remove
+ * the error for the id
+ * @param {*} prev Previous errors *
+ * @param {*} id ID of the field whose error is to be either updated or removed
+ * @param {*} error the error object
+ * @returns the Update Errors with the specific field with id being either removed or updated
+ */
+
+
const buildErrors = (prev, id, error) => {
const updatedErrors = { ...prev };
- if (error) {
+ if (error && id == error.details[0].path) {
updatedErrors[id] = error.details[0].message ?? "Validation error";
} else {
delete updatedErrors[id];
@@ -32,7 +42,15 @@ const getTouchedFieldErrors = (validation, touchedErrors) => {
return newErrors;
};
-
+/**
+ *
+ * @param {*} form The form object of the submitted form data
+ * @param {*} validation The Joi validation rules
+ * @param {*} setErrors The function used to set the local errors
+ * @returns true if there is no error or false if there is error after validating the form
+ * the error will be reset to {} if returns false; otherwise the errors object will be set with
+ * the new value
+ */
const hasValidationErrors = (form, validation, setErrors) => {
const { error } = validation.validate(form, {
abortEarly: false,
@@ -48,6 +66,10 @@ const hasValidationErrors = (form, validation, setErrors) => {
"refreshTokenTTL",
"jwtTTL",
"notify-email-list",
+ "_id",
+ "__v",
+ "createdAt",
+ "updatedAt"
].includes(err.path[0])
) {
newErrors[err.path[0]] = err.message ?? "Validation error";
@@ -66,8 +88,6 @@ const hasValidationErrors = (form, validation, setErrors) => {
newErrors["usage_temperature"] = null;
}
});
-
- console.log("newErrors", newErrors);
if (Object.values(newErrors).some((v) => v)) {
setErrors(newErrors);
return true;
@@ -76,6 +96,7 @@ const hasValidationErrors = (form, validation, setErrors) => {
return false;
}
}
+ setErrors({});
return false;
};
export { buildErrors, hasValidationErrors, getTouchedFieldErrors };
diff --git a/Client/src/Validation/validation.js b/Client/src/Validation/validation.js
index ec9245cdb..988035cbe 100644
--- a/Client/src/Validation/validation.js
+++ b/Client/src/Validation/validation.js
@@ -91,58 +91,61 @@ const credentials = joi.object({
});
const monitorValidation = joi.object({
- url: joi.when('type', {
- is: 'docker',
- then: joi.string()
- .trim().regex(/^[a-z0-9]{64}$/),
- otherwise: joi
- .string()
- .trim()
- .custom((value, helpers) => {
- // Regex from https://gist.github.com/dperini/729294
- var urlRegex = new RegExp(
- "^" +
- // protocol identifier (optional)
- // short syntax // still required
- "(?:(?:https?|ftp):\\/\\/)?" +
- // user:pass BasicAuth (optional)
- "(?:" +
- // IP address dotted notation octets
- // excludes loopback network 0.0.0.0
- // excludes reserved space >= 224.0.0.0
- // excludes network & broadcast addresses
- // (first & last IP address of each class)
- "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
- "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
- "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
- "|" +
- // host & domain names, may end with dot
- // can be replaced by a shortest alternative
- // (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
- "(?:" +
- "(?:" +
- "[a-z0-9\\u00a1-\\uffff]" +
- "[a-z0-9\\u00a1-\\uffff_-]{0,62}" +
- ")?" +
- "[a-z0-9\\u00a1-\\uffff]\\." +
- ")+" +
- // TLD identifier name, may end with dot
- "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" +
- ")" +
- // port number (optional)
- "(?::\\d{2,5})?" +
- // resource path (optional)
- "(?:[/?#]\\S*)?" +
- "$",
- "i"
- );
- if (!urlRegex.test(value)) {
- return helpers.error("string.invalidUrl");
- }
+ url: joi
+ .when("type", {
+ is: "docker",
+ then: joi
+ .string()
+ .trim()
+ .regex(/^[a-z0-9]{64}$/),
+ otherwise: joi
+ .string()
+ .trim()
+ .custom((value, helpers) => {
+ // Regex from https://gist.github.com/dperini/729294
+ var urlRegex = new RegExp(
+ "^" +
+ // protocol identifier (optional)
+ // short syntax // still required
+ "(?:(?:https?|ftp):\\/\\/)?" +
+ // user:pass BasicAuth (optional)
+ "(?:" +
+ // IP address dotted notation octets
+ // excludes loopback network 0.0.0.0
+ // excludes reserved space >= 224.0.0.0
+ // excludes network & broadcast addresses
+ // (first & last IP address of each class)
+ "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
+ "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
+ "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
+ "|" +
+ // host & domain names, may end with dot
+ // can be replaced by a shortest alternative
+ // (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
+ "(?:" +
+ "(?:" +
+ "[a-z0-9\\u00a1-\\uffff]" +
+ "[a-z0-9\\u00a1-\\uffff_-]{0,62}" +
+ ")?" +
+ "[a-z0-9\\u00a1-\\uffff]\\." +
+ ")+" +
+ // TLD identifier name, may end with dot
+ "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" +
+ ")" +
+ // port number (optional)
+ "(?::\\d{2,5})?" +
+ // resource path (optional)
+ "(?:[/?#]\\S*)?" +
+ "$",
+ "i"
+ );
+ if (!urlRegex.test(value)) {
+ return helpers.error("string.invalidUrl");
+ }
- return value;
- })
- })
+ return value;
+ }),
+ })
.messages({
"string.empty": "This field is required.",
"string.uri": "The URL you provided is not valid.",
@@ -175,6 +178,52 @@ const imageValidation = joi.object({
}),
});
+const logoImageValidation = joi
+ .object({
+ type: joi
+ .string()
+ .valid("image/jpeg", "image/png")
+ .allow(null) // Allow null and empty string
+ .messages({
+ "any.only": "Invalid file format.",
+ "string.empty": "File type required.",
+ })
+ .optional(),
+ size: joi
+ .number()
+ .max(3000000)
+ .allow(null) // Allow null and empty string
+ .messages({
+ "number.base": "File size must be a number.",
+ "number.max": "File size must be less than 3MB.",
+ "number.empty": "File size required.",
+ })
+ .optional(),
+ })
+ .allow(null)
+ .optional(); // Make entire object optional
+
+const statusPageValidation = joi.object({
+ isPublished: joi.bool(),
+ companyName: joi
+ .string()
+ .trim()
+ .messages({ "string.empty": "Company name is required." }),
+ url: joi.string().trim().messages({ "string.empty": "URL is required." }),
+ timezone: joi.string().trim().messages({ "string.empty": "Timezone is required." }),
+ color: joi.string().trim().messages({ "string.empty": "Color is required." }),
+ theme: joi.string(),
+ monitors: joi.array().min(1).required().messages({
+ "string.pattern.base": "Must be a valid monitor ID",
+ "array.base": "Monitors must be an array",
+ "array.min": "At least one monitor is required",
+ "array.empty": "At least one monitor is required",
+ "any.required": "At least one monitor is required",
+ }),
+ logo: logoImageValidation,
+ showUptimePercentage: joi.boolean(),
+ showCharts: joi.boolean(),
+});
const settingsValidation = joi.object({
ttl: joi.number().required().messages({
"string.empty": "TTL is required",
@@ -289,4 +338,6 @@ export {
maintenanceWindowValidation,
advancedSettingsValidation,
infrastructureMonitorValidation,
+ statusPageValidation,
+ logoImageValidation,
};
diff --git a/Client/src/assets/icons/distributed-uptime.svg b/Client/src/assets/icons/distributed-uptime.svg
new file mode 100644
index 000000000..e612081e4
--- /dev/null
+++ b/Client/src/assets/icons/distributed-uptime.svg
@@ -0,0 +1,3 @@
+
diff --git a/Client/src/assets/icons/solana_logo.svg b/Client/src/assets/icons/solana_logo.svg
new file mode 100644
index 000000000..bd349af26
--- /dev/null
+++ b/Client/src/assets/icons/solana_logo.svg
@@ -0,0 +1,28 @@
+
+
+
diff --git a/Client/src/assets/icons/upt_logo.png b/Client/src/assets/icons/upt_logo.png
new file mode 100644
index 000000000..d443d0897
Binary files /dev/null and b/Client/src/assets/icons/upt_logo.png differ
diff --git a/Client/src/main.jsx b/Client/src/main.jsx
index c07cad40b..7ee52132e 100644
--- a/Client/src/main.jsx
+++ b/Client/src/main.jsx
@@ -17,7 +17,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
>
-
+
diff --git a/Docker/dist/docker-compose.yaml b/Docker/dist/docker-compose.yaml
index a73b75dc6..420941e7e 100644
--- a/Docker/dist/docker-compose.yaml
+++ b/Docker/dist/docker-compose.yaml
@@ -4,6 +4,7 @@ services:
restart: always
environment:
UPTIME_APP_API_BASE_URL: "http://localhost:5000/api/v1"
+ UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
ports:
- "80:80"
- "443:443"
diff --git a/Docker/prod/docker-compose.yaml b/Docker/prod/docker-compose.yaml
index 2f8907089..0da2da996 100644
--- a/Docker/prod/docker-compose.yaml
+++ b/Docker/prod/docker-compose.yaml
@@ -4,6 +4,7 @@ services:
restart: always
environment:
UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1"
+ UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
ports:
- "80:80"
- "443:443"
diff --git a/Docker/test/docker-compose.yaml b/Docker/test/docker-compose.yaml
index 40890e1d9..2c38e8af3 100644
--- a/Docker/test/docker-compose.yaml
+++ b/Docker/test/docker-compose.yaml
@@ -4,6 +4,7 @@ services:
restart: always
environment:
UPTIME_APP_API_BASE_URL: "https://checkmate-test.bluewavelabs.ca/api/v1"
+ UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
ports:
- "80:80"
- "443:443"
@@ -45,7 +46,8 @@ services:
start_period: 5s
mongodb:
image: uptime_database_mongo:latest
- command: ["mongod", "--quiet", "--auth"]
+ restart: always
+ command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"]
ports:
- "27017:27017"
volumes:
diff --git a/Docker/test/prod/docker-compose.yaml b/Docker/test/prod/docker-compose.yaml
index e262ef1a2..ba88f3b67 100644
--- a/Docker/test/prod/docker-compose.yaml
+++ b/Docker/test/prod/docker-compose.yaml
@@ -3,6 +3,7 @@ services:
image: uptime_client:latest
environment:
UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1"
+ UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/"
ports:
- "80:80"
- "443:443"
diff --git a/Server/controllers/distributedUptimeController.js b/Server/controllers/distributedUptimeController.js
index 5215c2cce..bef6662d3 100644
--- a/Server/controllers/distributedUptimeController.js
+++ b/Server/controllers/distributedUptimeController.js
@@ -1,16 +1,219 @@
import { handleError } from "./controllerUtils.js";
-import { successMessages } from "../utils/messages.js";
+import Monitor from "../db/models/Monitor.js";
+import DistributedUptimeCheck from "../db/models/DistributedUptimeCheck.js";
const SERVICE_NAME = "DistributedUptimeQueueController";
class DistributedUptimeController {
- constructor() {}
+ constructor(db, http, statusService) {
+ this.db = db;
+ this.http = http;
+ this.statusService = statusService;
+ this.resultsCallback = this.resultsCallback.bind(this);
+ this.getDistributedUptimeMonitors = this.getDistributedUptimeMonitors.bind(this);
+ this.getDistributedUptimeMonitorDetails =
+ this.getDistributedUptimeMonitorDetails.bind(this);
+ }
async resultsCallback(req, res, next) {
try {
+ const { id, result } = req.body;
+ // Calculate response time
+ const {
+ first_byte_took,
+ body_read_took,
+ dns_took,
+ conn_took,
+ connect_took,
+ tls_took,
+ status_code,
+ error,
+ } = result;
+
+ // Calculate response time
+ const responseTime =
+ (first_byte_took +
+ body_read_took +
+ dns_took +
+ conn_took +
+ connect_took +
+ tls_took) /
+ 1_000_000;
+
+ // Calculate if server is up or down
+ const isErrorStatus = status_code >= 400;
+ const hasError = error !== "";
+
+ const status = isErrorStatus || hasError ? false : true;
+
+ // Build response
+ const distributedUptimeResponse = {
+ monitorId: id,
+ type: "distributed_http",
+ payload: result,
+ status,
+ code: status_code,
+ responseTime,
+ first_byte_took,
+ body_read_took,
+ dns_took,
+ conn_took,
+ connect_took,
+ tls_took,
+ };
+ if (error) {
+ const code = status_code || this.NETWORK_ERROR;
+ distributedUptimeResponse.code = code;
+ distributedUptimeResponse.message =
+ this.http.STATUS_CODES[code] || "Network Error";
+ } else {
+ distributedUptimeResponse.message = this.http.STATUS_CODES[status_code];
+ }
+
+ await this.statusService.updateStatus(distributedUptimeResponse);
+
res.status(200).json({ message: "OK" });
} catch (error) {
- throw handleError(error, SERVICE_NAME, "resultsCallback");
+ next(handleError(error, SERVICE_NAME, "resultsCallback"));
+ }
+ }
+
+ async getDistributedUptimeMonitors(req, res, next) {
+ try {
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+ res.setHeader("Access-Control-Allow-Origin", "*");
+
+ const BATCH_DELAY = 1000;
+ let batchTimeout = null;
+ let opInProgress = false;
+
+ // Do things here
+ const notifyChange = async () => {
+ if (opInProgress) {
+ // Get data
+ const monitors = await this.db.getMonitorsByTeamId(req);
+ res.write(`data: ${JSON.stringify({ monitors })}\n\n`);
+ opInProgress = false;
+ }
+ batchTimeout = null;
+ };
+
+ const handleChange = () => {
+ opInProgress = true;
+ if (batchTimeout) clearTimeout(batchTimeout);
+ batchTimeout = setTimeout(notifyChange, BATCH_DELAY);
+ };
+
+ const monitorStream = Monitor.watch(
+ [{ $match: { operationType: { $in: ["insert", "update", "delete"] } } }],
+ { fullDocument: "updateLookup" }
+ );
+
+ const checksStream = DistributedUptimeCheck.watch(
+ [{ $match: { operationType: { $in: ["insert", "update", "delete"] } } }],
+ { fullDocument: "updateLookup" }
+ );
+
+ monitorStream.on("change", handleChange);
+ checksStream.on("change", handleChange);
+
+ // Send initial data
+ const monitors = await this.db.getMonitorsByTeamId(req);
+ res.write(`data: ${JSON.stringify({ monitors })}\n\n`);
+
+ // Handle client disconnect
+ req.on("close", () => {
+ if (batchTimeout) {
+ clearTimeout(batchTimeout);
+ }
+ monitorStream.close();
+ checksStream.close();
+ clearInterval(keepAlive);
+ });
+
+ // Keep connection alive
+ const keepAlive = setInterval(() => {
+ res.write(": keepalive\n\n");
+ }, 30000);
+
+ // Clean up on close
+ req.on("close", () => {
+ clearInterval(keepAlive);
+ });
+ } catch (error) {
+ console.log(error);
+ next(handleError(error, SERVICE_NAME, "getDistributedUptimeMonitors"));
+ }
+ }
+
+ async getDistributedUptimeMonitorDetails(req, res, next) {
+ try {
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+ res.setHeader("Access-Control-Allow-Origin", "*");
+
+ const BATCH_DELAY = 1000;
+ let batchTimeout = null;
+ let opInProgress = false;
+
+ // Do things here
+ const notifyChange = async () => {
+ try {
+ if (opInProgress) {
+ // Get data
+ const monitor = await this.db.getDistributedUptimeDetailsById(req);
+ res.write(`data: ${JSON.stringify({ monitor })}\n\n`);
+ opInProgress = false;
+ }
+ batchTimeout = null;
+ } catch (error) {
+ console.error("Error in notifyChange:", error);
+ opInProgress = false;
+ batchTimeout = null;
+ next(handleError(error, SERVICE_NAME, "getDistributedUptimeMonitorDetails"));
+ }
+ };
+
+ const handleChange = () => {
+ opInProgress = true;
+ if (batchTimeout) clearTimeout(batchTimeout);
+ batchTimeout = setTimeout(notifyChange, BATCH_DELAY);
+ };
+
+ const checksStream = DistributedUptimeCheck.watch(
+ [{ $match: { operationType: { $in: ["insert", "update", "delete"] } } }],
+ { fullDocument: "updateLookup" }
+ );
+
+ checksStream.on("change", handleChange);
+
+ // Send initial data
+ const monitor = await this.db.getDistributedUptimeDetailsById(req);
+ res.write(`data: ${JSON.stringify({ monitor })}\n\n`);
+
+ // Handle client disconnect
+ req.on("close", () => {
+ if (batchTimeout) {
+ clearTimeout(batchTimeout);
+ }
+ checksStream.close();
+ clearInterval(keepAlive);
+ });
+
+ // Keep connection alive
+ const keepAlive = setInterval(() => {
+ res.write(": keepalive\n\n");
+ }, 30000);
+
+ // Clean up on close
+ req.on("close", () => {
+ clearInterval(keepAlive);
+ });
+ } catch (error) {
+ next(handleError(error, SERVICE_NAME, "getDistributedUptimeMonitorDetails"));
}
}
}
diff --git a/Server/controllers/statusPageController.js b/Server/controllers/statusPageController.js
index a4ceb8039..bcf241cc2 100644
--- a/Server/controllers/statusPageController.js
+++ b/Server/controllers/statusPageController.js
@@ -2,8 +2,9 @@ import { handleError, handleValidationError } from "./controllerUtils.js";
import {
createStatusPageBodyValidation,
getStatusPageParamValidation,
+ imageValidation,
} from "../validation/joi.js";
-import { successMessages } from "../utils/messages.js";
+import { successMessages, errorMessages } from "../utils/messages.js";
const SERVICE_NAME = "statusPageController";
@@ -15,13 +16,14 @@ class StatusPageController {
createStatusPage = async (req, res, next) => {
try {
await createStatusPageBodyValidation.validateAsync(req.body);
+ await imageValidation.validateAsync(req.file);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
- const statusPage = await this.db.createStatusPage(req.body);
+ const statusPage = await this.db.createStatusPage(req.body, req.file);
return res.success({
msg: successMessages.STATUS_PAGE_CREATE,
data: statusPage,
@@ -30,17 +32,35 @@ class StatusPageController {
next(handleError(error, SERVICE_NAME, "createStatusPage"));
}
};
- getStatusPageByUrl = async (req, res, next) => {
+
+ updateStatusPage = async (req, res, next) => {
try {
- await getStatusPageParamValidation.validateAsync(req.params);
+ await createStatusPageBodyValidation.validateAsync(req.body);
+ await imageValidation.validateAsync(req.file);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
- const { url } = req.params;
- const statusPage = await this.db.getStatusPageByUrl(url);
+ const statusPage = await this.db.updateStatusPage(req.body, req.file);
+ if (statusPage === null) {
+ const error = new Error(errorMessages.STATUS_PAGE_NOT_FOUND);
+ error.status = 404;
+ throw error;
+ }
+ return res.success({
+ msg: successMessages.STATUS_PAGE_UPDATE,
+ data: statusPage,
+ });
+ } catch (error) {
+ next(handleError(error, SERVICE_NAME, "updateStatusPage"));
+ }
+ };
+
+ getStatusPage = async (req, res, next) => {
+ try {
+ const statusPage = await this.db.getStatusPage();
return res.success({
msg: successMessages.STATUS_PAGE_BY_URL,
data: statusPage,
@@ -49,6 +69,17 @@ class StatusPageController {
next(handleError(error, SERVICE_NAME, "getStatusPage"));
}
};
+
+ deleteStatusPage = async (req, res, next) => {
+ try {
+ await this.db.deleteStatusPage();
+ return res.success({
+ msg: successMessages.STATUS_PAGE_DELETE,
+ });
+ } catch (error) {
+ next(handleError(error, SERVICE_NAME, "deleteStatusPage"));
+ }
+ };
}
export default StatusPageController;
diff --git a/Server/db/models/DistributedUptimeCheck.js b/Server/db/models/DistributedUptimeCheck.js
index 0c7c25de3..adbeffd09 100644
--- a/Server/db/models/DistributedUptimeCheck.js
+++ b/Server/db/models/DistributedUptimeCheck.js
@@ -1,13 +1,121 @@
import mongoose from "mongoose";
+
import { BaseCheckSchema } from "./Check.js";
+// {
+// "id": "12123",
+// "result": {
+// "task_arrived": "2025-01-13T19:21:37.463466602Z",
+// "dns_start": "2025-01-14T00:21:33.1801319+05:00",
+// "dns_end": "2025-01-14T00:21:33.4582552+05:00",
+// "conn_start": "2025-01-14T00:21:33.1801319+05:00",
+// "conn_end": "2025-01-14T00:21:33.7076318+05:00",
+// "connect_start": "2025-01-14T00:21:33.4582552+05:00",
+// "connect_end": "2025-01-14T00:21:33.541899+05:00",
+// "tls_hand_shake_start": "2025-01-14T00:21:33.541899+05:00",
+// "tls_hand_shake_end": "2025-01-14T00:21:33.7076318+05:00",
+// "body_read_start": "2025-01-14T00:21:34.1894707+05:00",
+// "body_read_end": "2025-01-14T00:21:34.1894707+05:00",
+// "wrote_request": "2025-01-14T00:21:33.7076318+05:00",
+// "got_first_response_byte": "2025-01-14T00:21:34.1327652+05:00",
+// "first_byte_took": 425133400,
+// "body_read_took": 56030000,
+// "dns_took": 278123300,
+// "conn_took": 527499900,
+// "connect_took": 83643800,
+// "tls_took": 165732800,
+// "sni_name": "uprock.com",
+// "status_code": 200,
+// "body_size": 19320,
+// "request_header_size": 95,
+// "response_header_size": 246,
+// "response_headers": "X-Vercel-Id: bom1::iad1::sm87v-1736796096856-aec270c01f23\nDate: Mon, 13 Jan 2025 19:21:37 GMT\nServer: Vercel\nStrict-Transport-Security: max-age=63072000\nVary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url\nX-Matched-Path: /\nX-Powered-By: Next.js\nX-Vercel-Cache: MISS\nAge: 0\nCache-Control: private, no-cache, no-store, max-age=0, must-revalidate\nContent-Type: text/html; charset=utf-8",
+// "error": "",
+// "device_id": "d5f578e143a2cd603dd6bf5f846a86a538bde4a8fbe2ad1fca284ad9f033daf8",
+// "ip_address": "223.123.19.0",
+// "proof": "",
+// "created_at": "2025-01-13T19:21:37.463466912Z",
+// "continent": "AS",
+// "country_code": "PK",
+// "city": "Muzaffargarh",
+// "upt_burnt" : "0.01",
+// "location": {
+// "lat": 71.0968,
+// "lng": 30.0208
+// },
+// "payload": {
+// "callback": "https://webhook.site/2a15b0af-545a-4ac2-b913-153b97592d7a",
+// "x": "y"
+// }
+// }
+// }
+
+const LocationSchema = new mongoose.Schema(
+ {
+ lat: { type: Number, required: true },
+ lng: { type: Number, required: true },
+ },
+ { _id: false }
+);
+
const DistributedUptimeCheckSchema = mongoose.Schema(
{
...BaseCheckSchema.obj,
+ first_byte_took: {
+ type: Number,
+ required: false,
+ },
+ body_read_took: {
+ type: Number,
+ required: false,
+ },
+ dns_took: {
+ type: Number,
+ required: false,
+ },
+ conn_took: {
+ type: Number,
+ required: false,
+ },
+ connect_took: {
+ type: Number,
+ required: false,
+ },
+ tls_took: {
+ type: Number,
+ required: false,
+ },
+ location: {
+ type: LocationSchema,
+ required: false,
+ },
+ continent: {
+ type: String,
+ required: false,
+ },
+ countryCode: {
+ type: String,
+ required: false,
+ },
+ city: {
+ type: String,
+ required: false,
+ },
+ uptBurnt: {
+ type: mongoose.Schema.Types.Decimal128,
+ required: false,
+ },
},
{ timestamps: true }
);
+DistributedUptimeCheckSchema.pre("save", function (next) {
+ if (this.isModified("uptBurnt") && typeof this.uptBurnt === "string") {
+ this.uptBurnt = mongoose.Types.Decimal128.fromString(this.uptBurnt);
+ }
+ next();
+});
+
DistributedUptimeCheckSchema.index({ createdAt: 1 });
DistributedUptimeCheckSchema.index({ monitorId: 1, createdAt: 1 });
DistributedUptimeCheckSchema.index({ monitorId: 1, createdAt: -1 });
diff --git a/Server/db/models/StatusPage.js b/Server/db/models/StatusPage.js
index 1169d4661..e720327ed 100644
--- a/Server/db/models/StatusPage.js
+++ b/Server/db/models/StatusPage.js
@@ -23,11 +23,6 @@ const StatusPageSchema = mongoose.Schema(
required: true,
default: "#4169E1",
},
- theme: {
- type: String,
- required: true,
- default: "light",
- },
monitors: [
{
type: mongoose.Schema.Types.ObjectId,
@@ -35,6 +30,22 @@ const StatusPageSchema = mongoose.Schema(
required: true,
},
],
+ logo: {
+ data: Buffer,
+ contentType: String,
+ },
+ isPublished: {
+ type: Boolean,
+ default: false,
+ },
+ showCharts: {
+ type: Boolean,
+ default: true,
+ },
+ showUptimePercentage: {
+ type: Boolean,
+ default: true,
+ },
},
{ timestamps: true }
);
diff --git a/Server/db/mongo/MongoDB.js b/Server/db/mongo/MongoDB.js
index e62828a41..8f035c2dc 100644
--- a/Server/db/mongo/MongoDB.js
+++ b/Server/db/mongo/MongoDB.js
@@ -43,6 +43,11 @@ import * as hardwareCheckModule from "./modules/hardwareCheckModule.js";
import * as checkModule from "./modules/checkModule.js";
+//****************************************
+// Distributed Checks
+//****************************************
+import * as distributedCheckModule from "./modules/distributedCheckModule.js";
+
//****************************************
// Maintenance Window
//****************************************
@@ -74,6 +79,7 @@ class MongoDB {
Object.assign(this, pageSpeedCheckModule);
Object.assign(this, hardwareCheckModule);
Object.assign(this, checkModule);
+ Object.assign(this, distributedCheckModule);
Object.assign(this, maintenanceWindowModule);
Object.assign(this, notificationModule);
Object.assign(this, settingsModule);
diff --git a/Server/db/mongo/modules/distributedCheckModule.js b/Server/db/mongo/modules/distributedCheckModule.js
new file mode 100644
index 000000000..5c7fb09a8
--- /dev/null
+++ b/Server/db/mongo/modules/distributedCheckModule.js
@@ -0,0 +1,15 @@
+import DistributedUptimeCheck from "../../models/DistributedUptimeCheck.js";
+const SERVICE_NAME = "distributedCheckModule";
+
+const createDistributedCheck = async (checkData) => {
+ try {
+ const check = await new DistributedUptimeCheck({ ...checkData }).save();
+ return check;
+ } catch (error) {
+ error.service = SERVICE_NAME;
+ error.method = "createCheck";
+ throw error;
+ }
+};
+
+export { createDistributedCheck };
diff --git a/Server/db/mongo/modules/monitorModule.js b/Server/db/mongo/modules/monitorModule.js
index af4ea7e9f..9a8e98f4e 100644
--- a/Server/db/mongo/modules/monitorModule.js
+++ b/Server/db/mongo/modules/monitorModule.js
@@ -2,6 +2,7 @@ import Monitor from "../../models/Monitor.js";
import Check from "../../models/Check.js";
import PageSpeedCheck from "../../models/PageSpeedCheck.js";
import HardwareCheck from "../../models/HardwareCheck.js";
+import DistributedUptimeCheck from "../../models/DistributedUptimeCheck.js";
import { errorMessages } from "../../../utils/messages.js";
import Notification from "../../models/Notification.js";
import { NormalizeData, NormalizeDataUptimeDetails } from "../../../utils/dataUtils.js";
@@ -11,6 +12,7 @@ import { fileURLToPath } from "url";
import {
buildUptimeDetailsPipeline,
buildHardwareDetailsPipeline,
+ buildDistributedUptimeDetailsPipeline,
} from "./monitorModuleQueries.js";
import { ObjectId } from "mongodb";
const __filename = fileURLToPath(import.meta.url);
@@ -366,6 +368,48 @@ const getUptimeDetailsById = async (req) => {
}
};
+const getDistributedUptimeDetailsById = async (req) => {
+ try {
+ const { monitorId } = req.params;
+ const monitor = await Monitor.findById(monitorId);
+ if (monitor === null || monitor === undefined) {
+ throw new Error(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId));
+ }
+
+ const { dateRange, normalize } = req.query;
+ const dates = getDateRange(dateRange);
+ const formatLookup = {
+ day: "%Y-%m-%dT%H:%M:00Z",
+ week: "%Y-%m-%dT%H:00:00Z",
+ month: "%Y-%m-%dT00:00:00Z",
+ };
+
+ const dateString = formatLookup[dateRange];
+ const results = await DistributedUptimeCheck.aggregate(
+ buildDistributedUptimeDetailsPipeline(monitor, dates, dateString)
+ );
+
+ const monitorData = results[0];
+ const normalizedGroupChecks = NormalizeDataUptimeDetails(
+ monitorData.groupedChecks,
+ 10,
+ 100
+ );
+
+ const monitorStats = {
+ ...monitor.toObject(),
+ ...monitorData,
+ groupedChecks: normalizedGroupChecks,
+ };
+
+ return monitorStats;
+ } catch (error) {
+ error.service = SERVICE_NAME;
+ error.method = "getDistributedUptimeDetailsById";
+ throw error;
+ }
+};
+
/**
* Get stats by monitor ID
* @async
@@ -630,6 +674,26 @@ const getMonitorsByTeamId = async (req) => {
},
]
: []),
+ ...(limit
+ ? [
+ {
+ $lookup: {
+ from: "distributeduptimechecks",
+ let: { monitorId: "$_id" },
+ pipeline: [
+ {
+ $match: {
+ $expr: { $eq: ["$monitorId", "$$monitorId"] },
+ },
+ },
+ { $sort: { createdAt: -1 } },
+ ...(limit ? [{ $limit: limit }] : []),
+ ],
+ as: "distributeduptimechecks",
+ },
+ },
+ ]
+ : []),
{
$addFields: {
@@ -648,6 +712,10 @@ const getMonitorsByTeamId = async (req) => {
case: { $eq: ["$type", "hardware"] },
then: "$hardwarechecks",
},
+ {
+ case: { $eq: ["$type", "distributed_http"] },
+ then: "$distributeduptimechecks",
+ },
],
default: [],
},
@@ -815,6 +883,7 @@ export {
getMonitorById,
getMonitorsByTeamId,
getUptimeDetailsById,
+ getDistributedUptimeDetailsById,
createMonitor,
deleteMonitor,
deleteAllMonitors,
diff --git a/Server/db/mongo/modules/monitorModuleQueries.js b/Server/db/mongo/modules/monitorModuleQueries.js
index 094e1f932..489017f51 100644
--- a/Server/db/mongo/modules/monitorModuleQueries.js
+++ b/Server/db/mongo/modules/monitorModuleQueries.js
@@ -534,4 +534,188 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => {
];
};
-export { buildUptimeDetailsPipeline, buildHardwareDetailsPipeline };
+const buildDistributedUptimeDetailsPipeline = (monitor, dates, dateString) => {
+ return [
+ {
+ $match: {
+ monitorId: monitor._id,
+ },
+ },
+ {
+ $sort: {
+ createdAt: 1,
+ },
+ },
+ {
+ $facet: {
+ aggregateData: [
+ {
+ $group: {
+ _id: null,
+ avgResponseTime: {
+ $avg: "$responseTime",
+ },
+ lastCheck: {
+ $last: "$$ROOT",
+ },
+ totalChecks: {
+ $sum: 1,
+ },
+ uptBurnt: {
+ $sum: "$uptBurnt",
+ },
+ },
+ },
+ ],
+ // For the response time chart, should return checks for date window
+ // Grouped by: {day: hour}, {week: day}, {month: day}
+ groupedMapChecks: [
+ {
+ $match: {
+ createdAt: { $gte: dates.start, $lte: dates.end },
+ },
+ },
+ {
+ $group: {
+ _id: {
+ date: {
+ $dateToString: {
+ format: dateString,
+ date: "$createdAt",
+ },
+ },
+ city: "$city",
+ lat: "$location.lat",
+ lng: "$location.lng",
+ },
+ city: { $first: "$city" }, // Add this line to include city in output
+ lat: { $first: "$location.lat" },
+ lng: { $first: "$location.lng" },
+ avgResponseTime: {
+ $avg: "$responseTime",
+ },
+ totalChecks: {
+ $sum: 1,
+ },
+ },
+ },
+ {
+ $sort: {
+ "_id.date": 1,
+ },
+ },
+ ],
+ groupedChecks: [
+ {
+ $match: {
+ createdAt: { $gte: dates.start, $lte: dates.end },
+ },
+ },
+ {
+ $group: {
+ _id: {
+ $dateToString: {
+ format: dateString,
+ date: "$createdAt",
+ },
+ },
+ avgResponseTime: {
+ $avg: "$responseTime",
+ },
+ totalChecks: {
+ $sum: 1,
+ },
+ },
+ },
+ {
+ $sort: {
+ _id: 1,
+ },
+ },
+ ],
+ // Average response time for the date window
+ groupAvgResponseTime: [
+ {
+ $match: {
+ createdAt: { $gte: dates.start, $lte: dates.end },
+ },
+ },
+ {
+ $group: {
+ _id: null,
+ avgResponseTime: {
+ $avg: "$responseTime",
+ },
+ },
+ },
+ ],
+ latestChecks: [
+ {
+ $sort: { createdAt: -1 }, // Sort by newest first
+ },
+ {
+ $limit: 5, // Get only the first 5 documents
+ },
+ {
+ $project: {
+ responseTime: 1,
+ city: 1,
+ countryCode: 1,
+ uptBurnt: { $toString: "$uptBurnt" },
+ },
+ },
+ ],
+ },
+ },
+ {
+ $project: {
+ totalUptBurnt: {
+ $toString: {
+ $arrayElemAt: ["$aggregateData.uptBurnt", 0],
+ },
+ },
+ avgResponseTime: {
+ $arrayElemAt: ["$aggregateData.avgResponseTime", 0],
+ },
+ totalChecks: {
+ $arrayElemAt: ["$aggregateData.totalChecks", 0],
+ },
+ latestResponseTime: {
+ $arrayElemAt: ["$aggregateData.lastCheck.responseTime", 0],
+ },
+ timeSinceLastCheck: {
+ $let: {
+ vars: {
+ lastCheck: {
+ $arrayElemAt: ["$aggregateData.lastCheck", 0],
+ },
+ },
+ in: {
+ $cond: [
+ {
+ $ifNull: ["$$lastCheck", false],
+ },
+ {
+ $subtract: [new Date(), "$$lastCheck.createdAt"],
+ },
+ 0,
+ ],
+ },
+ },
+ },
+ groupedMapChecks: "$groupedMapChecks",
+ groupedChecks: "$groupedChecks",
+ groupedAvgResponseTime: {
+ $arrayElemAt: ["$groupAvgResponseTime", 0],
+ },
+ latestChecks: "$latestChecks",
+ },
+ },
+ ];
+};
+
+export {
+ buildUptimeDetailsPipeline,
+ buildHardwareDetailsPipeline,
+ buildDistributedUptimeDetailsPipeline,
+};
diff --git a/Server/db/mongo/modules/statusPageModule.js b/Server/db/mongo/modules/statusPageModule.js
index 194287448..2f31d2adb 100644
--- a/Server/db/mongo/modules/statusPageModule.js
+++ b/Server/db/mongo/modules/statusPageModule.js
@@ -1,11 +1,18 @@
import StatusPage from "../../models/StatusPage.js";
import { errorMessages } from "../../../utils/messages.js";
+import { NormalizeData } from "../../../utils/dataUtils.js";
const SERVICE_NAME = "statusPageModule";
-const createStatusPage = async (statusPageData) => {
+const createStatusPage = async (statusPageData, image) => {
try {
const statusPage = new StatusPage({ ...statusPageData });
+ if (image) {
+ statusPage.logo = {
+ data: image.buffer,
+ contentType: image.mimetype,
+ };
+ }
await statusPage.save();
return statusPage;
} catch (error) {
@@ -20,16 +27,115 @@ const createStatusPage = async (statusPageData) => {
}
};
-const getStatusPageByUrl = async (url) => {
+const updateStatusPage = async (statusPageData, image) => {
try {
- const statusPage = await StatusPage.findOne({ url });
- if (statusPage === null || statusPage === undefined) {
+ if (image) {
+ statusPageData.logo = {
+ data: image.buffer,
+ contentType: image.mimetype,
+ };
+ }
+ const statusPage = await StatusPage.findOneAndUpdate({}, statusPageData, {
+ new: true,
+ });
+
+ return statusPage;
+ } catch (error) {
+ error.service = SERVICE_NAME;
+ error.method = "updateStatusPage";
+ throw error;
+ }
+};
+
+const getStatusPage = async () => {
+ try {
+ const statusPageQuery = await StatusPage.aggregate([
+ { $limit: 1 },
+ {
+ $set: {
+ originalMonitors: "$monitors",
+ },
+ },
+ {
+ $lookup: {
+ from: "monitors",
+ localField: "monitors",
+ foreignField: "_id",
+ as: "monitors",
+ },
+ },
+ {
+ $unwind: "$monitors",
+ },
+ {
+ $lookup: {
+ from: "checks",
+ let: { monitorId: "$monitors._id" },
+ pipeline: [
+ {
+ $match: {
+ $expr: { $eq: ["$monitorId", "$$monitorId"] },
+ },
+ },
+ { $sort: { createdAt: -1 } },
+ { $limit: 25 },
+ ],
+ as: "monitors.checks",
+ },
+ },
+ {
+ $addFields: {
+ "monitors.orderIndex": {
+ $indexOfArray: ["$originalMonitors", "$monitors._id"],
+ },
+ },
+ },
+ {
+ $group: {
+ _id: "$_id",
+ statusPage: { $first: "$$ROOT" },
+ monitors: { $push: "$monitors" },
+ },
+ },
+ {
+ $project: {
+ statusPage: {
+ _id: 1,
+ color: 1,
+ companyName: 1,
+ isPublished: 1,
+ logo: 1,
+ originalMonitors: 1,
+ showCharts: 1,
+ showUptimePercentage: 1,
+ timezone: 1,
+ url: 1,
+ },
+ monitors: {
+ $sortArray: {
+ input: "$monitors",
+ sortBy: { orderIndex: 1 },
+ },
+ },
+ },
+ },
+ ]);
+ if (!statusPageQuery.length) {
const error = new Error(errorMessages.STATUS_PAGE_NOT_FOUND);
error.status = 404;
-
throw error;
}
- return statusPage;
+
+ const { statusPage, monitors } = statusPageQuery[0];
+
+ const normalizedMonitors = monitors.map((monitor) => {
+ return {
+ ...monitor,
+ checks: NormalizeData(monitor.checks, 10, 100),
+ };
+ });
+
+ return { statusPage, monitors: normalizedMonitors };
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getStatusPageByUrl";
@@ -37,4 +143,14 @@ const getStatusPageByUrl = async (url) => {
}
};
-export { createStatusPage, getStatusPageByUrl };
+const deleteStatusPage = async () => {
+ try {
+ await StatusPage.deleteOne({});
+ } catch (error) {
+ error.service = SERVICE_NAME;
+ error.method = "createStatusPage";
+ throw error;
+ }
+};
+
+export { createStatusPage, updateStatusPage, getStatusPage, deleteStatusPage };
diff --git a/Server/index.js b/Server/index.js
index a826ce64b..bc7be395d 100644
--- a/Server/index.js
+++ b/Server/index.js
@@ -252,7 +252,11 @@ const startApp = async () => {
ServiceRegistry.get(NotificationService.SERVICE_NAME)
);
- const distributedUptimeController = new DistributedUptimeController();
+ const distributedUptimeController = new DistributedUptimeController(
+ ServiceRegistry.get(MongoDB.SERVICE_NAME),
+ http,
+ ServiceRegistry.get(StatusService.SERVICE_NAME)
+ );
//Create routes
const authRoutes = new AuthRoutes(authController);
diff --git a/Server/package-lock.json b/Server/package-lock.json
index 4cfc4ff6b..5f390cc9b 100644
--- a/Server/package-lock.json
+++ b/Server/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"axios": "^1.7.2",
"bcrypt": "5.1.1",
- "bullmq": "5.40.0",
+ "bullmq": "5.40.2",
"cors": "^2.8.5",
"dockerode": "4.0.4",
"dotenv": "^16.4.5",
@@ -1748,9 +1748,9 @@
}
},
"node_modules/bullmq": {
- "version": "5.40.0",
- "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.40.0.tgz",
- "integrity": "sha512-tmrk32EmcbtUOGPSdwlDUcc0w+nAMqCisk8vEFFmG8aOzIehz0BxTNSj6Grh0qoMugRF3VglWk8HGUBnWqU2Fw==",
+ "version": "5.40.2",
+ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.40.2.tgz",
+ "integrity": "sha512-Cn4NUpwGAF4WnuXR2kTZCTAUEUHajSCn/IqiDG9ry1kVvAwwwg1Ati3J5HN2uZjqD5PBfNDXYnsc2+0PzakDwg==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
@@ -4206,9 +4206,10 @@
"license": "ISC"
},
"node_modules/ioredis": {
- "version": "5.4.2",
- "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.2.tgz",
- "integrity": "sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==",
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.5.0.tgz",
+ "integrity": "sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==",
+ "license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
@@ -5515,9 +5516,9 @@
}
},
"node_modules/mongodb": {
- "version": "6.12.0",
- "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz",
- "integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==",
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.13.0.tgz",
+ "integrity": "sha512-KeESYR5TEaFxOuwRqkOm3XOsMqCSkdeDMjaW5u2nuKfX7rqaofp7JQGoi7sVqQcNJTKuveNbzZtWMstb8ABP6Q==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.9",
@@ -5571,14 +5572,14 @@
}
},
"node_modules/mongoose": {
- "version": "8.9.7",
- "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.7.tgz",
- "integrity": "sha512-mvNXmU0V8qZzMR/qoK2mjT4Ti2ALdtfS0teK+twxhlGkwzOD76V02/zWajTu2MJ7QyEmZe9OWvnJsIY0iAuX3Q==",
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.10.0.tgz",
+ "integrity": "sha512-nLhk3Qrv6q/HpD2k1O7kbBqsq+/kmKpdv5KJ+LLhQlII3e1p/SSLoLP6jMuSiU6+iLK7zFw4T1niAk3mA3QVug==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.1",
"kareem": "2.6.3",
- "mongodb": "~6.12.0",
+ "mongodb": "~6.13.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
diff --git a/Server/package.json b/Server/package.json
index 3bea87852..6695a444a 100644
--- a/Server/package.json
+++ b/Server/package.json
@@ -18,7 +18,7 @@
"dependencies": {
"axios": "^1.7.2",
"bcrypt": "5.1.1",
- "bullmq": "5.40.0",
+ "bullmq": "5.40.2",
"cors": "^2.8.5",
"dockerode": "4.0.4",
"dotenv": "^16.4.5",
diff --git a/Server/routes/distributedUptimeRoute.js b/Server/routes/distributedUptimeRoute.js
index eac4cb675..3fbd88d3a 100644
--- a/Server/routes/distributedUptimeRoute.js
+++ b/Server/routes/distributedUptimeRoute.js
@@ -8,6 +8,14 @@ class DistributedUptimeRoutes {
}
initRoutes() {
this.router.post("/callback", this.distributedUptimeController.resultsCallback);
+ this.router.get(
+ "/monitors/:teamId",
+ this.distributedUptimeController.getDistributedUptimeMonitors
+ );
+ this.router.get(
+ "/monitors/details/:monitorId",
+ this.distributedUptimeController.getDistributedUptimeMonitorDetails
+ );
}
getRouter() {
diff --git a/Server/routes/statusPageRoute.js b/Server/routes/statusPageRoute.js
index ff2a2018a..37ae313ee 100644
--- a/Server/routes/statusPageRoute.js
+++ b/Server/routes/statusPageRoute.js
@@ -1,6 +1,7 @@
import { Router } from "express";
-
import { verifyJWT } from "../middleware/verifyJWT.js";
+import multer from "multer";
+const upload = multer();
class StatusPageRoutes {
constructor(statusPageController) {
@@ -10,8 +11,20 @@ class StatusPageRoutes {
}
initRoutes() {
- this.router.get("/:url", this.statusPageController.getStatusPageByUrl);
- this.router.post("/:url", verifyJWT, this.statusPageController.createStatusPage);
+ this.router.get("/", this.statusPageController.getStatusPage);
+ this.router.post(
+ "/",
+ upload.single("logo"),
+ verifyJWT,
+ this.statusPageController.createStatusPage
+ );
+ this.router.put(
+ "/",
+ upload.single("logo"),
+ verifyJWT,
+ this.statusPageController.updateStatusPage
+ );
+ this.router.delete("/", verifyJWT, this.statusPageController.deleteStatusPage);
}
getRouter() {
diff --git a/Server/service/jobQueue.js b/Server/service/jobQueue.js
index 419785540..f7c70f0a7 100644
--- a/Server/service/jobQueue.js
+++ b/Server/service/jobQueue.js
@@ -1,4 +1,4 @@
-const QUEUE_NAMES = ["uptime", "pagespeed", "hardware"];
+const QUEUE_NAMES = ["uptime", "pagespeed", "hardware", "distributed"];
const SERVICE_NAME = "JobQueue";
const JOBS_PER_WORKER = 5;
const QUEUE_LOOKUP = {
@@ -8,6 +8,7 @@ const QUEUE_LOOKUP = {
port: "uptime",
docker: "uptime",
pagespeed: "pagespeed",
+ distributed_http: "distributed",
};
const getSchedulerId = (monitor) => `scheduler:${monitor.type}:${monitor._id}`;
@@ -163,6 +164,9 @@ class NewJobQueue {
// Get the current status
await job.updateProgress(30);
const networkResponse = await this.networkService.getStatus(job);
+ if (job.data.type === "distributed_http") {
+ return;
+ }
// Handle status change
await job.updateProgress(60);
const { monitor, statusChanged, prevStatus } =
@@ -219,51 +223,6 @@ class NewJobQueue {
connection: this.connection,
concurrency: 5,
});
-
- // worker.on("active", (job) => {
- // this.logger.info({
- // message: `Worker started processing job: ${job.id}`,
- // service: SERVICE_NAME,
- // method: "createWorker",
- // });
- // });
-
- // worker.on("completed", (job) => {
- // this.logger.info({
- // message: `Worker completed job: ${job.id}`,
- // service: SERVICE_NAME,
- // method: "createWorker",
- // });
- // });
-
- // // Log job progress updates
- // worker.on("progress", (job, progress) => {
- // this.logger.info({
- // message: `Job progress: ${job.id}`,
- // service: SERVICE_NAME,
- // method: "createWorker",
- // details: `Progress: ${progress}%`,
- // });
- // });
-
- // // Log when a job fails
- // worker.on("failed", (job, err) => {
- // this.logger.error({
- // message: `Worker failed job: ${job.id}`,
- // service: SERVICE_NAME,
- // method: "createWorker",
- // details: `Error: ${err.message}`,
- // stack: err.stack,
- // });
- // });
-
- // worker.on("stalled", (jobId) => {
- // this.logger.warn({
- // message: `Worker stalled job: ${jobId}`,
- // service: SERVICE_NAME,
- // method: "createWorker",
- // });
- // });
return worker;
}
@@ -392,7 +351,6 @@ class NewJobQueue {
const jobs = await queue.getJobs();
const ret = await Promise.all(
jobs.map(async (job) => {
- console.log(job);
const state = await job.getState();
return { url: job.data.url, state, progress: job.progress };
})
diff --git a/Server/service/networkService.js b/Server/service/networkService.js
index 604e2bbe6..fd6fff522 100644
--- a/Server/service/networkService.js
+++ b/Server/service/networkService.js
@@ -1,5 +1,6 @@
import { errorMessages, successMessages } from "../utils/messages.js";
const SERVICE_NAME = "NetworkService";
+const UPROCK_ENDPOINT = "https://api.uprock.com/checkmate/push";
/**
* Constructs a new NetworkService instance.
@@ -19,6 +20,7 @@ class NetworkService {
this.TYPE_HARDWARE = "hardware";
this.TYPE_DOCKER = "docker";
this.TYPE_PORT = "port";
+ this.TYPE_DISTRIBUTED_HTTP = "distributed_http";
this.SERVICE_NAME = SERVICE_NAME;
this.NETWORK_ERROR = 5000;
this.PING_ERROR = 5001;
@@ -323,6 +325,35 @@ class NetworkService {
}
}
+ async requestDistributedHttp(job) {
+ try {
+ const monitor = job.data;
+ const CALLBACK_URL = process.env.NGROK_URL
+ ? process.env.NGROK_URL
+ : process.env.CALLBACK_URL;
+
+ const response = await this.axios.post(
+ UPROCK_ENDPOINT,
+ {
+ id: monitor._id,
+ url: monitor.url,
+ callback: `${CALLBACK_URL}/api/v1/distributed-uptime/callback`,
+ },
+ {
+ headers: {
+ "Content-Type": "application/json",
+ "x-checkmate-key": process.env.UPROCK_API_KEY,
+ },
+ }
+ );
+ } catch (error) {
+ console.log(error.message);
+ error.service = this.SERVICE_NAME;
+ error.method = "requestDistributedHttp";
+ throw error;
+ }
+ }
+
/**
* Handles unsupported job types by throwing an error with details.
*
@@ -406,6 +437,8 @@ class NetworkService {
return await this.requestDocker(job);
case this.TYPE_PORT:
return await this.requestPort(job);
+ case this.TYPE_DISTRIBUTED_HTTP:
+ return await this.requestDistributedHttp(job);
default:
return this.handleUnsupportedType(type);
diff --git a/Server/service/statusService.js b/Server/service/statusService.js
index cca443e9e..fa263da75 100644
--- a/Server/service/statusService.js
+++ b/Server/service/statusService.js
@@ -25,7 +25,7 @@ class StatusService {
* @param {Object} networkResponse - The network response containing monitorId and status.
* @param {string} networkResponse.monitorId - The ID of the monitor.
* @param {string} networkResponse.status - The new status of the monitor.
- * @returns {Promise