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 5240bec9b..51a0c8964 100644 --- a/Client/package-lock.json +++ b/Client/package-lock.json @@ -11,18 +11,25 @@ "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@fontsource/roboto": "^5.0.13", - "@mui/icons-material": "6.4.2", - "@mui/lab": "6.0.0-beta.25", - "@mui/material": "6.4.2", + "@hello-pangea/dnd": "^17.0.0", + "@mui/icons-material": "6.4.3", + "@mui/lab": "6.0.0-beta.26", + "@mui/material": "6.4.3", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.24.1", - "@mui/x-date-pickers": "7.24.1", + "@mui/x-data-grid": "7.25.0", + "@mui/x-date-pickers": "7.25.0", "@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", @@ -1103,9 +1213,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.2.tgz", - "integrity": "sha512-Qmod9fHsFWrtLxdSkZ4iDLRz2AUKt3C2ZEimuY+qKlQGVKJDNS5DuSlNOAgqfHFDq8mzB17ATN6HFcThwJlvUw==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.3.tgz", + "integrity": "sha512-hlyOzo2ObarllAOeT1ZSAusADE5NZNencUeIvXrdQ1Na+FL1lcznhbxfV5He1KqGiuR8Az3xtCUcYKwMVGFdzg==", "license": "MIT", "funding": { "type": "opencollective", @@ -1113,9 +1223,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.2.tgz", - "integrity": "sha512-uwsH1KRmxkJwK3NZyo1xL9pEduL16ftCnzYBYjd6nPNtm05QAoIc0aqedS9tqDV+Ab3q5C04HHOVsMDDv1EBpg==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.3.tgz", + "integrity": "sha512-3IY9LpjkwIJVgL/SkZQKKCUcumdHdQEsJaIavvsQze2QEztBt0HJ17naToN0DBBdhKdtwX5xXrfD6ZFUeWWk8g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0" @@ -1128,7 +1238,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^6.4.2", + "@mui/material": "^6.4.3", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -1139,16 +1249,16 @@ } }, "node_modules/@mui/lab": { - "version": "6.0.0-beta.25", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.25.tgz", - "integrity": "sha512-Dkh+gFp30Mbqg9A6VY1fOqP3amsgylYZLKb7fbO80GMecSfx+tb+HU5IJghYrhiR6JaY3nEK1AOxMO0kPydaUw==", + "version": "6.0.0-beta.26", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.26.tgz", + "integrity": "sha512-auu2dXp6jslzW4Cp0tfYWv0xO9FmuwROsjyWcB9wPlAsEoWhh5N1FW8dqESDwaSKqFz5LwV+Y2vsYjYsYX9aOw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", "@mui/base": "5.0.0-beta.69", - "@mui/system": "^6.4.2", + "@mui/system": "^6.4.3", "@mui/types": "^7.2.21", - "@mui/utils": "^6.4.2", + "@mui/utils": "^6.4.3", "clsx": "^2.1.1", "prop-types": "^15.8.1" }, @@ -1162,8 +1272,8 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material": "^6.4.2", - "@mui/material-pigment-css": "^6.4.2", + "@mui/material": "^6.4.3", + "@mui/material-pigment-css": "^6.4.3", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1184,16 +1294,16 @@ } }, "node_modules/@mui/material": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.2.tgz", - "integrity": "sha512-9jKr53KbAJyyBRx8LRmX7ATXHlGtxVQdPgm1uyXMoEPMVkSJW1yO3vFgfYoDbGx4ZHcCNuWa4FkFIPWVt9fghA==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.3.tgz", + "integrity": "sha512-ubtQjplbWneIEU8Y+4b2VA0CDBlyH5I3AmVFGmsLyDe/bf0ubxav5t11c8Afem6rkSFWPlZA2DilxmGka1xiKQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.4.2", - "@mui/system": "^6.4.2", + "@mui/core-downloads-tracker": "^6.4.3", + "@mui/system": "^6.4.3", "@mui/types": "^7.2.21", - "@mui/utils": "^6.4.2", + "@mui/utils": "^6.4.3", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", @@ -1212,7 +1322,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.4.2", + "@mui/material-pigment-css": "^6.4.3", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1239,13 +1349,13 @@ "license": "MIT" }, "node_modules/@mui/private-theming": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.2.tgz", - "integrity": "sha512-2CkQT0gNlogM50qGTBJgWA7hPPx4AeH8RE2xJa+PHtIOowiVPX52ZsQ0e7Ho18DAqEbkngQ6Uju037ER+TCY5A==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.3.tgz", + "integrity": "sha512-7x9HaNwDCeoERc4BoEWLieuzKzXu5ZrhRnEM6AUcRXUScQLvF1NFkTlP59+IJfTbEMgcGg1wWHApyoqcksrBpQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.4.2", + "@mui/utils": "^6.4.3", "prop-types": "^15.8.1" }, "engines": { @@ -1266,9 +1376,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.2.tgz", - "integrity": "sha512-cgjQK2bkllSYoWUBv93ALhCPJ0NhfO3NctsBf13/b4XSeQVfKPBAnR+P9mNpdFMa5a5RWwtWuBD3cZ5vktsN+g==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.3.tgz", + "integrity": "sha512-OC402VfK+ra2+f12Gef8maY7Y9n7B6CZcoQ9u7mIkh/7PKwW/xH81xwX+yW+Ak1zBT3HYcVjh2X82k5cKMFGoQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", @@ -1300,16 +1410,16 @@ } }, "node_modules/@mui/system": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.2.tgz", - "integrity": "sha512-wQbaPCtsxNsM5nR+NZpkFJBKVKH03GQnAjlkKENM8JQqGdWcRyM3f4fJZgzzNdIFpSQw4wpAQKnhfHkjf3d6yQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.3.tgz", + "integrity": "sha512-Q0iDwnH3+xoxQ0pqVbt8hFdzhq1g2XzzR4Y5pVcICTNtoCLJmpJS3vI4y/OIM1FHFmpfmiEC2IRIq7YcZ8nsmg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.4.2", - "@mui/styled-engine": "^6.4.2", + "@mui/private-theming": "^6.4.3", + "@mui/styled-engine": "^6.4.3", "@mui/types": "^7.2.21", - "@mui/utils": "^6.4.2", + "@mui/utils": "^6.4.3", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -1354,9 +1464,9 @@ } }, "node_modules/@mui/utils": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.2.tgz", - "integrity": "sha512-5NkhzlJkmR5+5RSs/Irqin1GPy2Z8vbLk/UzQrH9FEAnm6OA9SvuXjzgklxUs7N65VwEkGpKK1jMZ5K84hRdzQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.3.tgz", + "integrity": "sha512-jxHRHh3BqVXE9ABxDm+Tc3wlBooYz/4XPa0+4AI+iF38rV1/+btJmSUgG4shDtSWVs/I97aDn5jBCt6SF2Uq2A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", @@ -1390,15 +1500,15 @@ "license": "MIT" }, "node_modules/@mui/x-charts": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.24.1.tgz", - "integrity": "sha512-OdTS/nXaANPe4AoUFIDD4LlID8kK/00q+uqVOCkVClEvFQeAkj3pBaghdS4hY7rVqsCgsm+yOStQVJa9G2MR+Q==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.25.0.tgz", + "integrity": "sha512-+DhnojHrVTt8RsTgq8AztzdFpW1kzOgiBdo0Pkl0DyxVdaKELC5QaetFwim9nIxT2zmU/RmiBcOU+qbqmQpFNA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", "@mui/utils": "^5.16.6 || ^6.0.0", "@mui/x-charts-vendor": "7.20.0", - "@mui/x-internals": "7.24.1", + "@mui/x-internals": "7.25.0", "@react-spring/rafz": "^9.7.5", "@react-spring/web": "^9.7.5", "clsx": "^2.1.1", @@ -1448,14 +1558,14 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.24.1.tgz", - "integrity": "sha512-4sYTbMwsDotuTd2Cwa2JGTPXPWQs8RGJvocAKnIsNOzNdZNMrikE//HO35snriK8s4dauAApY7RVbeisjpVT+A==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.25.0.tgz", + "integrity": "sha512-e9ZLbCgnDiADFiDyXo91ucZFHEMkKBNpwpkaTq5KohzefJfMpMQjTEbJeueSfBG2G1Q1Am2TPeBqrNeReIA7RQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", "@mui/utils": "^5.16.6 || ^6.0.0", - "@mui/x-internals": "7.24.1", + "@mui/x-internals": "7.25.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1" @@ -1485,14 +1595,14 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.24.1.tgz", - "integrity": "sha512-ykQugMQHuQKBk3kViW/r0PpubtHQOlrd54bgbdafgTMCeM2VpXvv4zimzOu5IGnM6wEN8hupC7EXZbkrT6x46w==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.25.0.tgz", + "integrity": "sha512-t62OSFAKwj7KYQ8KcwTuKj6OgDuLQPSe4QUJcKDzD9rEhRIJVRUw2x27gBSdcls4l0PTrba19TghvDxCZprriw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", "@mui/utils": "^5.16.6 || ^6.0.0", - "@mui/x-internals": "7.24.1", + "@mui/x-internals": "7.25.0", "@types/react-transition-group": "^4.4.11", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -1551,9 +1661,9 @@ } }, "node_modules/@mui/x-internals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.24.1.tgz", - "integrity": "sha512-9BvJzpLJnS9BDphvkiv6v0QOLxbnu8jhwcexFjtCQ2ZyxtVuVsWzGZ2npT9sGOil7+eaFDmWnJtea/tgrPvSwQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.25.0.tgz", + "integrity": "sha512-tBUN54YznAkmtCIRAOl35Kgl0MjFDIjUbzIrbWRgVSIR3QJ8bXnVSkiRBi+P91SZEl9+ZW0rDj+osq7xFJV0kg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -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", @@ -1715,9 +1840,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.21.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.1.tgz", - "integrity": "sha512-KeBYSwohb8g4/wCcnksvKTYlg69O62sQeLynn2YE+5z7JWEj95if27kclW9QqbrlsQ2DINI8fjbV3zyuKfwjKg==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.22.0.tgz", + "integrity": "sha512-MBOl8MeOzpK0HQQQshKB7pABXbmyHizdTpqnrIseTbsv0nAepwC2ENZa1aaBExNQcpLoXmWthhak8SABLzvGPw==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -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", @@ -5447,12 +5928,12 @@ } }, "node_modules/react-router": { - "version": "6.28.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.2.tgz", - "integrity": "sha512-BgFY7+wEGVjHCiqaj2XiUBQ1kkzfg6UoKYwEe0wv+FF+HNPCxtS/MVPvLAPH++EsuCMReZl9RYVGqcHLk5ms3A==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.29.0.tgz", + "integrity": "sha512-DXZJoE0q+KyeVw75Ck6GkPxFak63C4fGqZGNijnWgzB/HzSP1ZfTlBj5COaGWwhrMQ/R8bXiq5Ooy4KG+ReyjQ==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.21.1" + "@remix-run/router": "1.22.0" }, "engines": { "node": ">=14.0.0" @@ -5462,13 +5943,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.28.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.2.tgz", - "integrity": "sha512-O81EWqNJWqvlN/a7eTudAdQm0TbI7hw+WIi7OwwMcTn5JMyZ0ibTFNGz+t+Lju0df4LcqowCegcrK22lB1q9Kw==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.29.0.tgz", + "integrity": "sha512-pkEbJPATRJ2iotK+wUwHfy0xs2T59YPEN8BQxVCPeBZvK7kfPESRc/nyxzdcxR17hXgUPYx2whMwl+eo9cUdnQ==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.21.1", - "react-router": "6.28.2" + "@remix-run/router": "1.22.0", + "react-router": "6.29.0" }, "engines": { "node": ">=14.0.0" @@ -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 b2e15c02d..6b006e24e 100644 --- a/Client/package.json +++ b/Client/package.json @@ -14,18 +14,25 @@ "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@fontsource/roboto": "^5.0.13", - "@mui/icons-material": "6.4.2", - "@mui/lab": "6.0.0-beta.25", - "@mui/material": "6.4.2", + "@hello-pangea/dnd": "^17.0.0", + "@mui/icons-material": "6.4.3", + "@mui/lab": "6.0.0-beta.26", + "@mui/material": "6.4.3", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.24.1", - "@mui/x-date-pickers": "7.24.1", + "@mui/x-data-grid": "7.25.0", + "@mui/x-date-pickers": "7.25.0", "@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/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/GenericFallback/NetworkError.jsx b/Client/src/Components/GenericFallback/NetworkError.jsx new file mode 100644 index 000000000..b26037a57 --- /dev/null +++ b/Client/src/Components/GenericFallback/NetworkError.jsx @@ -0,0 +1,20 @@ +import { Typography } from "@mui/material"; +import { useTheme } from "@emotion/react"; + +const NetworkError = () => { + const theme = useTheme(); + return ( + <> + + Network error + + Please check your connection + + ); +}; + +export default NetworkError; diff --git a/Client/src/Components/NetworkErrorFallback/index.jsx b/Client/src/Components/GenericFallback/index.jsx similarity index 81% rename from Client/src/Components/NetworkErrorFallback/index.jsx rename to Client/src/Components/GenericFallback/index.jsx index 1039b256b..af9ed2ea4 100644 --- a/Client/src/Components/NetworkErrorFallback/index.jsx +++ b/Client/src/Components/GenericFallback/index.jsx @@ -1,5 +1,5 @@ import { useTheme } from "@emotion/react"; -import { Box, Stack, Typography } from "@mui/material"; +import { Box, Stack } from "@mui/material"; import Skeleton from "../../assets/Images/create-placeholder.svg?react"; import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react"; import Background from "../../assets/Images/background-grid.svg?react"; @@ -11,13 +11,13 @@ import { useSelector } from "react-redux"; * @returns {JSX.Element} The rendered fallback UI. */ -const NetworkErrorFallback = () => { +const GenericFallback = ({ children }) => { const theme = useTheme(); const mode = useSelector((state) => state.ui.mode); return ( { maxWidth={"300px"} zIndex={1} > - - Network error - - Please check your connection + {children} ); }; -export default NetworkErrorFallback; +export default GenericFallback; 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 6a16b9300..971f17c00 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/CreateMonitorHeader/index.jsx b/Client/src/Components/MonitorCreateHeader/index.jsx similarity index 85% rename from Client/src/Components/CreateMonitorHeader/index.jsx rename to Client/src/Components/MonitorCreateHeader/index.jsx index b8fcbc39a..01a102b7d 100644 --- a/Client/src/Components/CreateMonitorHeader/index.jsx +++ b/Client/src/Components/MonitorCreateHeader/index.jsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import PropTypes from "prop-types"; import SkeletonLayout from "./skeleton"; -const CreateMonitorHeader = ({ isAdmin, shouldRender, path }) => { +const CreateMonitorHeader = ({ isAdmin, shouldRender = true, path }) => { const navigate = useNavigate(); if (!isAdmin) return null; if (!shouldRender) return ; @@ -28,6 +28,6 @@ export default CreateMonitorHeader; CreateMonitorHeader.propTypes = { isAdmin: PropTypes.bool.isRequired, - shouldRender: PropTypes.bool.isRequired, + shouldRender: PropTypes.bool, path: PropTypes.string.isRequired, }; diff --git a/Client/src/Components/CreateMonitorHeader/skeleton.jsx b/Client/src/Components/MonitorCreateHeader/skeleton.jsx similarity index 100% rename from Client/src/Components/CreateMonitorHeader/skeleton.jsx rename to Client/src/Components/MonitorCreateHeader/skeleton.jsx diff --git a/Client/src/Components/MonitorStatusHeader/ConfigButton/index.jsx b/Client/src/Components/MonitorStatusHeader/ConfigButton/index.jsx index fc6407f31..c966abf33 100644 --- a/Client/src/Components/MonitorStatusHeader/ConfigButton/index.jsx +++ b/Client/src/Components/MonitorStatusHeader/ConfigButton/index.jsx @@ -3,7 +3,7 @@ import { useTheme } from "@emotion/react"; import { useNavigate } from "react-router-dom"; import SettingsIcon from "../../../assets/icons/settings-bold.svg?react"; import PropTypes from "prop-types"; -const ConfigButton = ({ shouldRender, monitorId }) => { +const ConfigButton = ({ shouldRender = true, monitorId, path }) => { const theme = useTheme(); const navigate = useNavigate(); @@ -14,7 +14,7 @@ const ConfigButton = ({ shouldRender, monitorId }) => { + ); + } + + return ( + + + Showing statistics for past{" "} + {dateRange === "day" ? "24 hours" : dateRange === "week" ? "7 days" : "30 days"}. + + {timeFramePicker} ); }; -TimeFramePicker.propTypes = { +MonitorTimeFrameHeader.propTypes = { shouldRender: PropTypes.bool, + hasDateRange: PropTypes.bool, dateRange: PropTypes.string, setDateRange: PropTypes.func, }; -export default TimeFramePicker; +export default MonitorTimeFrameHeader; diff --git a/Client/src/Pages/Uptime/Details/Components/TimeFramePicker/skeleton.jsx b/Client/src/Components/MonitorTimeFrameHeader/skeleton.jsx similarity index 100% rename from Client/src/Pages/Uptime/Details/Components/TimeFramePicker/skeleton.jsx rename to Client/src/Components/MonitorTimeFrameHeader/skeleton.jsx diff --git a/Client/src/Components/ProgressBars/index.jsx b/Client/src/Components/ProgressBars/index.jsx index abb896844..42ae652c7 100644 --- a/Client/src/Components/ProgressBars/index.jsx +++ b/Client/src/Components/ProgressBars/index.jsx @@ -168,7 +168,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/Sidebar/index.jsx b/Client/src/Components/Sidebar/index.jsx index 3821306ad..03c6b54ba 100644 --- a/Client/src/Components/Sidebar/index.jsx +++ b/Client/src/Components/Sidebar/index.jsx @@ -43,7 +43,9 @@ 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 Groups from "../../assets/icons/groups.svg?react"; import "./index.css"; @@ -51,8 +53,10 @@ 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 +101,7 @@ const PATH_MAP = { monitors: "Dashboard", pagespeed: "Dashboard", infrastructure: "Dashboard", + ["distributed-uptime"]: "Dashboard", account: "Account", settings: "Settings", }; @@ -335,15 +340,13 @@ function Sidebar() { disableInteractive > 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} 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 && ( + {alt} + )} + + {heading} + {subHeading} + + ); }; diff --git a/Client/src/Components/StatusBoxes/index.jsx b/Client/src/Components/StatusBoxes/index.jsx index 6e09d0cba..a2c627d19 100644 --- a/Client/src/Components/StatusBoxes/index.jsx +++ b/Client/src/Components/StatusBoxes/index.jsx @@ -4,15 +4,21 @@ import SkeletonLayout from "./skeleton"; // Utils import { useTheme } from "@mui/material/styles"; import PropTypes from "prop-types"; -const StatusBoxes = ({ shouldRender, children }) => { +const StatusBoxes = ({ shouldRender, flexWrap = "nowrap", children }) => { const theme = useTheme(); if (!shouldRender) { - return ; + return ( + + ); } return ( {children} diff --git a/Client/src/Components/StatusBoxes/skeleton.jsx b/Client/src/Components/StatusBoxes/skeleton.jsx index b0f04ff3f..bc8b18251 100644 --- a/Client/src/Components/StatusBoxes/skeleton.jsx +++ b/Client/src/Components/StatusBoxes/skeleton.jsx @@ -1,21 +1,22 @@ import { Stack, Skeleton } from "@mui/material"; import { useTheme } from "@emotion/react"; import PropTypes from "prop-types"; -const SkeletonLayout = ({ numBoxes }) => { +const SkeletonLayout = ({ numBoxes, flexWrap }) => { const theme = useTheme(); return ( {Array.from({ length: numBoxes }).map((_, index) => { - const width = `${100 / numBoxes}%`; + const width = `${200 / numBoxes}%`; return ( ); @@ -25,6 +26,7 @@ const SkeletonLayout = ({ numBoxes }) => { }; SkeletonLayout.propTypes = { + flexWrap: PropTypes.string, numBoxes: PropTypes.number, }; diff --git a/Client/src/Components/Table/TablePagination/index.jsx b/Client/src/Components/Table/TablePagination/index.jsx index 014e2405f..8427f4270 100644 --- a/Client/src/Components/Table/TablePagination/index.jsx +++ b/Client/src/Components/Table/TablePagination/index.jsx @@ -8,7 +8,7 @@ Pagination.propTypes = { paginationLabel: PropTypes.string, // Label for the pagination. itemCount: PropTypes.number, // Total number of items for pagination. page: PropTypes.number, // Current page index. - rowsPerPage: PropTypes.number.isRequired, // Number of rows displayed per page. + rowsPerPage: PropTypes.number, // Number of rows displayed per page. handleChangePage: PropTypes.func.isRequired, // Function to handle page changes. handleChangeRowsPerPage: PropTypes.func, // Function to handle changes in rows per page. }; diff --git a/Client/src/Components/Table/index.jsx b/Client/src/Components/Table/index.jsx index 54e47ed1f..b716038e9 100644 --- a/Client/src/Components/Table/index.jsx +++ b/Client/src/Components/Table/index.jsx @@ -7,6 +7,7 @@ import { TableHead, TableRow, } from "@mui/material"; +import SkeletonLayout from "./skeleton"; import PropTypes from "prop-types"; import { useTheme } from "@emotion/react"; @@ -33,6 +34,7 @@ import { useTheme } from "@emotion/react"; */ const DataTable = ({ + shouldRender = true, headers = [], data = [], config = { @@ -41,6 +43,10 @@ const DataTable = ({ }, }) => { const theme = useTheme(); + if (!shouldRender) { + return ; + } + if ((headers?.length ?? 0) === 0) { return "No data"; } @@ -117,6 +123,7 @@ const DataTable = ({ }; DataTable.propTypes = { + shouldRender: PropTypes.bool, headers: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, diff --git a/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/skeleton.jsx b/Client/src/Components/Table/skeleton.jsx similarity index 56% rename from Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/skeleton.jsx rename to Client/src/Components/Table/skeleton.jsx index e3c4fa2e2..062d39156 100644 --- a/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/skeleton.jsx +++ b/Client/src/Components/Table/skeleton.jsx @@ -1,14 +1,14 @@ import { Skeleton } from "@mui/material"; -const UptimeDataTableSkeleton = () => { +const TableSkeleton = () => { return ( ); }; -export default UptimeDataTableSkeleton; +export default TableSkeleton; diff --git a/Client/src/Pages/Account/index.jsx b/Client/src/Pages/Account/index.jsx index 9fd38b5ef..0a1d2c104 100644 --- a/Client/src/Pages/Account/index.jsx +++ b/Client/src/Pages/Account/index.jsx @@ -87,33 +87,6 @@ const Account = ({ open = "profile" }) => { 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 + + + setSelectedMonitor(e.target.value)} + items={monitorNames} + sx={{ + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastTextSecondary, + }} + /> + + + + Filter by: + + + + + + + + + + Show: + + + + + + + + + + ); +}; + +OptionsHeader.propTypes = { + shouldRender: PropTypes.bool, + selectedMonitor: PropTypes.string, + setSelectedMonitor: PropTypes.func, + monitors: PropTypes.object, + filter: PropTypes.string, + setFilter: PropTypes.func, + dateRange: PropTypes.string, + setDateRange: PropTypes.func, +}; + +export default OptionsHeader; diff --git a/Client/src/Pages/Incidents/Components/OptionsHeader/skeleton.jsx b/Client/src/Pages/Incidents/Components/OptionsHeader/skeleton.jsx new file mode 100644 index 000000000..8a8990dc4 --- /dev/null +++ b/Client/src/Pages/Incidents/Components/OptionsHeader/skeleton.jsx @@ -0,0 +1,11 @@ +import { Stack, Skeleton } from "@mui/material"; + +const SkeletonLayout = () => { + return ( + + + + ); +}; + +export default SkeletonLayout; diff --git a/Client/src/Pages/Incidents/Hooks/useChecksFetch.jsx b/Client/src/Pages/Incidents/Hooks/useChecksFetch.jsx new file mode 100644 index 000000000..614006792 --- /dev/null +++ b/Client/src/Pages/Incidents/Hooks/useChecksFetch.jsx @@ -0,0 +1,60 @@ +import { useState, useEffect } from "react"; +import { networkService } from "../../../main"; +import { createToast } from "../../../Utils/toastUtils"; +import { useSelector } from "react-redux"; +const useChecksFetch = ({ selectedMonitor, filter, dateRange, page, rowsPerPage }) => { + //Redux + const { authToken, user } = useSelector((state) => state.auth); + + //Local + const [isLoading, setIsLoading] = useState(true); + const [networkError, setNetworkError] = useState(false); + const [checks, setChecks] = useState(undefined); + const [checksCount, setChecksCount] = useState(undefined); + + useEffect(() => { + const fetchChecks = async () => { + try { + setIsLoading(true); + let res; + + if (selectedMonitor === "0") { + res = await networkService.getChecksByTeam({ + authToken: authToken, + status: false, + teamId: user.teamId, + sortOrder: "desc", + limit: null, + dateRange, + filter: filter, + page: page, + rowsPerPage: rowsPerPage, + }); + } else { + res = await networkService.getChecksByMonitor({ + authToken: authToken, + status: false, + monitorId: selectedMonitor, + sortOrder: "desc", + limit: null, + dateRange, + filter: filter, + page, + rowsPerPage, + }); + } + setChecks(res.data.data.checks); + setChecksCount(res.data.data.checksCount); + } catch (error) { + setNetworkError(true); + createToast({ body: error.message }); + } finally { + setIsLoading(false); + } + }; + fetchChecks(); + }, [authToken, user, dateRange, page, rowsPerPage, filter, selectedMonitor]); + return { isLoading, networkError, checks, checksCount }; +}; + +export default useChecksFetch; diff --git a/Client/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx b/Client/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx new file mode 100644 index 000000000..20043e80d --- /dev/null +++ b/Client/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; +import { networkService } from "../../../main"; +import { createToast } from "../../../Utils/toastUtils"; +const useMonitorsFetch = ({ authToken, teamId }) => { + //Local state + const [isLoading, setIsLoading] = useState(true); + const [networkError, setNetworkError] = useState(false); + + const [monitors, setMonitors] = useState(undefined); + + useEffect(() => { + const fetchMonitors = async () => { + try { + setIsLoading(true); + const res = await networkService.getMonitorsByTeamId({ + authToken, + teamId, + limit: null, + types: null, + status: null, + checkOrder: null, + normalize: null, + page: null, + rowsPerPage: null, + filter: null, + field: null, + order: null, + }); + if (res?.data?.data?.monitors?.length > 0) { + const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => { + acc[monitor._id] = monitor; + return acc; + }, {}); + setMonitors(monitorLookup); + } + } catch (error) { + setNetworkError(true); + createToast({ + body: error.message, + }); + } finally { + setIsLoading(false); + } + }; + + fetchMonitors(); + }, [authToken, teamId]); + return { isLoading, monitors, networkError }; +}; + +export { useMonitorsFetch }; diff --git a/Client/src/Pages/Incidents/IncidentTable/Empty/Empty.jsx b/Client/src/Pages/Incidents/IncidentTable/Empty/Empty.jsx deleted file mode 100644 index 78b7e8d6d..000000000 --- a/Client/src/Pages/Incidents/IncidentTable/Empty/Empty.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useTheme } from "@emotion/react"; -import PlaceholderLight from "../../../../assets/Images/data_placeholder.svg?react"; -import PlaceholderDark from "../../../../assets/Images/data_placeholder_dark.svg?react"; -import { Box, Typography } from "@mui/material"; -import PropTypes from "prop-types"; - -const Empty = ({ styles, mode }) => { - const theme = useTheme(); - return ( - - - {mode === "light" ? : } - - - No incidents recorded yet. - - - ); -}; - -Empty.propTypes = { - styles: PropTypes.object, - mode: PropTypes.string, -}; - -export { Empty }; diff --git a/Client/src/Pages/Incidents/IncidentTable/Skeleton/Skeleton.jsx b/Client/src/Pages/Incidents/IncidentTable/Skeleton/Skeleton.jsx deleted file mode 100644 index a3a23c14c..000000000 --- a/Client/src/Pages/Incidents/IncidentTable/Skeleton/Skeleton.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Skeleton /* , Stack */ } from "@mui/material"; -const IncidentSkeleton = () => { - return ( - <> - - - - ); -}; - -export { IncidentSkeleton }; diff --git a/Client/src/Pages/Incidents/IncidentTable/index.jsx b/Client/src/Pages/Incidents/IncidentTable/index.jsx deleted file mode 100644 index 15d65cadd..000000000 --- a/Client/src/Pages/Incidents/IncidentTable/index.jsx +++ /dev/null @@ -1,187 +0,0 @@ -import PropTypes from "prop-types"; -import { Typography, Box } from "@mui/material"; - -import { useState, useEffect } from "react"; -import { useSelector } from "react-redux"; -import { networkService } from "../../../main"; -import { StatusLabel } from "../../../Components/Label"; -import { logger } from "../../../Utils/Logger"; -import { useTheme } from "@emotion/react"; -import { formatDateWithTz } from "../../../Utils/timeUtils"; -import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react"; -import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react"; -import { HttpStatusLabel } from "../../../Components/HttpStatusLabel"; -import { Empty } from "./Empty/Empty"; -import { IncidentSkeleton } from "./Skeleton/Skeleton"; -import DataTable from "../../../Components/Table"; -import Pagination from "../../../Components/Table/TablePagination"; - -const IncidentTable = ({ monitors, selectedMonitor, filter, dateRange }) => { - const uiTimezone = useSelector((state) => state.ui.timezone); - - const theme = useTheme(); - const { authToken, user } = useSelector((state) => state.auth); - const mode = useSelector((state) => state.ui.mode); - const [checks, setChecks] = useState([]); - const [checksCount, setChecksCount] = useState(0); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const fetchPage = async () => { - if (!monitors || Object.keys(monitors).length === 0) { - return; - } - try { - setIsLoading(true); - let res; - if (selectedMonitor === "0") { - res = await networkService.getChecksByTeam({ - authToken: authToken, - status: false, - teamId: user.teamId, - sortOrder: "desc", - limit: null, - dateRange, - filter: filter, - page: page, - rowsPerPage: rowsPerPage, - }); - } else { - res = await networkService.getChecksByMonitor({ - authToken: authToken, - status: false, - monitorId: selectedMonitor, - sortOrder: "desc", - limit: null, - dateRange, - filter: filter, - page, - rowsPerPage, - }); - } - - setChecks(res.data.data.checks); - setChecksCount(res.data.data.checksCount); - } catch (error) { - logger.error(error); - } finally { - setIsLoading(false); - } - }; - fetchPage(); - }, [authToken, user, monitors, selectedMonitor, filter, page, rowsPerPage, dateRange]); - - const handlePageChange = (_, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(event.target.value); - }; - - const headers = [ - { - id: "monitorName", - content: "Monitor Name", - render: (row) => monitors[row.monitorId]?.name ?? "N/A", - }, - { - id: "status", - content: "Status", - render: (row) => { - const status = row.status === true ? "up" : "down"; - return ( - - ); - }, - }, - { - id: "dateTime", - content: "Date & Time", - render: (row) => { - const formattedDate = formatDateWithTz( - row.createdAt, - "YYYY-MM-DD HH:mm:ss A", - uiTimezone - ); - return formattedDate; - }, - }, - { - id: "statusCode", - content: "Status Code", - render: (row) => , - }, - { id: "message", content: "Message", render: (row) => row.message }, - ]; - - let sharedStyles = { - border: 1, - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - backgroundColor: theme.palette.primary.main, - p: theme.spacing(30), - }; - - const hasChecks = checks?.length === 0; - const noIncidentsRecordedYet = hasChecks && selectedMonitor === "0"; - const noIncidentsForThatMonitor = hasChecks && selectedMonitor !== "0"; - - return ( - <> - {isLoading ? ( - - ) : noIncidentsRecordedYet ? ( - - ) : noIncidentsForThatMonitor ? ( - - - {mode === "light" ? : } - - - The monitor you have selected has no recorded incidents yet. - - - ) : ( - <> - - - - )} - - ); -}; - -IncidentTable.propTypes = { - monitors: PropTypes.object.isRequired, - selectedMonitor: PropTypes.string.isRequired, - filter: PropTypes.string.isRequired, - dateRange: PropTypes.string.isRequired, -}; - -export default IncidentTable; diff --git a/Client/src/Pages/Incidents/index.jsx b/Client/src/Pages/Incidents/index.jsx index 8305ec672..a5b534ccc 100644 --- a/Client/src/Pages/Incidents/index.jsx +++ b/Client/src/Pages/Incidents/index.jsx @@ -1,211 +1,63 @@ -import { useState, useEffect } from "react"; -import { useSelector } from "react-redux"; -import { ButtonGroup, Stack, Typography, Button } from "@mui/material"; -import { useParams } from "react-router-dom"; +// Components +import { Stack } from "@mui/material"; +import Breadcrumbs from "../../Components/Breadcrumbs"; -import { networkService } from "../../main"; +//Utils import { useTheme } from "@emotion/react"; -import Select from "../../Components/Inputs/Select"; -import IncidentTable from "./IncidentTable"; -import SkeletonLayout from "./skeleton"; +import { useMonitorsFetch } from "./Hooks/useMonitorsFetch"; +import { useSelector } from "react-redux"; +import OptionsHeader from "./Components/OptionsHeader"; +import { useState } from "react"; +import IncidentTable from "./Components/IncidentTable"; +import GenericFallback from "../../Components/GenericFallback"; +import NetworkError from "../../Components/GenericFallback/NetworkError"; +//Constants +const BREADCRUMBS = [{ name: `Incidents`, path: "/incidents" }]; const Incidents = () => { - const theme = useTheme(); - const authState = useSelector((state) => state.auth); - const { monitorId } = useParams(); + // Redux state + const { authToken, user } = useSelector((state) => state.auth); - const [monitors, setMonitors] = useState({}); + // Local state const [selectedMonitor, setSelectedMonitor] = useState("0"); - const [isLoading, setIsLoading] = useState(true); + const [filter, setFilter] = useState(undefined); + const [dateRange, setDateRange] = useState(undefined); + //Utils + const theme = useTheme(); - // TODO do something with these filters - const [filter, setFilter] = useState("all"); - const [dateRange, setDateRange] = useState("hour"); + const { monitors, isLoading, networkError } = useMonitorsFetch({ + authToken, + teamId: user.teamId, + }); - useEffect(() => { - const fetchMonitors = async () => { - try { - setIsLoading(true); - const res = await networkService.getMonitorsByTeamId({ - authToken: authState.authToken, - teamId: authState.user.teamId, - limit: null, - types: null, - status: null, - checkOrder: null, - normalize: null, - page: null, - rowsPerPage: null, - filter: null, - field: null, - order: null, - }); - if (res?.data?.data?.monitors?.length > 0) { - const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => { - acc[monitor._id] = monitor; - return acc; - }, {}); - setMonitors(monitorLookup); - monitorId !== undefined && setSelectedMonitor(monitorId); - } - } catch (error) { - console.info(error); - } finally { - setIsLoading(false); - } - }; - fetchMonitors(); - }, [authState, monitorId]); - - useEffect(() => {}, [monitors]); - - const handleSelect = (event) => { - setSelectedMonitor(event.target.value); - }; - - const isActuallyLoading = isLoading && Object.keys(monitors)?.length === 0; + if (networkError) { + return ( + + + + ); + } return ( - - {isActuallyLoading ? ( - - ) : ( - <> - - - - Incidents for - - + + + + + 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..ef8419069 --- /dev/null +++ b/Client/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx @@ -0,0 +1,102 @@ +// 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 = ({ deleteStatusPage, isDeleting }) => { + const theme = useTheme(); + const location = useLocation(); + const currentPath = location.pathname; + const navigate = useNavigate(); + if (currentPath === "/status/public") { + return null; + } + + return ( + + + + + + + + + ); +}; + +Controls.propTypes = { + deleteStatusPage: PropTypes.func, + isDeleting: PropTypes.bool, +}; + +const ControlsHeader = ({ statusPage, deleteStatusPage, isDeleting }) => { + const theme = useTheme(); + + return ( + + + {"Company + {statusPage?.companyName} + + + + ); +}; + +ControlsHeader.propTypes = { + statusPage: PropTypes.object, + deleteStatusPage: PropTypes.func, + isDeleting: PropTypes.bool, +}; + +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..65a4a182a --- /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 "../../../../Uptime/Monitors/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..815b50a52 --- /dev/null +++ b/Client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx @@ -0,0 +1,73 @@ +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"; +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 { + ...monitor, + percentage: uptimePercentage, + percentageColor, + }; +}; + +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 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..7e392182e --- /dev/null +++ b/Client/src/Pages/StatusPage/Status/index.jsx @@ -0,0 +1,141 @@ +// 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"; +// 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"; + +const PublicStatus = () => { + // Local state + + // 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/Configure/index.jsx b/Client/src/Pages/Uptime/Configure/index.jsx index bdb90891b..4f281a763 100644 --- a/Client/src/Pages/Uptime/Configure/index.jsx +++ b/Client/src/Pages/Uptime/Configure/index.jsx @@ -347,7 +347,7 @@ const Configure = () => { } + startAdornment={monitor?.type === "http" && } id="monitor-url" label="URL to monitor" placeholder="google.com" diff --git a/Client/src/Pages/Uptime/Create/index.jsx b/Client/src/Pages/Uptime/Create/index.jsx index 9086c87af..a0fcce43b 100644 --- a/Client/src/Pages/Uptime/Create/index.jsx +++ b/Client/src/Pages/Uptime/Create/index.jsx @@ -88,7 +88,7 @@ const CreateMonitor = () => { ? `http${https ? "s" : ""}://` + monitor.url : monitor.url, port: monitor.type === "port" ? monitor.port : undefined, - name: monitor.name === "" ? monitor.url : monitor.name, + name: monitor.name || monitor.url.substring(0, 50), type: monitor.type, interval: monitor.interval * MS_PER_MINUTE, }; @@ -122,7 +122,7 @@ const CreateMonitor = () => { form = { ...form, - description: form.name, + description: monitor.name || monitor.url, teamId: user.teamId, userId: user._id, notifications: monitor.notifications, @@ -138,17 +138,23 @@ const CreateMonitor = () => { const handleChange = (event, formName) => { const { value } = event.target; - setMonitor({ + + const newMonitor = { ...monitor, [formName]: value, - }); + }; + if (formName === 'type') { + newMonitor.url = ''; + } + setMonitor(newMonitor); const { error } = monitorValidation.validate( - { [formName]: value }, + { type: monitor.type, [formName]: value }, { abortEarly: false } ); setErrors((prev) => ({ ...prev, + url: undefined, ...(error ? { [formName]: error.details[0].message } : { [formName]: undefined }), })); }; diff --git a/Client/src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx b/Client/src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx index aa40442a9..77fffd497 100644 --- a/Client/src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx +++ b/Client/src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx @@ -41,6 +41,7 @@ const ChartBoxes = ({ header="Uptime" > @@ -93,26 +94,28 @@ const ChartBoxes = ({ icon={} header="Incidents" > - - - {hoveredIncidentsData !== null - ? hoveredIncidentsData.totalChecks - : (monitor?.groupedDownChecks?.reduce((count, checkGroup) => { - return count + checkGroup.totalChecks; - }, 0) ?? 0)} - - {hoveredIncidentsData !== null && hoveredIncidentsData.time !== null && ( - - {formatDateWithTz(hoveredIncidentsData._id, dateFormat, uiTimezone)} + + + + {hoveredIncidentsData !== null + ? hoveredIncidentsData.totalChecks + : (monitor?.groupedDownChecks?.reduce((count, checkGroup) => { + return count + checkGroup.totalChecks; + }, 0) ?? 0)} - )} - + {hoveredIncidentsData !== null && hoveredIncidentsData.time !== null && ( + + {formatDateWithTz(hoveredIncidentsData._id, dateFormat, uiTimezone)} + + )} + + { const [certificateExpiry, setCertificateExpiry] = useState(undefined); - const [certificateIsLoading, setCertificateIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { const fetchCertificate = async () => { @@ -20,7 +20,7 @@ const useCertificateFetch = ({ } try { - setCertificateIsLoading(true); + setIsLoading(true); const res = await networkService.getCertificateExpiry({ authToken: authToken, monitorId: monitorId, @@ -35,12 +35,12 @@ const useCertificateFetch = ({ setCertificateExpiry("N/A"); logger.error(error); } finally { - setCertificateIsLoading(false); + setIsLoading(false); } }; fetchCertificate(); }, [authToken, monitorId, certificateDateFormat, uiTimezone, monitor]); - return { certificateExpiry, certificateIsLoading }; + return [certificateExpiry, isLoading]; }; export default useCertificateFetch; diff --git a/Client/src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx b/Client/src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx index 541c29f6e..8f57f63c0 100644 --- a/Client/src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx +++ b/Client/src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx @@ -1,8 +1,7 @@ import { useState } from "react"; import { useEffect } from "react"; -import { logger } from "../../../../Utils/Logger"; import { networkService } from "../../../../main"; - +import { createToast } from "../../../../Utils/toastUtils"; export const useChecksFetch = ({ authToken, monitorId, @@ -12,12 +11,13 @@ export const useChecksFetch = ({ }) => { const [checks, setChecks] = useState(undefined); const [checksCount, setChecksCount] = useState(undefined); - const [checksAreLoading, setChecksAreLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [networkError, setNetworkError] = useState(false); useEffect(() => { const fetchChecks = async () => { try { - setChecksAreLoading(true); + setIsLoading(true); const res = await networkService.getChecksByMonitor({ authToken: authToken, monitorId: monitorId, @@ -31,15 +31,16 @@ export const useChecksFetch = ({ setChecks(res.data.data.checks); setChecksCount(res.data.data.checksCount); } catch (error) { - logger.error(error); + setNetworkError(true); + createToast({ body: error.message }); } finally { - setChecksAreLoading(false); + setIsLoading(false); } }; fetchChecks(); }, [authToken, monitorId, dateRange, page, rowsPerPage]); - return { checks, checksCount, checksAreLoading }; + return [checks, checksCount, isLoading, networkError]; }; export default useChecksFetch; diff --git a/Client/src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx b/Client/src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx index ca4643fd4..06d833d13 100644 --- a/Client/src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx +++ b/Client/src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx @@ -1,17 +1,17 @@ import { useEffect, useState } from "react"; import { networkService } from "../../../../main"; -import { logger } from "../../../../Utils/Logger"; import { useNavigate } from "react-router-dom"; +import { createToast } from "../../../../Utils/toastUtils"; export const useMonitorFetch = ({ authToken, monitorId, dateRange }) => { - const [monitorIsLoading, setMonitorsIsLoading] = useState(false); + const [networkError, setNetworkError] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [monitor, setMonitor] = useState(undefined); const navigate = useNavigate(); useEffect(() => { const fetchMonitors = async () => { try { - setMonitorsIsLoading(true); const res = await networkService.getUptimeDetailsById({ authToken: authToken, monitorId: monitorId, @@ -20,15 +20,15 @@ export const useMonitorFetch = ({ authToken, monitorId, dateRange }) => { }); setMonitor(res?.data?.data ?? {}); } catch (error) { - logger.error(error); - navigate("/not-found", { replace: true }); + setNetworkError(true); + createToast({ body: error.message }); } finally { - setMonitorsIsLoading(false); + setIsLoading(false); } }; fetchMonitors(); }, [authToken, dateRange, monitorId, navigate]); - return { monitor, monitorIsLoading }; + return [monitor, isLoading, networkError]; }; export default useMonitorFetch; diff --git a/Client/src/Pages/Uptime/Details/index.jsx b/Client/src/Pages/Uptime/Details/index.jsx index 33583b0cb..3063ba27a 100644 --- a/Client/src/Pages/Uptime/Details/index.jsx +++ b/Client/src/Pages/Uptime/Details/index.jsx @@ -1,13 +1,14 @@ // Components import Breadcrumbs from "../../../Components/Breadcrumbs"; import MonitorStatusHeader from "../../../Components/MonitorStatusHeader"; -import TimeFramePicker from "./Components/TimeFramePicker"; +import MonitorTimeFrameHeader from "../../../Components/MonitorTimeFrameHeader"; import ChartBoxes from "./Components/ChartBoxes"; import ResponseTimeChart from "./Components/Charts/ResponseTimeChart"; import ResponseTable from "./Components/ResponseTable"; import UptimeStatusBoxes from "./Components/UptimeStatusBoxes"; +import GenericFallback from "../../../Components/GenericFallback"; // MUI Components -import { Stack } from "@mui/material"; +import { Stack, Typography } from "@mui/material"; // Utils import { useState } from "react"; @@ -46,13 +47,13 @@ const UptimeDetails = () => { const theme = useTheme(); const isAdmin = useIsAdmin(); - const { monitor, monitorIsLoading } = useMonitorFetch({ + const [monitor, monitorIsLoading, monitorNetworkError] = useMonitorFetch({ authToken, monitorId, dateRange, }); - const { certificateExpiry, certificateIsLoading } = useCertificateFetch({ + const [certificateExpiry, certificateIsLoading] = useCertificateFetch({ monitor, authToken, monitorId, @@ -60,7 +61,7 @@ const UptimeDetails = () => { uiTimezone, }); - const { checks, checksCount, checksAreLoading } = useChecksFetch({ + const [checks, checksCount, checksAreLoading, checksNetworkError] = useChecksFetch({ authToken, monitorId, dateRange, @@ -77,10 +78,44 @@ const UptimeDetails = () => { setRowsPerPage(event.target.value); }; + if (monitorNetworkError || checksNetworkError) { + return ( + + + Network error + + Please check your connection + + ); + } + + // Empty view, displayed when loading is complete and there are no checks + if (!monitorIsLoading && !checksAreLoading && checksCount === 0) { + return ( + + + + + There is no check history for this monitor yet. + + + ); + } + return ( { monitor={monitor} certificateExpiry={certificateExpiry} /> - diff --git a/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx b/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx index 8a9fdcbe9..2824f80d6 100644 --- a/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx +++ b/Client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx @@ -9,7 +9,7 @@ import BarChart from "../../../../../Components/Charts/BarChart"; import ActionsMenu from "../ActionsMenu"; import LoadingSpinner from "../LoadingSpinner"; -import UptimeDataTableSkeleton from "./skeleton"; +import TableSkeleton from "../../../../../Components/Table/skeleton"; // Utils import { useTheme } from "@emotion/react"; @@ -175,7 +175,7 @@ const UptimeDataTable = ({ ]; if (monitorsAreLoading) { - return ; + return ; } return ( diff --git a/Client/src/Pages/Uptime/Monitors/index.jsx b/Client/src/Pages/Uptime/Monitors/index.jsx index 1356d9e7c..0784b46a8 100644 --- a/Client/src/Pages/Uptime/Monitors/index.jsx +++ b/Client/src/Pages/Uptime/Monitors/index.jsx @@ -4,15 +4,15 @@ import Greeting from "../../../Utils/greeting"; import StatusBoxes from "./Components/StatusBoxes"; import UptimeDataTable from "./Components/UptimeDataTable"; import Pagination from "../../../Components/Table/TablePagination"; -import CreateMonitorHeader from "../../../Components/CreateMonitorHeader"; +import CreateMonitorHeader from "../../../Components/MonitorCreateHeader"; import Fallback from "../../../Components/Fallback"; -import NetworkErrorFallback from "../../../Components/NetworkErrorFallback"; +import GenericFallback from "../../../Components/GenericFallback"; import SearchComponent from "./Components/SearchComponent"; import MonitorCountHeader from "../../../Components/MonitorCountHeader"; // MUI Components -import { Stack, Box, Button } from "@mui/material"; +import { Stack, Box, Button, Typography } from "@mui/material"; // Utils import { useState, useCallback } from "react"; @@ -109,8 +109,20 @@ const UptimeMonitors = () => { }); const totalMonitors = monitorsSummary?.totalMonitors ?? 0; - if (networkError) return ; - + if (networkError) { + return ( + + + Network error + + Please check your connection + + ); + } if (!isLoading && !monitorsAreLoading && totalMonitors === 0) { return ( { path="/uptime/configure/:monitorId/" element={} /> + } + /> + } + /> + } + /> } @@ -112,6 +133,16 @@ const Routes = () => { path="status" element={} /> + + } + /> + } + /> + } @@ -174,6 +205,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) => { + console.log("getDistributedUptimeMonitors connection opened:"); + }); + + 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..a7072df81 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, diff --git a/Client/src/Utils/Theme/globalTheme.js b/Client/src/Utils/Theme/globalTheme.js index 8805fe69f..7f90705d0 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: { @@ -306,6 +324,44 @@ 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, + }), + }, + }, }, 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 e40a9c174..988035cbe 100644 --- a/Client/src/Validation/validation.js +++ b/Client/src/Validation/validation.js @@ -92,56 +92,65 @@ const credentials = joi.object({ const monitorValidation = joi.object({ url: 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"); - } + .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.", "string.invalidUrl": "Please enter a valid URL with optional port", + "string.pattern.base": "Please enter a valid container ID.", }), port: joi.number(), name: joi.string().trim().max(50).allow("").messages({ @@ -169,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", @@ -283,4 +338,6 @@ export { maintenanceWindowValidation, advancedSettingsValidation, infrastructureMonitorValidation, + statusPageValidation, + logoImageValidation, }; diff --git a/Client/src/assets/icons/groups.svg b/Client/src/assets/icons/groups.svg new file mode 100644 index 000000000..fa2cae03c --- /dev/null +++ b/Client/src/assets/icons/groups.svg @@ -0,0 +1 @@ + \ No newline at end of file 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..09ea94449 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" 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..09c1497cf 100644 --- a/Server/controllers/distributedUptimeController.js +++ b/Server/controllers/distributedUptimeController.js @@ -1,16 +1,214 @@ 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, + }; + + 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..d09d9b47e 100644 --- a/Server/db/models/DistributedUptimeCheck.js +++ b/Server/db/models/DistributedUptimeCheck.js @@ -1,13 +1,97 @@ 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, + 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/checkModule.js b/Server/db/mongo/modules/checkModule.js index 3d158a812..6450b78a2 100644 --- a/Server/db/mongo/modules/checkModule.js +++ b/Server/db/mongo/modules/checkModule.js @@ -123,8 +123,12 @@ const getChecksByMonitor = async (req) => { }, { $project: { - checksCount: { $arrayElemAt: ["$summary.checksCount", 0] }, - checks: "$checks", + checksCount: { + $ifNull: [{ $arrayElemAt: ["$summary.checksCount", 0] }, 0], + }, + checks: { + $ifNull: ["$checks", []], + }, }, }, ]); 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 9ff80e7bf..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); @@ -340,6 +342,7 @@ const getUptimeDetailsById = async (req) => { }; const dateString = formatLookup[dateRange]; + const results = await Check.aggregate( buildUptimeDetailsPipeline(monitor, dates, dateString) ); @@ -365,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 @@ -629,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: { @@ -647,6 +712,10 @@ const getMonitorsByTeamId = async (req) => { case: { $eq: ["$type", "hardware"] }, then: "$hardwarechecks", }, + { + case: { $eq: ["$type", "distributed_http"] }, + then: "$distributeduptimechecks", + }, ], default: [], }, @@ -814,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 cdbcd51d8..489017f51 100644 --- a/Server/db/mongo/modules/monitorModuleQueries.js +++ b/Server/db/mongo/modules/monitorModuleQueries.js @@ -218,18 +218,20 @@ const buildUptimeDetailsPipeline = (monitor, dates, dateString) => { { $project: { uptimeStreak: { - $cond: [ - { $eq: [{ $size: { $first: "$uptimeStreak.streak.checks" } }, 0] }, - 0, - { - $subtract: [ - new Date(), + $let: { + vars: { + checks: { $ifNull: [{ $first: "$uptimeStreak.streak.checks" }, []] }, + }, + in: { + $cond: [ + { $eq: [{ $size: "$$checks" }, 0] }, + 0, { - $last: { $first: "$uptimeStreak.streak.checks.createdAt" }, + $subtract: [new Date(), { $last: "$$checks.createdAt" }], }, ], }, - ], + }, }, avgResponseTime: { $arrayElemAt: ["$aggregateData.avgResponseTime", 0], @@ -532,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 7a4129bc9..23d16856a 100644 --- a/Server/index.js +++ b/Server/index.js @@ -244,7 +244,11 @@ const startApp = async () => { ServiceRegistry.get(MongoDB.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 54fb6d236..4cfc4ff6b 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.39.0", + "bullmq": "5.40.0", "cors": "^2.8.5", "dockerode": "4.0.4", "dotenv": "^16.4.5", @@ -1748,9 +1748,9 @@ } }, "node_modules/bullmq": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.39.0.tgz", - "integrity": "sha512-aS+wtZsjW4/9mv2iZRynkSvEXCFS5h3Ko+OgArJco5rEgTE7v/TehDKv6gYEsKVbavOgiNlUNjgEhWhrzDSLBg==", + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.40.0.tgz", + "integrity": "sha512-tmrk32EmcbtUOGPSdwlDUcc0w+nAMqCisk8vEFFmG8aOzIehz0BxTNSj6Grh0qoMugRF3VglWk8HGUBnWqU2Fw==", "license": "MIT", "dependencies": { "cron-parser": "^4.9.0", @@ -5571,9 +5571,9 @@ } }, "node_modules/mongoose": { - "version": "8.9.5", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.5.tgz", - "integrity": "sha512-SPhOrgBm0nKV3b+IIHGqpUTOmgVL5Z3OO9AwkFEmvOZznXTvplbomstCnPOGAyungtRXE5pJTgKpKcZTdjeESg==", + "version": "8.9.7", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.7.tgz", + "integrity": "sha512-mvNXmU0V8qZzMR/qoK2mjT4Ti2ALdtfS0teK+twxhlGkwzOD76V02/zWajTu2MJ7QyEmZe9OWvnJsIY0iAuX3Q==", "license": "MIT", "dependencies": { "bson": "^6.10.1", diff --git a/Server/package.json b/Server/package.json index d8eeee5df..3bea87852 100644 --- a/Server/package.json +++ b/Server/package.json @@ -18,7 +18,7 @@ "dependencies": { "axios": "^1.7.2", "bcrypt": "5.1.1", - "bullmq": "5.39.0", + "bullmq": "5.40.0", "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 54edf39f9..8a58d4b88 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. * @@ -360,6 +391,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..04f654d59 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} - A promise that resolves to an object containing the monitor, statusChanged flag, and previous status if the status changed, or false if an error occurred. + * @returns {Promise} - A promise that resolves to an object containinfg the monitor, statusChanged flag, and previous status if the status changed, or false if an error occurred. * @returns {Promise} returnObject - The object returned by the function. * @returns {Object} returnObject.monitor - The monitor object. * @returns {boolean} returnObject.statusChanged - Flag indicating if the status has changed. @@ -85,6 +85,7 @@ class StatusService { buildCheck = (networkResponse) => { const { monitorId, teamId, type, status, responseTime, code, message, payload } = networkResponse; + const check = { monitorId, teamId, @@ -94,6 +95,14 @@ class StatusService { message, }; + if (type === "distributed_http") { + check.continent = payload.continent; + check.countryCode = payload.country_code; + check.city = payload.city; + check.location = payload.location; + check.uptBurnt = payload.upt_burnt; + } + if (type === "pagespeed") { const categories = payload.lighthouseResult?.categories; const audits = payload.lighthouseResult?.audits; @@ -145,6 +154,7 @@ class StatusService { hardware: this.db.createHardwareCheck, docker: this.db.createCheck, port: this.db.createCheck, + distributed_http: this.db.createDistributedCheck, }; const operation = operationMap[networkResponse.type]; diff --git a/Server/templates/addReview.mjml b/Server/templates/addReview.mjml new file mode 100644 index 000000000..22adbe8b0 --- /dev/null +++ b/Server/templates/addReview.mjml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{name}}!

+

+We hope you’re finding Checkmate helpful in monitoring your infrastructure. Your support means a lot to us, and we truly appreciate having you as part of our community. +

+

+If you’re happy with Checkmate, we’d love to hear about your experience! Leaving a review on G2 helps others discover Checkmate and supports our ongoing improvements. +

+G2 Link: TBD +

+Thank you for taking the time to share your thoughts - we greatly appreciate it! +

+Checkmate Team +
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/Server/utils/messages.js b/Server/utils/messages.js index 5fc3c70bc..ffcfc3f1f 100644 --- a/Server/utils/messages.js +++ b/Server/utils/messages.js @@ -136,7 +136,8 @@ const successMessages = { // Status Page STATUS_PAGE_BY_URL: "Got status page by url successfully", STATUS_PAGE_CREATE: "Status page created successfully", - + STATUS_PAGE_DELETE: "Status page deleted successfully", + STATUS_PAGE_UPDATE: "Status page updated successfully", // Docker DOCKER_SUCCESS: "Docker container status fetched successfully", diff --git a/Server/validation/joi.js b/Server/validation/joi.js index 9f20eb0d1..d595f00fd 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -423,7 +423,6 @@ const createStatusPageBodyValidation = joi.object({ url: joi.string().required(), timezone: joi.string().required(), color: joi.string().required(), - theme: joi.string().required(), monitors: joi .array() .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) @@ -434,8 +433,35 @@ const createStatusPageBodyValidation = joi.object({ "array.empty": "At least one monitor is required", "any.required": "Monitors are required", }), + isPublished: joi.boolean(), + showCharts: joi.boolean(), + showUptimePercentage: joi.boolean(), }); +const imageValidation = joi + .object({ + fieldname: joi.string().required(), + originalname: joi.string().required(), + encoding: joi.string().required(), + mimetype: joi + .string() + .valid("image/jpeg", "image/png", "image/jpg") + .required() + .messages({ + "string.valid": "File must be a valid image (jpeg, jpg, or png)", + }), + size: joi.number().max(3145728).required().messages({ + "number.max": "File size must be less than 3MB", + }), + buffer: joi.binary().required(), + destination: joi.string(), + filename: joi.string(), + path: joi.string(), + }) + .messages({ + "any.required": "Image file is required", + }); + export { roleValidatior, loginValidation, @@ -493,4 +519,5 @@ export { updateAppSettingsBodyValidation, createStatusPageBodyValidation, getStatusPageParamValidation, + imageValidation, };